diff --git a/catalog/live/fetchers.go b/catalog/live/fetchers.go index 9cde3af..003962b 100644 --- a/catalog/live/fetchers.go +++ b/catalog/live/fetchers.go @@ -52,7 +52,7 @@ const ( type FetchFunc func(env map[string]string) ([]Entry, error) const ( - DefaultDeepSeekBaseURL = "https://api.deepseek.com/v1" + DefaultDeepSeekBaseURL = "https://api.deepseek.com" DefaultPoolsideBaseURL = "https://inference.poolside.ai/v1" DefaultGroqBaseURL = "https://api.groq.com/openai/v1" DefaultClinePassBaseURL = "https://api.cline.bot/api/v1" // #nosec G101 -- public API base URL, not a secret value diff --git a/catalog/registry/derive.go b/catalog/registry/derive.go index 2037333..85b8cb5 100644 --- a/catalog/registry/derive.go +++ b/catalog/registry/derive.go @@ -38,6 +38,16 @@ func SpecByEnvVar(env string) (ProviderSpec, bool) { return DefaultRegistry.GetByEnv(env) } +// SpecByDeploymentID finds a provider spec by its deployment id. +func SpecByDeploymentID(deploymentID string) (ProviderSpec, bool) { + for _, spec := range DefaultRegistry.All() { + if spec.DeploymentID == deploymentID { + return spec, true + } + } + return ProviderSpec{}, false +} + // DisplayName returns the UI label for a provider id. func DisplayName(providerID string) string { if s, ok := SpecByProviderID(providerID); ok { @@ -80,21 +90,6 @@ func RuntimeProfileKey(providerID string) string { return "" } -// DirectFallbackProviderIDs returns direct-provider fallback ids for providerID. -func DirectFallbackProviderIDs(providerID string) []string { - spec, ok := SpecByProviderID(providerID) - if !ok || len(spec.DirectFallbacks) == 0 { - return nil - } - out := make([]string, 0, len(spec.DirectFallbacks)) - for _, id := range spec.DirectFallbacks { - if trimmed := strings.TrimSpace(id); trimmed != "" { - out = append(out, trimmed) - } - } - return out -} - // CredentialAliases returns compatibility env var names for providerID. func CredentialAliases(providerID string) []string { spec, ok := SpecByProviderID(providerID) diff --git a/catalog/registry/provider_spec_test.go b/catalog/registry/provider_spec_test.go index 01d8482..baaf223 100644 --- a/catalog/registry/provider_spec_test.go +++ b/catalog/registry/provider_spec_test.go @@ -24,9 +24,6 @@ func TestProviderSpecs_AgnesOpenAIOnlyLongCatOpenAIPrimary(t *testing.T) { 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. @@ -115,13 +112,6 @@ func TestProviderRuntimePolicy_Metadata(t *testing.T) { t.Fatalf("unexpected runtime preference prefix: %v", order[:3]) } - if got := registry.DirectFallbackProviderIDs("openai"); len(got) != 1 || got[0] != "anthropic" { - t.Fatalf("openai direct fallbacks = %v, want [anthropic]", got) - } - if got := registry.DirectFallbackProviderIDs("anthropic"); len(got) != 1 || got[0] != "openai" { - t.Fatalf("anthropic direct fallbacks = %v, want [openai]", got) - } - if got := registry.CredentialAliases("anthropic"); len(got) != 1 || got[0] != "CLAUDE_API_KEY" { t.Fatalf("anthropic credential aliases = %v", got) } diff --git a/catalog/registry/providers.go b/catalog/registry/providers.go index c01a439..707267c 100644 --- a/catalog/registry/providers.go +++ b/catalog/registry/providers.go @@ -25,7 +25,8 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeAnthropic, LiveFetcherKey: "anthropic", LiveCatalogKey: "anthropic", ProtocolID: "anthropic-messages", AdapterID: "anthropic", RuntimeProfileKey: "anthropic", - DirectFallbacks: []string{"openai"}, + DNSHost: "api.anthropic.com", + ThinkingToggleSupported: true, }, { ProviderID: "openai", DisplayName: "OpenAI", DeploymentID: "openai-direct", SortOrder: 15, ChatPreference: 1, @@ -35,7 +36,7 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.openai.com/v1", LiveFetcherKey: "openai", LiveCatalogKey: "openai", ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "openai", - DirectFallbacks: []string{"anthropic"}, + DNSHost: "api.openai.com", }, { ProviderID: "gemini", DisplayName: "Gemini API", DeploymentID: "gemini-direct", SortOrder: 9, ChatPreference: 5, @@ -46,14 +47,18 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeGemini, LiveFetcherKey: "gemini", LiveCatalogKey: "gemini", ProtocolID: "gemini-generate-content", AdapterID: "gemini", RuntimeProfileKey: "gemini", + DNSHost: "generativelanguage.googleapis.com", }, { 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", + ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.deepseek.com", LiveFetcherKey: "deepseek", LiveCatalogKey: "deepseek", ProtocolID: "openai-chat-completions", AdapterID: "deepseek", RuntimeProfileKey: "deepseek", + DNSHost: "api.deepseek.com", + ThinkingToggleSupported: true, + DefaultThinkingDisabled: true, }, { ProviderID: "grok", DisplayName: "xAI", DeploymentID: "grok-direct", SortOrder: 21, ChatPreference: 4, @@ -62,6 +67,7 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.x.ai/v1", LiveFetcherKey: "grok", LiveCatalogKey: "grok", ProtocolID: "openai-chat-completions", AdapterID: "grok", RuntimeProfileKey: "grok", + DNSHost: "api.x.ai", }, { ProviderID: "kimi", DisplayName: "Kimi", DeploymentID: "kimi-direct", SortOrder: 11, ChatPreference: 14, @@ -70,6 +76,9 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.moonshot.ai/v1", LiveFetcherKey: "kimi", LiveCatalogKey: "kimi", ProtocolID: "openai-chat-completions", AdapterID: "kimi", RuntimeProfileKey: "kimi", + DNSHost: "api.moonshot.ai", + ThinkingToggleSupported: true, + DefaultThinkingDisabled: true, }, { ProviderID: "zai_coding", DisplayName: "Z.AI — Coding Plan", DeploymentID: "zai_coding-direct", SortOrder: 24, ChatPreference: 8, @@ -78,7 +87,13 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/coding/paas/v4", LiveFetcherKey: "zai_coding", LiveCatalogKey: "zai_coding", ProtocolID: "openai-chat-completions", AdapterID: "zai_coding", RuntimeProfileKey: "zai_coding", - PrepareCredentialEnv: true, + PrepareCredentialEnv: true, + DNSHost: "api.z.ai", + ThinkingToggleSupported: true, + RegionOptions: []RegionOption{ + {Value: "international", DisplayName: "International (api.z.ai)", Endpoint: "https://api.z.ai/api/coding/paas/v4"}, + {Value: "cn", DisplayName: "China (open.bigmodel.cn)", Endpoint: "https://open.bigmodel.cn/api/coding/paas/v4"}, + }, }, { ProviderID: "zai_payg", DisplayName: "Z.AI — Pay-as-you-go", DeploymentID: "zai_payg-direct", SortOrder: 25, ChatPreference: 9, @@ -87,7 +102,13 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/paas/v4", LiveFetcherKey: "zai_payg", LiveCatalogKey: "zai_payg", ProtocolID: "openai-chat-completions", AdapterID: "zai_payg", RuntimeProfileKey: "zai_payg", - PrepareCredentialEnv: true, + PrepareCredentialEnv: true, + DNSHost: "api.z.ai", + ThinkingToggleSupported: true, + RegionOptions: []RegionOption{ + {Value: "international", DisplayName: "International (api.z.ai)", Endpoint: "https://api.z.ai/api/paas/v4"}, + {Value: "cn", DisplayName: "China (open.bigmodel.cn)", Endpoint: "https://open.bigmodel.cn/api/paas/v4"}, + }, }, { ProviderID: "xiaomi_mimo_token_plan", DisplayName: "Xiaomi MiMo — Token Plan", DeploymentID: "xiaomi_mimo_token_plan-direct", SortOrder: 23, ChatPreference: 16, @@ -96,16 +117,26 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "", LiveFetcherKey: "xiaomi_mimo_token_plan", LiveCatalogKey: "xiaomi_mimo_token_plan", ProtocolID: "openai-chat-completions", AdapterID: "xiaomi_mimo", RuntimeProfileKey: "xiaomi_mimo_token_plan", - PrepareCredentialEnv: true, + PrepareCredentialEnv: true, + DNSHost: "api.xiaomimimo.com", + ThinkingToggleSupported: true, + DefaultThinkingDisabled: true, + RegionOptions: []RegionOption{ + {Value: "cn", DisplayName: "China (cn)"}, + {Value: "sgp", DisplayName: "Singapore (sgp)"}, + {Value: "ams", DisplayName: "Amsterdam (ams)"}, + }, }, { 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"}, - ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.xiaomimimo.com/v1", + BaseURLEnv: []string{"XIAOMI_MIMO_PAYG_BASE_URL", "XIAOMI_BASE_URL"}, + ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.xiaomimimo.com/v1", LiveFetcherKey: "xiaomi_mimo_payg", LiveCatalogKey: "xiaomi_mimo_payg", ProtocolID: "openai-chat-completions", AdapterID: "xiaomi_mimo", RuntimeProfileKey: "xiaomi_mimo_payg", + DNSHost: "api.xiaomimimo.com", + ThinkingToggleSupported: true, + DefaultThinkingDisabled: true, }, { ProviderID: "minimax_token_plan", DisplayName: "MiniMax — Token Plan", DeploymentID: "minimax_token_plan-direct", SortOrder: 14, ChatPreference: 17, @@ -114,6 +145,9 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.minimax.io/v1", LiveFetcherKey: "minimax_token_plan", LiveCatalogKey: "minimax_token_plan", ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "minimax_token_plan", + DNSHost: "api.minimax.io", + ThinkingToggleSupported: true, + DefaultThinkingDisabled: true, }, { ProviderID: "minimax_payg", DisplayName: "MiniMax — Pay-as-you-go", DeploymentID: "minimax_payg-direct", SortOrder: 13, ChatPreference: 18, @@ -122,6 +156,9 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.minimax.io/v1", LiveFetcherKey: "minimax_payg", LiveCatalogKey: "minimax_payg", ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "minimax_payg", + DNSHost: "api.minimax.io", + ThinkingToggleSupported: true, + DefaultThinkingDisabled: true, }, // ── Cloud platform providers ────────────────────────────────────── @@ -133,6 +170,7 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeNone, LiveFetcherKey: "azure", LiveCatalogKey: "azure", ProtocolID: "openai-chat-completions", AdapterID: "openai-azure", RuntimeProfileKey: "azure", + DNSHost: "", }, { ProviderID: "bedrock", DisplayName: "Amazon Bedrock", DeploymentID: "anthropic-bedrock", SortOrder: 2, ChatPreference: 7, @@ -143,6 +181,7 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeNone, LiveFetcherKey: "bedrock", LiveCatalogKey: "bedrock", ProtocolID: "anthropic-messages", AdapterID: "anthropic-bedrock", RuntimeProfileKey: "bedrock", + DNSHost: "", }, { ProviderID: "vertex", DisplayName: "Vertex AI", DeploymentID: "gemini-vertex", SortOrder: 20, ChatPreference: 6, @@ -153,6 +192,7 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeNone, LiveFetcherKey: "vertex", LiveCatalogKey: "vertex", ProtocolID: "gemini-generate-content", AdapterID: "gemini-vertex", RuntimeProfileKey: "vertex", + DNSHost: "", }, // ── Aggregators ─────────────────────────────────────────────────── @@ -163,6 +203,8 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://openrouter.ai/api/v1", LiveFetcherKey: "openrouter", LiveCatalogKey: "openrouter", ProtocolID: "openai-chat-completions", AdapterID: "openrouter", RuntimeProfileKey: "openrouter", + DNSHost: "openrouter.ai", + ThinkingToggleSupported: true, }, { ProviderID: "concentrate", DisplayName: "Concentrate AI (Pay-as-you-go)", DeploymentID: "concentrate-payg", SortOrder: 7, ChatPreference: 4, @@ -172,6 +214,7 @@ func providerSpecs() []ProviderSpec { LiveFetcherKey: "concentrate", LiveCatalogKey: "concentrate", PublicModelCatalog: true, ProtocolID: "openai-responses", AdapterID: "concentrate-responses", RuntimeProfileKey: "concentrate", + DNSHost: "api.concentrate.ai", }, { ProviderID: "opengateway", DisplayName: "OpenGateway (Pay-as-you-go)", DeploymentID: "opengateway-payg", SortOrder: 27, ChatPreference: 28, @@ -181,6 +224,7 @@ func providerSpecs() []ProviderSpec { LiveFetcherKey: "opengateway", LiveCatalogKey: "opengateway", PublicModelCatalog: true, ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "opengateway", + DNSHost: "opengateway.gitlawb.com", }, { ProviderID: "stepfun", DisplayName: "StepFun", DeploymentID: "stepfun-direct", SortOrder: 26, ChatPreference: 27, @@ -189,6 +233,11 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.stepfun.ai/v1", LiveFetcherKey: "stepfun", LiveCatalogKey: "stepfun", ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "stepfun", + DNSHost: "api.stepfun.ai", + RegionOptions: []RegionOption{ + {Value: "global", DisplayName: "Global (api.stepfun.ai)", Endpoint: "https://api.stepfun.ai/v1"}, + {Value: "cn", DisplayName: "China (api.stepfun.com)", Endpoint: "https://api.stepfun.com/v1"}, + }, }, { ProviderID: "agnes", DisplayName: "Agnes AI", DeploymentID: "agnes-direct", SortOrder: 1, ChatPreference: 25, @@ -197,6 +246,8 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://apihub.agnes-ai.com/v1", LiveFetcherKey: "agnes", LiveCatalogKey: "agnes", ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "agnes", + DNSHost: "apihub.agnes-ai.com", + ThinkingToggleSupported: true, }, { ProviderID: "longcat", DisplayName: "LongCat", DeploymentID: "longcat-direct", SortOrder: 12, ChatPreference: 26, @@ -205,6 +256,9 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.longcat.chat/openai/v1", LiveFetcherKey: "longcat", LiveCatalogKey: "longcat", ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "longcat", + DNSHost: "api.longcat.chat", + ThinkingToggleSupported: true, + DefaultThinkingDisabled: true, }, // ── Niche ───────────────────────────────────────────────────────── @@ -215,6 +269,7 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://inference.canopywave.io/v1", LiveFetcherKey: "canopywave", LiveCatalogKey: "canopywave", ProtocolID: "openai-chat-completions", AdapterID: "canopywave", RuntimeProfileKey: "canopywave", + DNSHost: "inference.canopywave.io", }, { ProviderID: "poolside", DisplayName: "Poolside", DeploymentID: "poolside", SortOrder: 19, ChatPreference: 20, @@ -223,6 +278,7 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://inference.poolside.ai/v1", LiveFetcherKey: "poolside", LiveCatalogKey: "poolside", ProtocolID: "openai-chat-completions", AdapterID: "poolside", RuntimeProfileKey: "poolside", + DNSHost: "inference.poolside.ai", }, { ProviderID: "groq", DisplayName: "Groq", DeploymentID: "groq-direct", SortOrder: 10, ChatPreference: 21, @@ -231,6 +287,7 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.groq.com/openai/v1", LiveFetcherKey: "groq", LiveCatalogKey: "groq", ProtocolID: "openai-chat-completions", AdapterID: "groq", RuntimeProfileKey: "groq", + DNSHost: "api.groq.com", }, { ProviderID: "clinepass", DisplayName: "ClinePass", DeploymentID: "clinepass", SortOrder: 6, ChatPreference: 22, @@ -240,6 +297,7 @@ func providerSpecs() []ProviderSpec { ProbeKind: ProbeNone, LiveFetcherKey: "clinepass", LiveCatalogKey: "clinepass", ProtocolID: "openai-chat-completions", AdapterID: "clinepass", RuntimeProfileKey: "clinepass", + DNSHost: "api.cline.bot", }, { ProviderID: "opencodego", DisplayName: "OpenCode Go", DeploymentID: "opencodego", SortOrder: 16, ChatPreference: 13, @@ -249,6 +307,8 @@ func providerSpecs() []ProviderSpec { ProbeBaseURL: opencodego.DefaultBaseURL, LiveFetcherKey: "opencodego", LiveCatalogKey: "opencodego", ProtocolID: "openai-chat-completions", AdapterID: "opencodego", RuntimeProfileKey: "opencodego", + DNSHost: "", + ThinkingToggleSupported: true, }, // ── Local ───────────────────────────────────────────────────────── @@ -267,6 +327,7 @@ func providerSpecs() []ProviderSpec { RetryOnCodes: []int{500, 503}, AbortOnCodes: []int{400}, }, + DNSHost: "localhost", }, } } diff --git a/catalog/registry/spec.go b/catalog/registry/spec.go index 6c415a3..3345e2c 100644 --- a/catalog/registry/spec.go +++ b/catalog/registry/spec.go @@ -21,6 +21,13 @@ type RetryConfig struct { AbortOnCodes []int `json:"abort_on_codes,omitempty"` } +// RegionOption describes one selectable region for a regional gateway. +type RegionOption struct { + Value string + DisplayName string + Endpoint string +} + // ProviderSpec is the single source of truth for setup providers. // Every registered provider discovers models via its live list API only (no remote bootstrap). type ProviderSpec struct { @@ -42,7 +49,6 @@ type ProviderSpec struct { ProtocolID string AdapterID string RuntimeProfileKey string - DirectFallbacks []string PrepareCredentialEnv bool RetryConfig *RetryConfig IsLocal bool @@ -54,6 +60,17 @@ type ProviderSpec struct { // describes a non-secret value, as with Ollama's base URL. RuntimeBaseURL string RuntimeCredentialEnv string + // RegionOptions lists selectable regions for regional gateways. + // Populated at registration time; nil for non-regional providers. + RegionOptions []RegionOption + // DNSHost is the primary DNS hostname for connectivity preflight checks. + DNSHost string + // ThinkingToggleSupported indicates the provider's wire protocol honors + // the ThinkingEnabled toggle parameter. + ThinkingToggleSupported bool + // DefaultThinkingDisabled means the provider defaults thinking OFF when + // no explicit preference is set (e.g. longcat, kimi, deepseek). + DefaultThinkingDisabled bool } // EnvFallback describes one deployment env_fallback row. diff --git a/client/adapters/agnes.go b/client/adapters/agnes.go new file mode 100644 index 0000000..9e0da7f --- /dev/null +++ b/client/adapters/agnes.go @@ -0,0 +1,40 @@ +package adapters + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +// AgnesClient uses the OpenAI-compatible Agnes AI endpoint. +// Official docs expose an OpenAI-compatible API only. +type AgnesClient struct { + openAI *OpenAIClient +} + +// NewAgnesClient builds an Agnes AI provider client. +// openAIBase is typically "https://apihub.agnes-ai.com/v1". +func NewAgnesClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *AgnesClient { + openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + agnOpts := append(append([]core.ClientOption{}, opts...), core.WithProviderName("agnes")) + return &AgnesClient{ + openAI: NewOpenAIClient(apiKey, openAIBase, compat, agnOpts...), + } +} + +func (c *AgnesClient) Name() string { return "agnes" } + +func (c *AgnesClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return c.openAI.Chat(ctx, messages, opts) +} + +func (c *AgnesClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + return c.openAI.StreamChat(ctx, messages, opts) +} + +func (c *AgnesClient) Ping(ctx context.Context) error { + return c.openAI.Ping(ctx) +} + +var _ core.Provider = (*AgnesClient)(nil) diff --git a/client/adapters/agnes_test.go b/client/adapters/agnes_test.go new file mode 100644 index 0000000..d265f35 --- /dev/null +++ b/client/adapters/agnes_test.go @@ -0,0 +1,65 @@ +package adapters + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestNewAgnesClient_OpenAI(t *testing.T) { + t.Parallel() + client := NewAgnesClient("agnes-key", "https://apihub.agnes-ai.com/v1", &AgnesCompat) + if client == nil || client.openAI == nil { + t.Fatal("expected OpenAI client") + } + if client.Name() != "agnes" { + t.Fatalf("Name = %q", client.Name()) + } +} + +func TestAgnesClient_ChatUsesOpenAIPath(t *testing.T) { + t.Parallel() + 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 := NewAgnesClient("key", server.URL, &AgnesCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "agnes-2.0-flash", MaxTokens: 16}) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.Content != "ok" { + t.Fatalf("resp = %+v", resp) + } + if !strings.Contains(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) + } +} + +func TestAgnesClient_Ping(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + client := NewAgnesClient("key", server.URL, &AgnesCompat, core.WithTimeout(2*time.Second)) + if err := client.Ping(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/client/adapters/anthropic.go b/client/adapters/anthropic.go index 0147650..3014243 100644 --- a/client/adapters/anthropic.go +++ b/client/adapters/anthropic.go @@ -166,11 +166,13 @@ func resolveThinking(opts core.ChatOptions) *anthropicThinking { case "disabled": return thinkingDisabled() case "enabled": - thinking := thinkingForBudget(opts.ThinkingBudgetTokens) - if thinking != nil && opts.ThinkingDisplay != "" { - thinking.Display = opts.ThinkingDisplay - } - return thinking + // The legacy type:"enabled" with a fixed budget_tokens is deprecated on + // Claude 4.6 and rejected on Claude 4.7+ (docs recommend migrating to + // adaptive). Use adaptive so an explicit "enabled" request stays + // compatible across model generations while keeping thinking on. A + // caller-provided fixed budget is still honored via the legacy no-mode + // path below (thinkingForBudget). + return thinkingAdaptive() default: // Legacy behavior: ThinkingEnabled toggle wins, else budget > 0 enables with budget. if opts.ThinkingEnabled != nil { @@ -179,7 +181,11 @@ func resolveThinking(opts core.ChatOptions) *anthropicThinking { } return thinkingDisabled() } - return thinkingForBudget(opts.ThinkingBudgetTokens) + thinking := thinkingForBudget(opts.ThinkingBudgetTokens) + if thinking != nil && opts.ThinkingDisplay != "" { + thinking.Display = opts.ThinkingDisplay + } + return thinking } } diff --git a/client/adapters/anthropic_test.go b/client/adapters/anthropic_test.go index e7208a7..ec29abe 100644 --- a/client/adapters/anthropic_test.go +++ b/client/adapters/anthropic_test.go @@ -470,7 +470,7 @@ func TestResolveThinking(t *testing.T) { }{ {"adaptive", "adaptive", 0, nil, &AnthropicThinking{Type: "adaptive"}}, {"disabled", "disabled", 0, nil, &AnthropicThinking{Type: "disabled"}}, - {"enabled", "enabled", 1000, nil, &AnthropicThinking{Type: "enabled", BudgetTokens: 1000}}, + {"enabled", "enabled", 1000, nil, &AnthropicThinking{Type: "adaptive"}}, {"budget_legacy", "", 500, nil, &AnthropicThinking{Type: "enabled", BudgetTokens: 500}}, {"unset", "", 0, nil, nil}, {"thinking_enabled_true", "", 0, &on, &AnthropicThinking{Type: "adaptive"}}, diff --git a/client/adapters/canopywave.go b/client/adapters/canopywave.go new file mode 100644 index 0000000..b5d25d9 --- /dev/null +++ b/client/adapters/canopywave.go @@ -0,0 +1,39 @@ +package adapters + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +// CanopyWaveClient uses the OpenAI-compatible CanopyWave endpoint. +type CanopyWaveClient struct { + openAI *OpenAIClient +} + +// NewCanopyWaveClient builds a CanopyWave provider client. +// openAIBase is typically "https://inference.canopywave.io/v1". +func NewCanopyWaveClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *CanopyWaveClient { + openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + opts = append(append([]core.ClientOption{}, opts...), core.WithProviderName("canopywave")) + return &CanopyWaveClient{ + openAI: NewOpenAIClient(apiKey, openAIBase, compat, opts...), + } +} + +func (c *CanopyWaveClient) Name() string { return "canopywave" } + +func (c *CanopyWaveClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return c.openAI.Chat(ctx, messages, opts) +} + +func (c *CanopyWaveClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + return c.openAI.StreamChat(ctx, messages, opts) +} + +func (c *CanopyWaveClient) Ping(ctx context.Context) error { + return c.openAI.Ping(ctx) +} + +var _ core.Provider = (*CanopyWaveClient)(nil) diff --git a/client/adapters/canopywave_test.go b/client/adapters/canopywave_test.go new file mode 100644 index 0000000..c710904 --- /dev/null +++ b/client/adapters/canopywave_test.go @@ -0,0 +1,65 @@ +package adapters + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestNewCanopyWaveClient_OpenAI(t *testing.T) { + t.Parallel() + client := NewCanopyWaveClient("cw-key", "https://inference.canopywave.io/v1", &CanopyWaveCompat) + if client == nil || client.openAI == nil { + t.Fatal("expected OpenAI client") + } + if client.Name() != "canopywave" { + t.Fatalf("Name = %q", client.Name()) + } +} + +func TestCanopyWaveClient_ChatUsesOpenAIPath(t *testing.T) { + t.Parallel() + 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 := NewCanopyWaveClient("key", server.URL, &CanopyWaveCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "canopywave-2.0", MaxTokens: 16}) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.Content != "ok" { + t.Fatalf("resp = %+v", resp) + } + if !strings.Contains(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) + } +} + +func TestCanopyWaveClient_Ping(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + client := NewCanopyWaveClient("key", server.URL, &CanopyWaveCompat, core.WithTimeout(2*time.Second)) + if err := client.Ping(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/client/adapters/clinepass.go b/client/adapters/clinepass.go new file mode 100644 index 0000000..201859e --- /dev/null +++ b/client/adapters/clinepass.go @@ -0,0 +1,39 @@ +package adapters + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +// ClinePassClient uses the OpenAI-compatible ClinePass endpoint. +type ClinePassClient struct { + openAI *OpenAIClient +} + +// NewClinePassClient builds a ClinePass provider client. +// openAIBase is typically "https://api.cline.bot/api/v1". +func NewClinePassClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *ClinePassClient { + openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + opts = append(append([]core.ClientOption{}, opts...), core.WithProviderName("clinepass")) + return &ClinePassClient{ + openAI: NewOpenAIClient(apiKey, openAIBase, compat, opts...), + } +} + +func (c *ClinePassClient) Name() string { return "clinepass" } + +func (c *ClinePassClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return c.openAI.Chat(ctx, messages, opts) +} + +func (c *ClinePassClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + return c.openAI.StreamChat(ctx, messages, opts) +} + +func (c *ClinePassClient) Ping(ctx context.Context) error { + return c.openAI.Ping(ctx) +} + +var _ core.Provider = (*ClinePassClient)(nil) diff --git a/client/adapters/clinepass_test.go b/client/adapters/clinepass_test.go new file mode 100644 index 0000000..9957911 --- /dev/null +++ b/client/adapters/clinepass_test.go @@ -0,0 +1,65 @@ +package adapters + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestNewClinePassClient_OpenAI(t *testing.T) { + t.Parallel() + client := NewClinePassClient("cp-key", "https://api.cline.bot/api/v1", &ClinePassCompat) + if client == nil || client.openAI == nil { + t.Fatal("expected OpenAI client") + } + if client.Name() != "clinepass" { + t.Fatalf("Name = %q", client.Name()) + } +} + +func TestClinePassClient_ChatUsesOpenAIPath(t *testing.T) { + t.Parallel() + 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 := NewClinePassClient("key", server.URL, &ClinePassCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "cline-2.0", MaxTokens: 16}) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.Content != "ok" { + t.Fatalf("resp = %+v", resp) + } + if !strings.Contains(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) + } +} + +func TestClinePassClient_Ping(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + client := NewClinePassClient("key", server.URL, &ClinePassCompat, core.WithTimeout(2*time.Second)) + if err := client.Ping(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/client/adapters/compat.go b/client/adapters/compat.go index 3d9c8fa..bce67cf 100644 --- a/client/adapters/compat.go +++ b/client/adapters/compat.go @@ -13,10 +13,13 @@ type OpenAICompatConfig struct { 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" - // 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. - StripReasoningFromInput bool `json:"strip_reasoning_from_input,omitempty"` + // RequiresReasoningPassback instructs buildRequestBase to forward the + // reasoning_content captured from a prior assistant turn (core.EyrieMessage.Thinking) + // back into the request's assistant messages. DeepSeek requires the assistant's + // reasoning_content to be passed back whenever that turn performed a tool call, + // otherwise the API returns HTTP 400. When no tool call happened the field is + // ignored by the provider, so forwarding is always safe for compliant providers. + RequiresReasoningPassback bool `json:"requires_reasoning_passback,omitempty"` // SupportsCacheRole enables Kimi/Moonshot context-cache injection: when // core.ChatOptions.KimiContextCacheID is non-empty, buildRequestBase prepends a // {"role":"cache","content":} message per the MoonshotAI-Cookbook spec. @@ -66,7 +69,6 @@ var ( MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, ThinkingFormat: "openrouter", - StripReasoningFromInput: true, } PoolsideCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", @@ -104,10 +106,9 @@ var ( } // LongCatCompat: OpenAI-compatible; enables thinking by default, so disable it. LongCatCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - ThinkingFormat: "longcat", - DefaultDisableThinking: true, - StripReasoningFromInput: true, + MaxTokensField: "max_tokens", + ThinkingFormat: "longcat", + DefaultDisableThinking: true, } // MiniMaxCompat: OpenAI-compatible; enables thinking by default, so disable it. MiniMaxCompat = OpenAICompatConfig{ @@ -116,14 +117,22 @@ var ( DefaultDisableThinking: true, } // DeepSeekCompat: OpenAI-compatible with usage in streaming. - // The provider rejects reasoning_content in input messages with HTTP 400, so we strip it. + // DeepSeek requires the assistant's reasoning_content to be passed back whenever + // that turn performed a tool call (HTTP 400 otherwise), so we forward it. // Enables thinking by default, so disable it. DeepSeekCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + SupportsUsageInStreaming: true, + RequiresReasoningPassback: true, + ThinkingFormat: "deepseek", + DefaultDisableThinking: true, + } + ConcentrateCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, - StripReasoningFromInput: true, - ThinkingFormat: "deepseek", - DefaultDisableThinking: true, + } + StepFunCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", } ) @@ -190,6 +199,32 @@ func init() { p.Compat = &DeepSeekCompat OpenAICompatibleProviders["deepseek"] = p } + if p, ok := OpenAICompatibleProviders["opengateway"]; ok { + p.Compat = &OpenGatewayCompat + OpenAICompatibleProviders["opengateway"] = p + } + if p, ok := OpenAICompatibleProviders["longcat"]; ok { + p.Compat = &LongCatCompat + OpenAICompatibleProviders["longcat"] = p + } + for _, id := range []string{"minimax_token_plan", "minimax_payg"} { + if p, ok := OpenAICompatibleProviders[id]; ok { + p.Compat = &MiniMaxCompat + OpenAICompatibleProviders[id] = p + } + } + if p, ok := OpenAICompatibleProviders["stepfun"]; ok { + p.Compat = &StepFunCompat + OpenAICompatibleProviders["stepfun"] = p + } + if p, ok := OpenAICompatibleProviders["concentrate"]; ok { + p.Compat = &ConcentrateCompat + OpenAICompatibleProviders["concentrate"] = p + } + if p, ok := OpenAICompatibleProviders["agnes"]; ok { + p.Compat = &AgnesCompat + OpenAICompatibleProviders["agnes"] = 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 6e2691c..d3de9bc 100644 --- a/client/adapters/deepseek.go +++ b/client/adapters/deepseek.go @@ -2,69 +2,38 @@ 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 OpenAI-compatible DeepSeek endpoint. 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 { +// openAIBase is typically "https://api.deepseek.com" +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(), + 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 bb7e410..f2308cf 100644 --- a/client/adapters/deepseek_test.go +++ b/client/adapters/deepseek_test.go @@ -15,8 +15,8 @@ import ( func TestNewDeepSeekClient_OpenAIOnly(t *testing.T) { t.Parallel() - client := NewDeepSeekClient("ds-key", "https://api.deepseek.com/v1", "", &DeepSeekCompat) - if client == nil || client.router.OpenAI == nil { + client := NewDeepSeekClient("ds-key", "https://api.deepseek.com", &DeepSeekCompat) + if client == nil || client.openAI == nil { t.Fatal("expected OpenAI client") } if client.Name() != "deepseek" { @@ -38,7 +38,7 @@ func TestDeepSeekClient_ChatUsesOpenAIPath(t *testing.T) { })) defer server.Close() - client := NewDeepSeekClient("key", server.URL, "", &DeepSeekCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + 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.Fatal(err) @@ -58,7 +58,7 @@ func TestDeepSeekClient_Ping(t *testing.T) { _, _ = io.WriteString(w, `{"data":[]}`) })) defer server.Close() - client := NewDeepSeekClient("key", server.URL, "", &DeepSeekCompat, core.WithTimeout(2*time.Second)) + 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/gemini_direct.go b/client/adapters/gemini_direct.go new file mode 100644 index 0000000..6e596e2 --- /dev/null +++ b/client/adapters/gemini_direct.go @@ -0,0 +1,42 @@ +package adapters + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +// GeminiOpenAIClient uses the OpenAI-compatible Gemini endpoint +// (generativelanguage.googleapis.com openai compatibility layer). +// Distinct from the native GeminiClient which uses generateContent. +type GeminiOpenAIClient struct { + openAI *OpenAIClient +} + +// NewGeminiOpenAIClient builds a Gemini provider client over the +// OpenAI-compatible endpoint. +// openAIBase is typically "https://generativelanguage.googleapis.com/v1beta/openai". +func NewGeminiOpenAIClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *GeminiOpenAIClient { + openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + opts = append(append([]core.ClientOption{}, opts...), core.WithProviderName("gemini")) + return &GeminiOpenAIClient{ + openAI: NewOpenAIClient(apiKey, openAIBase, compat, opts...), + } +} + +func (c *GeminiOpenAIClient) Name() string { return "gemini" } + +func (c *GeminiOpenAIClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return c.openAI.Chat(ctx, messages, opts) +} + +func (c *GeminiOpenAIClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + return c.openAI.StreamChat(ctx, messages, opts) +} + +func (c *GeminiOpenAIClient) Ping(ctx context.Context) error { + return c.openAI.Ping(ctx) +} + +var _ core.Provider = (*GeminiOpenAIClient)(nil) diff --git a/client/adapters/gemini_direct_test.go b/client/adapters/gemini_direct_test.go new file mode 100644 index 0000000..00b5891 --- /dev/null +++ b/client/adapters/gemini_direct_test.go @@ -0,0 +1,65 @@ +package adapters + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestNewGeminiOpenAIClient_OpenAI(t *testing.T) { + t.Parallel() + client := NewGeminiOpenAIClient("gemini-key", "https://generativelanguage.googleapis.com/v1beta/openai", &GeminiCompat) + if client == nil || client.openAI == nil { + t.Fatal("expected OpenAI client") + } + if client.Name() != "gemini" { + t.Fatalf("Name = %q", client.Name()) + } +} + +func TestGeminiOpenAIClient_ChatUsesOpenAIPath(t *testing.T) { + t.Parallel() + 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 := NewGeminiOpenAIClient("key", server.URL, &GeminiCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "gemini-2.0-flash", MaxTokens: 16}) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.Content != "ok" { + t.Fatalf("resp = %+v", resp) + } + if !strings.Contains(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) + } +} + +func TestGeminiOpenAIClient_Ping(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + client := NewGeminiOpenAIClient("key", server.URL, &GeminiCompat, core.WithTimeout(2*time.Second)) + if err := client.Ping(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/client/adapters/grok.go b/client/adapters/grok.go new file mode 100644 index 0000000..5b158d9 --- /dev/null +++ b/client/adapters/grok.go @@ -0,0 +1,39 @@ +package adapters + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +// GrokClient uses the OpenAI-compatible xAI (Grok) endpoint. +type GrokClient struct { + openAI *OpenAIClient +} + +// NewGrokClient builds an xAI (Grok) provider client. +// openAIBase is typically "https://api.x.ai/v1". +func NewGrokClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *GrokClient { + openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + opts = append(append([]core.ClientOption{}, opts...), core.WithProviderName("grok")) + return &GrokClient{ + openAI: NewOpenAIClient(apiKey, openAIBase, compat, opts...), + } +} + +func (c *GrokClient) Name() string { return "grok" } + +func (c *GrokClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return c.openAI.Chat(ctx, messages, opts) +} + +func (c *GrokClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + return c.openAI.StreamChat(ctx, messages, opts) +} + +func (c *GrokClient) Ping(ctx context.Context) error { + return c.openAI.Ping(ctx) +} + +var _ core.Provider = (*GrokClient)(nil) diff --git a/client/adapters/grok_test.go b/client/adapters/grok_test.go new file mode 100644 index 0000000..334b3bd --- /dev/null +++ b/client/adapters/grok_test.go @@ -0,0 +1,65 @@ +package adapters + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestNewGrokClient_OpenAI(t *testing.T) { + t.Parallel() + client := NewGrokClient("grok-key", "https://api.x.ai/v1", &GrokCompat) + if client == nil || client.openAI == nil { + t.Fatal("expected OpenAI client") + } + if client.Name() != "grok" { + t.Fatalf("Name = %q", client.Name()) + } +} + +func TestGrokClient_ChatUsesOpenAIPath(t *testing.T) { + t.Parallel() + 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 := NewGrokClient("key", server.URL, &GrokCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "grok-2", MaxTokens: 16}) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.Content != "ok" { + t.Fatalf("resp = %+v", resp) + } + if !strings.Contains(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) + } +} + +func TestGrokClient_Ping(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + client := NewGrokClient("key", server.URL, &GrokCompat, core.WithTimeout(2*time.Second)) + if err := client.Ping(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/client/adapters/groq.go b/client/adapters/groq.go new file mode 100644 index 0000000..fd0c090 --- /dev/null +++ b/client/adapters/groq.go @@ -0,0 +1,39 @@ +package adapters + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +// GroqClient uses the OpenAI-compatible Groq endpoint. +type GroqClient struct { + openAI *OpenAIClient +} + +// NewGroqClient builds a Groq provider client. +// openAIBase is typically "https://api.groq.com/openai/v1". +func NewGroqClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *GroqClient { + openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + opts = append(append([]core.ClientOption{}, opts...), core.WithProviderName("groq")) + return &GroqClient{ + openAI: NewOpenAIClient(apiKey, openAIBase, compat, opts...), + } +} + +func (c *GroqClient) Name() string { return "groq" } + +func (c *GroqClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return c.openAI.Chat(ctx, messages, opts) +} + +func (c *GroqClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + return c.openAI.StreamChat(ctx, messages, opts) +} + +func (c *GroqClient) Ping(ctx context.Context) error { + return c.openAI.Ping(ctx) +} + +var _ core.Provider = (*GroqClient)(nil) diff --git a/client/adapters/groq_test.go b/client/adapters/groq_test.go new file mode 100644 index 0000000..44fd059 --- /dev/null +++ b/client/adapters/groq_test.go @@ -0,0 +1,65 @@ +package adapters + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestNewGroqClient_OpenAI(t *testing.T) { + t.Parallel() + client := NewGroqClient("groq-key", "https://api.groq.com/openai/v1", &GroqCompat) + if client == nil || client.openAI == nil { + t.Fatal("expected OpenAI client") + } + if client.Name() != "groq" { + t.Fatalf("Name = %q", client.Name()) + } +} + +func TestGroqClient_ChatUsesOpenAIPath(t *testing.T) { + t.Parallel() + 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 := NewGroqClient("key", server.URL, &GroqCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "llama-3.3-70b", MaxTokens: 16}) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.Content != "ok" { + t.Fatalf("resp = %+v", resp) + } + if !strings.Contains(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) + } +} + +func TestGroqClient_Ping(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + client := NewGroqClient("key", server.URL, &GroqCompat, core.WithTimeout(2*time.Second)) + if err := client.Ping(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/client/adapters/kimi.go b/client/adapters/kimi.go new file mode 100644 index 0000000..90405ec --- /dev/null +++ b/client/adapters/kimi.go @@ -0,0 +1,39 @@ +package adapters + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +// KimiClient uses the OpenAI-compatible Kimi (Moonshot) endpoint. +type KimiClient struct { + openAI *OpenAIClient +} + +// NewKimiClient builds a Kimi (Moonshot) provider client. +// openAIBase is typically "https://api.moonshot.ai/v1". +func NewKimiClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *KimiClient { + openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + opts = append(append([]core.ClientOption{}, opts...), core.WithProviderName("kimi")) + return &KimiClient{ + openAI: NewOpenAIClient(apiKey, openAIBase, compat, opts...), + } +} + +func (c *KimiClient) Name() string { return "kimi" } + +func (c *KimiClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return c.openAI.Chat(ctx, messages, opts) +} + +func (c *KimiClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + return c.openAI.StreamChat(ctx, messages, opts) +} + +func (c *KimiClient) Ping(ctx context.Context) error { + return c.openAI.Ping(ctx) +} + +var _ core.Provider = (*KimiClient)(nil) diff --git a/client/adapters/kimi_test.go b/client/adapters/kimi_test.go new file mode 100644 index 0000000..02a9b40 --- /dev/null +++ b/client/adapters/kimi_test.go @@ -0,0 +1,65 @@ +package adapters + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestNewKimiClient_OpenAI(t *testing.T) { + t.Parallel() + client := NewKimiClient("kimi-key", "https://api.moonshot.ai/v1", &KimiCompat) + if client == nil || client.openAI == nil { + t.Fatal("expected OpenAI client") + } + if client.Name() != "kimi" { + t.Fatalf("Name = %q", client.Name()) + } +} + +func TestKimiClient_ChatUsesOpenAIPath(t *testing.T) { + t.Parallel() + 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 := NewKimiClient("key", server.URL, &KimiCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "kimi-k2", MaxTokens: 16}) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.Content != "ok" { + t.Fatalf("resp = %+v", resp) + } + if !strings.Contains(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) + } +} + +func TestKimiClient_Ping(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + client := NewKimiClient("key", server.URL, &KimiCompat, core.WithTimeout(2*time.Second)) + if err := client.Ping(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/client/adapters/longcat.go b/client/adapters/longcat.go new file mode 100644 index 0000000..9f5afcf --- /dev/null +++ b/client/adapters/longcat.go @@ -0,0 +1,112 @@ +package adapters + +import ( + "context" + "errors" + "log/slog" + "net/http" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" + + "github.com/GrayCodeAI/eyrie/types" +) + +// LongCatClient uses the OpenAI-compatible LongCat endpoint first, with +// Anthropic-compatible fallback on retriable errors. Both protocols are +// documented at https://longcat.chat/platform/docs/api/chat and +// https://longcat.chat/platform/docs/api/messages. +type LongCatClient struct { + router ProtocolRouter + logger *slog.Logger +} + +// NewLongCatClient builds a LongCat dual-protocol client. +// openAIBase should be "https://api.longcat.chat/openai/v1". +// anthropicBase should be "https://api.longcat.chat/anthropic". +// The same apiKey is used for both sides. +func NewLongCatClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *LongCatClient { + openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + anthropicBase = strings.TrimRight(strings.TrimSpace(anthropicBase), "/") + + lcOpts := append([]core.ClientOption{core.WithProviderName("longcat")}, opts...) + o := NewOpenAIClient(apiKey, openAIBase, compat, lcOpts...) + + var a *AnthropicClient + if anthropicBase != "" { + a = NewAnthropicClient(apiKey, anthropicBase, lcOpts...) + } + + return &LongCatClient{ + router: ProtocolRouter{OpenAI: o, Anthropic: a}, + logger: slog.Default(), + } +} + +func (c *LongCatClient) Name() string { + if c.router.OpenAI != nil { + return c.router.OpenAI.Name() + } + return "longcat" +} + +func (c *LongCatClient) 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 && longcatFallbackChatError(err) { + c.logger.Info("LongCat: OpenAI endpoint failed; retrying via Anthropic compatibility", + "error", err) + return true + } + return false + }) +} + +func (c *LongCatClient) 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 && longcatFallbackChatError(err) { + c.logger.Info("LongCat: OpenAI stream failed; retrying via Anthropic compatibility", + "error", err) + return true + } + return false + }, + }) +} + +func (c *LongCatClient) Ping(ctx context.Context) error { + if err := c.router.OpenAI.Ping(ctx); err == nil { + return nil + } else if c.router.Anthropic == nil || !longcatRetryableChatError(err) { + return err + } + return c.router.Anthropic.Ping(ctx) +} + +func longcatFallbackChatError(err error) bool { + if longcatRetryableChatError(err) { + return true + } + if err == nil { + return false + } + return oaCompatUnsupportedError(err) +} + +func longcatRetryableChatError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + if n := parseHTTPStatusFromError(msg); n > 0 { + return n >= 500 || n == http.StatusUnauthorized || n == http.StatusForbidden + } + var eyrieErr *core.EyrieError + if errors.As(err, &eyrieErr) { + return eyrieErr.IsRetriable() + } + return types.IsTransient(err) +} + +var _ core.Provider = (*LongCatClient)(nil) diff --git a/client/adapters/longcat_test.go b/client/adapters/longcat_test.go new file mode 100644 index 0000000..249911e --- /dev/null +++ b/client/adapters/longcat_test.go @@ -0,0 +1,105 @@ +package adapters + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestNewLongCatClient_Dual(t *testing.T) { + t.Parallel() + client := NewLongCatClient("lc-key", "https://api.longcat.chat/openai/v1", "https://api.longcat.chat/anthropic", &LongCatCompat) + if client == nil || client.router.OpenAI == nil || client.router.Anthropic == nil { + t.Fatalf("expected dual OpenAI + Anthropic clients, got %+v", client.router) + } + if client.Name() != "longcat" { + t.Fatalf("Name = %q", client.Name()) + } +} + +func TestLongCatClient_ChatUsesOpenAIPath(t *testing.T) { + t.Parallel() + 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 := NewLongCatClient("key", server.URL, "", &LongCatCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "LongCat-2.0", MaxTokens: 16}) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.Content != "ok" { + t.Fatalf("resp = %+v", resp) + } + if !strings.Contains(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) + } +} + +func TestLongCatClient_FallsBackToAnthropic(t *testing.T) { + t.Parallel() + var openAIHits, anthropicHits int32 + + openAI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&openAIHits, 1) + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"error":{"message":"upstream failure","type":"server_error","code":500}}`) + })) + defer openAI.Close() + + anthropic := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&anthropicHits, 1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "msg_1", + "type": "message", + "role": "assistant", + "content": []map[string]string{{"type": "text", "text": "recovered via anthropic"}}, + "model": "LongCat-2.0", + "stop_reason": "end_turn", + "usage": map[string]int{"input_tokens": 10, "output_tokens": 8}, + }) + })) + defer anthropic.Close() + + client := NewLongCatClient("key", openAI.URL, anthropic.URL, &LongCatCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "LongCat-2.0", MaxTokens: 16}) + if err != nil { + t.Fatal(err) + } + if atomic.LoadInt32(&openAIHits) == 0 || atomic.LoadInt32(&anthropicHits) == 0 { + t.Fatalf("expected both protocols hit: openAI=%d anthropic=%d", openAIHits, anthropicHits) + } + if resp == nil || resp.Content != "recovered via anthropic" { + t.Fatalf("resp = %+v, want anthropic content", resp) + } +} + +func TestLongCatClient_Ping(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + client := NewLongCatClient("key", server.URL, "", &LongCatCompat, 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 93cda77..7012ef9 100644 --- a/client/adapters/mimo.go +++ b/client/adapters/mimo.go @@ -2,117 +2,47 @@ 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 OpenAI-compatible MiMo endpoint. 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 { +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() - } - return c.providerID -} +func (c *MiMoClient) Name() string { return c.openAI.Name() } 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) -} - -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")) + return c.openAI.Ping(ctx) } -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() - } - // Conservative: only retry on explicitly transient errors (not the optimistic "unknown → true") - return types.IsTransient(err) -} +var _ core.Provider = (*MiMoClient)(nil) +// parseHTTPStatusFromError extracts an HTTP status code from an error message. func parseHTTPStatusFromError(msg string) int { for _, prefix := range []string{"HTTP ", "status ", "error ("} { if i := strings.Index(msg, prefix); i >= 0 { @@ -131,18 +61,10 @@ 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 d2d9346..3c97aad 100644 --- a/client/adapters/mimo_test.go +++ b/client/adapters/mimo_test.go @@ -8,36 +8,25 @@ import ( "github.com/GrayCodeAI/eyrie/client/core" ) -func TestMiMoClientChatFallsBackToAnthropicOnParamIncorrect(t *testing.T) { +func TestMiMoClientChatUsesOpenAIEndpoint(t *testing.T) { t.Parallel() - anthropicCalls := 0 - openAITransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + transport := 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) + t.Fatalf("path = %q", req.URL.Path) } if req.Header.Get("api-key") != "tp-test-key" { t.Fatalf("missing MiMo api-key auth header") } 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.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, @@ -48,27 +37,33 @@ 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 TestMiMoClientNoAnthropicFallback(t *testing.T) { + t.Parallel() + 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("tp-test-key", "https://openai.example/v1", &XiaomiCompat, "xiaomi_mimo_token_plan") + client.openAI.httpClient = &http.Client{Transport: transport} + _, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ + Model: "mimo-v2.5-pro", + }) + if err == nil { + t.Fatal("expected error, got nil") } } -func TestMiMoClientPreservesProtocolBaseURLs(t *testing.T) { +func TestMiMoClientPreservesOpenAIBaseURL(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" { + if got := client.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 got := client.router.Anthropic.baseURL; got != "https://anthropic.example/anthropic" { - t.Fatalf("Anthropic base URL = %q", got) - } } diff --git a/client/adapters/minimax.go b/client/adapters/minimax.go new file mode 100644 index 0000000..bc792a8 --- /dev/null +++ b/client/adapters/minimax.go @@ -0,0 +1,39 @@ +package adapters + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +// MiniMaxClient uses the OpenAI-compatible MiniMax endpoint. +type MiniMaxClient struct { + openAI *OpenAIClient +} + +// NewMiniMaxClient builds a MiniMax provider client. +// openAIBase is typically "https://api.minimax.io/v1". +func NewMiniMaxClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *MiniMaxClient { + openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + opts = append(append([]core.ClientOption{}, opts...), core.WithProviderName("minimax")) + return &MiniMaxClient{ + openAI: NewOpenAIClient(apiKey, openAIBase, compat, opts...), + } +} + +func (c *MiniMaxClient) Name() string { return "minimax" } + +func (c *MiniMaxClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return c.openAI.Chat(ctx, messages, opts) +} + +func (c *MiniMaxClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + return c.openAI.StreamChat(ctx, messages, opts) +} + +func (c *MiniMaxClient) Ping(ctx context.Context) error { + return c.openAI.Ping(ctx) +} + +var _ core.Provider = (*MiniMaxClient)(nil) diff --git a/client/adapters/minimax_test.go b/client/adapters/minimax_test.go new file mode 100644 index 0000000..4650932 --- /dev/null +++ b/client/adapters/minimax_test.go @@ -0,0 +1,65 @@ +package adapters + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestNewMiniMaxClient_OpenAI(t *testing.T) { + t.Parallel() + client := NewMiniMaxClient("mm-key", "https://api.minimax.io/v1", &MiniMaxCompat) + if client == nil || client.openAI == nil { + t.Fatal("expected OpenAI client") + } + if client.Name() != "minimax" { + t.Fatalf("Name = %q", client.Name()) + } +} + +func TestMiniMaxClient_ChatUsesOpenAIPath(t *testing.T) { + t.Parallel() + 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 := NewMiniMaxClient("key", server.URL, &MiniMaxCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "MiniMax-M1", MaxTokens: 16}) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.Content != "ok" { + t.Fatalf("resp = %+v", resp) + } + if !strings.Contains(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) + } +} + +func TestMiniMaxClient_Ping(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + client := NewMiniMaxClient("key", server.URL, &MiniMaxCompat, core.WithTimeout(2*time.Second)) + if err := client.Ping(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/client/adapters/ollama.go b/client/adapters/ollama.go new file mode 100644 index 0000000..071a062 --- /dev/null +++ b/client/adapters/ollama.go @@ -0,0 +1,39 @@ +package adapters + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +// OllamaClient uses the OpenAI-compatible local Ollama endpoint. +type OllamaClient struct { + openAI *OpenAIClient +} + +// NewOllamaClient builds an Ollama provider client. +// openAIBase is typically "http://localhost:11434/v1". +func NewOllamaClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *OllamaClient { + openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + opts = append(append([]core.ClientOption{}, opts...), core.WithProviderName("ollama")) + return &OllamaClient{ + openAI: NewOpenAIClient(apiKey, openAIBase, compat, opts...), + } +} + +func (c *OllamaClient) Name() string { return "ollama" } + +func (c *OllamaClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return c.openAI.Chat(ctx, messages, opts) +} + +func (c *OllamaClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + return c.openAI.StreamChat(ctx, messages, opts) +} + +func (c *OllamaClient) Ping(ctx context.Context) error { + return c.openAI.Ping(ctx) +} + +var _ core.Provider = (*OllamaClient)(nil) diff --git a/client/adapters/ollama_test.go b/client/adapters/ollama_test.go new file mode 100644 index 0000000..f0c6d97 --- /dev/null +++ b/client/adapters/ollama_test.go @@ -0,0 +1,65 @@ +package adapters + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestNewOllamaClient_OpenAI(t *testing.T) { + t.Parallel() + client := NewOllamaClient("", "http://localhost:11434/v1", &OllamaCompat) + if client == nil || client.openAI == nil { + t.Fatal("expected OpenAI client") + } + if client.Name() != "ollama" { + t.Fatalf("Name = %q", client.Name()) + } +} + +func TestOllamaClient_ChatUsesOpenAIPath(t *testing.T) { + t.Parallel() + 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 := NewOllamaClient("", server.URL, &OllamaCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "llama3", MaxTokens: 16}) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.Content != "ok" { + t.Fatalf("resp = %+v", resp) + } + if !strings.Contains(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) + } +} + +func TestOllamaClient_Ping(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + client := NewOllamaClient("", server.URL, &OllamaCompat, core.WithTimeout(2*time.Second)) + if err := client.Ping(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/client/adapters/openai.go b/client/adapters/openai.go index 7885fc0..a68a3f8 100644 --- a/client/adapters/openai.go +++ b/client/adapters/openai.go @@ -8,6 +8,7 @@ import ( "io" "log/slog" "net/http" + "strings" "github.com/GrayCodeAI/eyrie/client/core" "github.com/GrayCodeAI/hawk-core-contracts/llm" @@ -206,8 +207,10 @@ func openAIToolChoice(tc *core.ToolChoiceOption) interface{} { // buildRequestBase builds an OpenAI-compatible request body. // When compat is non-nil, MaxTokensField and SupportsUsageInStreaming overrides are applied. -// core.EyrieMessage.Thinking (reasoning_content from prior responses) is never forwarded into -// the wire format — providers like DeepSeek return HTTP 400 if it appears in input messages. +// For providers with RequiresReasoningPassback set, the reasoning_content captured from +// a prior assistant turn (core.EyrieMessage.Thinking) is forwarded back into assistant +// messages. DeepSeek requires this whenever that turn performed a tool call, otherwise +// it returns HTTP 400. For all other providers reasoning_content is not transmitted. func buildRequestBase(messages []core.EyrieMessage, opts core.ChatOptions, stream bool, compat *OpenAICompatConfig) openaiRequest { var msgs []map[string]interface{} for _, m := range messages { @@ -226,6 +229,12 @@ func buildRequestBase(messages []core.EyrieMessage, opts core.ChatOptions, strea continue } msg := map[string]interface{}{"role": m.Role, "content": m.Content} + // Forward the prior assistant turn's reasoning_content back to providers + // that require it (DeepSeek tool-call round-trips), as an assistant field. + if compat != nil && compat.RequiresReasoningPassback && + m.Role == "assistant" && strings.TrimSpace(m.Thinking) != "" { + msg["reasoning_content"] = m.Thinking + } // Handle ContentParts (multi-modal): takes precedence over Content/Images if len(m.ContentParts) > 0 { content := make([]map[string]interface{}, 0, len(m.ContentParts)) @@ -449,14 +458,11 @@ func buildRequestBase(messages []core.EyrieMessage, opts core.ChatOptions, strea 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"} - } + // OpenRouter unified reasoning object. On/off is controlled with the + // boolean "enabled" field; the docs warn NOT to send effort:"none" + // to disable because models with mandatory reasoning reject it. + // (https://openrouter.ai/docs/guides/best-practices/reasoning-tokens) + req.Reasoning = map[string]interface{}{"enabled": *thinkingEnabled} } } } diff --git a/client/adapters/openai_test.go b/client/adapters/openai_test.go index c93313b..dc1e325 100644 --- a/client/adapters/openai_test.go +++ b/client/adapters/openai_test.go @@ -494,8 +494,8 @@ func TestBuildRequestBase_OpenRouterReasoning(t *testing.T) { 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) + if reqOff.Reasoning == nil || reqOff.Reasoning["enabled"] != false { + t.Fatalf("OpenRouter off Reasoning = %v, want enabled=false", reqOff.Reasoning) } } diff --git a/client/adapters/opengateway.go b/client/adapters/opengateway.go new file mode 100644 index 0000000..abd6098 --- /dev/null +++ b/client/adapters/opengateway.go @@ -0,0 +1,39 @@ +package adapters + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +// OpenGatewayClient uses the OpenAI-compatible OpenGateway endpoint. +type OpenGatewayClient struct { + openAI *OpenAIClient +} + +// NewOpenGatewayClient builds an OpenGateway provider client. +// openAIBase is typically "https://opengateway.gitlawb.com/v1". +func NewOpenGatewayClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *OpenGatewayClient { + openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + opts = append(append([]core.ClientOption{}, opts...), core.WithProviderName("opengateway")) + return &OpenGatewayClient{ + openAI: NewOpenAIClient(apiKey, openAIBase, compat, opts...), + } +} + +func (c *OpenGatewayClient) Name() string { return "opengateway" } + +func (c *OpenGatewayClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return c.openAI.Chat(ctx, messages, opts) +} + +func (c *OpenGatewayClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + return c.openAI.StreamChat(ctx, messages, opts) +} + +func (c *OpenGatewayClient) Ping(ctx context.Context) error { + return c.openAI.Ping(ctx) +} + +var _ core.Provider = (*OpenGatewayClient)(nil) diff --git a/client/adapters/opengateway_test.go b/client/adapters/opengateway_test.go new file mode 100644 index 0000000..03dbc5c --- /dev/null +++ b/client/adapters/opengateway_test.go @@ -0,0 +1,65 @@ +package adapters + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestNewOpenGatewayClient_OpenAI(t *testing.T) { + t.Parallel() + client := NewOpenGatewayClient("og-key", "https://opengateway.gitlawb.com/v1", &OpenGatewayCompat) + if client == nil || client.openAI == nil { + t.Fatal("expected OpenAI client") + } + if client.Name() != "opengateway" { + t.Fatalf("Name = %q", client.Name()) + } +} + +func TestOpenGatewayClient_ChatUsesOpenAIPath(t *testing.T) { + t.Parallel() + 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 := NewOpenGatewayClient("key", server.URL, &OpenGatewayCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "gpt-4o", MaxTokens: 16}) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.Content != "ok" { + t.Fatalf("resp = %+v", resp) + } + if !strings.Contains(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) + } +} + +func TestOpenGatewayClient_Ping(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + client := NewOpenGatewayClient("key", server.URL, &OpenGatewayCompat, core.WithTimeout(2*time.Second)) + if err := client.Ping(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/client/adapters/openrouter.go b/client/adapters/openrouter.go new file mode 100644 index 0000000..2405550 --- /dev/null +++ b/client/adapters/openrouter.go @@ -0,0 +1,39 @@ +package adapters + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +// OpenRouterClient uses the OpenAI-compatible OpenRouter endpoint. +type OpenRouterClient struct { + openAI *OpenAIClient +} + +// NewOpenRouterClient builds an OpenRouter provider client. +// openAIBase is typically "https://openrouter.ai/api/v1". +func NewOpenRouterClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *OpenRouterClient { + openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + opts = append(append([]core.ClientOption{}, opts...), core.WithProviderName("openrouter")) + return &OpenRouterClient{ + openAI: NewOpenAIClient(apiKey, openAIBase, compat, opts...), + } +} + +func (c *OpenRouterClient) Name() string { return "openrouter" } + +func (c *OpenRouterClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return c.openAI.Chat(ctx, messages, opts) +} + +func (c *OpenRouterClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + return c.openAI.StreamChat(ctx, messages, opts) +} + +func (c *OpenRouterClient) Ping(ctx context.Context) error { + return c.openAI.Ping(ctx) +} + +var _ core.Provider = (*OpenRouterClient)(nil) diff --git a/client/adapters/openrouter_test.go b/client/adapters/openrouter_test.go new file mode 100644 index 0000000..cd6d0ce --- /dev/null +++ b/client/adapters/openrouter_test.go @@ -0,0 +1,65 @@ +package adapters + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestNewOpenRouterClient_OpenAI(t *testing.T) { + t.Parallel() + client := NewOpenRouterClient("or-key", "https://openrouter.ai/api/v1", &OpenRouterCompat) + if client == nil || client.openAI == nil { + t.Fatal("expected OpenAI client") + } + if client.Name() != "openrouter" { + t.Fatalf("Name = %q", client.Name()) + } +} + +func TestOpenRouterClient_ChatUsesOpenAIPath(t *testing.T) { + t.Parallel() + 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 := NewOpenRouterClient("key", server.URL, &OpenRouterCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "anthropic/claude", MaxTokens: 16}) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.Content != "ok" { + t.Fatalf("resp = %+v", resp) + } + if !strings.Contains(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) + } +} + +func TestOpenRouterClient_Ping(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + client := NewOpenRouterClient("key", server.URL, &OpenRouterCompat, core.WithTimeout(2*time.Second)) + if err := client.Ping(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/client/adapters/provider_registry.go b/client/adapters/provider_registry.go index 2061150..721f7da 100644 --- a/client/adapters/provider_registry.go +++ b/client/adapters/provider_registry.go @@ -90,7 +90,7 @@ func DetectProvider() string { "opencodego": func() bool { return credentials.HasSecret(ctx, "OPENCODEGO_API_KEY") }, "kimi": func() bool { return credentials.HasSecret(ctx, "MOONSHOT_API_KEY") }, "xiaomi_mimo_payg": func() bool { - return credentials.HasSecret(ctx, config.EnvXiaomiPaygAPIKey) || credentials.HasSecret(ctx, "XIAOMI_MIMO_API_KEY") + return credentials.HasSecret(ctx, config.EnvXiaomiPaygAPIKey) }, "xiaomi_mimo_token_plan": func() bool { return credentials.HasSecret(ctx, config.EnvXiaomiTokenPlanAPIKey) diff --git a/client/adapters/stepfun.go b/client/adapters/stepfun.go new file mode 100644 index 0000000..645190d --- /dev/null +++ b/client/adapters/stepfun.go @@ -0,0 +1,40 @@ +package adapters + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +// StepFunClient uses the OpenAI-compatible StepFun endpoint. +// Official docs: https://platform.stepfun.com/docs/en/api-reference +type StepFunClient struct { + openAI *OpenAIClient +} + +// NewStepFunClient builds a StepFun provider client. +// openAIBase is typically "https://api.stepfun.ai/v1". +func NewStepFunClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *StepFunClient { + openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + sfOpts := append(append([]core.ClientOption{}, opts...), core.WithProviderName("stepfun")) + return &StepFunClient{ + openAI: NewOpenAIClient(apiKey, openAIBase, compat, sfOpts...), + } +} + +func (c *StepFunClient) Name() string { return "stepfun" } + +func (c *StepFunClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return c.openAI.Chat(ctx, messages, opts) +} + +func (c *StepFunClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + return c.openAI.StreamChat(ctx, messages, opts) +} + +func (c *StepFunClient) Ping(ctx context.Context) error { + return c.openAI.Ping(ctx) +} + +var _ core.Provider = (*StepFunClient)(nil) diff --git a/client/adapters/stepfun_test.go b/client/adapters/stepfun_test.go new file mode 100644 index 0000000..4969a62 --- /dev/null +++ b/client/adapters/stepfun_test.go @@ -0,0 +1,65 @@ +package adapters + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestNewStepFunClient_OpenAI(t *testing.T) { + t.Parallel() + client := NewStepFunClient("stepfun-key", "https://api.stepfun.ai/v1", &StepFunCompat) + if client == nil || client.openAI == nil { + t.Fatal("expected OpenAI client") + } + if client.Name() != "stepfun" { + t.Fatalf("Name = %q", client.Name()) + } +} + +func TestStepFunClient_ChatUsesOpenAIPath(t *testing.T) { + t.Parallel() + 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 := NewStepFunClient("key", server.URL, &StepFunCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "step-3.5-flash", MaxTokens: 16}) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.Content != "ok" { + t.Fatalf("resp = %+v", resp) + } + if !strings.Contains(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) + } +} + +func TestStepFunClient_Ping(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + client := NewStepFunClient("key", server.URL, &StepFunCompat, 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 5d6ba58..3c52a7c 100644 --- a/client/aliases.go +++ b/client/aliases.go @@ -229,7 +229,6 @@ var ( emit = core.Emit parseImageString = core.ParseImageString applyGuardrails = core.ApplyGuardrails - isRetriableError = core.IsRetriableError ) // NewStreamResult creates a StreamResult with a cancel function for resource @@ -262,6 +261,8 @@ type ( OpenAIClient = adapters.OpenAIClient // GeminiClient implements Provider for the Google Gemini API. GeminiClient = adapters.GeminiClient + // GeminiOpenAIClient implements Provider for the OpenAI-compatible Gemini endpoint. + GeminiOpenAIClient = adapters.GeminiOpenAIClient // AzureClient implements Provider for the Azure OpenAI API. AzureClient = adapters.AzureClient // BedrockClient implements Provider for the AWS Bedrock API. @@ -274,6 +275,30 @@ type ( ZAIClient = adapters.ZAIClient // MiMoClient implements Provider for the Xiaomi MiMo API. MiMoClient = adapters.MiMoClient + // AgnesClient implements Provider for the Agnes AI API. + AgnesClient = adapters.AgnesClient + // StepFunClient implements Provider for the StepFun API. + StepFunClient = adapters.StepFunClient + // LongCatClient implements Provider for the LongCat API. + LongCatClient = adapters.LongCatClient + // GrokClient implements Provider for the xAI (Grok) API. + GrokClient = adapters.GrokClient + // OpenRouterClient implements Provider for the OpenRouter API. + OpenRouterClient = adapters.OpenRouterClient + // CanopyWaveClient implements Provider for the CanopyWave API. + CanopyWaveClient = adapters.CanopyWaveClient + // OpenGatewayClient implements Provider for the OpenGateway API. + OpenGatewayClient = adapters.OpenGatewayClient + // GroqClient implements Provider for the Groq API. + GroqClient = adapters.GroqClient + // ClinePassClient implements Provider for the ClinePass API. + ClinePassClient = adapters.ClinePassClient + // OllamaClient implements Provider for the Ollama API. + OllamaClient = adapters.OllamaClient + // KimiClient implements Provider for the Kimi (Moonshot) API. + KimiClient = adapters.KimiClient + // MiniMaxClient implements Provider for the MiniMax API. + MiniMaxClient = adapters.MiniMaxClient // ConcentrateResponsesClient implements Provider for the Concentrate Responses API. ConcentrateResponsesClient = adapters.ConcentrateResponsesClient // OpenCodeGoClient implements Provider for the OpenCode Go API. @@ -311,6 +336,10 @@ func NewGeminiClient(apiKey, baseURL string) *GeminiClient { return adapters.NewGeminiClient(apiKey, baseURL) } +func NewGeminiOpenAIClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...ClientOption) *GeminiOpenAIClient { + return adapters.NewGeminiOpenAIClient(apiKey, openAIBase, compat, opts...) +} + func NewAzureClient(apiKey, endpoint, apiVersion string) *AzureClient { return adapters.NewAzureClient(apiKey, endpoint, apiVersion) } @@ -323,16 +352,64 @@ 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 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 NewAgnesClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...ClientOption) *AgnesClient { + return adapters.NewAgnesClient(apiKey, openAIBase, compat, opts...) +} + +func NewStepFunClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...ClientOption) *StepFunClient { + return adapters.NewStepFunClient(apiKey, openAIBase, compat, opts...) +} + +func NewLongCatClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, opts ...ClientOption) *LongCatClient { + return adapters.NewLongCatClient(apiKey, openAIBase, anthropicBase, compat, opts...) +} + +func NewGrokClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...ClientOption) *GrokClient { + return adapters.NewGrokClient(apiKey, openAIBase, compat, opts...) +} + +func NewOpenRouterClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...ClientOption) *OpenRouterClient { + return adapters.NewOpenRouterClient(apiKey, openAIBase, compat, opts...) +} + +func NewCanopyWaveClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...ClientOption) *CanopyWaveClient { + return adapters.NewCanopyWaveClient(apiKey, openAIBase, compat, opts...) +} + +func NewOpenGatewayClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...ClientOption) *OpenGatewayClient { + return adapters.NewOpenGatewayClient(apiKey, openAIBase, compat, opts...) +} + +func NewGroqClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...ClientOption) *GroqClient { + return adapters.NewGroqClient(apiKey, openAIBase, compat, opts...) +} + +func NewClinePassClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...ClientOption) *ClinePassClient { + return adapters.NewClinePassClient(apiKey, openAIBase, compat, opts...) +} + +func NewOllamaClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...ClientOption) *OllamaClient { + return adapters.NewOllamaClient(apiKey, openAIBase, compat, opts...) +} + +func NewKimiClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...ClientOption) *KimiClient { + return adapters.NewKimiClient(apiKey, openAIBase, compat, opts...) +} + +func NewMiniMaxClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...ClientOption) *MiniMaxClient { + return adapters.NewMiniMaxClient(apiKey, openAIBase, compat, opts...) } func NewConcentrateResponsesClient(apiKey, baseURL string, opts ...ClientOption) *ConcentrateResponsesClient { @@ -386,8 +463,6 @@ var ( dynamicProviderEnvVar = adapters.DynamicProviderEnvVar geminiSharedParserEnvVar = adapters.GeminiSharedParserEnvVar processGeminiStream = adapters.ProcessGeminiStream - mimoRetryableChatError = adapters.MimoRetryableChatError - mimoFallbackChatError = adapters.MimoFallbackChatError oaCompatUnsupportedError = adapters.OACompatUnsupportedError CoreProviders = adapters.CoreProviders OpenAICompatibleProviders = adapters.OpenAICompatibleProviders diff --git a/client/anthropic_features_test.go b/client/anthropic_features_test.go index fb64c3e..ce6f8d8 100644 --- a/client/anthropic_features_test.go +++ b/client/anthropic_features_test.go @@ -516,8 +516,10 @@ func TestResolveThinking_Modes(t *testing.T) { }{ {"adaptive", ChatOptions{ThinkingMode: "adaptive"}, "adaptive", false}, {"disabled", ChatOptions{ThinkingMode: "disabled"}, "disabled", false}, - {"enabled with budget", ChatOptions{ThinkingMode: "enabled", ThinkingBudgetTokens: 10000}, "enabled", false}, - {"enabled zero budget", ChatOptions{ThinkingMode: "enabled"}, "", true}, + // Explicit "enabled" maps to adaptive (type:enabled+budget_tokens is + // deprecated on Claude 4.6 and rejected on 4.7+). + {"enabled with budget", ChatOptions{ThinkingMode: "enabled", ThinkingBudgetTokens: 10000}, "adaptive", false}, + {"enabled zero budget", ChatOptions{ThinkingMode: "enabled"}, "adaptive", false}, {"legacy budget", ChatOptions{ThinkingBudgetTokens: 5000}, "enabled", false}, {"legacy zero", ChatOptions{}, "", true}, } @@ -542,9 +544,11 @@ func TestResolveThinking_Modes(t *testing.T) { func TestResolveThinking_Display(t *testing.T) { t.Parallel() - got := resolveThinking(ChatOptions{ThinkingMode: "enabled", ThinkingBudgetTokens: 5000, ThinkingDisplay: "omitted"}) - if got == nil || got.Display != "omitted" { - t.Fatalf("expected display=omitted, got %+v", got) + // Display is honored on the legacy fixed-budget path (type:enabled with + // budget_tokens), which is the only path still carrying a fixed budget. + got := resolveThinking(ChatOptions{ThinkingBudgetTokens: 5000, ThinkingDisplay: "omitted"}) + if got == nil || got.Type != "enabled" || got.Display != "omitted" { + t.Fatalf("expected enabled with display=omitted, got %+v", got) } } diff --git a/client/coalesce_test.go b/client/coalesce_test.go index f260f87..f2a4e0d 100644 --- a/client/coalesce_test.go +++ b/client/coalesce_test.go @@ -290,31 +290,39 @@ func TestCoalesceStats(t *testing.T) { }() } - // Small delay, should see one inflight request - time.Sleep(10 * time.Millisecond) - stats = coalescer.Stats() - if stats.InflightRequests != 1 { - t.Errorf("Expected 1 inflight request, got %d", stats.InflightRequests) - } - // One waiter is the executing goroutine, two more should be waiting - if stats.TotalWaiters != 3 { - t.Errorf("Expected 3 total waiters, got %d", stats.TotalWaiters) - } + // Small delay, should see one inflight request with all three waiters + waitForStats(t, coalescer, func(s InflightStats) bool { + return s.InflightRequests == 1 && s.TotalWaiters == 3 + }) wg.Wait() // After completion, should still have one cached entry - time.Sleep(10 * time.Millisecond) - stats = coalescer.Stats() - if stats.InflightRequests != 1 { - t.Errorf("Expected 1 cached request after completion, got %d", stats.InflightRequests) - } + waitForStats(t, coalescer, func(s InflightStats) bool { + return s.InflightRequests == 1 + }) // After TTL expires, entry should be cleaned up - time.Sleep(200 * time.Millisecond) - stats = coalescer.Stats() - if stats.InflightRequests != 0 { - t.Errorf("Expected 0 inflight requests after TTL, got %d", stats.InflightRequests) + waitForStats(t, coalescer, func(s InflightStats) bool { + return s.InflightRequests == 0 + }) +} + +// waitForStats polls the coalescer until pred is satisfied or the deadline +// elapses. Avoids wall-clock timing flakiness under the race detector and +// heavily loaded CI runners. +func waitForStats(t *testing.T, c *Coalescer, pred func(InflightStats) bool) InflightStats { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + s := c.Stats() + if pred(s) { + return s + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for coalescing stats, last=%+v", s) + } + time.Sleep(5 * time.Millisecond) } } diff --git a/client/compat.go b/client/compat.go index b0de4e8..44ba29e 100644 --- a/client/compat.go +++ b/client/compat.go @@ -27,5 +27,6 @@ var ( DeepSeekCompat = adapters.DeepSeekCompat AgnesCompat = adapters.AgnesCompat LongCatCompat = adapters.LongCatCompat + StepFunCompat = adapters.StepFunCompat MiniMaxCompat = adapters.MiniMaxCompat ) diff --git a/client/compat_test.go b/client/compat_test.go index 2d070c0..04768f8 100644 --- a/client/compat_test.go +++ b/client/compat_test.go @@ -1,8 +1,6 @@ package client import ( - "context" - "fmt" "testing" ) @@ -294,222 +292,3 @@ func TestCompatSupportsCaseInsensitiveFeatureNames(t *testing.T) { t.Error("feature name lookup should be case-insensitive") } } - -// --------------------------------------------------------------------------- -// 4. Fallback behavior -// --------------------------------------------------------------------------- - -func TestCompatFallbackChainOrder(t *testing.T) { - t.Parallel() - p1 := NewMockProvider(MockModeError) - p2 := NewMockProvider(MockModeError) - p3 := NewMockProvider(MockModeFixed) - p3.Response = "from third" - - fp, _ := NewFallbackProvider(p1, p2, p3) - resp, err := fp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp.Content != "from third" { - t.Errorf("expected 'from third', got %q", resp.Content) - } - if p1.CallCount() != 1 || p2.CallCount() != 1 || p3.CallCount() != 1 { - t.Errorf("expected each provider called once: p1=%d p2=%d p3=%d", - p1.CallCount(), p2.CallCount(), p3.CallCount()) - } -} - -func TestCompatFallbackStopsOnFirstSuccess(t *testing.T) { - t.Parallel() - p1 := NewMockProvider(MockModeFixed) - p1.Response = "first" - p2 := NewMockProvider(MockModeFixed) - p2.Response = "second" - - fp, _ := NewFallbackProvider(p1, p2) - resp, err := fp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp.Content != "first" { - t.Errorf("expected 'first', got %q", resp.Content) - } - if p2.CallCount() != 0 { - t.Errorf("second provider should not be called, got %d", p2.CallCount()) - } -} - -func TestCompatFallbackNonRetriableStopsChain(t *testing.T) { - t.Parallel() - // 400 errors are non-retriable; fallback should not proceed. - p1 := &errorProvider{err: fmt.Errorf("HTTP 400 bad request")} - p2 := NewMockProvider(MockModeFixed) - p2.Response = "should not reach" - - fp, _ := NewFallbackProvider(p1, p2) - _, err := fp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err == nil { - t.Fatal("expected error for 400") - } - if p2.CallCount() != 0 { - t.Errorf("p2 should not be called for non-retriable error, got %d", p2.CallCount()) - } -} - -func TestCompatFallbackRetriableContinuesChain(t *testing.T) { - t.Parallel() - retriableStatuses := []int{429, 500, 502, 503} - for _, code := range retriableStatuses { - t.Run(fmt.Sprintf("HTTP_%d", code), func(t *testing.T) { - p1 := &errorProvider{err: fmt.Errorf("HTTP %d from provider", code)} - p2 := NewMockProvider(MockModeFixed) - p2.Response = "fallback" - p2.Reset() - - fp, _ := NewFallbackProvider(p1, p2) - resp, err := fp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err != nil { - t.Fatalf("unexpected error for HTTP %d: %v", code, err) - } - if resp.Content != "fallback" { - t.Errorf("expected 'fallback', got %q", resp.Content) - } - if p2.CallCount() != 1 { - t.Errorf("p2 should be called once, got %d", p2.CallCount()) - } - }) - } -} - -func TestCompatFallbackStreamFallsBack(t *testing.T) { - t.Parallel() - p1 := NewMockProvider(MockModeError) - p2 := NewMockProvider(MockModeFixed) - p2.Response = "streamed" - - fp, _ := NewFallbackProvider(p1, p2) - sr, err := fp.StreamChat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - defer sr.Close() - - var content string - for evt := range sr.Events { - if evt.Type == "content" { - content += evt.Content - } - } - if content == "" { - t.Error("expected streamed content from fallback") - } -} - -func TestCompatFallbackStatsTrackSuccesses(t *testing.T) { - t.Parallel() - p1 := NewMockProvider(MockModeError) - p2 := NewMockProvider(MockModeFixed) - p2.Response = "ok" - - fp, _ := NewFallbackProvider(p1, p2) - for i := 0; i < 3; i++ { - _, err := fp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - } - - stats := fp.Stats() - if stats["mock"] != 3 { - t.Errorf("expected 3 successes, got %d", stats["mock"]) - } -} - -func TestCompatFallbackNameFormat(t *testing.T) { - t.Parallel() - p1 := NewMockProvider(MockModeFixed) - p2 := NewMockProvider(MockModeFixed) - p3 := NewMockProvider(MockModeFixed) - - fp, _ := NewFallbackProvider(p1, p2, p3) - want := "fallback(mock->mock->mock)" - if fp.Name() != want { - t.Errorf("Name() = %q, want %q", fp.Name(), want) - } -} - -func TestCompatFallbackPingChainSucceedsOnFirst(t *testing.T) { - t.Parallel() - p1 := NewMockProvider(MockModeFixed) - p2 := NewMockProvider(MockModeFixed) - - fp, _ := NewFallbackProvider(p1, p2) - if err := fp.Ping(context.Background()); err != nil { - t.Fatalf("ping failed: %v", err) - } -} - -func TestCompatFallbackPingChainFallsBack(t *testing.T) { - t.Parallel() - p1 := &errorProvider{err: fmt.Errorf("ping failed")} - p2 := NewMockProvider(MockModeFixed) - - fp, _ := NewFallbackProvider(p1, p2) - if err := fp.Ping(context.Background()); err != nil { - t.Fatalf("expected ping to succeed on second provider, got: %v", err) - } -} - -func TestCompatFallbackPingAllFail(t *testing.T) { - t.Parallel() - p1 := &errorProvider{err: fmt.Errorf("fail 1")} - p2 := &errorProvider{err: fmt.Errorf("fail 2")} - - fp, _ := NewFallbackProvider(p1, p2) - if err := fp.Ping(context.Background()); err == nil { - t.Error("expected error when all providers fail ping") - } -} - -func TestCompatFallbackContextCancellation(t *testing.T) { - t.Parallel() - p1 := NewMockProvider(MockModeFixed) - p1.Response = "ok" - p1.Delay = 5_000_000_000 // 5 seconds - - fp, _ := NewFallbackProvider(p1) - - ctx, cancel := context.WithTimeout(context.Background(), 50_000_000) // 50ms - defer cancel() - - _, err := fp.Chat(ctx, []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err == nil { - t.Fatal("expected error from cancelled context") - } -} - -func TestCompatFallbackErrorWithNoProviders(t *testing.T) { - t.Parallel() - fp, err := NewFallbackProvider() - if err == nil { - t.Error("expected error from NewFallbackProvider with no providers") - } - if fp != nil { - t.Error("expected nil provider from NewFallbackProvider with no providers") - } -} diff --git a/client/errors_test.go b/client/errors_test.go index b827a74..5a65dca 100644 --- a/client/errors_test.go +++ b/client/errors_test.go @@ -220,39 +220,6 @@ func TestAnthropicToolCallParsing(t *testing.T) { } } -func TestFallbackProviderIntegration(t *testing.T) { - t.Parallel() - // First provider always fails - failServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(500) - fmt.Fprint(w, `{"error":{"type":"server_error","message":"down"}}`) - })) - defer failServer.Close() - - // Second provider succeeds - okServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(map[string]interface{}{ - "id": "msg_ok", "content": []map[string]interface{}{{"type": "text", "text": "from fallback"}}, - "stop_reason": "end_turn", "usage": map[string]int{"input_tokens": 1, "output_tokens": 1}, - }) - })) - defer okServer.Close() - - primary := NewAnthropicClient("key1", failServer.URL, WithRetry(NewRetryConfig(0, 0, 0))) - secondary := NewAnthropicClient("key2", okServer.URL, WithRetry(NewRetryConfig(0, 0, 0))) - fb, _ := NewFallbackProvider(primary, secondary) - - resp, err := fb.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "Hi"}, - }, ChatOptions{Model: "claude-sonnet-4-6"}) - if err != nil { - t.Fatalf("expected fallback to succeed, got: %v", err) - } - if resp.Content != "from fallback" { - t.Errorf("expected 'from fallback', got %s", resp.Content) - } -} - func TestStreamParsingEdgeCases(t *testing.T) { t.Parallel() t.Run("empty events ignored", func(t *testing.T) { diff --git a/client/fallback.go b/client/fallback.go deleted file mode 100644 index bb2aba1..0000000 --- a/client/fallback.go +++ /dev/null @@ -1,224 +0,0 @@ -package client - -import ( - "context" - "fmt" - "log/slog" - "os" - "strings" - "sync" - "sync/atomic" - "time" -) - -// FallbackProvider wraps multiple Providers and automatically falls back to the -// next one when the current provider returns a retriable error (429, 500, 502, -// 503, timeout). It does NOT fall back on client errors (400, 401, 403) because -// those indicate a problem with the request itself, not the provider. -// -// Inspired by BerriAI/litellm's fallback chain feature. -// -// FallbackProvider is safe for concurrent use. -type FallbackProvider struct { - providers []Provider - logger *slog.Logger - - // PerProviderTimeout bounds each individual provider attempt. Zero means - // no per-provider timeout (the caller's context is the only deadline). - PerProviderTimeout time.Duration - - // stats tracks how many times each provider served a request. - mu sync.RWMutex - stats map[string]*atomic.Int64 -} - -// Compile-time check that FallbackProvider implements Provider. -var _ Provider = (*FallbackProvider)(nil) - -// NewFallbackProvider creates a FallbackProvider that tries providers in order. -// At least one provider must be supplied. -func NewFallbackProvider(providers ...Provider) (*FallbackProvider, error) { - if len(providers) == 0 { - return nil, fmt.Errorf("eyrie: FallbackProvider requires at least one provider") - } - stats := make(map[string]*atomic.Int64, len(providers)) - for _, p := range providers { - if _, ok := stats[p.Name()]; !ok { - stats[p.Name()] = &atomic.Int64{} - } - } - return &FallbackProvider{ - providers: providers, - logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})), - stats: stats, - }, nil -} - -// SetLogger sets a custom logger for the FallbackProvider. -func (fp *FallbackProvider) SetLogger(l *slog.Logger) { - fp.mu.Lock() - fp.logger = l - fp.mu.Unlock() -} - -// Name returns a composite name listing all providers in the chain. -func (fp *FallbackProvider) Name() string { - names := make([]string, len(fp.providers)) - for i, p := range fp.providers { - names[i] = p.Name() - } - return "fallback(" + strings.Join(names, "->") + ")" -} - -// Ping tries to ping each provider in order, returning nil on the first success. -func (fp *FallbackProvider) Ping(ctx context.Context) error { - var lastErr error - for _, p := range fp.providers { - if err := ctx.Err(); err != nil { - return err - } - if err := p.Ping(ctx); err != nil { - lastErr = err - continue - } - return nil - } - return fmt.Errorf("eyrie: all providers failed ping: %w", lastErr) -} - -// attemptCtx returns a context bounded by PerProviderTimeout if configured, -// otherwise the original context unchanged. -func (fp *FallbackProvider) attemptCtx(ctx context.Context) (context.Context, context.CancelFunc) { - if fp.PerProviderTimeout > 0 { - return context.WithTimeout(ctx, fp.PerProviderTimeout) - } - return ctx, func() {} -} - -// Chat sends a non-streaming chat request, falling back through the provider -// chain on retriable errors. Returns the first successful response. -func (fp *FallbackProvider) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { - var lastErr error - - for i, p := range fp.providers { - if err := ctx.Err(); err != nil { - return nil, err - } - - fp.logger.Debug( - "fallback: trying provider", - "provider", p.Name(), - "index", i, - "total", len(fp.providers), - ) - - attemptCtx, cancel := fp.attemptCtx(ctx) - resp, err := p.Chat(attemptCtx, messages, opts) - cancel() - if err == nil { - fp.recordSuccess(p.Name()) - fp.logger.Debug( - "fallback: provider succeeded", - "provider", p.Name(), - "index", i, - ) - return resp, nil - } - - // Check if the error is retriable; if not, return immediately. - if !isRetriableError(err) { - fp.logger.Warn( - "fallback: non-retriable error, not falling back", - "provider", p.Name(), - "error", err, - ) - return nil, err - } - - fp.logger.Warn( - "fallback: provider failed, trying next", - "provider", p.Name(), - "index", i, - "error", err, - ) - lastErr = err - } - - return nil, fmt.Errorf("eyrie: all %d fallback providers failed: %w", len(fp.providers), lastErr) -} - -// StreamChat sends a streaming chat request, falling back through the provider -// chain on retriable errors. Returns the first successful stream. -func (fp *FallbackProvider) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { - var lastErr error - - for i, p := range fp.providers { - if err := ctx.Err(); err != nil { - return nil, err - } - - fp.logger.Debug( - "fallback: trying provider for stream", - "provider", p.Name(), - "index", i, - "total", len(fp.providers), - ) - - // Note: PerProviderTimeout is not applied to StreamChat because the - // stream is long-lived; the caller's context governs its lifetime. - // The timeout only guards the initial connection attempt via the - // provider's internal dial/connect behavior. - sr, err := p.StreamChat(ctx, messages, opts) - if err == nil { - fp.recordSuccess(p.Name()) - fp.logger.Debug( - "fallback: provider stream succeeded", - "provider", p.Name(), - "index", i, - ) - return sr, nil - } - - if !isRetriableError(err) { - fp.logger.Warn( - "fallback: non-retriable stream error, not falling back", - "provider", p.Name(), - "error", err, - ) - return nil, err - } - - fp.logger.Warn( - "fallback: provider stream failed, trying next", - "provider", p.Name(), - "index", i, - "error", err, - ) - lastErr = err - } - - return nil, fmt.Errorf("eyrie: all %d fallback providers failed streaming: %w", len(fp.providers), lastErr) -} - -// Stats returns a snapshot of how many times each provider served a request. -func (fp *FallbackProvider) Stats() map[string]int64 { - fp.mu.RLock() - defer fp.mu.RUnlock() - result := make(map[string]int64, len(fp.stats)) - for name, counter := range fp.stats { - result[name] = counter.Load() - } - return result -} - -func (fp *FallbackProvider) recordSuccess(name string) { - fp.mu.RLock() - counter, ok := fp.stats[name] - fp.mu.RUnlock() - if ok { - counter.Add(1) - } -} - -// isRetriableError is an unexported bridge to core.IsRetriableError, -// declared in aliases.go so fallback.go and weighted.go read unchanged. diff --git a/client/fallback_test.go b/client/fallback_test.go deleted file mode 100644 index 8798529..0000000 --- a/client/fallback_test.go +++ /dev/null @@ -1,332 +0,0 @@ -package client - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/GrayCodeAI/eyrie/types" -) - -func TestFallbackProviderSuccess(t *testing.T) { - t.Parallel() - primary := NewMockProvider(MockModeFixed) - primary.Response = "from primary" - - fp, _ := NewFallbackProvider(primary) - resp, err := fp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp.Content != "from primary" { - t.Errorf("expected 'from primary', got %q", resp.Content) - } -} - -func TestFallbackProviderFallsBack(t *testing.T) { - t.Parallel() - // Primary always errors (retriable). - primary := NewMockProvider(MockModeError) - // Secondary succeeds. - secondary := NewMockProvider(MockModeFixed) - secondary.Response = "from secondary" - - fp, _ := NewFallbackProvider(primary, secondary) - resp, err := fp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp.Content != "from secondary" { - t.Errorf("expected 'from secondary', got %q", resp.Content) - } - if primary.CallCount() != 1 { - t.Errorf("expected primary to be called once, got %d", primary.CallCount()) - } - if secondary.CallCount() != 1 { - t.Errorf("expected secondary to be called once, got %d", secondary.CallCount()) - } -} - -func TestFallbackProviderAllFail(t *testing.T) { - t.Parallel() - p1 := NewMockProvider(MockModeError) - p2 := NewMockProvider(MockModeError) - p3 := NewMockProvider(MockModeError) - - fp, _ := NewFallbackProvider(p1, p2, p3) - _, err := fp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err == nil { - t.Fatal("expected error when all providers fail") - } - if p1.CallCount() != 1 || p2.CallCount() != 1 || p3.CallCount() != 1 { - t.Errorf("expected each provider called once: p1=%d p2=%d p3=%d", - p1.CallCount(), p2.CallCount(), p3.CallCount()) - } -} - -func TestFallbackProviderStats(t *testing.T) { - t.Parallel() - primary := NewMockProvider(MockModeError) - secondary := NewMockProvider(MockModeFixed) - secondary.Response = "ok" - - fp, _ := NewFallbackProvider(primary, secondary) - - for i := 0; i < 5; i++ { - _, err := fp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err != nil { - t.Fatalf("unexpected error on call %d: %v", i, err) - } - } - - stats := fp.Stats() - if stats["mock"] != 5 { - t.Errorf("expected mock to have 5 successes, got %d", stats["mock"]) - } -} - -func TestFallbackProviderRespectsContextCancellation(t *testing.T) { - t.Parallel() - // A slow primary provider. - primary := NewMockProvider(MockModeFixed) - primary.Response = "slow" - primary.Delay = 5 * time.Second - - secondary := NewMockProvider(MockModeFixed) - secondary.Response = "fast" - - fp, _ := NewFallbackProvider(primary, secondary) - - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - - _, err := fp.Chat(ctx, []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err == nil { - t.Fatal("expected error from cancelled context") - } -} - -func TestFallbackProviderStreamFallback(t *testing.T) { - t.Parallel() - primary := NewMockProvider(MockModeError) - secondary := NewMockProvider(MockModeFixed) - secondary.Response = "streamed from secondary" - - fp, _ := NewFallbackProvider(primary, secondary) - - sr, err := fp.StreamChat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - defer sr.Close() - - var content string - for evt := range sr.Events { - if evt.Type == "content" { - content += evt.Content - } - } - if content == "" { - t.Error("expected some streamed content") - } -} - -func TestFallbackProviderPing(t *testing.T) { - t.Parallel() - p1 := NewMockProvider(MockModeFixed) - p2 := NewMockProvider(MockModeFixed) - - fp, _ := NewFallbackProvider(p1, p2) - if err := fp.Ping(context.Background()); err != nil { - t.Fatalf("ping failed: %v", err) - } -} - -func TestFallbackProviderName(t *testing.T) { - t.Parallel() - p1 := NewMockProvider(MockModeFixed) - p2 := NewMockProvider(MockModeFixed) - - fp, _ := NewFallbackProvider(p1, p2) - name := fp.Name() - if name != "fallback(mock->mock)" { - t.Errorf("unexpected name: %s", name) - } -} - -func TestIsRetriableError(t *testing.T) { - t.Parallel() - tests := []struct { - name string - err error - retriable bool - }{ - // nil - {"nil error", nil, false}, - - // Retriable HTTP status codes: 408, 429, 500, 502, 503, 504, 529 - {"HTTP 408", fmt.Errorf("HTTP 408 from api.openai.com"), true}, - {"HTTP 429", fmt.Errorf("HTTP 429 from api.openai.com"), true}, - {"HTTP 500", fmt.Errorf("HTTP 500 from api.openai.com"), true}, - {"HTTP 502", fmt.Errorf("HTTP 502 from api.openai.com"), true}, - {"HTTP 503", fmt.Errorf("HTTP 503 from api.openai.com"), true}, - {"HTTP 504", fmt.Errorf("HTTP 504 from api.openai.com"), true}, - {"HTTP 529", fmt.Errorf("HTTP 529 from api.openai.com"), true}, - - // Non-retriable HTTP status codes: 400, 401, 403, 404, 422 - {"HTTP 400", fmt.Errorf("HTTP 400 from api.openai.com"), false}, - {"HTTP 401", fmt.Errorf("HTTP 401 from api.openai.com"), false}, - {"HTTP 403", fmt.Errorf("HTTP 403 from api.openai.com"), false}, - {"HTTP 404", fmt.Errorf("HTTP 404 from api.openai.com"), false}, - {"HTTP 422", fmt.Errorf("HTTP 422 from api.openai.com"), false}, - - // Context errors - {"context deadline", context.DeadlineExceeded, true}, - {"context cancelled", context.Canceled, false}, - - // TransientError type — delegated to IsTransient, returns true - {"TransientError 500", &types.TransientError{StatusCode: 500, Message: "oops"}, true}, - {"TransientError 429", &types.TransientError{StatusCode: 429, Message: "rate limit"}, true}, - {"wrapped TransientError", fmt.Errorf("outer: %w", &types.TransientError{StatusCode: 503, Message: "down"}), true}, - - // Message-based pattern matching (delegated to IsTransient) - {"timeout message", fmt.Errorf("request timeout"), true}, - {"timed out", fmt.Errorf("connection timed out"), true}, - {"deadline exceeded message", fmt.Errorf("deadline exceeded"), true}, - {"connection refused", fmt.Errorf("connection refused"), true}, - {"connection reset", fmt.Errorf("connection reset by peer"), true}, - {"EOF", fmt.Errorf("unexpected EOF"), true}, - {"broken pipe", fmt.Errorf("broken pipe"), true}, - {"temporarily unavailable", fmt.Errorf("temporarily unavailable"), true}, - {"overloaded", fmt.Errorf("server overloaded"), true}, - {"try again", fmt.Errorf("please try again later"), true}, - {"rate limit", fmt.Errorf("eyrie: rate limit exceeded"), true}, - {"rate_limit", fmt.Errorf("rate_limit hit"), true}, - - // APIConnectionTimeoutError (recognized by IsTransient) - {"APIConnectionTimeoutError", types.NewAPIConnectionTimeoutError("timed out"), true}, - - // KEY DIVERGENCE: unknown errors are retriable in isRetriableError - // (optimistic) but NOT retriable in IsTransient (conservative). - {"unknown error — optimistic", fmt.Errorf("eyrie: mock error"), true}, - {"unknown error — weird", fmt.Errorf("something weird happened"), true}, - {"unknown error — crash", fmt.Errorf("provider crashed"), true}, - {"unknown error — assertion", fmt.Errorf("internal assertion failed"), true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := isRetriableError(tt.err) - if got != tt.retriable { - t.Errorf("isRetriableError(%v) = %v, want %v", tt.err, got, tt.retriable) - } - }) - } -} - -// TestIsRetriableErrorVsIsTransientDivergence documents the intentional -// behavioral difference: isRetriableError is optimistic (unknown errors retriable), -// while types.IsTransient is conservative (unknown errors NOT retriable). -func TestIsRetriableErrorVsIsTransientDivergence(t *testing.T) { - t.Parallel() - unknownErrors := []error{ - fmt.Errorf("something weird happened"), - fmt.Errorf("provider crashed"), - fmt.Errorf("internal assertion failed"), - fmt.Errorf("unexpected response format"), - } - - for _, err := range unknownErrors { - // IsTransient: conservative — unknown errors are NOT retriable. - if types.IsTransient(err) { - t.Errorf("types.IsTransient should be conservative for %q, got true", err) - } - // isRetriableError: optimistic — unknown errors ARE retriable. - if !isRetriableError(err) { - t.Errorf("isRetriableError should be optimistic for %q, got false", err) - } - } - - // Known retriable errors should be retriable in BOTH functions. - retriableErrors := []error{ - context.DeadlineExceeded, - fmt.Errorf("HTTP 503 service unavailable"), - fmt.Errorf("rate limit exceeded"), - &types.TransientError{StatusCode: 500, Message: "oops"}, - } - for _, err := range retriableErrors { - if !types.IsTransient(err) { - t.Errorf("types.IsTransient should be true for %q", err) - } - if !isRetriableError(err) { - t.Errorf("isRetriableError should be true for %q", err) - } - } - - // context.Canceled is NOT retriable in either function. - if types.IsTransient(context.Canceled) { - t.Error("types.IsTransient(context.Canceled) should be false") - } - if isRetriableError(context.Canceled) { - t.Error("isRetriableError(context.Canceled) should be false") - } -} - -func TestFallbackProviderErrorOnEmpty(t *testing.T) { - t.Parallel() - fp, err := NewFallbackProvider() - if err == nil { - t.Error("expected error from NewFallbackProvider with no providers") - } - if fp != nil { - t.Error("expected nil provider from NewFallbackProvider with no providers") - } -} - -func TestFallbackProviderNonRetriableDoesNotFallback(t *testing.T) { - t.Parallel() - // Create a custom mock that returns a 401-like error. - primary := &errorProvider{err: fmt.Errorf("HTTP 401 unauthorized")} - secondary := NewMockProvider(MockModeFixed) - secondary.Response = "should not reach" - - fp, _ := NewFallbackProvider(primary, secondary) - _, err := fp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err == nil { - t.Fatal("expected error for 401") - } - // Secondary should NOT have been called. - if secondary.CallCount() != 0 { - t.Errorf("secondary was called %d times; should not be called for non-retriable error", secondary.CallCount()) - } -} - -// errorProvider is a minimal Provider that always returns a specific error. -type errorProvider struct { - err error -} - -func (e *errorProvider) Chat(_ context.Context, _ []EyrieMessage, _ ChatOptions) (*EyrieResponse, error) { - return nil, e.err -} - -func (e *errorProvider) StreamChat(_ context.Context, _ []EyrieMessage, _ ChatOptions) (*StreamResult, error) { - return nil, e.err -} -func (e *errorProvider) Ping(_ context.Context) error { return e.err } -func (e *errorProvider) Name() string { return "error-provider" } diff --git a/client/mimo_test.go b/client/mimo_test.go index 453f86e..05033d3 100644 --- a/client/mimo_test.go +++ b/client/mimo_test.go @@ -1,45 +1,11 @@ package client import ( - "errors" - "net/http" "testing" - "github.com/GrayCodeAI/eyrie/catalog/xiaomi" eyriecfg "github.com/GrayCodeAI/eyrie/config" ) -func TestMimoRetryableChatError_HTTPStatus(t *testing.T) { - err401 := errors.New("eyrie: openai API error: credential probe failed: invalid API key (HTTP 401)") - if !mimoRetryableChatError(err401) { - t.Fatal("expected 401 retryable") - } - err400 := errors.New("eyrie: openai API error (HTTP 400)") - if mimoRetryableChatError(err400) { - t.Fatal("expected 400 not retryable") - } -} - -func TestMimoRetryableChatError_UsesXiaomiHelper(t *testing.T) { - if !xiaomi.IsRetryableHTTPStatus(http.StatusServiceUnavailable) { - t.Fatal("expected 503 retryable in xiaomi helper") - } - err := errors.New("provider unavailable (HTTP 503)") - if !mimoRetryableChatError(err) { - t.Fatal("expected 503 retryable") - } -} - -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/provider_registry.go b/client/provider_registry.go index 32d3ea3..1a08710 100644 --- a/client/provider_registry.go +++ b/client/provider_registry.go @@ -142,11 +142,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/reasoning_thinking_test.go b/client/reasoning_thinking_test.go index bad2e14..b02e29e 100644 --- a/client/reasoning_thinking_test.go +++ b/client/reasoning_thinking_test.go @@ -170,12 +170,13 @@ func TestThinkingForBudget(t *testing.T) { } } -// TestBuildRequestBase_DeepSeekStripsReasoningContent verifies that the +// TestBuildRequestBase_DeepSeekForwardsReasoningContent verifies that the // EyrieMessage.Thinking field (which carries reasoning_content captured from a -// prior DeepSeek response) is never forwarded in the outgoing request body. -// DeepSeek returns HTTP 400 if reasoning_content appears in a multi-turn -// conversation, so StripReasoningFromInput=true is the correct behaviour. -func TestBuildRequestBase_DeepSeekStripsReasoningContent(t *testing.T) { +// prior DeepSeek response) IS forwarded back into assistant messages for the +// DeepSeek provider. DeepSeek requires the assistant's reasoning_content to be +// passed back whenever that turn performed a tool call — otherwise the API +// returns HTTP 400. +func TestBuildRequestBase_DeepSeekForwardsReasoningContent(t *testing.T) { t.Parallel() compat := &DeepSeekCompat @@ -198,6 +199,48 @@ func TestBuildRequestBase_DeepSeekStripsReasoningContent(t *testing.T) { t.Fatalf("messages field missing or wrong type") } + // The assistant turn's reasoning_content must be forwarded back. + asst, ok := msgs[1].(map[string]interface{}) + if !ok { + t.Fatalf("messages[1] wrong type") + } + if asst["role"] != "assistant" { + t.Errorf("messages[1].role = %v, want assistant", asst["role"]) + } + if asst["content"] != "4" { + t.Errorf("messages[1].content = %v, want 4", asst["content"]) + } + if rc, present := asst["reasoning_content"]; !present || rc != "Let me compute: 2+2 = 4" { + t.Errorf("messages[1].reasoning_content = %v, want forwarded thinking text", rc) + } +} + +// TestBuildRequestBase_NonDeepSeekDoesNotForwardReasoningContent verifies that +// providers without RequiresReasoningPassback never transmit reasoning_content +// in the request body, preserving the prior behavior for all other providers. +func TestBuildRequestBase_NonDeepSeekDoesNotForwardReasoningContent(t *testing.T) { + t.Parallel() + compat := &OpenAICompat // requires passback false + + messages := []EyrieMessage{ + {Role: "user", Content: "What is 2+2?"}, + {Role: "assistant", Content: "4", Thinking: "Let me compute: 2+2 = 4"}, + {Role: "user", Content: "Are you sure?"}, + } + + req := buildRequestBase(messages, ChatOptions{Model: "gpt-4o"}, false, compat) + + body, _ := json.Marshal(req) + var parsed map[string]interface{} + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("failed to unmarshal request: %v", err) + } + + msgs, ok := parsed["messages"].([]interface{}) + if !ok { + t.Fatalf("messages field missing or wrong type") + } + for i, raw := range msgs { msg, ok := raw.(map[string]interface{}) if !ok { @@ -207,18 +250,6 @@ func TestBuildRequestBase_DeepSeekStripsReasoningContent(t *testing.T) { t.Errorf("message[%d] must not contain reasoning_content, got %v", i, msg) } } - - // Verify the assistant turn still carries its text content. - if len(msgs) < 3 { - t.Fatalf("expected 3 messages, got %d", len(msgs)) - } - asst, _ := msgs[1].(map[string]interface{}) - if asst["role"] != "assistant" { - t.Errorf("messages[1].role = %v, want assistant", asst["role"]) - } - if asst["content"] != "4" { - t.Errorf("messages[1].content = %v, want 4", asst["content"]) - } } func TestAnthropicRequest_ThinkingSerialization(t *testing.T) { diff --git a/client/thinking_policy.go b/client/thinking_policy.go index acbc25d..e5d8f20 100644 --- a/client/thinking_policy.go +++ b/client/thinking_policy.go @@ -17,7 +17,7 @@ import ( // minimax → thinking={"type":"adaptive"|"disabled"} // agnes → chat_template_kwargs.enable_thinking // qwen → enable_thinking (top-level) -// openrouter → reasoning={enabled|effort:"none"} +// openrouter → reasoning={enabled: true|false} const ( ThinkingFormatNone = "" ThinkingFormatZAI = "zai" diff --git a/client/weighted.go b/client/weighted.go deleted file mode 100644 index d6ddd92..0000000 --- a/client/weighted.go +++ /dev/null @@ -1,240 +0,0 @@ -package client - -import ( - "context" - "crypto/rand" - "encoding/binary" - "errors" - "fmt" - randv2 "math/rand/v2" - "sort" - "strings" - "sync" - "sync/atomic" -) - -// WeightedProviderConfig associates a Provider with a selection weight. -type WeightedProviderConfig struct { - Provider Provider - Weight float64 // relative weight (e.g., 0.8 for 80%) -} - -// WeightedProvider selects a provider based on configured weights, -// with automatic failover to remaining providers on retriable errors. -// -// WeightedProvider is safe for concurrent use. -type WeightedProvider struct { - configs []normalizedConfig // sorted by descending weight - mu sync.Mutex - rng *randv2.Rand - - // stats tracks how many times each provider served a request. - stats map[string]*atomic.Int64 -} - -// normalizedConfig holds a provider with its normalized (0-1) weight. -type normalizedConfig struct { - provider Provider - weight float64 -} - -// Compile-time check that WeightedProvider implements Provider. -var _ Provider = (*WeightedProvider)(nil) - -// NewWeightedProvider creates a WeightedProvider that selects providers -// based on the configured weights. At least one provider must be supplied -// and every weight must be positive; an error is returned otherwise. -// Weights are normalized to sum to 1.0. -func NewWeightedProvider(configs []WeightedProviderConfig) (*WeightedProvider, error) { - if len(configs) == 0 { - return nil, errors.New("eyrie: WeightedProvider requires at least one provider config") - } - - // Compute total weight for normalization. - var total float64 - for _, c := range configs { - if c.Weight <= 0 { - return nil, errors.New("eyrie: WeightedProvider weights must be positive") - } - total += c.Weight - } - - normalized := make([]normalizedConfig, len(configs)) - for i, c := range configs { - normalized[i] = normalizedConfig{ - provider: c.Provider, - weight: c.Weight / total, - } - } - - // Sort by descending weight for failover ordering. - sort.Slice(normalized, func(i, j int) bool { - return normalized[i].weight > normalized[j].weight - }) - - stats := make(map[string]*atomic.Int64, len(configs)) - for _, c := range normalized { - if _, ok := stats[c.provider.Name()]; !ok { - stats[c.provider.Name()] = &atomic.Int64{} - } - } - - // Seed from crypto/rand to avoid deterministic sequences. - var seed [16]byte - if _, err := rand.Read(seed[:]); err != nil { - return nil, fmt.Errorf("eyrie: failed to read crypto entropy: %w", err) - } - s1 := binary.BigEndian.Uint64(seed[:8]) - s2 := binary.BigEndian.Uint64(seed[8:]) - - return &WeightedProvider{ - configs: normalized, - rng: randv2.New(randv2.NewPCG(s1, s2)), // #nosec G404 -- non-cryptographic weighted provider selection, not a security decision - stats: stats, - }, nil -} - -// Name returns a composite name showing providers and their weights. -func (wp *WeightedProvider) Name() string { - parts := make([]string, len(wp.configs)) - for i, c := range wp.configs { - parts[i] = fmt.Sprintf("%s:%.2f", c.provider.Name(), c.weight) - } - return "weighted(" + strings.Join(parts, ",") + ")" -} - -// Ping tries to ping each provider, returning nil on the first success. -func (wp *WeightedProvider) Ping(ctx context.Context) error { - var lastErr error - for _, c := range wp.configs { - if err := ctx.Err(); err != nil { - return err - } - if err := c.provider.Ping(ctx); err != nil { - lastErr = err - continue - } - return nil - } - return fmt.Errorf("eyrie: all weighted providers failed ping: %w", lastErr) -} - -// Chat sends a non-streaming chat request using weighted random selection -// with failover on retriable errors. -func (wp *WeightedProvider) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { - selected := wp.selectProvider() - var lastErr error - - // Try the selected provider first. - if err := ctx.Err(); err != nil { - return nil, err - } - resp, err := selected.Chat(ctx, messages, opts) - if err == nil { - wp.recordSuccess(selected.Name()) - return resp, nil - } - - // If non-retriable, return immediately. - if !isRetriableError(err) { - return nil, err - } - lastErr = err - - // Failover: try remaining providers in weight-descending order. - for _, c := range wp.configs { - if c.provider == selected { - continue - } - if err := ctx.Err(); err != nil { - return nil, err - } - resp, err := c.provider.Chat(ctx, messages, opts) - if err == nil { - wp.recordSuccess(c.provider.Name()) - return resp, nil - } - if !isRetriableError(err) { - return nil, err - } - lastErr = err - } - - return nil, fmt.Errorf("eyrie: all weighted providers failed: %w", lastErr) -} - -// StreamChat sends a streaming chat request using weighted random selection -// with failover on retriable errors. -func (wp *WeightedProvider) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { - selected := wp.selectProvider() - var lastErr error - - // Try the selected provider first. - if err := ctx.Err(); err != nil { - return nil, err - } - sr, err := selected.StreamChat(ctx, messages, opts) - if err == nil { - wp.recordSuccess(selected.Name()) - return sr, nil - } - - // If non-retriable, return immediately. - if !isRetriableError(err) { - return nil, err - } - lastErr = err - - // Failover: try remaining providers in weight-descending order. - for _, c := range wp.configs { - if c.provider == selected { - continue - } - if err := ctx.Err(); err != nil { - return nil, err - } - sr, err := c.provider.StreamChat(ctx, messages, opts) - if err == nil { - wp.recordSuccess(c.provider.Name()) - return sr, nil - } - if !isRetriableError(err) { - return nil, err - } - lastErr = err - } - - return nil, fmt.Errorf("eyrie: all weighted providers failed streaming: %w", lastErr) -} - -// Stats returns a snapshot of how many times each provider served a request. -func (wp *WeightedProvider) Stats() map[string]int64 { - result := make(map[string]int64, len(wp.stats)) - for name, counter := range wp.stats { - result[name] = counter.Load() - } - return result -} - -// selectProvider picks a provider based on weighted random selection. -func (wp *WeightedProvider) selectProvider() Provider { - wp.mu.Lock() - r := wp.rng.Float64() - wp.mu.Unlock() - - var cumulative float64 - for _, c := range wp.configs { - cumulative += c.weight - if r < cumulative { - return c.provider - } - } - // Fallback to last provider (handles floating point edge case). - return wp.configs[len(wp.configs)-1].provider -} - -func (wp *WeightedProvider) recordSuccess(name string) { - if counter, ok := wp.stats[name]; ok { - counter.Add(1) - } -} diff --git a/client/weighted_test.go b/client/weighted_test.go deleted file mode 100644 index d13658a..0000000 --- a/client/weighted_test.go +++ /dev/null @@ -1,305 +0,0 @@ -package client - -import ( - "context" - "fmt" - "math" - randv2 "math/rand/v2" - "testing" -) - -func TestWeightedProviderSingleProvider(t *testing.T) { - t.Parallel() - p := NewMockProvider(MockModeFixed) - p.Response = "only one" - - wp := mustWeightedProvider(t, []WeightedProviderConfig{ - {Provider: p, Weight: 1.0}, - }) - - for i := 0; i < 10; i++ { - resp, err := wp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err != nil { - t.Fatalf("call %d: unexpected error: %v", i, err) - } - if resp.Content != "only one" { - t.Errorf("call %d: expected 'only one', got %q", i, resp.Content) - } - } - - if p.CallCount() != 10 { - t.Errorf("expected 10 calls, got %d", p.CallCount()) - } -} - -func TestWeightedProviderDistribution(t *testing.T) { - t.Parallel() - // Use named providers so stats can distinguish them. - primary := &namedProvider{name: "primary", mock: NewMockProvider(MockModeFixed)} - primary.mock.Response = "from primary" - - secondary := &namedProvider{name: "secondary", mock: NewMockProvider(MockModeFixed)} - secondary.mock.Response = "from secondary" - - wp := mustWeightedProvider(t, []WeightedProviderConfig{ - {Provider: primary, Weight: 0.8}, - {Provider: secondary, Weight: 0.2}, - }) - - const iterations = 1000 - counts := map[string]int{"primary": 0, "secondary": 0} - - for i := 0; i < iterations; i++ { - resp, err := wp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err != nil { - t.Fatalf("call %d: unexpected error: %v", i, err) - } - switch resp.Content { - case "from primary": - counts["primary"]++ - case "from secondary": - counts["secondary"]++ - default: - t.Fatalf("unexpected response: %q", resp.Content) - } - } - - // Check distribution is roughly 80/20 with tolerance of 8%. - primaryRatio := float64(counts["primary"]) / float64(iterations) - secondaryRatio := float64(counts["secondary"]) / float64(iterations) - - if math.Abs(primaryRatio-0.8) > 0.08 { - t.Errorf("primary ratio %.3f is too far from expected 0.80", primaryRatio) - } - if math.Abs(secondaryRatio-0.2) > 0.08 { - t.Errorf("secondary ratio %.3f is too far from expected 0.20", secondaryRatio) - } -} - -func TestWeightedProviderFailoverOnRetriableError(t *testing.T) { - t.Parallel() - // Primary always returns a retriable error. - primary := &namedProvider{name: "primary", mock: nil, err: fmt.Errorf("HTTP 503 service unavailable")} - // Secondary succeeds. - secondary := &namedProvider{name: "secondary", mock: NewMockProvider(MockModeFixed)} - secondary.mock.Response = "fallback success" - - wp := newWeightedProviderForTest(t, []WeightedProviderConfig{ - {Provider: primary, Weight: 1.0}, // will always be selected - {Provider: secondary, Weight: 0.01}, // extremely low weight - }) - - resp, err := wp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp.Content != "fallback success" { - t.Errorf("expected 'fallback success', got %q", resp.Content) - } -} - -func TestWeightedProviderNoFailoverOnNonRetriableError(t *testing.T) { - t.Parallel() - // Primary returns a 400 (non-retriable). - primary := &namedProvider{name: "primary", mock: nil, err: fmt.Errorf("HTTP 400 bad request")} - // Secondary would succeed if reached. - secondary := &namedProvider{name: "secondary", mock: NewMockProvider(MockModeFixed)} - secondary.mock.Response = "should not reach" - - wp := newWeightedProviderForTest(t, []WeightedProviderConfig{ - {Provider: primary, Weight: 1.0}, // always selected - {Provider: secondary, Weight: 0.01}, - }) - - _, err := wp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err == nil { - t.Fatal("expected error for non-retriable 400") - } - // Secondary should NOT have been called. - if secondary.mock.CallCount() != 0 { - t.Errorf("secondary was called %d times; should not be called for non-retriable error", secondary.mock.CallCount()) - } -} - -func TestWeightedProviderNoFailoverOn401(t *testing.T) { - t.Parallel() - // Primary returns a 401 (non-retriable). - primary := &namedProvider{name: "primary", mock: nil, err: fmt.Errorf("HTTP 401 unauthorized")} - secondary := &namedProvider{name: "secondary", mock: NewMockProvider(MockModeFixed)} - secondary.mock.Response = "should not reach" - - wp := newWeightedProviderForTest(t, []WeightedProviderConfig{ - {Provider: primary, Weight: 1.0}, - {Provider: secondary, Weight: 0.01}, - }) - - _, err := wp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err == nil { - t.Fatal("expected error for non-retriable 401") - } - if secondary.mock.CallCount() != 0 { - t.Errorf("secondary should not be called for 401 error") - } -} - -func TestWeightedProviderAllFail(t *testing.T) { - t.Parallel() - p1 := &namedProvider{name: "p1", mock: nil, err: fmt.Errorf("HTTP 503 service unavailable")} - p2 := &namedProvider{name: "p2", mock: nil, err: fmt.Errorf("HTTP 502 bad gateway")} - p3 := &namedProvider{name: "p3", mock: nil, err: fmt.Errorf("HTTP 500 internal error")} - - wp := mustWeightedProvider(t, []WeightedProviderConfig{ - {Provider: p1, Weight: 0.5}, - {Provider: p2, Weight: 0.3}, - {Provider: p3, Weight: 0.2}, - }) - - _, err := wp.Chat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err == nil { - t.Fatal("expected error when all providers fail") - } -} - -func TestWeightedProviderName(t *testing.T) { - t.Parallel() - p1 := &namedProvider{name: "anthropic", mock: NewMockProvider(MockModeFixed)} - p2 := &namedProvider{name: "openai", mock: NewMockProvider(MockModeFixed)} - - wp := mustWeightedProvider(t, []WeightedProviderConfig{ - {Provider: p1, Weight: 0.8}, - {Provider: p2, Weight: 0.2}, - }) - - expected := "weighted(anthropic:0.80,openai:0.20)" - if wp.Name() != expected { - t.Errorf("expected name %q, got %q", expected, wp.Name()) - } -} - -func TestWeightedProviderPing(t *testing.T) { - t.Parallel() - p1 := &namedProvider{name: "failing", mock: nil, err: fmt.Errorf("ping failed")} - p2 := &namedProvider{name: "ok", mock: NewMockProvider(MockModeFixed)} - - wp := mustWeightedProvider(t, []WeightedProviderConfig{ - {Provider: p1, Weight: 0.8}, - {Provider: p2, Weight: 0.2}, - }) - - // Should succeed because p2 pings ok (even though p1 fails). - if err := wp.Ping(context.Background()); err != nil { - t.Fatalf("expected ping to succeed, got: %v", err) - } -} - -func TestWeightedProviderStreamFailover(t *testing.T) { - t.Parallel() - primary := &namedProvider{name: "primary", mock: nil, err: fmt.Errorf("HTTP 429 rate limited")} - secondary := &namedProvider{name: "secondary", mock: NewMockProvider(MockModeFixed)} - secondary.mock.Response = "streamed from secondary" - - wp := mustWeightedProvider(t, []WeightedProviderConfig{ - {Provider: primary, Weight: 1.0}, - {Provider: secondary, Weight: 0.01}, - }) - - sr, err := wp.StreamChat(context.Background(), []EyrieMessage{ - {Role: "user", Content: "hello"}, - }, ChatOptions{Model: "test"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - defer sr.Close() - - var content string - for evt := range sr.Events { - if evt.Type == "content" { - content += evt.Content - } - } - if content == "" { - t.Error("expected some streamed content") - } -} - -func TestWeightedProviderErrorOnEmpty(t *testing.T) { - t.Parallel() - if _, err := NewWeightedProvider(nil); err == nil { - t.Error("expected error with no provider configs") - } -} - -func TestWeightedProviderErrorOnZeroWeight(t *testing.T) { - t.Parallel() - p := NewMockProvider(MockModeFixed) - if _, err := NewWeightedProvider([]WeightedProviderConfig{ - {Provider: p, Weight: 0}, - }); err == nil { - t.Error("expected error with zero weight") - } -} - -// zeroRandSource always yields 0 from Float64(), so weighted selection picks the highest-weight provider. -type zeroRandSource struct{} - -func (zeroRandSource) Uint64() uint64 { return 0 } - -func newWeightedProviderForTest(tb testing.TB, configs []WeightedProviderConfig) *WeightedProvider { - tb.Helper() - wp := mustWeightedProvider(tb, configs) - wp.rng = randv2.New(zeroRandSource{}) - return wp -} - -// mustWeightedProvider constructs a WeightedProvider, failing the test on error. -func mustWeightedProvider(tb testing.TB, configs []WeightedProviderConfig) *WeightedProvider { - tb.Helper() - wp, err := NewWeightedProvider(configs) - if err != nil { - tb.Fatalf("NewWeightedProvider: %v", err) - } - return wp -} - -// namedProvider wraps a mock provider with a custom name, used to distinguish -// providers in stats and test assertions. -type namedProvider struct { - name string - mock *MockProvider - err error // if set, all calls return this error -} - -func (n *namedProvider) Name() string { return n.name } - -func (n *namedProvider) Ping(_ context.Context) error { - if n.err != nil { - return n.err - } - return nil -} - -func (n *namedProvider) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { - if n.err != nil { - return nil, n.err - } - return n.mock.Chat(ctx, messages, opts) -} - -func (n *namedProvider) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { - if n.err != nil { - return nil, n.err - } - return n.mock.StreamChat(ctx, messages, opts) -} diff --git a/config/deployment_env_sync_test.go b/config/deployment_env_sync_test.go index fd26088..9dc3049 100644 --- a/config/deployment_env_sync_test.go +++ b/config/deployment_env_sync_test.go @@ -14,8 +14,8 @@ func TestBuildRoutingPolicyFromDeployments_OpenAI(t *testing.T) { "openrouter": {APIKey: "or-test"}, } policy := BuildRoutingPolicyFromDeployments(deployments) - if len(policy.Providers["openai"]) < 2 { - t.Fatalf("expected openai stages with openrouter fallback, got %+v", policy.Providers["openai"]) + if len(policy.Providers["openai"]) != 1 { + t.Fatalf("expected single weighted openai stage, got %+v", policy.Providers["openai"]) } primary := policy.Providers["openai"][0] if len(primary.Deployments) != 2 { diff --git a/config/profiles.go b/config/profiles.go index b151942..e79f76f 100644 --- a/config/profiles.go +++ b/config/profiles.go @@ -145,7 +145,7 @@ var ( APIKeys: []APIKeyDef{{Env: "CANOPYWAVE_API_KEY", Source: "canopywave"}, {Env: "OPENAI_API_KEY", Source: "openai"}}, } DeepSeekRuntimeProfile = RuntimeProviderProfile{ - Mode: "openai", DefaultBaseURL: "https://api.deepseek.com/v1", + Mode: "openai", DefaultBaseURL: "https://api.deepseek.com", DetectionEnv: []string{"DEEPSEEK_API_KEY"}, ModelEnv: []string{"DEEPSEEK_MODEL", "OPENAI_MODEL"}, BaseURLEnv: []string{"DEEPSEEK_BASE_URL"}, diff --git a/config/provider_env.go b/config/provider_env.go index 4c5b8e3..09657cd 100644 --- a/config/provider_env.go +++ b/config/provider_env.go @@ -793,7 +793,7 @@ func ApplyProviderEnv(provider string, config *ProviderConfig, activeModel strin collectOpenAICompatibleProvider(env, "OPENGATEWAY", apiKey, m, base, overwrite) case ProviderDeepSeek: apiKey := AsNonEmptyString(config.DeepSeekAPIKey) - base := firstNonEmpty(config.DeepSeekBaseURL, "https://api.deepseek.com/v1") + base := firstNonEmpty(config.DeepSeekBaseURL, "https://api.deepseek.com") m := activeModel if m == "" { m = catalog.GetProviderDefaultModel("deepseek", cat) diff --git a/config/providers.go b/config/providers.go index 3ce4cc5..2afbd0e 100644 --- a/config/providers.go +++ b/config/providers.go @@ -35,11 +35,13 @@ const ( DefaultKimiOpenAIBaseURL = "https://api.moonshot.ai/v1" DefaultXiaomiOpenAIBaseURL = "https://api.xiaomimimo.com/v1" DefaultMiniMaxOpenAIBaseURL = "https://api.minimax.io/v1" - DefaultMiniMaxAnthropicBaseURL = "https://api.minimax.io" 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 DefaultStepFunOpenAIBaseURL = "https://api.stepfun.ai/v1" + DefaultAgnesOpenAIBaseURL = "https://apihub.agnes-ai.com/v1" + DefaultLongCatOpenAIBaseURL = "https://api.longcat.chat/openai/v1" + DefaultLongCatAnthropicBaseURL = "https://api.longcat.chat/anthropic" ) // ProviderTransport is the transport type for provider requests. diff --git a/config/routing_build.go b/config/routing_build.go index dfb6cc7..bf340b5 100644 --- a/config/routing_build.go +++ b/config/routing_build.go @@ -1,5 +1,9 @@ package config +import ( + "github.com/GrayCodeAI/eyrie/catalog/registry" +) + // BuildRoutingPolicyFromDeployments builds deployment routing from configured deployments. // Hawk should not author routing rules — consume this JSON from eyrie only. func BuildRoutingPolicyFromDeployments(deployments map[string]DeploymentConfig) *RoutingPolicy { @@ -64,9 +68,6 @@ func openAIProviderStages(deployments map[string]DeploymentConfig) []RoutingStag default: return nil } - if _, ok := deployments["openrouter"]; ok { - stages = append(stages, openRouterFallbackStage()...) - } return stages } @@ -92,9 +93,6 @@ func anthropicProviderStages(deployments map[string]DeploymentConfig) []RoutingS if len(stages) == 0 { return nil } - if _, ok := deployments["openrouter"]; ok { - stages = append(stages, openRouterFallbackStage()...) - } return stages } @@ -117,11 +115,7 @@ func longcatProviderStages(deployments map[string]DeploymentConfig) []RoutingSta func googleProviderStages(deployments map[string]DeploymentConfig) []RoutingStage { if _, ok := deployments["gemini-direct"]; ok { - stages := singleDeploymentStages("gemini-direct", 1) - if _, ok := deployments["openrouter"]; ok { - stages = append(stages, openRouterFallbackStage()...) - } - return stages + return singleDeploymentStages("gemini-direct", 1) } if _, ok := deployments["gemini-vertex"]; ok { return singleDeploymentStages("gemini-vertex", 1) @@ -136,13 +130,6 @@ func grokProviderStages(deployments map[string]DeploymentConfig) []RoutingStage return nil } -func openRouterFallbackStage() []RoutingStage { - return []RoutingStage{{ - Deployments: []DeploymentChoice{{DeploymentID: "openrouter", Weight: 100}}, - Retries: 1, - }} -} - func deploymentOwnerProviderID(deploymentID string) string { switch deploymentID { case "anthropic-direct", "anthropic-bedrock", "anthropic-vertex": @@ -153,35 +140,12 @@ func deploymentOwnerProviderID(deploymentID string) string { return "google" case "grok-direct": return "xai" - case "longcat-direct": - return "longcat" - case "agnes-direct": - return "agnes" - case "openrouter": - return "openrouter" - case "canopywave": - return "canopywave" - case "poolside": - return "poolside" - case "groq-direct": - return "groq" - case "clinepass": - return "clinepass" - case "zai_payg-direct": - return "zai_payg" - case "zai_coding-direct": - return "zai_coding" - case "ollama-local": - return "ollama" - case "opencodego": - return "opencodego" - case "kimi-direct": - return "kimi" case "xiaomi_mimo_payg-direct", "xiaomi_mimo-direct": return "xiaomi_mimo_payg" - case "xiaomi_mimo_token_plan-direct": - return "xiaomi_mimo_token_plan" default: + if spec, ok := registry.SpecByDeploymentID(deploymentID); ok { + return spec.ProviderID + } return "" } } diff --git a/config/runtime.go b/config/runtime.go index 4d27b04..d70efb7 100644 --- a/config/runtime.go +++ b/config/runtime.go @@ -29,7 +29,7 @@ func IsOpenAICompatibleRuntimeEnabled() bool { "OPENROUTER_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY", "CANOPYWAVE_API_KEY", "DEEPSEEK_API_KEY", "ZAI_API_KEY", "OPENAI_API_KEY", "OPENCODEGO_API_KEY", "OLLAMA_BASE_URL", - "MOONSHOT_API_KEY", "XIAOMI_MIMO_API_KEY", "XIAOMI_MIMO_PAYG_API_KEY", "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", + "MOONSHOT_API_KEY", "XIAOMI_MIMO_PAYG_API_KEY", "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", } for _, k := range keys { if envValue(k) != "" { diff --git a/config/xiaomi_profile.go b/config/xiaomi_profile.go index 91eea82..2d354d9 100644 --- a/config/xiaomi_profile.go +++ b/config/xiaomi_profile.go @@ -1,8 +1,6 @@ package config import ( - "strings" - "github.com/GrayCodeAI/eyrie/catalog/xiaomi" ) @@ -45,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) { - openAIBase, err := ResolveXiaomiOpenAIBase(providerID, cfg) - if err != nil { - return "", err - } - // Anthropic base strips the /v1 suffix; same host, different protocol path. - return strings.TrimSuffix(strings.TrimRight(openAIBase, "/"), "/v1"), nil -} - // 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/credentials/account_test.go b/credentials/account_test.go index 4e4761c..e104796 100644 --- a/credentials/account_test.go +++ b/credentials/account_test.go @@ -72,7 +72,7 @@ func TestAccountForEnv_EnvForAccount_RoundTrip(t *testing.T) { canonical := []string{ "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "ZAI_API_KEY", "CANOPYWAVE_API_KEY", - "OPENCODEGO_API_KEY", "XAI_API_KEY", "MOONSHOT_API_KEY", "XIAOMI_MIMO_API_KEY", "OLLAMA_BASE_URL", + "OPENCODEGO_API_KEY", "XAI_API_KEY", "MOONSHOT_API_KEY", "OLLAMA_BASE_URL", } for _, envKey := range canonical { t.Run(envKey, func(t *testing.T) { diff --git a/credentials/lookup.go b/credentials/lookup.go index a844ec5..b46f9f8 100644 --- a/credentials/lookup.go +++ b/credentials/lookup.go @@ -37,26 +37,9 @@ func LookupSecret(ctx context.Context, envKey string) string { func lookupSecretAccount(ctx context.Context, account string) (string, error) { secret, err := DefaultStore().Get(ctx, account) - if err == nil && strings.TrimSpace(secret) != "" { - return secret, nil - } - if alias := keychainAccountAliasFor(account); alias != "" { - if aliasSecret, aliasErr := DefaultStore().Get(ctx, alias); aliasErr == nil && strings.TrimSpace(aliasSecret) != "" { - return aliasSecret, nil - } - } return secret, err } -func keychainAccountAliasFor(account string) string { - switch strings.ToLower(strings.TrimSpace(account)) { - case "xiaomi_mimo_payg_api_key": - return "xiaomi_mimo_api_key" - default: - return "" - } -} - // HasSecret reports whether the store has a non-empty secret for an env key name. // It is intentionally silent — it is a boolean predicate used in capability // checks that iterate the full provider catalog at startup, and any logging diff --git a/credentials/migrate.go b/credentials/migrate.go index 59590ba..a0c2b3c 100644 --- a/credentials/migrate.go +++ b/credentials/migrate.go @@ -26,14 +26,12 @@ func markEnvFileMigrationDone() { // MigrateEnvFileCredentials imports API keys from plaintext credential files // (~/.hawk/env, ~/.hawk/.env) into the OS secret store and removes them. -// It also copies deprecated keychain account names (e.g. xiaomi_mimo_api_key → payg). func MigrateEnvFileCredentials(ctx context.Context) (int, error) { if ctx == nil { ctx = context.Background() } if envFileMigrationDone() { - n, _ := MigrateKeychainAccountAliases(ctx) - return n, nil + return 0, nil } total := 0 for _, path := range []string{hawkEnvPath(), hawkDotEnvPath()} { @@ -43,47 +41,10 @@ func MigrateEnvFileCredentials(ctx context.Context) (int, error) { } total += n } - n, err := MigrateKeychainAccountAliases(ctx) - if err != nil { - return total, err - } - total += n markEnvFileMigrationDone() return total, nil } -var keychainAccountAliases = []struct{ from, to string }{ - {"xiaomi_mimo_api_key", "xiaomi_mimo_payg_api_key"}, -} - -// MigrateKeychainAccountAliases copies secrets from deprecated keychain -// accounts to their canonical account when the canonical one is empty. -func MigrateKeychainAccountAliases(ctx context.Context) (int, error) { - if ctx == nil { - ctx = context.Background() - } - cs, ok := DefaultStore().(*CombinedStore) - if !ok || cs.Keychain == nil { - return 0, nil - } - migrated := 0 - for _, pair := range keychainAccountAliases { - existing, err := cs.Keychain.Get(ctx, pair.to) - if err == nil && strings.TrimSpace(existing) != "" { - continue - } - secret, err := cs.Keychain.Get(ctx, pair.from) - if err != nil || strings.TrimSpace(secret) == "" { - continue - } - if err := cs.Keychain.Set(ctx, pair.to, strings.TrimSpace(secret)); err != nil { - continue - } - migrated++ - } - return migrated, nil -} - func migrateEnvFileAt(ctx context.Context, path string) (int, error) { secrets, err := readEnvFile(path) if err != nil { diff --git a/credentials/migrate_test.go b/credentials/migrate_test.go index 1c0a4ed..7fa6e78 100644 --- a/credentials/migrate_test.go +++ b/credentials/migrate_test.go @@ -332,26 +332,3 @@ func TestMigrateEnvFileCredentialsAt_NilKeychain(t *testing.T) { t.Fatalf("expected ErrKeychainUnavailable, got: %v", err) } } - -func TestMigrateKeychainAccountAliases_XiaomiPayg(t *testing.T) { - ms := &MapStore{} - cs := &CombinedStore{Keychain: ms} - SetDefaultStore(cs) - t.Cleanup(func() { SetDefaultStore(nil) }) - - ctx := context.Background() - if err := ms.Set(ctx, "xiaomi_mimo_api_key", "sk-stored-mimo"); err != nil { - t.Fatal(err) - } - n, err := MigrateKeychainAccountAliases(ctx) - if err != nil { - t.Fatal(err) - } - if n != 1 { - t.Fatalf("expected 1 migrated, got %d", n) - } - got, err := ms.Get(ctx, "xiaomi_mimo_payg_api_key") - if err != nil || got != "sk-stored-mimo" { - t.Fatalf("payg account = %q err=%v", got, err) - } -} diff --git a/credentials/store.go b/credentials/store.go index 066072e..609569a 100644 --- a/credentials/store.go +++ b/credentials/store.go @@ -97,7 +97,7 @@ func discoveryEnvKeys(ctx context.Context) []string { return []string{ "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "XAI_API_KEY", - "MOONSHOT_API_KEY", "XIAOMI_MIMO_API_KEY", "XIAOMI_MIMO_PAYG_API_KEY", "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", + "MOONSHOT_API_KEY", "XIAOMI_MIMO_PAYG_API_KEY", "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "CANOPYWAVE_API_KEY", "OPENCODEGO_API_KEY", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "VERTEX_ACCESS_TOKEN", "GOOGLE_OAUTH_ACCESS_TOKEN", diff --git a/engine/catalog.go b/engine/catalog.go index de2276e..b2b56ee 100644 --- a/engine/catalog.go +++ b/engine/catalog.go @@ -118,7 +118,7 @@ func snapshotFromCompiled(compiled *catalog.CompiledCatalog) CatalogSnapshot { inputPrice = offering.Pricing.RatesPer1M["input_tokens"] outputPrice = offering.Pricing.RatesPer1M["output_tokens"] } - snapshot.Models = append(snapshot.Models, Model{ + m := Model{ ID: id, CanonicalID: id, DisplayName: catalog.DisplayModelLabel(id, model.Name), Description: model.Name, Owner: catalog.DisplayModelOwner(model.ProviderID, id), ProviderID: model.ProviderID, @@ -128,7 +128,9 @@ func snapshotFromCompiled(compiled *catalog.CompiledCatalog) CatalogSnapshot { PriceKnown: modelPriceKnown(id, model.Name, inputPrice, outputPrice, model.ContextWindow), Capabilities: capabilityNames(offering.Capabilities), Source: "catalog", LiveMetadata: append([]byte(nil), offering.LiveMetadata...), - }) + } + applyProviderThinkingDefaults(&m) + snapshot.Models = append(snapshot.Models, m) } if compiled.Catalog != nil && !compiled.Catalog.StaleAfter.IsZero() { snapshot.Stale = time.Now().UTC().After(compiled.Catalog.StaleAfter) @@ -185,14 +187,16 @@ func listPublicModels(ctx context.Context, providerID, catalogURL string) ([]Mod out := make([]Model, 0, len(ids)) for _, id := range ids { model := index[id] - out = append(out, Model{ + m := Model{ ID: id, CanonicalID: id, DisplayName: model.Name, Description: model.Description, Owner: "Xiaomi", ProviderID: "xiaomi", GatewayID: gatewayID, ContextWindow: model.ContextLength, MaxOutputTokens: model.MaxOutputLength, InputPricePer1M: model.InputPricePer1M, OutputPricePer1M: model.OutputPricePer1M, PriceKnown: model.InputPricePer1M > 0 || model.OutputPricePer1M > 0, Source: "public", LiveMetadata: append([]byte(nil), model.Raw...), - }) + } + applyProviderThinkingDefaults(&m) + out = append(out, m) } return out, nil } @@ -223,3 +227,24 @@ func capabilityNames(caps catalog.CapabilitySet) []string { sort.Strings(out) return out } + +// applyProviderThinkingDefaults enriches a Model with thinking metadata +// from the provider's registry spec. This lets hosts read Model fields +// instead of maintaining hardcoded provider-name switches. +func applyProviderThinkingDefaults(m *Model) { + providerID := NormalizeProviderID(m.ProviderID) + if providerID == "" { + providerID = NormalizeProviderID(m.GatewayID) + } + spec, ok := registry.SpecByProviderID(providerID) + if !ok { + return + } + if spec.ThinkingToggleSupported { + m.SupportsThinkingToggle = true + } + if spec.DefaultThinkingDisabled { + disabled := false + m.DefaultThinkingEnabled = &disabled + } +} diff --git a/engine/control_plane.go b/engine/control_plane.go index 458248a..9236c2b 100644 --- a/engine/control_plane.go +++ b/engine/control_plane.go @@ -10,6 +10,7 @@ import ( "github.com/GrayCodeAI/eyrie/catalog/registry" "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/credentials" + llm "github.com/GrayCodeAI/hawk-core-contracts/llm" ) // ResolveCredential validates credential input and returns safe provider @@ -72,6 +73,28 @@ func RegisteredGatewayCount() int { return len(registry.CredentialRegistry()) } +// DefaultThinkingDisabled reports whether a provider defaults thinking OFF when unset. +func (e *Engine) DefaultThinkingDisabled(providerID string) bool { + return DefaultThinkingDisabled(providerID) +} + +// ThinkingToggleSupported reports whether a provider's wire protocol honors thinking toggles. +func (e *Engine) ThinkingToggleSupported(providerID string) bool { + return ThinkingToggleSupported(providerID) +} + +// DefaultThinkingDisabled reports whether a provider defaults thinking OFF when unset. +func DefaultThinkingDisabled(providerID string) bool { + spec, ok := registry.SpecByProviderID(providerID) + return ok && spec.DefaultThinkingDisabled +} + +// ThinkingToggleSupported reports whether a provider's wire protocol honors thinking toggles. +func ThinkingToggleSupported(providerID string) bool { + spec, ok := registry.SpecByProviderID(providerID) + return ok && spec.ThinkingToggleSupported +} + // 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 { @@ -79,12 +102,20 @@ func (e *Engine) GatewayDefinitions() []Gateway { out := make([]Gateway, 0, len(specs)+len(e.customGateways)) for _, spec := range specs { providerSpec, _ := registry.SpecByProviderID(spec.ProviderID) - out = append(out, Gateway{ + gw := Gateway{ ID: spec.ProviderID, DisplayName: spec.DisplayName, DeploymentID: spec.DeploymentID, CredentialEnv: spec.EnvVar, RequiresKey: spec.RequiresKey, SortOrder: spec.SortOrder, ChatPreference: providerSpec.ChatPreference, SupportsLiveDiscovery: strings.TrimSpace(providerSpec.LiveFetcherKey) != "", - }) + DNSHost: providerSpec.DNSHost, + } + if len(providerSpec.RegionOptions) > 0 { + gw.RegionOptions = make([]llm.GatewayRegionOption, len(providerSpec.RegionOptions)) + for i, ro := range providerSpec.RegionOptions { + gw.RegionOptions[i] = llm.GatewayRegionOption{Value: ro.Value, DisplayName: ro.DisplayName, Endpoint: ro.Endpoint} + } + } + out = append(out, gw) } for _, gateway := range e.customGateways { out = append(out, Gateway{ diff --git a/engine/engine.go b/engine/engine.go index 0986422..123c2b8 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -305,7 +305,7 @@ func modelsFromCatalogEntries(compiled *catalog.CompiledCatalog, requestedProvid if metadataMarksLive && len(entry.LiveMetadata) > 0 { source = "live" } - out = append(out, Model{ + m := Model{ ID: entry.ID, CanonicalID: canonicalID, DisplayName: entry.DisplayName, Description: entry.Description, Owner: entry.Owner, ProviderID: owner, GatewayID: gatewayID, ContextWindow: entry.ContextWindow, MaxOutputTokens: entry.MaxOutput, @@ -313,7 +313,9 @@ func modelsFromCatalogEntries(compiled *catalog.CompiledCatalog, requestedProvid PriceKnown: modelPriceKnown(entry.ID, entry.DisplayName, entry.InputPricePer1M, entry.OutputPricePer1M, entry.ContextWindow), Capabilities: capabilities, Source: source, LiveMetadata: append([]byte(nil), entry.LiveMetadata...), - }) + } + applyProviderThinkingDefaults(&m) + out = append(out, m) } return out } diff --git a/engine/host_control.go b/engine/host_control.go index d881b17..db94af3 100644 --- a/engine/host_control.go +++ b/engine/host_control.go @@ -134,6 +134,12 @@ func (e *Engine) GatewayRegion(providerID string) (label string, required bool) func gatewayRegionFromConfig(providerID string, cfg *config.ProviderConfig) (label string, required bool) { providerID = NormalizeProviderID(providerID) + // Check registry: any provider with RegionOptions is regional. + spec, hasSpec := registry.SpecByProviderID(providerID) + if !hasSpec || len(spec.RegionOptions) == 0 { + return "", false + } + // Provider is regional. Delegate to provider-specific config readers. switch providerID { case runtime.GatewayXiaomiTokenPlan: if cfg == nil { @@ -147,8 +153,16 @@ func gatewayRegionFromConfig(providerID string, cfg *config.ProviderConfig) (lab } region, err := zai.NormalizeRegion(cfg.ZAICodingRegion) return string(region), err != nil + case "zai_payg": + if cfg == nil { + return "", true + } + region, err := zai.NormalizeRegion(cfg.ZAIRegion) + return string(region), err != nil default: - return "", false + // Future regional providers: default to required with no label when + // no config reader exists yet. + return "", true } } diff --git a/engine/host_facade_contract_test.go b/engine/host_facade_contract_test.go index 11034d2..b562d0a 100644 --- a/engine/host_facade_contract_test.go +++ b/engine/host_facade_contract_test.go @@ -113,7 +113,6 @@ func TestCredentialAliasesDriveStatusDiscoveryAndRemoval(t *testing.T) { }{ {provider: "anthropic", primary: "ANTHROPIC_API_KEY", alias: "CLAUDE_API_KEY"}, {provider: "gemini", primary: "GEMINI_API_KEY", alias: "GOOGLE_API_KEY"}, - {provider: "xiaomi_mimo_payg", primary: "XIAOMI_MIMO_PAYG_API_KEY", alias: "XIAOMI_MIMO_API_KEY"}, } { t.Run(test.provider, func(t *testing.T) { dir := t.TempDir() diff --git a/engine/model_policy.go b/engine/model_policy.go index bb27e25..bd4841c 100644 --- a/engine/model_policy.go +++ b/engine/model_policy.go @@ -244,11 +244,13 @@ func (e *Engine) customGatewayForModel(modelID string) (CustomGateway, bool) { } func customGatewayModel(gateway CustomGateway) Model { - return Model{ + m := Model{ ID: gateway.DefaultModel, CanonicalID: gateway.DefaultModel, DisplayName: gateway.DefaultModel, Owner: gateway.DisplayName, ProviderID: gateway.ID, GatewayID: gateway.ID, ContextWindow: gateway.ContextWindow, Capabilities: customGatewayCapabilityNames(gateway), Source: "custom", } + applyProviderThinkingDefaults(&m) + return m } func catalogTier(class ModelClass) catalog.ModelTier { diff --git a/go.mod b/go.mod index c881b58..3becbbf 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/GrayCodeAI/eyrie go 1.26.5 require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.11 + github.com/GrayCodeAI/hawk-core-contracts v0.1.12 github.com/google/uuid v1.6.0 github.com/tiktoken-go/tokenizer v0.8.0 github.com/zalando/go-keyring v0.2.8 diff --git a/go.sum b/go.sum index 1d16f8b..99238cd 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/GrayCodeAI/hawk-core-contracts v0.1.11 h1:qv6zIoi4InxYxAwgFZrmoberjvEBdB50nSKTd4qVyBE= -github.com/GrayCodeAI/hawk-core-contracts v0.1.11/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= +github.com/GrayCodeAI/hawk-core-contracts v0.1.12 h1:percfsd771JLmO9gMkrQtENEPBA9ZN3dG1Nc1moN3ZQ= +github.com/GrayCodeAI/hawk-core-contracts v0.1.12/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 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= diff --git a/lefthook.yml b/lefthook.yml index 438c957..7a98161 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -44,7 +44,14 @@ pre-commit: echo "lefthook: golangci-lint not installed — skipping (install: https://golangci-lint.run/usage/install/)" exit 0 fi - golangci-lint run --new-from-rev=HEAD~1 --fix {staged_files} + # Lint the directories holding staged files. Running golangci-lint on + # named files fails with "named files must all be in one directory" + # when staged .go files span multiple packages. The dirs are fed via + # xargs so they arrive as separate arguments regardless of shell. + STAGED=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.go$' || true) + if [ -n "$STAGED" ]; then + echo "$STAGED" | xargs -n1 dirname | sort -u | xargs golangci-lint run --fix + fi stage_fixed: true yaml-lint: @@ -57,10 +64,12 @@ pre-commit: forbidden-strings: run: | - # Catch obvious credential-shaped strings in staged additions. + # Catch obvious credential-shaped literals in staged additions. + # Only does so for quoted value literals — not bare `==` comparison + # guards like `if apiKey == ""`. bad=$(git diff --cached --diff-filter=AM -U0 -- {staged_files} \ | grep -E '^\+' \ - | grep -Ei '(aws_secret|password\s*=|api[_-]?key\s*=|BEGIN [A-Z]+ PRIVATE KEY)' \ + | grep -Ei "(aws_secret|password|api[_-]?key)\s*:?=\s*[\"']|BEGIN [A-Z]+ PRIVATE KEY" \ | grep -v 'example\|placeholder\|TODO\|x-release-please' || true) if [ -n "$bad" ]; then echo "lefthook: possible secret in staged changes:" diff --git a/runtime/transport.go b/runtime/transport.go index 747b218..e239b44 100644 --- a/runtime/transport.go +++ b/runtime/transport.go @@ -3,7 +3,6 @@ package runtime import ( "context" - "github.com/GrayCodeAI/eyrie/catalog/registry" "github.com/GrayCodeAI/eyrie/client" ) @@ -59,35 +58,10 @@ func resolveChatTransportSelection(ctx context.Context, selection SelectionState return transport, nil } -func directChatProvider(ctx context.Context, primary string) client.Provider { +func directChatProvider(_ context.Context, primary string) client.Provider { primary = NormalizeProviderID(primary) if primary == "" { return nil } - providers := []client.Provider{ - client.NewLazyProvider(&client.EyrieConfig{Provider: primary}), - } - for _, providerID := range directFallbackProviderIDs(ctx, primary) { - providers = append(providers, client.NewLazyProvider(&client.EyrieConfig{Provider: providerID})) - } - if len(providers) == 1 { - return providers[0] - } - fp, err := client.NewFallbackProvider(providers...) - if err != nil { - // Cannot happen: providers has at least 2 elements here. - return providers[0] - } - return fp -} - -func directFallbackProviderIDs(ctx context.Context, primary string) []string { - primary = NormalizeProviderID(primary) - var out []string - for _, providerID := range registry.DirectFallbackProviderIDs(primary) { - if providerConfigured(ctx, providerID) { - out = append(out, providerID) - } - } - return out + return client.NewLazyProvider(&client.EyrieConfig{Provider: primary}) } diff --git a/runtime/transport_policy_test.go b/runtime/transport_policy_test.go index 287f755..5e11981 100644 --- a/runtime/transport_policy_test.go +++ b/runtime/transport_policy_test.go @@ -62,7 +62,7 @@ func TestEffectiveSelection_InfersProviderFromModelOverride(t *testing.T) { } } -func TestResolveChatTransport_DirectOpenAIFallsBackToAnthropic(t *testing.T) { +func TestResolveChatTransport_DirectOpenAISingleProvider(t *testing.T) { dir := t.TempDir() t.Setenv("HAWK_CONFIG_DIR", dir) if err := os.WriteFile(filepath.Join(dir, "provider.json"), []byte("{}\n"), 0o600); err != nil { @@ -93,8 +93,8 @@ func TestResolveChatTransport_DirectOpenAIFallsBackToAnthropic(t *testing.T) { if transport.Provider == nil { t.Fatal("expected transport provider") } - if got := transport.Provider.Name(); got != "fallback(openai->anthropic)" { - t.Fatalf("provider name = %q, want fallback(openai->anthropic)", got) + if got := transport.Provider.Name(); got != "openai" { + t.Fatalf("provider name = %q, want openai (no fallback chain)", got) } if transport.Selection.Provider != "openai" { t.Fatalf("selection provider = %q, want openai", transport.Selection.Provider) diff --git a/setup/deployment.go b/setup/deployment.go index 3ce0200..854eef3 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -253,13 +253,13 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c if apiKey == "" { return nil, false } - return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultGrokOpenAIBaseURL), &client.GrokCompat), true + return client.NewGrokClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultGrokOpenAIBaseURL), &client.GrokCompat), true case "gemini-direct": apiKey := FirstNonEmpty(deployment.APIKey, lookup("GEMINI_API_KEY")) if apiKey == "" { return nil, false } - return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultGeminiOpenAIBaseURL), &client.GeminiCompat), true + return client.NewGeminiOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultGeminiOpenAIBaseURL), &client.GeminiCompat), true case "gemini-vertex": projectID := FirstNonEmpty(deployment.ProjectID, getenv("VERTEX_PROJECT_ID")) region := FirstNonEmpty(deployment.Region, getenv("VERTEX_REGION")) @@ -273,27 +273,26 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c if apiKey == "" { return nil, false } - return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultOpenRouterOpenAIBaseURL), &client.OpenRouterCompat), true + return client.NewOpenRouterClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultOpenRouterOpenAIBaseURL), &client.OpenRouterCompat), true case "canopywave": apiKey := FirstNonEmpty(deployment.APIKey, lookup("CANOPYWAVE_API_KEY")) if apiKey == "" { return nil, false } - return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultCanopyWaveOpenAIBaseURL), &client.CanopyWaveCompat), true + return client.NewCanopyWaveClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultCanopyWaveOpenAIBaseURL), &client.CanopyWaveCompat), true case "opengateway-payg": apiKey := FirstNonEmpty(deployment.APIKey, lookup("OPENGATEWAY_API_KEY")) if apiKey == "" { return nil, false } - return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultOpenGatewayOpenAIBaseURL), &client.OpenGatewayCompat), true + return client.NewOpenGatewayClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultOpenGatewayOpenAIBaseURL), &client.OpenGatewayCompat), true case "deepseek-direct": apiKey := FirstNonEmpty(deployment.APIKey, lookup("DEEPSEEK_API_KEY")) if apiKey == "" { 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 + openBase := FirstNonEmpty(deployment.BaseURL, "https://api.deepseek.com") + return client.NewDeepSeekClient(apiKey, openBase, &client.DeepSeekCompat), true case "poolside": apiKey := FirstNonEmpty(deployment.APIKey, lookup("POOLSIDE_API_KEY")) if apiKey == "" { @@ -305,20 +304,20 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c if apiKey == "" { return nil, false } - return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultGroqOpenAIBaseURL), &client.GroqCompat), true + return client.NewGroqClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultGroqOpenAIBaseURL), &client.GroqCompat), true case "clinepass": apiKey := FirstNonEmpty(deployment.APIKey, lookup("CLINE_API_KEY")) if apiKey == "" { return nil, false } - return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultClinePassOpenAIBaseURL), &client.ClinePassCompat), true + return client.NewClinePassClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultClinePassOpenAIBaseURL), &client.ClinePassCompat), true case "zai_payg-direct": return newZAIDeploymentClient(deployment, "zai_payg", "ZAI_API_KEY", lookup, cfg) case "zai_coding-direct": return newZAIDeploymentClient(deployment, "zai_coding", "ZAI_CODING_API_KEY", lookup, cfg) case "ollama-local": baseURL := config.NormalizeOllamaOpenAIBaseURL(FirstNonEmpty(deployment.BaseURL, getenv("OLLAMA_BASE_URL"), config.OllamaDefaultBaseURL)) - return client.NewOpenAIClient(FirstNonEmpty(deployment.APIKey, lookup("OLLAMA_API_KEY")), baseURL, &client.OllamaCompat), true + return client.NewOllamaClient(FirstNonEmpty(deployment.APIKey, lookup("OLLAMA_API_KEY")), baseURL, &client.OllamaCompat), true case "opencodego": apiKey := FirstNonEmpty(deployment.APIKey, lookup("OPENCODEGO_API_KEY")) if apiKey == "" { @@ -330,7 +329,25 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c if apiKey == "" { return nil, false } - return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultKimiOpenAIBaseURL), &client.KimiCompat), true + return client.NewKimiClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultKimiOpenAIBaseURL), &client.KimiCompat), true + case "agnes-direct": + apiKey := FirstNonEmpty(deployment.APIKey, lookup("AGNES_API_KEY")) + if apiKey == "" { + return nil, false + } + return client.NewAgnesClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultAgnesOpenAIBaseURL), &client.AgnesCompat), true + case "longcat-direct": + apiKey := FirstNonEmpty(deployment.APIKey, lookup("LONGCAT_API_KEY")) + if apiKey == "" { + return nil, false + } + return client.NewLongCatClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultLongCatOpenAIBaseURL), config.DefaultLongCatAnthropicBaseURL, &client.LongCatCompat), true + case "stepfun-direct": + apiKey := FirstNonEmpty(deployment.APIKey, lookup("STEP_API_KEY")) + if apiKey == "" { + return nil, false + } + return client.NewStepFunClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultStepFunOpenAIBaseURL), &client.StepFunCompat), true case "xiaomi_mimo_payg-direct": return newMiMoDeploymentClient(deployment, config.ProviderXiaomiMimoPayg, "XIAOMI_MIMO_PAYG_API_KEY", lookup, cfg) case "xiaomi_mimo_token_plan-direct": @@ -340,13 +357,13 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c if apiKey == "" { return nil, false } - return newMiniMaxDualProtocolClient(apiKey, deployment.BaseURL), true + return client.NewMiniMaxClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultMiniMaxOpenAIBaseURL), &client.MiniMaxCompat), true case "minimax_payg-direct": apiKey := FirstNonEmpty(deployment.APIKey, lookup("MINIMAX_PAYG_API_KEY")) if apiKey == "" { return nil, false } - return newMiniMaxDualProtocolClient(apiKey, deployment.BaseURL), true + return client.NewMiniMaxClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultMiniMaxOpenAIBaseURL), &client.MiniMaxCompat), true case "concentrate-payg": apiKey := FirstNonEmpty(deployment.APIKey, lookup("CONCENTRATE_API_KEY")) if apiKey == "" { @@ -374,8 +391,7 @@ 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 @@ -492,21 +508,6 @@ 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 { - 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 -} - // CloneStringMap returns a shallow copy of m. func CloneStringMap(in map[string]string) map[string]string { if len(in) == 0 { diff --git a/setup/deployment_test.go b/setup/deployment_test.go index 54e1a27..278e443 100644 --- a/setup/deployment_test.go +++ b/setup/deployment_test.go @@ -2,6 +2,7 @@ package setup import ( "context" + "reflect" "testing" "github.com/GrayCodeAI/eyrie/catalog" @@ -508,9 +509,9 @@ func TestProviderForDeployment_GrokDirect(t *testing.T) { if !ok { t.Fatal("expected grok-direct to be configured") } - // Grok uses OpenAIClient which reports "openai" as its name. - if p.Name() != "openai" { - t.Fatalf("provider name = %q, want openai", p.Name()) + // Grok uses a dedicated GrokClient. + if p.Name() != "grok" { + t.Fatalf("provider name = %q, want grok", p.Name()) } } @@ -519,8 +520,11 @@ func TestProviderForDeployment_GeminiDirect(t *testing.T) { if !ok { t.Fatal("expected gemini-direct to be configured") } - if p.Name() != "openai" { - t.Fatalf("provider name = %q, want openai", p.Name()) + if _, ok := p.(*client.GeminiOpenAIClient); !ok { + t.Fatalf("provider type = %T, want *client.GeminiOpenAIClient", p) + } + if p.Name() != "gemini" { + t.Fatalf("provider name = %q, want gemini", p.Name()) } } @@ -529,8 +533,8 @@ func TestProviderForDeployment_OpenRouter(t *testing.T) { if !ok { t.Fatal("expected openrouter to be configured") } - if p.Name() != "openai" { - t.Fatalf("provider name = %q, want openai", p.Name()) + if p.Name() != "openrouter" { + t.Fatalf("provider name = %q, want openrouter", p.Name()) } } @@ -563,8 +567,8 @@ func TestProviderForDeployment_OpenGateway(t *testing.T) { if !ok { t.Fatal("expected opengateway-payg to be configured") } - if p.Name() != "openai" { - t.Fatalf("provider name = %q, want openai", p.Name()) + if p.Name() != "opengateway" { + t.Fatalf("provider name = %q, want opengateway", p.Name()) } } @@ -581,8 +585,8 @@ func TestProviderForDeployment_CanopyWave(t *testing.T) { if !ok { t.Fatal("expected canopywave to be configured") } - if p.Name() != "openai" { - t.Fatalf("provider name = %q, want openai", p.Name()) + if p.Name() != "canopywave" { + t.Fatalf("provider name = %q, want canopywave", p.Name()) } } @@ -611,9 +615,9 @@ func TestProviderForDeployment_OllamaLocal(t *testing.T) { if !ok { t.Fatal("expected ollama-local to always be configured (no key needed)") } - // Ollama uses OpenAIClient which reports "openai" as its name. - if p.Name() != "openai" { - t.Fatalf("provider name = %q, want openai", p.Name()) + // Ollama uses a dedicated OllamaClient. + if p.Name() != "ollama" { + t.Fatalf("provider name = %q, want ollama", p.Name()) } } @@ -685,9 +689,46 @@ func TestProviderForDeployment_KimiDirect(t *testing.T) { if !ok { t.Fatal("expected kimi-direct to be configured") } - // Kimi uses OpenAIClient which reports "openai" as its name. - if p.Name() != "openai" { - t.Fatalf("provider name = %q, want openai", p.Name()) + // Kimi uses a dedicated KimiClient. + if p.Name() != "kimi" { + t.Fatalf("provider name = %q, want kimi", p.Name()) + } +} + +func TestProviderForDeployment_AgnesLongCatStepFunDirect(t *testing.T) { + tests := []struct { + deploymentID string + apiKey string + wantName string + wantType any + }{ + {"agnes-direct", "agnes-key", "agnes", (*client.AgnesClient)(nil)}, + {"longcat-direct", "longcat-key", "longcat", (*client.LongCatClient)(nil)}, + {"stepfun-direct", "stepfun-key", "stepfun", (*client.StepFunClient)(nil)}, + } + for _, tt := range tests { + t.Run(tt.deploymentID, func(t *testing.T) { + p, ok := ProviderForDeployment(tt.deploymentID, config.DeploymentConfig{APIKey: tt.apiKey}) + if !ok { + t.Fatalf("expected %s to be configured", tt.deploymentID) + } + if p.Name() != tt.wantName { + t.Fatalf("provider name = %q, want %q", p.Name(), tt.wantName) + } + if reflect.TypeOf(p) != reflect.TypeOf(tt.wantType) { + t.Fatalf("provider type = %T, want %T", p, tt.wantType) + } + }) + } +} + +func TestProviderForDeployment_AgnesLongCatStepFunRequiresKey(t *testing.T) { + for _, deploymentID := range []string{"agnes-direct", "longcat-direct", "stepfun-direct"} { + t.Run(deploymentID, func(t *testing.T) { + if _, ok := ProviderForDeployment(deploymentID, config.DeploymentConfig{}); ok { + t.Fatalf("expected %s to be unavailable without key", deploymentID) + } + }) } }