diff --git a/AGENTS.md b/AGENTS.md index c5f164b..f28ce4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,7 @@ When starting any new work (feature, fix, refactor, chore), always create a feat ## Design Principles -- **Model-agnostic** — single interface for 23 registered LLM providers -- **Plan-accurate provider metadata** — Concentrate AI is pay-as-you-go, uses - `concentrate-payg` as its deployment identifier, and uses its native Responses - API (`/v1/responses`) without legacy Chat Completions or Messages routing +- **Model-agnostic** — single interface for 75+ LLM providers - **Host-neutral engine** — Eyrie owns provider routing, transport, caching, retry/fallback, and normalized telemetry; hosts own product UX and semantics - **Streaming-first** — all responses are streamed; blocking is opt-in diff --git a/catalog/live_enrich.go b/catalog/live_enrich.go index 2bf0c27..519855c 100644 --- a/catalog/live_enrich.go +++ b/catalog/live_enrich.go @@ -56,8 +56,8 @@ func FetchLiveProviderCatalog(env map[string]string) (Catalog, []LiveProviderEnr ID: deploymentID, Name: providerID, ProviderID: providerID, - APIProtocolID: spec.ProtocolID, - AdapterConstructor: spec.AdapterID, + APIProtocolID: "openai-chat-completions", + AdapterConstructor: "openai", NativeModelIDSource: NativeModelIDDiscovered, ModelMappingsRequired: false, } @@ -83,7 +83,7 @@ func FetchLiveProviderCatalog(env map[string]string) (Catalog, []LiveProviderEnr } else if hasInputPricing(entry.RawJSON) { canonicalID = providerID + "/" + entryID } - } else { + } else if hasInputPricing(entry.RawJSON) { canonicalID = providerID + "/" + entryID } @@ -123,7 +123,7 @@ func FetchLiveModelEntriesForProvider(env map[string]string, providerID string) return nil, fmt.Errorf("catalog: provider %q has no live model list API", providerID) } env = registry.ScopedProviderEnv(spec, env) - if !spec.PublicModelCatalog && !registry.CredentialPresent(spec, env) { + if !registry.CredentialPresent(spec, env) { return nil, fmt.Errorf("catalog: set %s for %s", spec.CredentialEnv, providerID) } entries, err := live.Fetch(spec.LiveFetcherKey, env) diff --git a/catalog/registry/providers.go b/catalog/registry/providers.go index 50a5e49..4603703 100644 --- a/catalog/registry/providers.go +++ b/catalog/registry/providers.go @@ -17,7 +17,7 @@ func providerSpecs() []ProviderSpec { return []ProviderSpec{ // ── Direct API providers ────────────────────────────────────────── { - ProviderID: "anthropic", DisplayName: "Anthropic", DeploymentID: "anthropic-direct", SortOrder: 3, ChatPreference: 2, + ProviderID: "anthropic", DisplayName: "Anthropic", DeploymentID: "anthropic-direct", SortOrder: 1, ChatPreference: 2, TransportKind: "anthropic", RequiresKey: true, CredentialEnv: "ANTHROPIC_API_KEY", CredentialAliases: []string{"CLAUDE_API_KEY"}, @@ -28,7 +28,7 @@ func providerSpecs() []ProviderSpec { DirectFallbacks: []string{"openai"}, }, { - ProviderID: "openai", DisplayName: "OpenAI", DeploymentID: "openai-direct", SortOrder: 15, ChatPreference: 1, + ProviderID: "openai", DisplayName: "OpenAI", DeploymentID: "openai-direct", SortOrder: 2, ChatPreference: 1, TransportKind: "openai", RequiresKey: true, CredentialEnv: "OPENAI_API_KEY", BaseURLEnv: []string{"OPENAI_BASE_URL", "OPENAI_API_BASE"}, @@ -38,7 +38,7 @@ func providerSpecs() []ProviderSpec { DirectFallbacks: []string{"anthropic"}, }, { - ProviderID: "gemini", DisplayName: "Gemini API", DeploymentID: "gemini-direct", SortOrder: 9, ChatPreference: 5, + ProviderID: "gemini", DisplayName: "Gemini API", DeploymentID: "gemini-direct", SortOrder: 3, ChatPreference: 5, RuntimeBaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", RequiresKey: true, CredentialEnv: "GEMINI_API_KEY", CredentialAliases: []string{"GOOGLE_API_KEY"}, @@ -48,7 +48,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "gemini-generate-content", AdapterID: "gemini", RuntimeProfileKey: "gemini", }, { - ProviderID: "deepseek", DisplayName: "DeepSeek", DeploymentID: "deepseek-direct", SortOrder: 8, ChatPreference: 11, + ProviderID: "deepseek", DisplayName: "DeepSeek", DeploymentID: "deepseek-direct", SortOrder: 4, ChatPreference: 11, RequiresKey: true, CredentialEnv: "DEEPSEEK_API_KEY", BaseURLEnv: []string{"DEEPSEEK_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.deepseek.com/v1", @@ -56,7 +56,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "deepseek", RuntimeProfileKey: "deepseek", }, { - ProviderID: "grok", DisplayName: "xAI", DeploymentID: "grok-direct", SortOrder: 21, ChatPreference: 4, + ProviderID: "grok", DisplayName: "xAI", DeploymentID: "grok-direct", SortOrder: 5, ChatPreference: 4, RequiresKey: true, CredentialEnv: "XAI_API_KEY", BaseURLEnv: []string{"XAI_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.x.ai/v1", @@ -64,7 +64,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "grok", RuntimeProfileKey: "grok", }, { - ProviderID: "kimi", DisplayName: "Kimi", DeploymentID: "kimi-direct", SortOrder: 11, ChatPreference: 14, + ProviderID: "kimi", DisplayName: "Kimi", DeploymentID: "kimi-direct", SortOrder: 6, ChatPreference: 14, RequiresKey: true, CredentialEnv: "MOONSHOT_API_KEY", BaseURLEnv: []string{"MOONSHOT_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.moonshot.ai/v1", @@ -72,7 +72,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "kimi", RuntimeProfileKey: "kimi", }, { - ProviderID: "zai_coding", DisplayName: "Z.AI — Coding Plan", DeploymentID: "zai_coding-direct", SortOrder: 24, ChatPreference: 8, + ProviderID: "zai_coding", DisplayName: "Z.AI — Coding Plan", DeploymentID: "zai_coding-direct", SortOrder: 7, ChatPreference: 8, RequiresKey: true, CredentialEnv: "ZAI_CODING_API_KEY", BaseURLEnv: []string{"ZAI_CODING_BASE_URL", "ZAI_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/coding/paas/v4", @@ -81,7 +81,7 @@ func providerSpecs() []ProviderSpec { PrepareCredentialEnv: true, }, { - ProviderID: "zai_payg", DisplayName: "Z.AI — Pay-as-you-go", DeploymentID: "zai_payg-direct", SortOrder: 25, ChatPreference: 9, + ProviderID: "zai_payg", DisplayName: "Z.AI — Pay-as-you-go", DeploymentID: "zai_payg-direct", SortOrder: 8, ChatPreference: 9, RequiresKey: true, CredentialEnv: "ZAI_API_KEY", BaseURLEnv: []string{"ZAI_BASE_URL", "ZAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/paas/v4", @@ -90,7 +90,7 @@ func providerSpecs() []ProviderSpec { PrepareCredentialEnv: true, }, { - ProviderID: "xiaomi_mimo_token_plan", DisplayName: "Xiaomi MiMo — Token Plan", DeploymentID: "xiaomi_mimo_token_plan-direct", SortOrder: 23, ChatPreference: 16, + ProviderID: "xiaomi_mimo_token_plan", DisplayName: "Xiaomi MiMo — Token Plan", DeploymentID: "xiaomi_mimo_token_plan-direct", SortOrder: 9, ChatPreference: 16, RequiresKey: true, CredentialEnv: "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", BaseURLEnv: []string{"XIAOMI_MIMO_TOKEN_PLAN_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "", @@ -99,7 +99,7 @@ func providerSpecs() []ProviderSpec { PrepareCredentialEnv: true, }, { - ProviderID: "xiaomi_mimo_payg", DisplayName: "Xiaomi MiMo — Pay-as-you-go", DeploymentID: "xiaomi_mimo_payg-direct", SortOrder: 22, ChatPreference: 15, + ProviderID: "xiaomi_mimo_payg", DisplayName: "Xiaomi MiMo — Pay-as-you-go", DeploymentID: "xiaomi_mimo_payg-direct", SortOrder: 10, ChatPreference: 15, RequiresKey: true, CredentialEnv: "XIAOMI_MIMO_PAYG_API_KEY", CredentialAliases: []string{"XIAOMI_MIMO_API_KEY"}, BaseURLEnv: []string{"XIAOMI_MIMO_PAYG_BASE_URL", "XIAOMI_BASE_URL"}, @@ -108,7 +108,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "xiaomi_mimo", RuntimeProfileKey: "xiaomi_mimo_payg", }, { - ProviderID: "minimax_token_plan", DisplayName: "MiniMax — Token Plan", DeploymentID: "minimax_token_plan-direct", SortOrder: 14, ChatPreference: 17, + ProviderID: "minimax_token_plan", DisplayName: "MiniMax — Token Plan", DeploymentID: "minimax_token_plan-direct", SortOrder: 11, ChatPreference: 17, RequiresKey: true, CredentialEnv: "MINIMAX_TOKEN_PLAN_API_KEY", BaseURLEnv: []string{"MINIMAX_TOKEN_PLAN_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.minimax.io/v1", @@ -116,7 +116,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "minimax_token_plan", }, { - ProviderID: "minimax_payg", DisplayName: "MiniMax — Pay-as-you-go", DeploymentID: "minimax_payg-direct", SortOrder: 13, ChatPreference: 18, + ProviderID: "minimax_payg", DisplayName: "MiniMax — Pay-as-you-go", DeploymentID: "minimax_payg-direct", SortOrder: 12, ChatPreference: 18, RequiresKey: true, CredentialEnv: "MINIMAX_PAYG_API_KEY", BaseURLEnv: []string{"MINIMAX_PAYG_BASE_URL", "MINIMAX_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.minimax.io/v1", @@ -126,7 +126,7 @@ func providerSpecs() []ProviderSpec { // ── Cloud platform providers ────────────────────────────────────── { - ProviderID: "azure", DisplayName: "Azure OpenAI", DeploymentID: "openai-azure", SortOrder: 4, ChatPreference: 12, + ProviderID: "azure", DisplayName: "Azure OpenAI", DeploymentID: "openai-azure", SortOrder: 13, ChatPreference: 12, TransportKind: "azure", RequiresKey: true, CredentialEnv: "AZURE_OPENAI_API_KEY", BaseURLEnv: []string{"AZURE_OPENAI_ENDPOINT"}, @@ -135,7 +135,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "openai-azure", RuntimeProfileKey: "azure", }, { - ProviderID: "bedrock", DisplayName: "Amazon Bedrock", DeploymentID: "anthropic-bedrock", SortOrder: 2, ChatPreference: 7, + ProviderID: "bedrock", DisplayName: "Amazon Bedrock", DeploymentID: "anthropic-bedrock", SortOrder: 14, ChatPreference: 7, TransportKind: "bedrock", RequiresKey: true, CredentialEnv: "AWS_SECRET_ACCESS_KEY", CredentialEnvFallbacks: []string{"AWS_ACCESS_KEY_ID", "AWS_SESSION_TOKEN"}, @@ -145,7 +145,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "anthropic-messages", AdapterID: "anthropic-bedrock", RuntimeProfileKey: "bedrock", }, { - ProviderID: "vertex", DisplayName: "Vertex AI", DeploymentID: "gemini-vertex", SortOrder: 20, ChatPreference: 6, + ProviderID: "vertex", DisplayName: "Vertex AI", DeploymentID: "gemini-vertex", SortOrder: 15, ChatPreference: 6, TransportKind: "vertex", RequiresKey: true, CredentialEnv: "VERTEX_ACCESS_TOKEN", CredentialEnvFallbacks: []string{"GOOGLE_OAUTH_ACCESS_TOKEN"}, @@ -157,7 +157,7 @@ func providerSpecs() []ProviderSpec { // ── Aggregators ─────────────────────────────────────────────────── { - ProviderID: "openrouter", DisplayName: "OpenRouter", DeploymentID: "openrouter", SortOrder: 17, ChatPreference: 3, + ProviderID: "openrouter", DisplayName: "OpenRouter", DeploymentID: "openrouter", SortOrder: 16, ChatPreference: 3, RequiresKey: true, CredentialEnv: "OPENROUTER_API_KEY", BaseURLEnv: []string{"OPENROUTER_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://openrouter.ai/api/v1", @@ -200,7 +200,7 @@ func providerSpecs() []ProviderSpec { // ── Niche ───────────────────────────────────────────────────────── { - ProviderID: "canopywave", DisplayName: "CanopyWave", DeploymentID: "canopywave", SortOrder: 5, ChatPreference: 10, + ProviderID: "canopywave", DisplayName: "CanopyWave", DeploymentID: "canopywave", SortOrder: 17, ChatPreference: 10, RequiresKey: true, CredentialEnv: "CANOPYWAVE_API_KEY", BaseURLEnv: []string{"CANOPYWAVE_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://inference.canopywave.io/v1", @@ -208,7 +208,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "canopywave", RuntimeProfileKey: "canopywave", }, { - ProviderID: "poolside", DisplayName: "Poolside", DeploymentID: "poolside", SortOrder: 19, ChatPreference: 20, + ProviderID: "poolside", DisplayName: "Poolside", DeploymentID: "poolside", SortOrder: 18, ChatPreference: 20, RequiresKey: true, CredentialEnv: "POOLSIDE_API_KEY", BaseURLEnv: []string{"POOLSIDE_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://inference.poolside.ai/v1", @@ -216,7 +216,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "poolside", RuntimeProfileKey: "poolside", }, { - ProviderID: "groq", DisplayName: "Groq", DeploymentID: "groq-direct", SortOrder: 10, ChatPreference: 21, + ProviderID: "groq", DisplayName: "Groq", DeploymentID: "groq-direct", SortOrder: 19, ChatPreference: 21, RequiresKey: true, CredentialEnv: "GROQ_API_KEY", BaseURLEnv: []string{"GROQ_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.groq.com/openai/v1", @@ -224,7 +224,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "groq", RuntimeProfileKey: "groq", }, { - ProviderID: "clinepass", DisplayName: "ClinePass", DeploymentID: "clinepass", SortOrder: 6, ChatPreference: 22, + ProviderID: "clinepass", DisplayName: "ClinePass", DeploymentID: "clinepass", SortOrder: 20, ChatPreference: 22, RuntimeBaseURL: "https://api.cline.bot/api/v1", RequiresKey: true, CredentialEnv: "CLINE_API_KEY", BaseURLEnv: []string{"CLINE_API_BASE"}, @@ -233,7 +233,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "clinepass", RuntimeProfileKey: "clinepass", }, { - ProviderID: "opencodego", DisplayName: "OpenCode Go", DeploymentID: "opencodego", SortOrder: 16, ChatPreference: 13, + ProviderID: "opencodego", DisplayName: "OpenCode Go", DeploymentID: "opencodego", SortOrder: 21, ChatPreference: 13, RequiresKey: true, CredentialEnv: "OPENCODEGO_API_KEY", BaseURLEnv: []string{"OPENCODEGO_BASE_URL"}, ProbeKind: ProbeOpenAIModels, @@ -244,7 +244,7 @@ func providerSpecs() []ProviderSpec { // ── Local ───────────────────────────────────────────────────────── { - ProviderID: "ollama", DisplayName: "Ollama", DeploymentID: "ollama-local", SortOrder: 18, ChatPreference: 19, + ProviderID: "ollama", DisplayName: "Ollama", DeploymentID: "ollama-local", SortOrder: 22, ChatPreference: 19, RuntimeBaseURL: "http://localhost:11434/v1", RuntimeCredentialEnv: "OLLAMA_API_KEY", RequiresKey: false, CredentialEnv: "OLLAMA_BASE_URL", BaseURLEnv: []string{"OLLAMA_BASE_URL"}, diff --git a/catalog/v1_test.go b/catalog/v1_test.go index 349f6e3..0a88490 100644 --- a/catalog/v1_test.go +++ b/catalog/v1_test.go @@ -238,32 +238,6 @@ func TestCapabilitySetFromEntry_ToolsAliasSupportsFunctionCalling(t *testing.T) } } -func TestCapabilitySetFromEntry_KeepsContextAndMaxOutput(t *testing.T) { - t.Parallel() - set := CapabilitySetFromEntry(live.Entry{ - ContextWindow: 1_048_576, - MaxOutput: 131_072, - Features: []string{"tools", "thinking:enabled", "image_input"}, - ThinkingEnabled: true, - ImageInput: true, - }) - if set.MaxInputTokens != 1_048_576 { - t.Fatalf("MaxInputTokens = %d", set.MaxInputTokens) - } - if set.MaxOutputTokens != 131_072 { - t.Fatalf("MaxOutputTokens = %d", set.MaxOutputTokens) - } - if set.FunctionCalling != CapabilitySupported { - t.Fatalf("FunctionCalling = %q", set.FunctionCalling) - } - if set.ExplicitThinkingBudget != CapabilitySupported { - t.Fatalf("ExplicitThinkingBudget = %q", set.ExplicitThinkingBudget) - } - if set.ImageInput != CapabilitySupported { - t.Fatalf("ImageInput = %q", set.ImageInput) - } -} - func TestCapabilitySetFromLegacy_EmptyFeatures(t *testing.T) { t.Parallel() entry := ModelCatalogEntry{ID: "test-model"} diff --git a/client/adapters/anthropic.go b/client/adapters/anthropic.go index e6c0f40..13f714b 100644 --- a/client/adapters/anthropic.go +++ b/client/adapters/anthropic.go @@ -159,12 +159,6 @@ func thinkingDisabled() *anthropicThinking { } // resolveThinking builds the thinking config from core.ChatOptions. -// Explicit ThinkingMode / ThinkingBudgetTokens win. Otherwise ThinkingEnabled -// maps to Anthropic's documented toggle: -// - false → {type:"disabled"} -// - true → {type:"adaptive"} (recommended for current Claude models) -// -// See https://platform.claude.com/docs/en/build-with-claude/extended-thinking func resolveThinking(opts core.ChatOptions) *anthropicThinking { switch opts.ThinkingMode { case "adaptive": @@ -178,16 +172,6 @@ func resolveThinking(opts core.ChatOptions) *anthropicThinking { } return thinking default: - thinkingEnabled := opts.ThinkingEnabled - if thinkingEnabled == nil { - thinkingEnabled = opts.GLMThinkingEnabled - } - if thinkingEnabled != nil { - if !*thinkingEnabled { - return thinkingDisabled() - } - return thinkingAdaptive() - } // Legacy behavior: if budget > 0, enable with budget return thinkingForBudget(opts.ThinkingBudgetTokens) } diff --git a/client/adapters/compat.go b/client/adapters/compat.go index 5a06052..1990cd2 100644 --- a/client/adapters/compat.go +++ b/client/adapters/compat.go @@ -12,7 +12,7 @@ type OpenAICompatConfig struct { RequiresToolResultName bool `json:"requires_tool_result_name,omitempty"` RequiresAssistantAfterToolResult bool `json:"requires_assistant_after_tool_result,omitempty"` RequiresThinkingAsText bool `json:"requires_thinking_as_text,omitempty"` - ThinkingFormat string `json:"thinking_format,omitempty"` // "zai","longcat","kimi","deepseek","xiaomi","minimax","agnes","qwen","openrouter" + 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. @@ -21,17 +21,6 @@ type OpenAICompatConfig struct { // core.ChatOptions.KimiContextCacheID is non-empty, buildRequestBase prepends a // {"role":"cache","content":} message per the MoonshotAI-Cookbook spec. SupportsCacheRole bool `json:"supports_cache_role,omitempty"` - // OmitMaxTokens instructs buildRequestBase to leave max_tokens unset instead - // of sending the default 4096. Providers that pre-authorize the maximum token - // cost (e.g. Agnes AI) can return insufficient_user_quota when that hold - // exceeds the account balance; omitting max_tokens lets the provider apply its - // own default, which avoids the oversized hold. - OmitMaxTokens bool `json:"omit_max_tokens,omitempty"` - // DefaultDisableThinking: when ThinkingEnabled is nil, emit an explicit - // thinking={"type":"disabled"} for formats that use the thinking object. - // LongCat enables thinking by default when the field is omitted; that often - // burns the entire max_tokens budget on reasoning_content with no reply. - DefaultDisableThinking bool `json:"default_disable_thinking,omitempty"` } // Per-provider compat configs. @@ -45,36 +34,9 @@ var ( MaxTokensField: "max_tokens", } OpenRouterCompat = OpenAICompatConfig{ - // OpenRouter: reasoning={enabled|effort} per official reasoning-tokens guide. - ThinkingFormat: "openrouter", - MaxTokensField: "max_tokens", + ThinkingFormat: "openrouter", MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, } - // AgnesCompat: Agnes AI is OpenAI-compatible only (chat completions). It does - // not honor OpenAI-specific features like store/developer-role/reasoning, - // so those are left disabled. It uses the standard max_tokens field. - // Agnes pre-authorizes the maximum token cost of every request; sending the - // default 4096 max_tokens makes that hold exceed the account balance and - // triggers an insufficient_user_quota (403). OmitMaxTokens leaves max_tokens - // unset so Agnes applies its own default, which keeps the hold small — this - // matches the behavior of a bare curl to the Agnes API. - // Thinking uses chat_template_kwargs.enable_thinking per Agnes OpenAI docs. - AgnesCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - SupportsUsageInStreaming: true, - OmitMaxTokens: true, - ThinkingFormat: "agnes", - } - // LongCatCompat: OpenAI-compatible only (https://api.longcat.chat/openai). - // Official docs: max_tokens, thinking={"type":"enabled"|"disabled"}, tools. - // DefaultDisableThinking is required: omitting thinking leaves LongCat's - // server-side default (enabled), which commonly yields reasoning-only replies. - LongCatCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - SupportsUsageInStreaming: true, - ThinkingFormat: "longcat", - DefaultDisableThinking: true, - } GeminiCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, } @@ -91,9 +53,8 @@ var ( OpenCodeGoCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, - // OpenCode Go routes many models through OpenRouter-style reasoning. - ThinkingFormat: "openrouter", - StripReasoningFromInput: true, + ThinkingFormat: "openrouter", + StripReasoningFromInput: true, } PoolsideCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", @@ -107,17 +68,9 @@ var ( KimiCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsCacheRole: true, - // Kimi K2.5/K2.6: thinking={"type":"enabled"|"disabled"} (defaults enabled). - // https://platform.kimi.ai/docs/guide/use-kimi-k2-thinking-model - ThinkingFormat: "kimi", - DefaultDisableThinking: true, } XiaomiCompat = OpenAICompatConfig{ MaxTokensField: "max_completion_tokens", - // Xiaomi MiMo: thinking={"type":"enabled"|"disabled"} (on by default for pro). - // https://mimo.mi.com/docs/en-US/quick-start/usage-guide/text-generation/deep-thinking - ThinkingFormat: "xiaomi", - DefaultDisableThinking: true, } AzureCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", @@ -129,29 +82,11 @@ var ( MaxTokensField: "max_tokens", } // DeepSeekCompat: OpenAI-compatible with usage in streaming. - // The provider rejects reasoning_content in input messages with HTTP 400, so we strip it - // for non-tool turns. Thinking mode: thinking={"type":...} (defaults enabled per - // https://api-docs.deepseek.com/guides/thinking_mode). + // The provider rejects reasoning_content in input messages with HTTP 400, so we strip it. DeepSeekCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, StripReasoningFromInput: true, - ThinkingFormat: "deepseek", - DefaultDisableThinking: true, - } - // ConcentrateCompat: OpenAI-compatible with usage in streaming. - ConcentrateCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - SupportsUsageInStreaming: true, - } - // MiniMaxCompat: OpenAI chat completions at api.minimax.io/v1. - // Official OpenAI path: thinking.type is "disabled" | "adaptive" (default on when omitted). - // https://platform.minimax.io/docs/api-reference/text-openai-api - MiniMaxCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - SupportsUsageInStreaming: true, - ThinkingFormat: "minimax", - DefaultDisableThinking: true, } ) @@ -162,14 +97,6 @@ func init() { DynamicMu.Lock() defer DynamicMu.Unlock() - if p, ok := OpenAICompatibleProviders["agnes"]; ok { - p.Compat = &AgnesCompat - OpenAICompatibleProviders["agnes"] = p - } - if p, ok := OpenAICompatibleProviders["longcat"]; ok { - p.Compat = &LongCatCompat - OpenAICompatibleProviders["longcat"] = p - } if p, ok := OpenAICompatibleProviders["grok"]; ok { p.Compat = &GrokCompat OpenAICompatibleProviders["grok"] = p @@ -226,16 +153,6 @@ func init() { p.Compat = &DeepSeekCompat OpenAICompatibleProviders["deepseek"] = p } - if p, ok := OpenAICompatibleProviders["concentrate"]; ok { - p.Compat = &ConcentrateCompat - OpenAICompatibleProviders["concentrate"] = p - } - for _, id := range []string{"minimax_payg", "minimax_token_plan"} { - if p, ok := OpenAICompatibleProviders[id]; ok { - p.Compat = &MiniMaxCompat - OpenAICompatibleProviders[id] = p - } - } if p, ok := CoreProviders["openai"]; ok { p.Compat = &OpenAICompat CoreProviders["openai"] = p diff --git a/client/adapters/deepseek.go b/client/adapters/deepseek.go index e320960..6e2691c 100644 --- a/client/adapters/deepseek.go +++ b/client/adapters/deepseek.go @@ -2,37 +2,69 @@ package adapters import ( "context" + "log/slog" "strings" "github.com/GrayCodeAI/eyrie/client/core" ) -// DeepSeekClient uses the official OpenAI-compatible DeepSeek surface only -// (https://api.deepseek.com). +// DeepSeekClient uses OpenAI-compatible DeepSeek endpoints first, +// with optional Anthropic-compat fallback if the OpenAI endpoint is down. type DeepSeekClient struct { - openai *OpenAIClient + router ProtocolRouter + logger *slog.Logger } -// NewDeepSeekClient builds an OpenAI-compatible DeepSeek client. -// openAIBase is typically "https://api.deepseek.com/v1". -func NewDeepSeekClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *DeepSeekClient { +// 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 = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + anthropicBase = strings.TrimRight(strings.TrimSpace(anthropicBase), "/") dsOpts := append(append([]core.ClientOption{}, opts...), core.WithProviderName("deepseek")) - return &DeepSeekClient{openai: NewOpenAIClient(apiKey, openAIBase, compat, dsOpts...)} + 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(), + } } 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.openai.Chat(ctx, messages, opts) + 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 + }) } func (c *DeepSeekClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { - return c.openai.StreamChat(ctx, messages, opts) + 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 + }, + }) } func (c *DeepSeekClient) Ping(ctx context.Context) error { - return c.openai.Ping(ctx) + 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) } var _ core.Provider = (*DeepSeekClient)(nil) diff --git a/client/adapters/mimo.go b/client/adapters/mimo.go index 67e8ba0..93cda77 100644 --- a/client/adapters/mimo.go +++ b/client/adapters/mimo.go @@ -2,70 +2,115 @@ package adapters import ( "context" + "errors" + "log/slog" "net/http" "strconv" "strings" - "github.com/GrayCodeAI/eyrie/catalog/xiaomi" "github.com/GrayCodeAI/eyrie/client/core" + + "github.com/GrayCodeAI/eyrie/catalog/xiaomi" + "github.com/GrayCodeAI/eyrie/types" ) -// MiMoClient uses the official OpenAI-compatible Xiaomi MiMo surface only. +// MiMoClient uses OpenAI-compatible MiMo endpoints first, with optional Anthropic-compat fallback. type MiMoClient struct { - openai *OpenAIClient + router ProtocolRouter providerID string + logger *slog.Logger } -// NewMiMoClient builds an OpenAI-compatible MiMo client (payg or token_plan). -func NewMiMoClient(apiKey, openAIBase string, compat *OpenAICompatConfig, providerID string, opts ...core.ClientOption) *MiMoClient { +// 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 { 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{ - openai: NewOpenAIClient(apiKey, openAIBase, compat, mimoOpts...), + router: ProtocolRouter{OpenAI: o, Anthropic: a}, 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.openai != nil { - return c.openai.Name() + if c.router.OpenAI != nil { + return c.router.OpenAI.Name() } return c.providerID } func (c *MiMoClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { - return c.openai.Chat(ctx, messages, opts) + 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 + }) } func (c *MiMoClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { - return c.openai.StreamChat(ctx, messages, opts) + 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 + }, + }) } func (c *MiMoClient) Ping(ctx context.Context) error { - return c.openai.Ping(ctx) + 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) } -var _ core.Provider = (*MiMoClient)(nil) - -// mimoAuthHeaders sets MiMo-preferred authentication on outbound requests. -func mimoAuthHeaders(req *http.Request, apiKey string) { - xiaomi.SetMimoRequestAuth(req, apiKey) +func 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")) } -// ProviderID reports the configured MiMo gateway identity. -func (c *MiMoClient) ProviderID() string { return c.providerID } - -// MimoRetryableChatError reports whether an error is retryable for MiMo HTTP status rules. -// Kept for callers that classify MiMo errors outside the transport. -func MimoRetryableChatError(err error) bool { +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 { - return xiaomi.IsRetryableHTTPStatus(n) + if xiaomi.IsRetryableHTTPStatus(n) { + return true + } + } + // Structured path: trust core.EyrieError's IsRetriable + var eyrieErr *core.EyrieError + if errors.As(err, &eyrieErr) { + return eyrieErr.IsRetriable() } - return false + // Conservative: only retry on explicitly transient errors (not the optimistic "unknown → true") + return types.IsTransient(err) } func parseHTTPStatusFromError(msg string) int { @@ -85,3 +130,19 @@ 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 9149908..d2d9346 100644 --- a/client/adapters/mimo_test.go +++ b/client/adapters/mimo_test.go @@ -2,48 +2,42 @@ package adapters import ( "context" - "fmt" "net/http" - "strings" "testing" "github.com/GrayCodeAI/eyrie/client/core" - "github.com/GrayCodeAI/eyrie/types" ) -func TestMiMoClient_OpenAIOnly(t *testing.T) { +func TestMiMoClientChatFallsBackToAnthropicOnParamIncorrect(t *testing.T) { t.Parallel() - client := NewMiMoClient("tp-test-key", "https://openai.example/v1/", &XiaomiCompat, "xiaomi_mimo_token_plan") - if client == nil || client.openai == nil { - t.Fatal("expected OpenAI client") - } - if got := client.openai.baseURL; got != "https://openai.example/v1" { - t.Fatalf("OpenAI base URL = %q", got) - } - if client.ProviderID() != "xiaomi_mimo_token_plan" { - t.Fatalf("ProviderID = %q", client.ProviderID()) - } -} - -func TestMiMoClient_ChatUsesOpenAIAndMimoAuth(t *testing.T) { - t.Parallel() - var gotPath, gotAPIKey, gotAuth string - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - gotPath = req.URL.Path - gotAPIKey = req.Header.Get("api-key") - gotAuth = req.Header.Get("Authorization") + anthropicCalls := 0 + openAITransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != "/v1/chat/completions" { + t.Fatalf("openai path = %q", req.URL.Path) + } + return jsonResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"message": "Param Incorrect"}}), nil + }) + anthropicTransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + anthropicCalls++ + if req.URL.Path != "/anthropic/v1/messages" { + t.Fatalf("anthropic path = %q", req.URL.Path) + } + if req.Header.Get("api-key") != "tp-test-key" { + t.Fatalf("missing MiMo api-key auth header") + } return jsonResponse(http.StatusOK, map[string]any{ - "id": "chat", - "choices": []map[string]any{ - {"message": map[string]string{"role": "assistant", "content": "ok"}, "finish_reason": "stop"}, - }, + "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}, }), nil }) - client := NewMiMoClient("tp-test-key", "https://openai.example/v1", &XiaomiCompat, "xiaomi_mimo_token_plan") - client.openai.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.openai.httpClient = &http.Client{Transport: transport} - + 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} response, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ Model: "mimo-v2.5-pro", MaxTokens: 1024, @@ -54,101 +48,27 @@ func TestMiMoClient_ChatUsesOpenAIAndMimoAuth(t *testing.T) { if response.Content != "ok" { t.Fatalf("content = %q, want ok", response.Content) } - if !strings.HasSuffix(gotPath, "/chat/completions") { - t.Fatalf("path = %q, want /chat/completions", gotPath) - } - if gotAPIKey != "tp-test-key" && !strings.Contains(gotAuth, "tp-test-key") { - t.Fatalf("missing MiMo auth headers: api-key=%q Authorization=%q", gotAPIKey, gotAuth) - } -} - -func TestMiMoClient_Name(t *testing.T) { - t.Parallel() - client := NewMiMoClient("key", "https://oai.example/v1", &XiaomiCompat, "providerA") - if client.Name() != "providerA" { - t.Errorf("Name() = %q, want providerA", client.Name()) - } -} - -func TestMiMoClient_Name_NoOpenAI(t *testing.T) { - t.Parallel() - c := &MiMoClient{providerID: "bare-id"} - if c.Name() != "bare-id" { - t.Errorf("Name() = %q, want bare-id", c.Name()) - } -} - -func TestMiMoClient_Ping_SuccessViaOpenAI(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusOK, map[string]any{"data": []map[string]any{}}), nil - }) - client := NewMiMoClient("key", "https://oai.example/v1", &XiaomiCompat, "p") - client.openai.httpClient = &http.Client{Transport: transport} - if err := client.Ping(context.Background()); err != nil { - t.Fatalf("Ping: %v", err) - } -} - -func TestMiMoClient_Chat_SurfacesOpenAIError(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("key", "https://oai.example/v1", &XiaomiCompat, "p") - client.openai.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.openai.httpClient = &http.Client{Transport: transport} - - _, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "mimo"}) - if err == nil { - t.Fatal("expected OpenAI error without Anthropic fallback") - } -} - -func TestMiMoClient_StreamChat_SurfacesOpenAIError(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("key", "https://oai.example/v1", &XiaomiCompat, "p") - client.openai.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.openai.httpClient = &http.Client{Transport: transport} - - _, err := client.StreamChat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "mimo"}) - if err == nil { - t.Fatal("expected OpenAI error without Anthropic fallback") + if anthropicCalls != 1 { + t.Fatalf("anthropic calls = %d, want 1", anthropicCalls) } } -func TestMimoRetryableChatError_HTTPStatus(t *testing.T) { +func TestMiMoClientPreservesProtocolBaseURLs(t *testing.T) { t.Parallel() - if !MimoRetryableChatError(fmt.Errorf("HTTP 401")) { - t.Error("expected 401 to be retryable") - } - if MimoRetryableChatError(fmt.Errorf("HTTP 400")) { - t.Error("expected 400 not retryable") - } - if MimoRetryableChatError(fmt.Errorf("connection refused")) { - t.Error("expected non-HTTP errors not retryable via status helper") + client := NewMiMoClient( + "key", + "https://openai.example/v1/", + "https://anthropic.example/anthropic/", + &XiaomiCompat, + "xiaomi_mimo_token_plan", + ) + if got := client.router.OpenAI.baseURL; got != "https://openai.example/v1" { + t.Fatalf("OpenAI base URL = %q", got) } -} - -func TestParseHTTPStatusFromError(t *testing.T) { - t.Parallel() - tests := []struct { - msg string - want int - }{ - {"HTTP 404 Not Found", 404}, - {"status 500 internal", 500}, - {"error (403) forbidden", 403}, - {"no status here", 0}, - {"", 0}, + if client.router.Anthropic == nil { + t.Fatal("Anthropic fallback client is nil") } - for _, tt := range tests { - got := parseHTTPStatusFromError(tt.msg) - if got != tt.want { - t.Errorf("parseHTTPStatusFromError(%q) = %d, want %d", tt.msg, got, tt.want) - } + if got := client.router.Anthropic.baseURL; got != "https://anthropic.example/anthropic" { + t.Fatalf("Anthropic base URL = %q", got) } } diff --git a/client/adapters/opencodego.go b/client/adapters/opencodego.go index 54f3ad1..33fdb9a 100644 --- a/client/adapters/opencodego.go +++ b/client/adapters/opencodego.go @@ -4,19 +4,16 @@ import ( "context" "strings" - "github.com/GrayCodeAI/eyrie/catalog/opencodego" "github.com/GrayCodeAI/eyrie/client/core" + + "github.com/GrayCodeAI/eyrie/catalog/opencodego" ) -// OpenCodeGoClient routes each model to exactly one protocol: -// - OpenAI /v1/chat/completions when the model has an OpenAI-compatible endpoint -// - Anthropic /v1/messages when the model is Anthropic-only on OpenCode Go -// -// Never falls back across protocols for the same request (official Go docs list -// one endpoint per model). +// OpenCodeGoClient routes OpenCode Go models through OpenAIClient and +// AnthropicClient per opencode.ai/docs/go, with cross-protocol fallback when +// the primary path returns no answer text (e.g. reasoning-only MiniMax streams). type OpenCodeGoClient struct { - openai *OpenAIClient - anthropic *AnthropicClient + router ProtocolRouter } // NewOpenCodeGoClient builds an OpenCode Go provider client. @@ -26,10 +23,10 @@ func NewOpenCodeGoClient(apiKey, baseURL string, opts ...core.ClientOption) *Ope openBase = opencodego.DefaultBaseURL } ocgOpts := append(append([]core.ClientOption{}, opts...), core.WithProviderName("opencodego")) - return &OpenCodeGoClient{ - openai: NewOpenAIClient(apiKey, openBase, &OpenCodeGoCompat, ocgOpts...), - anthropic: NewAnthropicClient(apiKey, AnthropicBaseFromOpenAIV1(openBase), ocgOpts...), - } + return &OpenCodeGoClient{router: ProtocolRouter{ + OpenAI: NewOpenAIClient(apiKey, openBase, &OpenCodeGoCompat, ocgOpts...), + Anthropic: NewAnthropicClient(apiKey, AnthropicBaseFromOpenAIV1(openBase), ocgOpts...), + }} } func (c *OpenCodeGoClient) Name() string { return "opencodego" } @@ -37,22 +34,48 @@ func (c *OpenCodeGoClient) Name() string { return "opencodego" } func (c *OpenCodeGoClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { opts.Model = opencodego.NativeModelID(opts.Model) if opencodego.UsesMessagesAPI(opts.Model) { - return c.anthropic.Chat(ctx, messages, opts) + return c.router.Chat(ctx, messages, opts, ChatProtocolMessages, openCodeGoMessagesFallback) } - return c.openai.Chat(ctx, messages, opts) + return c.router.OpenAI.Chat(ctx, messages, opts) } func (c *OpenCodeGoClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { opts.Model = opencodego.NativeModelID(opts.Model) if opencodego.UsesMessagesAPI(opts.Model) { - return c.anthropic.StreamChat(ctx, messages, opts) + return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ + Primary: ChatProtocolMessages, + ReasoningOnlyFallback: true, + }) } - return c.openai.StreamChat(ctx, messages, opts) + return c.router.OpenAI.StreamChat(ctx, messages, opts) } func (c *OpenCodeGoClient) Ping(ctx context.Context) error { - // Health-check the OpenAI gateway surface; Anthropic-only models share the same host. - return c.openai.Ping(ctx) + if err := c.router.OpenAI.Ping(ctx); err == nil { + return nil + } + return c.router.Anthropic.Ping(ctx) +} + +func openCodeGoMessagesFallback(primaryErr error, primaryResp *core.EyrieResponse) bool { + if primaryErr != nil { + return oaCompatUnsupportedError(primaryErr) + } + return !core.ResponseHasContent(primaryResp) +} + +func oaCompatUnsupportedError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "status=401") || + strings.Contains(msg, "http 401") || + strings.Contains(msg, "oa-compat") || + strings.Contains(msg, "not supported") } +// OACompatUnsupportedError reports whether an error indicates OA-compat is unsupported. +func OACompatUnsupportedError(err error) bool { return oaCompatUnsupportedError(err) } + var _ core.Provider = (*OpenCodeGoClient)(nil) diff --git a/client/adapters/opencodego_test.go b/client/adapters/opencodego_test.go index 940464f..3f25d65 100644 --- a/client/adapters/opencodego_test.go +++ b/client/adapters/opencodego_test.go @@ -2,12 +2,12 @@ package adapters import ( "context" + "io" "net/http" "strings" "testing" "github.com/GrayCodeAI/eyrie/client/core" - "github.com/GrayCodeAI/eyrie/types" ) func TestOpenCodeGoClientRoutesMiniMaxToAnthropic(t *testing.T) { @@ -25,7 +25,7 @@ func TestOpenCodeGoClientRoutesMiniMaxToAnthropic(t *testing.T) { }) client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") - client.anthropic.httpClient = &http.Client{Transport: transport} + client.router.Anthropic.httpClient = &http.Client{Transport: transport} response, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ Model: "minimax-m2.5", MaxTokens: 256, }) @@ -58,7 +58,7 @@ func TestOpenCodeGoClientRoutesKimiToOpenAI(t *testing.T) { }) client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") - client.openai.httpClient = &http.Client{Transport: transport} + client.router.OpenAI.httpClient = &http.Client{Transport: transport} response, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ Model: "kimi-k2.5", MaxTokens: 256, }) @@ -73,7 +73,7 @@ func TestOpenCodeGoClientRoutesKimiToOpenAI(t *testing.T) { } } -func TestOpenCodeGoClientQwenUsesAnthropicOnly(t *testing.T) { +func TestOpenCodeGoClientQwen401FallsBackToOpenAI(t *testing.T) { t.Parallel() var paths []string transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { @@ -83,22 +83,62 @@ func TestOpenCodeGoClientQwenUsesAnthropicOnly(t *testing.T) { "error": map[string]string{"message": "Invalid API key"}, }), nil } - t.Fatalf("unexpected OpenAI path %q — no cross-protocol fallback", req.URL.Path) - return nil, nil + return jsonResponse(http.StatusOK, map[string]any{ + "id": "chatcmpl-1", "object": "chat.completion", + "choices": []map[string]any{ + {"message": map[string]string{"role": "assistant", "content": "OK"}, "finish_reason": "stop"}, + }, + }), nil }) client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") - client.anthropic.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.anthropic.httpClient = &http.Client{Transport: transport} - client.openai.httpClient = &http.Client{Transport: transport} - _, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ + client.router.Anthropic.httpClient = &http.Client{Transport: transport} + client.router.OpenAI.httpClient = &http.Client{Transport: transport} + response, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ Model: "qwen3.7-max", MaxTokens: 256, }) - if err == nil { - t.Fatal("expected Anthropic error without OpenAI fallback") + if err != nil { + t.Fatalf("Chat: %v", err) + } + if response.Content != "OK" { + t.Fatalf("content = %q, want OK; paths=%v", response.Content, paths) + } +} + +func TestOpenCodeGoClientMessagesEmptyFallsBackToOpenAI(t *testing.T) { + t.Parallel() + var paths []string + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + paths = append(paths, req.URL.Path) + if strings.HasSuffix(req.URL.Path, "/messages") { + return jsonResponse(http.StatusOK, map[string]any{ + "id": "msg_1", "type": "message", "role": "assistant", + "content": []map[string]string{{"type": "thinking", "thinking": "hmm"}}, + "stop_reason": "end_turn", + }), nil + } + return jsonResponse(http.StatusOK, map[string]any{ + "id": "chatcmpl-1", "object": "chat.completion", + "choices": []map[string]any{ + {"message": map[string]string{"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}, + }, + }), nil + }) + + client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") + client.router.Anthropic.httpClient = &http.Client{Transport: transport} + client.router.OpenAI.httpClient = &http.Client{Transport: transport} + response, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ + Model: "minimax-m3", MaxTokens: 256, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if response.Content != "Hello!" { + t.Fatalf("content = %q, want Hello!; paths=%v", response.Content, paths) } - if len(paths) != 1 || !strings.HasSuffix(paths[0], "/messages") { - t.Fatalf("paths = %v, want single /messages call", paths) + if len(paths) < 2 { + t.Fatalf("expected anthropic then openai, got %v", paths) } } @@ -122,7 +162,7 @@ func TestOpenCodeGoClientNormalizesModelID(t *testing.T) { }), nil }) client := NewOpenCodeGoClient("key", "https://opencode.example/zen/go/v1") - client.openai.httpClient = &http.Client{Transport: transport} + client.router.OpenAI.httpClient = &http.Client{Transport: transport} _, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ Model: "opencode-go/kimi-k2.6", MaxTokens: 16, }) @@ -134,46 +174,61 @@ func TestOpenCodeGoClientNormalizesModelID(t *testing.T) { } } -func TestOpenCodeGoClient_Name(t *testing.T) { - t.Parallel() - client := NewOpenCodeGoClient("key", "https://opencode.example/zen/go/v1") - if client.Name() != "opencodego" { - t.Errorf("Name() = %q, want opencodego", client.Name()) - } -} - -func TestOpenCodeGoClient_Ping_Success(t *testing.T) { +func TestOpenCodeGoClientStreamMiniMaxReasoningOnlyFallsBackToChat(t *testing.T) { t.Parallel() + var paths []string transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusOK, map[string]any{}), nil + paths = append(paths, req.URL.Path) + if strings.HasSuffix(req.URL.Path, "/messages") { + body := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":10}}}\n\n" + + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\"}}\n\n" + + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"text\":\"hmm\"}}\n\n" + + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n" + + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + } + if strings.HasSuffix(req.URL.Path, "/chat/completions") && req.Header.Get("Accept") == "text/event-stream" { + t.Fatal("stream fallback should not run before non-streaming chat fallback") + } + return jsonResponse(http.StatusOK, map[string]any{ + "id": "chatcmpl-1", "object": "chat.completion", + "choices": []map[string]any{ + {"message": map[string]string{"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}, + }, + }), nil }) - client := NewOpenCodeGoClient("key", "https://opencode.example/zen/go/v1") - client.openai.httpClient = &http.Client{Transport: transport} - if err := client.Ping(context.Background()); err != nil { - t.Fatalf("Ping: %v", err) - } -} -func TestOpenCodeGoClient_Ping_OpenAIOnly(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusUnauthorized, map[string]any{}), nil + client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") + client.router.Anthropic.httpClient = &http.Client{Transport: transport} + client.router.OpenAI.httpClient = &http.Client{Transport: transport} + result, err := client.StreamChat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hello how are you?"}}, core.ChatOptions{ + Model: "minimax-m3", MaxTokens: 256, }) - client := NewOpenCodeGoClient("key", "https://openai.example/v1") - client.openai.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.openai.httpClient = &http.Client{Transport: transport} - if err := client.Ping(context.Background()); err == nil { - t.Fatal("expected ping error without Anthropic fallback") + if err != nil { + t.Fatalf("StreamChat: %v", err) } -} + defer result.Close() -func TestNewOpenCodeGoClient_DefaultBaseURL(t *testing.T) { - t.Parallel() - client := NewOpenCodeGoClient("key", "") - if client == nil { - t.Fatal("expected non-nil client") + var content string + for event := range result.Events { + switch event.Type { + case "thinking": + t.Fatal("reasoning-only primary stream must not leak thinking before chat fallback") + case "content": + content += event.Content + case "error": + t.Fatalf("unexpected stream error: %s", event.Error) + } + } + if content != "Hello!" { + t.Fatalf("content = %q, want Hello!; paths=%v", content, paths) } - if client.Name() != "opencodego" { - t.Errorf("Name = %q, want opencodego", client.Name()) + if len(paths) < 2 { + t.Fatalf("expected /messages then /chat/completions, got %v", paths) } } diff --git a/client/adapters/poolside_test.go b/client/adapters/poolside_test.go index d6d6aa4..cee2c10 100644 --- a/client/adapters/poolside_test.go +++ b/client/adapters/poolside_test.go @@ -70,58 +70,3 @@ func TestPoolsideClientReasoningOnlyStreamFallsBackToChat(t *testing.T) { t.Fatalf("requests = %d, want stream plus chat fallback", requests) } } - -func TestPoolsideClientReasoningOnlyStreamFallbackBumpsMaxTokens(t *testing.T) { - t.Parallel() - var requests int - var fallbackMaxTokens int - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - requests++ - var body struct { - MaxTokens int `json:"max_tokens"` - } - if err := jsonDecodeRequest(req, &body); err != nil { - t.Fatalf("decode request: %v", err) - } - if requests == 2 { - fallbackMaxTokens = body.MaxTokens - } - if requests == 1 { - return jsonResponse(http.StatusOK, map[string]any{ - "choices": []map[string]any{{ - "message": map[string]string{"role": "assistant", "reasoning_content": "thinking"}, - "finish_reason": "stop", - }}, - }), nil - } - return jsonResponse(http.StatusOK, map[string]any{ - "choices": []map[string]any{{ - "message": map[string]string{"role": "assistant", "content": "Hi"}, - "finish_reason": "stop", - }}, - }), nil - }) - - client := NewPoolsideClient("poolside-test-key", "https://poolside.example/v1") - client.openAI.httpClient = &http.Client{Transport: transport} - result, err := client.StreamChat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ - Model: "poolside/laguna-m.1", MaxTokens: 128, - }) - if err != nil { - t.Fatalf("StreamChat: %v", err) - } - defer result.Close() - - var content string - for event := range result.Events { - if event.Type == "content" { - content += event.Content - } - } - if content != "Hi" { - t.Fatalf("content = %q, want Hi", content) - } - if fallbackMaxTokens != 512 { - t.Fatalf("fallback max_tokens = %d, want 512 (bumped from 128)", fallbackMaxTokens) - } -} diff --git a/client/adapters/protocol_router.go b/client/adapters/protocol_router.go new file mode 100644 index 0000000..86cda44 --- /dev/null +++ b/client/adapters/protocol_router.go @@ -0,0 +1,241 @@ +package adapters + +import ( + "context" + "fmt" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" + "github.com/GrayCodeAI/hawk-core-contracts/llm" +) + +// ChatProtocol selects which existing eyrie client handles a gateway request. +type ChatProtocol int + +const ( + // ChatProtocolCompletions routes via OpenAIClient (POST /v1/chat/completions). + ChatProtocolCompletions ChatProtocol = iota + // ChatProtocolMessages routes via AnthropicClient (POST /v1/messages). + ChatProtocolMessages +) + +type streamOpener func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.StreamResult, error) + +type chatOpener func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.EyrieResponse, error) + +// protocolStreamFallback retries a reasoning-only /v1/messages stream via the +// alternate protocol. Non-streaming Chat is tried first because OpenCode Go +// MiniMax often returns answer text on chat/completions while the stream only +// exposes reasoning_content. +type protocolStreamFallback struct { + stream streamOpener + chat chatOpener +} + +// ProtocolChatFallback decides whether to retry via the alternate protocol +// after the primary attempt. resp is non-nil only when primary returned err=nil. +type ProtocolChatFallback func(primaryErr error, primaryResp *core.EyrieResponse) bool + +// ProtocolRouter picks between OpenAIClient and AnthropicClient for gateways +// that expose both APIs (OpenCode Go, MiMo). All HTTP work stays in openai.go +// and anthropic.go; this type only orchestrates routing and fallback. +type ProtocolRouter struct { + OpenAI *OpenAIClient + Anthropic *AnthropicClient +} + +// ProtocolStreamConfig controls streaming across two protocols. +type ProtocolStreamConfig struct { + Primary ChatProtocol + FallbackOnError func(error) bool + ReasoningOnlyFallback bool // retry alternate protocol when primary stream is reasoning-only +} + +// Chat sends a request via the primary protocol, optionally falling back to the +// alternate protocol when fallback returns true. +func (r ProtocolRouter) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions, primary ChatProtocol, fallback ProtocolChatFallback) (*core.EyrieResponse, error) { + primaryClient, fallbackClient := r.providers(primary) + resp, err := primaryClient.Chat(ctx, messages, opts) + if fallback == nil || !fallback(err, resp) { + return resp, err + } + fallbackResp, fallbackErr := fallbackClient.Chat(ctx, messages, opts) + if fallbackErr == nil && core.ResponseHasContent(fallbackResp) { + return fallbackResp, nil + } + return resp, err +} + +// StreamChat streams via the primary protocol. When FallbackOnError matches, +// the alternate protocol is tried immediately. When ReasoningOnlyFallback is set +// and the primary is /v1/messages, an empty reasoning-only stream retries via +// chat/completions. +func (r ProtocolRouter) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions, cfg ProtocolStreamConfig) (*core.StreamResult, error) { + primaryClient, fallbackClient := r.providers(cfg.Primary) + result, err := primaryClient.StreamChat(ctx, messages, opts) + if err != nil { + if cfg.FallbackOnError != nil && cfg.FallbackOnError(err) { + return fallbackClient.StreamChat(ctx, messages, opts) + } + return result, err + } + if cfg.ReasoningOnlyFallback && cfg.Primary == ChatProtocolMessages { + fallback := fallbackClient + return newStreamWithReasoningFallback(ctx, messages, opts, result, protocolStreamFallback{ + chat: func(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return fallback.Chat(ctx, messages, opts) + }, + stream: func(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + return fallback.StreamChat(ctx, messages, opts) + }, + }), nil + } + return result, nil +} + +func (r ProtocolRouter) providers(primary ChatProtocol) (core.Provider, core.Provider) { + if primary == ChatProtocolMessages { + return r.Anthropic, r.OpenAI + } + return r.OpenAI, r.Anthropic +} + +// AnthropicBaseFromOpenAIV1 strips a trailing /v1 from an OpenAI-compatible base URL. +func AnthropicBaseFromOpenAIV1(openAIBase string) string { + base := strings.TrimRight(strings.TrimSpace(openAIBase), "/") + if strings.HasSuffix(base, "/v1") { + return strings.TrimSuffix(base, "/v1") + } + return base +} + +func streamResultFromChat(resp *core.EyrieResponse) *core.StreamResult { + out := make(chan core.EyrieStreamEvent, core.StreamChannelBuffer) + go func() { + defer close(out) + if resp == nil { + return + } + if strings.TrimSpace(resp.Thinking) != "" { + out <- core.EyrieStreamEvent{Type: "thinking", Thinking: resp.Thinking} + } + if strings.TrimSpace(resp.Content) != "" { + out <- core.EyrieStreamEvent{Type: "content", Content: resp.Content} + } + for i := range resp.ToolCalls { + tc := resp.ToolCalls[i] + out <- core.EyrieStreamEvent{Type: "tool_call", ToolCall: &tc} + } + if resp.Usage != nil { + out <- core.EyrieStreamEvent{Type: "usage", Usage: resp.Usage} + } + stop := resp.FinishReason + if stop == "" { + stop = "stop" + } + out <- core.EyrieStreamEvent{Type: "done", StopReason: stop} + }() + return llm.NewStreamResult(out, "", func() {}) +} + +func (f protocolStreamFallback) open(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + if f.chat != nil { + resp, err := f.chat(ctx, messages, opts) + if err == nil && core.ResponseHasContent(resp) { + return streamResultFromChat(resp), nil + } + } + if f.stream != nil { + return f.stream(ctx, messages, opts) + } + return nil, fmt.Errorf("eyrie: protocol stream fallback is not configured") +} + +func newStreamWithReasoningFallback(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions, primary *core.StreamResult, fallback protocolStreamFallback) *core.StreamResult { + out := make(chan core.EyrieStreamEvent, core.StreamChannelBuffer) + cancelCtx, cancel := context.WithCancel(ctx) + go func() { + defer close(out) + defer primary.Close() + defer cancel() + + var ( + sawReasoning bool + content strings.Builder + toolCalls int + buffered []core.EyrieStreamEvent + streamErr bool + ) + flush := func() { + for _, ev := range buffered { + select { + case out <- ev: + case <-cancelCtx.Done(): + return + } + } + buffered = nil + } + for ev := range primary.Events { + switch ev.Type { + case "thinking": + sawReasoning = true + case "content": + content.WriteString(ev.Content) + case "tool_call": + toolCalls++ + case "error": + if !isReasoningOnlyStreamDiagnostic(ev.Error) { + streamErr = true + } + } + if strings.TrimSpace(content.String()) != "" || toolCalls > 0 { + flush() + select { + case out <- ev: + case <-cancelCtx.Done(): + return + } + continue + } + buffered = append(buffered, ev) + } + + health := core.DetectResponseHealth(core.ResponseSignals{ + SawReasoning: sawReasoning, + ContentLen: len(strings.TrimSpace(content.String())), + ToolCalls: toolCalls, + StreamEnded: true, + StreamErr: streamErr, + }) + if health != core.ResponseErrorOnlyReasoning { + flush() + return + } + + fallbackResult, err := fallback.open(cancelCtx, messages, opts) + if err != nil { + flush() + select { + case out <- core.EyrieStreamEvent{Type: "error", Error: err.Error()}: + case <-cancelCtx.Done(): + } + return + } + defer fallbackResult.Close() + for ev := range fallbackResult.Events { + select { + case out <- ev: + case <-cancelCtx.Done(): + return + } + } + }() + return llm.NewStreamResult(out, "", cancel) +} + +func isReasoningOnlyStreamDiagnostic(message string) bool { + message = strings.ToLower(strings.TrimSpace(message)) + return strings.Contains(message, "error_only_reasoning") || + strings.Contains(message, "reasoning tokens but no answer") +} diff --git a/client/adapters/protocol_router_test.go b/client/adapters/protocol_router_test.go new file mode 100644 index 0000000..bad6055 --- /dev/null +++ b/client/adapters/protocol_router_test.go @@ -0,0 +1,153 @@ +package adapters + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/GrayCodeAI/eyrie/client/core" + "github.com/GrayCodeAI/hawk-core-contracts/llm" +) + +func TestStreamResultFromChat(t *testing.T) { + t.Parallel() + result := streamResultFromChat(&core.EyrieResponse{ + Content: "Hi there!", + FinishReason: "stop", + }) + var content string + for event := range result.Events { + if event.Type == "content" { + content += event.Content + } + } + if content != "Hi there!" { + t.Fatalf("content = %q, want Hi there!", content) + } +} + +func TestNewStreamWithReasoningFallbackChatFirst(t *testing.T) { + t.Parallel() + primaryEvents := make(chan core.EyrieStreamEvent, 4) + primaryEvents <- core.EyrieStreamEvent{Type: "thinking", Thinking: "internal reasoning"} + primaryEvents <- core.EyrieStreamEvent{Type: "done", StopReason: "end_turn"} + close(primaryEvents) + primary := llm.NewStreamResult(primaryEvents, "", func() {}) + + var chatCalled, streamCalled bool + fallback := protocolStreamFallback{ + chat: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.EyrieResponse, error) { + chatCalled = true + return &core.EyrieResponse{Content: "Hello from chat fallback!", FinishReason: "stop"}, nil + }, + stream: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.StreamResult, error) { + streamCalled = true + return nil, fmt.Errorf("stream fallback should not run") + }, + } + + result := newStreamWithReasoningFallback(context.Background(), nil, core.ChatOptions{}, primary, fallback) + var content string + for event := range result.Events { + switch event.Type { + case "thinking": + t.Fatal("primary reasoning must not leak before chat fallback succeeds") + case "content": + content += event.Content + } + } + if content != "Hello from chat fallback!" { + t.Fatalf("content = %q, want Hello from chat fallback!", content) + } + if !chatCalled { + t.Fatal("expected chat fallback to run") + } + if streamCalled { + t.Fatal("stream fallback must not run when chat fallback succeeds") + } +} + +func TestNewStreamWithReasoningFallbackStreamWhenChatEmpty(t *testing.T) { + t.Parallel() + primaryEvents := make(chan core.EyrieStreamEvent, 4) + primaryEvents <- core.EyrieStreamEvent{Type: "thinking", Thinking: "internal reasoning"} + primaryEvents <- core.EyrieStreamEvent{Type: "done", StopReason: "end_turn"} + close(primaryEvents) + primary := llm.NewStreamResult(primaryEvents, "", func() {}) + + fallbackEvents := make(chan core.EyrieStreamEvent, 4) + fallbackEvents <- core.EyrieStreamEvent{Type: "content", Content: "stream answer"} + fallbackEvents <- core.EyrieStreamEvent{Type: "done", StopReason: "stop"} + close(fallbackEvents) + + var chatCalled, streamCalled bool + fallback := protocolStreamFallback{ + chat: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.EyrieResponse, error) { + chatCalled = true + return &core.EyrieResponse{Thinking: "still thinking"}, nil + }, + stream: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.StreamResult, error) { + streamCalled = true + return llm.NewStreamResult(fallbackEvents, "", func() {}), nil + }, + } + + result := newStreamWithReasoningFallback(context.Background(), nil, core.ChatOptions{}, primary, fallback) + var content string + for event := range result.Events { + if event.Type == "content" { + content += event.Content + } + } + if content != "stream answer" { + t.Fatalf("content = %q, want stream answer", content) + } + if !chatCalled || !streamCalled { + t.Fatalf("chatCalled=%v streamCalled=%v, want both true", chatCalled, streamCalled) + } +} + +func TestProtocolRouterChatFallbackOnError(t *testing.T) { + t.Parallel() + openAI := NewOpenAIClient("key", "https://example/openai", nil) + anthropic := NewAnthropicClient("key", "https://example") + openAI.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusBadGateway, map[string]string{"error": "down"}), nil + })} + anthropic.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, map[string]any{ + "id": "msg_1", "type": "message", "role": "assistant", + "content": []map[string]string{{"type": "text", "text": "fallback"}}, + "stop_reason": "end_turn", + }), nil + })} + + router := ProtocolRouter{OpenAI: openAI, Anthropic: anthropic} + response, err := router.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ + Model: "test", MaxTokens: 16, + }, ChatProtocolCompletions, func(err error, _ *core.EyrieResponse) bool { + return err != nil + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if response.Content != "fallback" { + t.Fatalf("content = %q, want fallback", response.Content) + } +} + +func TestProtocolRouterNoFallbackWhenNil(t *testing.T) { + t.Parallel() + openAI := NewOpenAIClient("key", "https://example/openai", nil) + openAI.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusBadGateway, map[string]string{"error": "down"}), nil + })} + router := ProtocolRouter{OpenAI: openAI} + _, err := router.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ + Model: "test", MaxTokens: 16, + }, ChatProtocolCompletions, nil) + if err == nil { + t.Fatal("expected error without fallback") + } +} diff --git a/client/adapters/provider_registry.go b/client/adapters/provider_registry.go index b72e151..2061150 100644 --- a/client/adapters/provider_registry.go +++ b/client/adapters/provider_registry.go @@ -76,20 +76,19 @@ func staticProviderMaps() (map[string]ProviderRegistryConfig, map[string]Provide func DetectProvider() string { ctx := context.Background() checks := map[string]func() bool{ - "anthropic": func() bool { return credentials.HasSecret(ctx, "ANTHROPIC_API_KEY") }, - "deepseek": func() bool { return credentials.HasSecret(ctx, "DEEPSEEK_API_KEY") }, - "openrouter": func() bool { return credentials.HasSecret(ctx, "OPENROUTER_API_KEY") }, - "concentrate": func() bool { return credentials.HasSecret(ctx, "CONCENTRATE_API_KEY") }, - "grok": func() bool { return credentials.HasSecret(ctx, "XAI_API_KEY") }, - "gemini": func() bool { return credentials.HasSecret(ctx, "GEMINI_API_KEY") }, - "zai_payg": func() bool { return credentials.HasSecret(ctx, "ZAI_API_KEY") }, - "zai_coding": func() bool { return credentials.HasSecret(ctx, "ZAI_CODING_API_KEY") }, - "canopywave": func() bool { return credentials.HasSecret(ctx, "CANOPYWAVE_API_KEY") }, - "poolside": func() bool { return credentials.HasSecret(ctx, "POOLSIDE_API_KEY") }, - "groq": func() bool { return credentials.HasSecret(ctx, "GROQ_API_KEY") }, - "openai": func() bool { return credentials.HasSecret(ctx, "OPENAI_API_KEY") }, - "opencodego": func() bool { return credentials.HasSecret(ctx, "OPENCODEGO_API_KEY") }, - "kimi": func() bool { return credentials.HasSecret(ctx, "MOONSHOT_API_KEY") }, + "anthropic": func() bool { return credentials.HasSecret(ctx, "ANTHROPIC_API_KEY") }, + "deepseek": func() bool { return credentials.HasSecret(ctx, "DEEPSEEK_API_KEY") }, + "openrouter": func() bool { return credentials.HasSecret(ctx, "OPENROUTER_API_KEY") }, + "grok": func() bool { return credentials.HasSecret(ctx, "XAI_API_KEY") }, + "gemini": func() bool { return credentials.HasSecret(ctx, "GEMINI_API_KEY") }, + "zai_payg": func() bool { return credentials.HasSecret(ctx, "ZAI_API_KEY") }, + "zai_coding": func() bool { return credentials.HasSecret(ctx, "ZAI_CODING_API_KEY") }, + "canopywave": func() bool { return credentials.HasSecret(ctx, "CANOPYWAVE_API_KEY") }, + "poolside": func() bool { return credentials.HasSecret(ctx, "POOLSIDE_API_KEY") }, + "groq": func() bool { return credentials.HasSecret(ctx, "GROQ_API_KEY") }, + "openai": func() bool { return credentials.HasSecret(ctx, "OPENAI_API_KEY") }, + "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") }, diff --git a/client/adapters/test_helpers_test.go b/client/adapters/test_helpers_test.go index 1805eae..dcef8ba 100644 --- a/client/adapters/test_helpers_test.go +++ b/client/adapters/test_helpers_test.go @@ -4,15 +4,9 @@ import ( "bytes" "encoding/json" "io" - "log/slog" "net/http" - "testing" ) -func testLogger(t *testing.T) *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) -} - type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { @@ -41,5 +35,3 @@ func jsonDecodeRequest(req *http.Request, value any) error { } return json.Unmarshal(body, value) } - -func float64Ptr(v float64) *float64 { return &v } diff --git a/client/adapters/zai.go b/client/adapters/zai.go index afc29cb..9cf8dd4 100644 --- a/client/adapters/zai.go +++ b/client/adapters/zai.go @@ -2,46 +2,123 @@ package adapters import ( "context" + "errors" + "log/slog" + "net/http" "strings" "github.com/GrayCodeAI/eyrie/client/core" + + "github.com/GrayCodeAI/eyrie/types" ) -// ZAIClient uses the official OpenAI-compatible Z.AI surface only -// (paas/v4 or coding/paas/v4). +// ZAIClient uses the OpenAI-compatible endpoint (paas/v4 or coding/paas/v4) first, +// with Anthropic-compatible fallback (/api/anthropic) on retriable errors. +// This provides proper separation for General vs Coding Plan while giving +// both protocol surfaces (exactly as Xiaomi MiMo does for its plans). type ZAIClient struct { - openai *OpenAIClient + router ProtocolRouter providerID string + logger *slog.Logger } -// NewZAIClient builds an OpenAI-compatible Z.AI client for a plan/region gateway. +// NewZAIClient builds a Z.AI dual-protocol client for a given plan/region gateway. // openAIBase should be the resolved general or coding paas base. -func NewZAIClient(apiKey, openAIBase string, compat *OpenAICompatConfig, providerID string, opts ...core.ClientOption) *ZAIClient { +// anthropicBase should be the resolved /api/anthropic (global or cn). +// The same apiKey is used for both sides; the plan subscription attached to the key +// controls quota/billing when using the coding path. +func NewZAIClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, providerID string, opts ...core.ClientOption) *ZAIClient { openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + anthropicBase = strings.TrimRight(strings.TrimSpace(anthropicBase), "/") + zaiOpts := append([]core.ClientOption{core.WithProviderName(providerID)}, opts...) + o := NewOpenAIClient(apiKey, openAIBase, compat, zaiOpts...) + + var a *AnthropicClient + if anthropicBase != "" { + a = NewAnthropicClient(apiKey, anthropicBase, zaiOpts...) + } + return &ZAIClient{ - openai: NewOpenAIClient(apiKey, openAIBase, compat, zaiOpts...), + router: ProtocolRouter{OpenAI: o, Anthropic: a}, providerID: providerID, + logger: slog.Default(), } } func (c *ZAIClient) Name() string { - if c.openai != nil { - return c.openai.Name() + if c.router.OpenAI != nil { + return c.router.OpenAI.Name() } return c.providerID } func (c *ZAIClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { - return c.openai.Chat(ctx, messages, opts) + return c.router.Chat(ctx, messages, opts, ChatProtocolCompletions, func(err error, _ *core.EyrieResponse) bool { + if err != nil && c.router.Anthropic != nil && zaiFallbackChatError(err) { + c.logger.Info("Z.AI: OpenAI endpoint failed; retrying via Anthropic compatibility", + "provider", c.providerID, "error", err) + return true + } + return false + }) } func (c *ZAIClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { - return c.openai.StreamChat(ctx, messages, opts) + return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ + Primary: ChatProtocolCompletions, + FallbackOnError: func(err error) bool { + if c.router.Anthropic != nil && zaiFallbackChatError(err) { + c.logger.Info("Z.AI: OpenAI stream failed; retrying via Anthropic compatibility", + "provider", c.providerID, "error", err) + return true + } + return false + }, + }) } func (c *ZAIClient) Ping(ctx context.Context) error { - return c.openai.Ping(ctx) + if err := c.router.OpenAI.Ping(ctx); err == nil { + return nil + } else if c.router.Anthropic == nil || !zaiRetryableChatError(err) { + return err + } + return c.router.Anthropic.Ping(ctx) +} + +func zaiFallbackChatError(err error) bool { + if zaiRetryableChatError(err) { + return true + } + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + // Common Z.AI / GLM specific transient or format issues seen in the wild + // (similar to reasoning_content or param problems on other compat layers). + return strings.Contains(msg, "param incorrect") || + strings.Contains(msg, "invalid format") || + strings.Contains(msg, "reasoning_content") || + (strings.Contains(msg, "http 400") && strings.Contains(msg, "zai")) +} + +func zaiRetryableChatError(err error) bool { + if err == nil { + return false + } + // Z.AI-specific: check HTTP status codes + msg := err.Error() + if n := parseHTTPStatusFromError(msg); n > 0 { + return n >= 500 || n == http.StatusUnauthorized || n == http.StatusForbidden + } + // Structured path: trust core.EyrieError's IsRetriable + var eyrieErr *core.EyrieError + if errors.As(err, &eyrieErr) { + return eyrieErr.IsRetriable() + } + // Conservative: only retry on explicitly transient errors + return types.IsTransient(err) } var _ core.Provider = (*ZAIClient)(nil) diff --git a/client/aliases.go b/client/aliases.go index 518af17..bdf6879 100644 --- a/client/aliases.go +++ b/client/aliases.go @@ -278,10 +278,20 @@ type ( OpenCodeGoClient = adapters.OpenCodeGoClient // PoolsideClient implements Poolside reasoning-only stream recovery. PoolsideClient = adapters.PoolsideClient + // ProtocolRouter routes between OpenAI and Anthropic protocols. + ProtocolRouter = adapters.ProtocolRouter + // ProtocolStreamConfig controls streaming across two protocols. + ProtocolStreamConfig = adapters.ProtocolStreamConfig // TokenCountResult holds token counting results. TokenCountResult = adapters.TokenCountResult ) +// Adapter protocol constants. +const ( + ChatProtocolCompletions = adapters.ChatProtocolCompletions + ChatProtocolMessages = adapters.ChatProtocolMessages +) + // Adapter constructors. func NewAnthropicClient(apiKey, baseURL string, opts ...ClientOption) *AnthropicClient { return adapters.NewAnthropicClient(apiKey, baseURL, opts...) @@ -311,16 +321,16 @@ func NewVertexClient(projectID, region, token string) *VertexClient { return adapters.NewVertexClient(projectID, region, token) } -func NewDeepSeekClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...ClientOption) *DeepSeekClient { - return adapters.NewDeepSeekClient(apiKey, openAIBase, compat, opts...) +func NewDeepSeekClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, opts ...ClientOption) *DeepSeekClient { + return adapters.NewDeepSeekClient(apiKey, openAIBase, anthropicBase, compat, opts...) } -func NewZAIClient(apiKey, openAIBase string, compat *OpenAICompatConfig, providerID string, opts ...ClientOption) *ZAIClient { - return adapters.NewZAIClient(apiKey, openAIBase, compat, providerID, 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 string, compat *OpenAICompatConfig, providerID string, opts ...ClientOption) *MiMoClient { - return adapters.NewMiMoClient(apiKey, openAIBase, compat, providerID, opts...) +func NewMiMoClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, providerID string, opts ...ClientOption) *MiMoClient { + return adapters.NewMiMoClient(apiKey, openAIBase, anthropicBase, compat, providerID, opts...) } func NewOpenCodeGoClient(apiKey, baseURL string, opts ...ClientOption) *OpenCodeGoClient { @@ -371,6 +381,8 @@ var ( geminiSharedParserEnvVar = adapters.GeminiSharedParserEnvVar processGeminiStream = adapters.ProcessGeminiStream mimoRetryableChatError = adapters.MimoRetryableChatError + mimoFallbackChatError = adapters.MimoFallbackChatError + oaCompatUnsupportedError = adapters.OACompatUnsupportedError CoreProviders = adapters.CoreProviders OpenAICompatibleProviders = adapters.OpenAICompatibleProviders sha256Hex = adapters.Sha256Hex diff --git a/client/compat.go b/client/compat.go index 1d56230..d7adb90 100644 --- a/client/compat.go +++ b/client/compat.go @@ -7,25 +7,21 @@ type OpenAICompatConfig = adapters.OpenAICompatConfig // Per-provider compat configs. var ( - OpenAICompat = adapters.OpenAICompat - AgnesCompat = adapters.AgnesCompat - LongCatCompat = adapters.LongCatCompat - GrokCompat = adapters.GrokCompat - OpenRouterCompat = adapters.OpenRouterCompat - GeminiCompat = adapters.GeminiCompat - ZAICompat = adapters.ZAICompat - CanopyWaveCompat = adapters.CanopyWaveCompat - OllamaCompat = adapters.OllamaCompat - OpenCodeGoCompat = adapters.OpenCodeGoCompat - PoolsideCompat = adapters.PoolsideCompat - GroqCompat = adapters.GroqCompat - ClinePassCompat = adapters.ClinePassCompat - KimiCompat = adapters.KimiCompat - XiaomiCompat = adapters.XiaomiCompat - AzureCompat = adapters.AzureCompat - BedrockCompat = adapters.BedrockCompat - VertexCompat = adapters.VertexCompat - DeepSeekCompat = adapters.DeepSeekCompat - ConcentrateCompat = adapters.ConcentrateCompat - MiniMaxCompat = adapters.MiniMaxCompat + OpenAICompat = adapters.OpenAICompat + GrokCompat = adapters.GrokCompat + OpenRouterCompat = adapters.OpenRouterCompat + GeminiCompat = adapters.GeminiCompat + ZAICompat = adapters.ZAICompat + CanopyWaveCompat = adapters.CanopyWaveCompat + OllamaCompat = adapters.OllamaCompat + OpenCodeGoCompat = adapters.OpenCodeGoCompat + PoolsideCompat = adapters.PoolsideCompat + GroqCompat = adapters.GroqCompat + ClinePassCompat = adapters.ClinePassCompat + KimiCompat = adapters.KimiCompat + XiaomiCompat = adapters.XiaomiCompat + AzureCompat = adapters.AzureCompat + BedrockCompat = adapters.BedrockCompat + VertexCompat = adapters.VertexCompat + DeepSeekCompat = adapters.DeepSeekCompat ) diff --git a/client/mimo_test.go b/client/mimo_test.go index 6e30d7e..453f86e 100644 --- a/client/mimo_test.go +++ b/client/mimo_test.go @@ -30,6 +30,16 @@ func TestMimoRetryableChatError_UsesXiaomiHelper(t *testing.T) { } } +func TestMimoFallbackChatError_ParamIncorrect(t *testing.T) { + err := errors.New("eyrie: xiaomi_mimo_token_plan API error (request_id=): : Param Incorrect") + if !mimoFallbackChatError(err) { + t.Fatal("expected Param Incorrect to fallback to Anthropic compatibility") + } + if mimoFallbackChatError(errors.New("eyrie: openai API error: invalid model")) { + t.Fatal("non-MiMo unrelated errors should not trigger fallback") + } +} + func TestGetOrCreateProvider_XiaomiTokenPlanUsesMimoBase(t *testing.T) { t.Setenv("HAWK_CONFIG_DIR", t.TempDir()) if err := eyriecfg.SaveProviderConfig(&eyriecfg.ProviderConfig{ diff --git a/client/opencodego_test.go b/client/opencodego_test.go index 92e9b5d..e0d4e24 100644 --- a/client/opencodego_test.go +++ b/client/opencodego_test.go @@ -1,6 +1,7 @@ package client import ( + "fmt" "testing" "github.com/GrayCodeAI/eyrie/catalog/opencodego" @@ -32,3 +33,21 @@ func TestOpenCodeGoAnthropicBase(t *testing.T) { t.Fatalf("base = %q, want https://opencode.ai/zen/go", got) } } + +func TestOpenCodeGoOACompatUnsupportedError(t *testing.T) { + t.Parallel() + tests := []struct { + err error + want bool + }{ + {nil, false}, + {fmt.Errorf("status=401 unauthorized"), true}, + {fmt.Errorf("oa-compat not supported"), true}, + {fmt.Errorf("HTTP 400 bad request"), false}, + } + for _, tc := range tests { + if got := oaCompatUnsupportedError(tc.err); got != tc.want { + t.Errorf("OACompatUnsupportedError(%v) = %v, want %v", tc.err, got, tc.want) + } + } +} diff --git a/client/protocol_router_test.go b/client/protocol_router_test.go new file mode 100644 index 0000000..2396984 --- /dev/null +++ b/client/protocol_router_test.go @@ -0,0 +1,15 @@ +package client + +import ( + "testing" +) + +func TestAnthropicBaseFromOpenAIV1(t *testing.T) { + t.Parallel() + if got := AnthropicBaseFromOpenAIV1("https://example.com/zen/go/v1"); got != "https://example.com/zen/go" { + t.Fatalf("got %q", got) + } + if got := AnthropicBaseFromOpenAIV1("https://example.com/zen/go"); got != "https://example.com/zen/go" { + t.Fatalf("got %q", got) + } +} diff --git a/client/provider_policy.go b/client/provider_policy.go index ed07d09..f619835 100644 --- a/client/provider_policy.go +++ b/client/provider_policy.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/GrayCodeAI/eyrie/client/core" + "github.com/GrayCodeAI/eyrie/config" ) // ApplyProviderChatDefaults applies provider policy that host applications @@ -13,25 +14,8 @@ func ApplyProviderChatDefaults(provider string, opts ChatOptions) ChatOptions { if strings.EqualFold(strings.TrimSpace(provider), "anthropic") { opts.EnableCaching = true } - opts = NormalizeThinkingOptions(opts) - switch strings.ToLower(strings.TrimSpace(provider)) { - case "zai_payg", "zai_coding", "agnes", "openrouter", "opencodego", "anthropic": - // Keep ThinkingEnabled — wire encoding comes from each provider's ThinkingFormat - // (or Anthropic resolveThinking). - case "longcat", "kimi", "deepseek", "xiaomi_mimo", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", - "minimax_payg", "minimax_token_plan": - // These providers enable thinking when the field is omitted. Default off so - // simple chat does not burn max_tokens on reasoning_content alone. - if EffectiveThinkingEnabled(opts) == nil { - disabled := false - opts.ThinkingEnabled = &disabled - opts.GLMThinkingEnabled = &disabled - } - default: - if !ProviderSupportsThinkingToggle(provider) { - opts.ThinkingEnabled = nil - opts.GLMThinkingEnabled = nil - } + if !config.IsZAIProvider(provider) { + opts.GLMThinkingEnabled = nil } return opts } diff --git a/client/provider_policy_test.go b/client/provider_policy_test.go index c5f9f72..f3df322 100644 --- a/client/provider_policy_test.go +++ b/client/provider_policy_test.go @@ -15,32 +15,11 @@ func TestApplyProviderChatDefaults(t *testing.T) { t.Fatal("non-Anthropic provider enabled caching") } enabled := true - if opts := ApplyProviderChatDefaults("zai_payg", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil { + if opts := ApplyProviderChatDefaults("zai_payg", ChatOptions{GLMThinkingEnabled: &enabled}); opts.GLMThinkingEnabled == nil { t.Fatal("Z.AI thinking preference was removed") } - if opts := ApplyProviderChatDefaults("longcat", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil || !*opts.ThinkingEnabled { - t.Fatal("LongCat explicit thinking preference was removed") - } - if opts := ApplyProviderChatDefaults("longcat", ChatOptions{}); opts.ThinkingEnabled == nil || *opts.ThinkingEnabled { - t.Fatal("LongCat should default ThinkingEnabled=false when unset") - } - if opts := ApplyProviderChatDefaults("kimi", ChatOptions{}); opts.ThinkingEnabled == nil || *opts.ThinkingEnabled { - t.Fatal("Kimi should default ThinkingEnabled=false when unset") - } - if opts := ApplyProviderChatDefaults("deepseek", ChatOptions{}); opts.ThinkingEnabled == nil || *opts.ThinkingEnabled { - t.Fatal("DeepSeek should default ThinkingEnabled=false when unset") - } - if opts := ApplyProviderChatDefaults("agnes", ChatOptions{GLMThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil { - t.Fatal("Agnes should normalize deprecated GLMThinkingEnabled alias") - } - if opts := ApplyProviderChatDefaults("openrouter", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil { - t.Fatal("OpenRouter should keep ThinkingEnabled") - } - if opts := ApplyProviderChatDefaults("anthropic", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil { - t.Fatal("Anthropic should keep ThinkingEnabled") - } - if opts := ApplyProviderChatDefaults("openai", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled != nil || opts.GLMThinkingEnabled != nil { - t.Fatal("non-thinking provider thinking preference was retained") + if opts := ApplyProviderChatDefaults("openai", ChatOptions{GLMThinkingEnabled: &enabled}); opts.GLMThinkingEnabled != nil { + t.Fatal("non-Z.AI thinking preference was retained") } } diff --git a/client/provider_registry.go b/client/provider_registry.go index fa21c45..32d3ea3 100644 --- a/client/provider_registry.go +++ b/client/provider_registry.go @@ -132,7 +132,8 @@ func (c *EyrieClient) getOrCreateProvider(providerName string) (Provider, error) if err != nil { return nil, err } - p = adapters.NewZAIClient(apiKey, openAIBase, info.Compat, providerName) + anthropicBase := config.ResolveZAIAnthropicBase(providerCfg) + p = adapters.NewZAIClient(apiKey, openAIBase, anthropicBase, info.Compat, providerName) break } if config.IsXiaomiMimoProvider(providerName) { @@ -141,17 +142,17 @@ func (c *EyrieClient) getOrCreateProvider(providerName string) (Provider, error) if err != nil { return nil, err } - p = adapters.NewMiMoClient(apiKey, openAIBase, info.Compat, providerName) + anthropicBase, err := config.ResolveXiaomiAnthropicBase(providerName, providerCfg) + if err != nil { + return nil, err + } + p = adapters.NewMiMoClient(apiKey, openAIBase, anthropicBase, info.Compat, providerName) break } if providerName == "opencodego" { p = adapters.NewOpenCodeGoClient(apiKey, baseURL) break } - if providerName == "concentrate" { - p = adapters.NewConcentrateResponsesClient(apiKey, baseURL) - break - } if providerName == "poolside" { p = adapters.NewPoolsideClient(apiKey, baseURL) break diff --git a/config/provider_secrets.go b/config/provider_secrets.go index 3139d14..157d43a 100644 --- a/config/provider_secrets.go +++ b/config/provider_secrets.go @@ -44,7 +44,6 @@ func SanitizeProviderConfigForDisk(cfg ProviderConfig) ProviderConfig { cfg.PoolsideAPIKey = "" cfg.GroqAPIKey = "" cfg.ClinePassAPIKey = "" - cfg.AgnesAPIKey = "" if cfg.Deployments != nil { deployments := make(map[string]DeploymentConfig, len(cfg.Deployments)) for id, deployment := range cfg.Deployments { @@ -92,7 +91,6 @@ func LegacyProviderSecretsStrict(cfg ProviderConfig) (map[string]string, error) put("POOLSIDE_API_KEY", cfg.PoolsideAPIKey) put("GROQ_API_KEY", cfg.GroqAPIKey) put("CLINE_API_KEY", cfg.ClinePassAPIKey) - put("AGNES_API_KEY", cfg.AgnesAPIKey) deploymentIDs := make([]string, 0, len(cfg.Deployments)) for id := range cfg.Deployments { @@ -134,14 +132,11 @@ func legacyDeploymentCredentialEnv(deploymentID string) string { "grok-direct": "XAI_API_KEY", "gemini-direct": "GEMINI_API_KEY", "openrouter": "OPENROUTER_API_KEY", - "concentrate-payg": "CONCENTRATE_API_KEY", "canopywave": "CANOPYWAVE_API_KEY", "deepseek-direct": "DEEPSEEK_API_KEY", "poolside": "POOLSIDE_API_KEY", "groq-direct": "GROQ_API_KEY", "clinepass": "CLINE_API_KEY", - "agnes-direct": "AGNES_API_KEY", - "longcat-direct": "LONGCAT_API_KEY", "zai_payg-direct": "ZAI_API_KEY", "zai_coding-direct": "ZAI_CODING_API_KEY", "opencodego": "OPENCODEGO_API_KEY", @@ -161,7 +156,7 @@ func providerConfigSecrets(cfg ProviderConfig) []string { cfg.OpenRouterAPIKey, cfg.GeminiAPIKey, cfg.OpenCodeGoAPIKey, cfg.MoonshotAPIKey, cfg.XiaomiMimoPaygAPIKey, cfg.XiaomiMimoTokenPlanAPIKey, cfg.MiniMaxTokenPlanAPIKey, cfg.MiniMaxPaygAPIKey, - cfg.PoolsideAPIKey, cfg.GroqAPIKey, cfg.ClinePassAPIKey, cfg.AgnesAPIKey, + cfg.PoolsideAPIKey, cfg.GroqAPIKey, cfg.ClinePassAPIKey, } } diff --git a/config/provider_secrets_test.go b/config/provider_secrets_test.go index d79b063..c6e4ca9 100644 --- a/config/provider_secrets_test.go +++ b/config/provider_secrets_test.go @@ -52,18 +52,6 @@ func TestLegacyProviderSecretsStrictRejectsUnmappedDeploymentFields(t *testing.T } } -func TestLegacyProviderSecretsStrictMapsLongCatDirect(t *testing.T) { - secrets, err := LegacyProviderSecretsStrict(ProviderConfig{Deployments: map[string]DeploymentConfig{ - "longcat-direct": {APIKey: "lc-direct-secret-1234567890"}, - }}) - if err != nil { - t.Fatal(err) - } - if secrets["LONGCAT_API_KEY"] != "lc-direct-secret-1234567890" { - t.Fatalf("LONGCAT_API_KEY = %q", secrets["LONGCAT_API_KEY"]) - } -} - func TestLegacyProviderSecretsStrictMapsBedrockCompatibilityFields(t *testing.T) { secrets, err := LegacyProviderSecretsStrict(ProviderConfig{Deployments: map[string]DeploymentConfig{ "anthropic-bedrock": {APIKey: "AKIALEGACY123456789", Token: "legacy-secret-1234567890"}, diff --git a/conversation/engine_test.go b/conversation/engine_test.go index dcb34c1..adc7277 100644 --- a/conversation/engine_test.go +++ b/conversation/engine_test.go @@ -168,7 +168,7 @@ func (m *maxTokensMockProvider) StreamChat(_ context.Context, msgs []client.Eyri sr := &client.StreamResult{Events: ch} // Wrap Close so we can count invocations. return &client.StreamResult{ - Events: sr.Events, + Events: sr.Events, RequestID: sr.RequestID, }, nil } @@ -258,8 +258,10 @@ func TestConversationEngine_ContextCancelClosesStream(t *testing.T) { } // blockingMockProvider returns a StreamResult whose Events channel blocks -// until the context is cancelled. -type blockingMockProvider struct{} +// until the context is cancelled. It signals via a channel when Close is called. +type blockingMockProvider struct { + closed chan struct{} +} func (b *blockingMockProvider) Name() string { return "blocking-mock" diff --git a/engine/control_plane.go b/engine/control_plane.go index cbb2ee6..62870dd 100644 --- a/engine/control_plane.go +++ b/engine/control_plane.go @@ -65,13 +65,6 @@ func (e *Engine) CredentialProviders(context.Context) []CredentialProvider { return out } -// RegisteredProviderCount reports the number of first-class providers exposed -// by Eyrie's canonical registry. Hosts can use this for informational UI copy -// without importing lower-level catalog packages. -func RegisteredProviderCount() int { - return len(registry.All()) -} - // GatewayDefinitions returns pure registry/custom metadata in setup UI order. // It does not read credentials, provider state, or the model catalog. func (e *Engine) GatewayDefinitions() []Gateway { diff --git a/engine/convert.go b/engine/convert.go index 8037854..d6a1518 100644 --- a/engine/convert.go +++ b/engine/convert.go @@ -25,14 +25,7 @@ func toClientOptions(req GenerateRequest, route Route, stream bool) client.ChatO opts.ThinkingBudgetTokens = advanced.ThinkingBudgetTokens opts.ThinkingMode = advanced.ThinkingMode opts.ThinkingDisplay = advanced.ThinkingDisplay - opts.ThinkingEnabled = advanced.ThinkingEnabled opts.GLMThinkingEnabled = advanced.GLMThinkingEnabled - if opts.ThinkingEnabled == nil && opts.GLMThinkingEnabled != nil { - opts.ThinkingEnabled = opts.GLMThinkingEnabled - } - if opts.GLMThinkingEnabled == nil && opts.ThinkingEnabled != nil { - opts.GLMThinkingEnabled = opts.ThinkingEnabled - } opts.VirtualKeyID = advanced.VirtualKeyID opts.KimiContextCacheID = advanced.KimiContextCacheID opts.KimiCacheResetTTL = advanced.KimiCacheResetTTL diff --git a/engine/convert_test.go b/engine/convert_test.go index e3463ab..68b649d 100644 --- a/engine/convert_test.go +++ b/engine/convert_test.go @@ -222,9 +222,9 @@ func TestToClientOptions_NoOutputSchemaLeavesResponseFormatNil(t *testing.T) { func TestToClientOptions_ClonesSlicesAndMaps(t *testing.T) { // Verify that mutating the request after conversion does not affect the options. req := llm.GenerateRequest{ - Tools: []llm.EyrieTool{{Name: "a"}, {Name: "b"}}, - Options: llm.GenerationOptions{StopSequences: []string{"x", "y"}}, - OutputSchema: "orig", + Tools: []llm.EyrieTool{{Name: "a"}, {Name: "b"}}, + Options: llm.GenerationOptions{StopSequences: []string{"x", "y"}}, + OutputSchema: "orig", } route := Route{Provider: "test", Model: "test/model"} opts := toClientOptions(req, route, false) diff --git a/engine/engine.go b/engine/engine.go index 87c2765..19346ba 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -552,13 +552,7 @@ func offeringSupports(compiled *catalog.CompiledCatalog, modelID string, req Req for _, offering := range offerings { caps := offering.Capabilities if req.Tools && caps.FunctionCalling != catalog.CapabilitySupported { - // Treat unknown capability as supported. Providers that don't - // report function-calling capability (e.g. free tiers, smaller - // gateways) may still support tools. Better to try and fail than - // block models from being used. Only skip on explicit unsupported. - if caps.FunctionCalling == catalog.CapabilityUnsupported { - continue - } + continue } if req.Vision && caps.ImageInput != catalog.CapabilitySupported { continue diff --git a/engine/engine_test.go b/engine/engine_test.go index 8429db1..525ce75 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -212,42 +212,20 @@ func TestSelectCompatibleModelUsesCapabilitiesAndIntent(t *testing.T) { }, } - // vendor/text has unknown capabilities — treated as supporting tools - // and selected because it's the cheapest option model, provider := selectCompatibleModel(compiled, SelectionRequest{ Requirements: Requirements{Tools: true, MinimumContext: 100_000}, Preference: Preference{Intent: llm.IntentEconomical}, }) - if model != "vendor/text" || provider != "vendor" { - t.Fatalf("selected %q via %q, want vendor/text via vendor", model, provider) + if model != "vendor/cheap" || provider != "vendor" { + t.Fatalf("selected %q via %q, want vendor/cheap via vendor", model, provider) } model, _ = selectCompatibleModel(compiled, SelectionRequest{ Requirements: Requirements{Tools: true, MinimumContext: 150_000}, Preference: Preference{Intent: llm.IntentReasoning}, }) - // vendor/text selected: unknown capabilities treated as supported, largest context - if model != "vendor/text" { - t.Fatalf("selected %q, want vendor/text", model) - } -} - -func TestSelectCompatibleModel_ConcentrateLegacyCacheAssumesTools(t *testing.T) { - compiled := &catalog.CompiledCatalog{ - ModelsByID: map[string]catalog.Model{ - "concentrate/deepseek-v4-pro": {ID: "concentrate/deepseek-v4-pro", ProviderID: "concentrate", ContextWindow: 1_040_000}, - }, - OfferingsByCanonicalModel: map[string][]catalog.ModelOffering{ - "concentrate/deepseek-v4-pro": {{CanonicalModelID: "concentrate/deepseek-v4-pro", DeploymentID: "concentrate-payg"}}, - }, - } - - model, provider := selectCompatibleModel(compiled, SelectionRequest{ - Requirements: Requirements{Tools: true}, - Preference: Preference{PreferredProvider: "concentrate"}, - }) - if model != "concentrate/deepseek-v4-pro" || provider != "concentrate" { - t.Fatalf("selected %q via %q, want Concentrate model with tools", model, provider) + if model != "vendor/rich" { + t.Fatalf("selected %q, want vendor/rich", model) } } diff --git a/engine/host_facade_contract_test.go b/engine/host_facade_contract_test.go index a15a711..11034d2 100644 --- a/engine/host_facade_contract_test.go +++ b/engine/host_facade_contract_test.go @@ -7,16 +7,9 @@ import ( "testing" "github.com/GrayCodeAI/eyrie/catalog" - "github.com/GrayCodeAI/eyrie/catalog/registry" "github.com/GrayCodeAI/eyrie/credentials" ) -func TestRegisteredProviderCountMatchesCanonicalRegistry(t *testing.T) { - if got, want := RegisteredProviderCount(), len(registry.All()); got != want { - t.Fatalf("registered provider count = %d, want %d", got, want) - } -} - func TestGatewayDefinitionsArePureMetadataWithSeparateRanks(t *testing.T) { store := &countingStore{inner: &credentials.MapStore{}} eng, err := New(Options{SecretStore: store, StateDir: t.TempDir(), CustomGateways: []CustomGateway{}}) diff --git a/go.mod b/go.mod index f69aa18..aa9abf2 100644 --- a/go.mod +++ b/go.mod @@ -3,38 +3,35 @@ module github.com/GrayCodeAI/eyrie go 1.26.5 require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.9 + github.com/GrayCodeAI/hawk-core-contracts v0.1.8 github.com/google/uuid v1.6.0 github.com/tiktoken-go/tokenizer v0.8.0 github.com/zalando/go-keyring v0.2.8 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 - google.golang.org/grpc v1.82.0 + google.golang.org/grpc v1.81.1 modernc.org/sqlite v1.51.0 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dlclark/regexp2/v2 v2.2.1 // indirect + github.com/dlclark/regexp2/v2 v2.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/stretchr/objx v0.5.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.44.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.72.5 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index ea04922..78e324e 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,13 @@ -github.com/GrayCodeAI/hawk-core-contracts v0.1.9 h1:uXX/gtNM+3kxSEzu+rZkHykzcEaAbASn1lmPyOGMXvc= -github.com/GrayCodeAI/hawk-core-contracts v0.1.9/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= +github.com/GrayCodeAI/hawk-core-contracts v0.1.8 h1:SkDsGZJXL+3DYG0Fi3NXvNe/NlhP/KZn+Feofnx35Zc= +github.com/GrayCodeAI/hawk-core-contracts v0.1.8/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= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= -github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2/v2 v2.1.0 h1:jHXRmHRZGbuQzDZjMlCAXOvQb75iv3HyLDzXGj5H1AY= +github.com/dlclark/regexp2/v2 v2.1.0/go.mod h1:Bz5TMy5d8fPK0ximH0Yi9KvsRHNnvXqUx9XG6a4wB+I= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -21,8 +21,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -31,8 +31,8 @@ github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= @@ -49,30 +49,30 @@ go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= -google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/operationsgraph/operations_graph.go b/operationsgraph/operations_graph.go index 1baf537..3da70c1 100644 --- a/operationsgraph/operations_graph.go +++ b/operationsgraph/operations_graph.go @@ -33,11 +33,11 @@ type OperationEdge struct { // OperationsGraph represents a graph of operations for eyrie. type OperationsGraph struct { mu sync.RWMutex - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` Nodes map[string]*OperationNode `json:"nodes"` - Edges []OperationEdge `json:"edges"` - Attrs map[string]interface{} `json:"attrs,omitempty"` + Edges []OperationEdge `json:"edges"` + Attrs map[string]interface{} `json:"attrs,omitempty"` } // NewOperationsGraph creates a new operations graph. @@ -126,7 +126,7 @@ func (g *OperationsGraph) ToGraphSpec() *graphcontracts.GraphSpec { nodes = append(nodes, graphcontracts.NodeSpec{ ID: id, - Type: graphcontracts.NodeTypeFunction, + Type: graphcontracts.NodeTypeOperations, Name: node.Name, Config: config, }) @@ -142,9 +142,9 @@ func (g *OperationsGraph) ToGraphSpec() *graphcontracts.GraphSpec { } return &graphcontracts.GraphSpec{ - ID: g.ID, - Name: g.Name, - Nodes: nodes, - Edges: edges, + ID: g.ID, + Name: g.Name, + Nodes: nodes, + Edges: edges, } } diff --git a/setup/deployment.go b/setup/deployment.go index 13fc214..87bd813 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -11,7 +11,6 @@ import ( "github.com/GrayCodeAI/eyrie/catalog/xiaomi" "github.com/GrayCodeAI/eyrie/catalog/zai" "github.com/GrayCodeAI/eyrie/client" - "github.com/GrayCodeAI/eyrie/client/adapters" "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/credentials" "github.com/GrayCodeAI/eyrie/router" @@ -274,12 +273,6 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c return nil, false } return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultOpenRouterOpenAIBaseURL), &client.OpenRouterCompat), true - case "concentrate-payg": - apiKey := FirstNonEmpty(deployment.APIKey, lookup("CONCENTRATE_API_KEY")) - if apiKey == "" { - return nil, false - } - return adapters.NewConcentrateResponsesClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultConcentrateOpenAIBaseURL)), true case "canopywave": apiKey := FirstNonEmpty(deployment.APIKey, lookup("CANOPYWAVE_API_KEY")) if apiKey == "" { @@ -292,7 +285,8 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c return nil, false } openBase := FirstNonEmpty(deployment.BaseURL, "https://api.deepseek.com/v1") - return client.NewDeepSeekClient(apiKey, openBase, &client.DeepSeekCompat), true + anthropicBase := "https://api.deepseek.com/anthropic" + return client.NewDeepSeekClient(apiKey, openBase, anthropicBase, &client.DeepSeekCompat), true case "poolside": apiKey := FirstNonEmpty(deployment.APIKey, lookup("POOLSIDE_API_KEY")) if apiKey == "" { @@ -339,25 +333,13 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c if apiKey == "" { return nil, false } - return newMiniMaxOpenAIClient(apiKey, deployment.BaseURL), true - case "agnes-direct": - apiKey := FirstNonEmpty(deployment.APIKey, lookup("AGNES_API_KEY")) - if apiKey == "" { - return nil, false - } - return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, "https://apihub.agnes-ai.com/v1"), &client.AgnesCompat), true - case "longcat-direct": - apiKey := FirstNonEmpty(deployment.APIKey, lookup("LONGCAT_API_KEY")) - if apiKey == "" { - return nil, false - } - return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, "https://api.longcat.chat/openai/v1"), &client.LongCatCompat), true + return newMiniMaxDualProtocolClient(apiKey, deployment.BaseURL), true case "minimax_payg-direct": apiKey := FirstNonEmpty(deployment.APIKey, lookup("MINIMAX_PAYG_API_KEY")) if apiKey == "" { return nil, false } - return newMiniMaxOpenAIClient(apiKey, deployment.BaseURL), true + return newMiniMaxDualProtocolClient(apiKey, deployment.BaseURL), true default: return nil, false } @@ -378,11 +360,13 @@ func newMiMoDeploymentClient(deployment config.DeploymentConfig, providerID, env openBase = override } } - return client.NewMiMoClient(apiKey, openBase, &client.XiaomiCompat, providerID), true + anthropicBase, _ := config.ResolveXiaomiAnthropicBase(providerID, cfg) + return client.NewMiMoClient(apiKey, openBase, anthropicBase, &client.XiaomiCompat, providerID), true } -// newZAIDeploymentClient constructs an OpenAI-compatible Z.AI client for the -// general or Coding Plan gateway. +// newZAIDeploymentClient constructs a dual-protocol (OpenAI + Anthropic) Z.AI client +// for either the general or Coding Plan gateway, resolving the correct bases +// for the plan + region (international or china) per official docs. func newZAIDeploymentClient(deployment config.DeploymentConfig, providerID, envKey string, lookup func(...string) string, cfg *config.ProviderConfig) (client.Provider, bool) { apiKey := FirstNonEmpty(deployment.APIKey, lookup(envKey)) if apiKey == "" { @@ -400,7 +384,9 @@ func newZAIDeploymentClient(deployment config.DeploymentConfig, providerID, envK } } - return client.NewZAIClient(apiKey, openBase, &client.ZAICompat, providerID), true + anthropicBase := resolveZAIAnthropicBaseForDeployment(plan, cfg) + + return client.NewZAIClient(apiKey, openBase, anthropicBase, &client.ZAICompat, providerID), true } func resolveZAIOpenAIBaseForDeployment(plan zai.Plan, providerID string, cfg *config.ProviderConfig, override string) (string, error) { @@ -416,6 +402,19 @@ func resolveZAIOpenAIBaseForDeployment(plan zai.Plan, providerID string, cfg *co return zai.ResolveOpenAIBase(plan, region, override) } +func resolveZAIAnthropicBaseForDeployment(plan zai.Plan, cfg *config.ProviderConfig) string { + // region from general or coding, prefer coding if set + regionStr := "" + if cfg != nil { + regionStr = cfg.ZAICodingRegion + if regionStr == "" { + regionStr = cfg.ZAIRegion + } + } + region, _ := zai.NormalizeRegion(regionStr) + return zai.ResolveAnthropicBase(region) +} + // DefaultDeploymentForProvider maps a logical provider name to a deployment ID. func DefaultDeploymentForProvider(provider string) string { switch provider { @@ -435,8 +434,6 @@ func DefaultDeploymentForProvider(provider string) string { return "anthropic-bedrock" case config.ProviderOpenRouter: return "openrouter" - case config.ProviderConcentrate: - return "concentrate-payg" case config.ProviderCanopyWave: return "canopywave" case config.ProviderPoolside: @@ -484,8 +481,6 @@ func LegacyDeploymentConfig(cfg *config.ProviderConfig, provider string) config. return config.DeploymentConfig{APIKey: cfg.GeminiAPIKey, BaseURL: cfg.GeminiBaseURL} case config.ProviderOpenRouter: return config.DeploymentConfig{APIKey: cfg.OpenRouterAPIKey, BaseURL: cfg.OpenRouterBaseURL} - case config.ProviderConcentrate: - return config.DeploymentConfig{APIKey: cfg.ConcentrateAPIKey, BaseURL: cfg.ConcentrateBaseURL} case config.ProviderCanopyWave: return config.DeploymentConfig{APIKey: cfg.CanopyWaveAPIKey, BaseURL: cfg.CanopyWaveBaseURL} case config.ProviderPoolside: @@ -568,10 +563,19 @@ func FirstNonEmpty(values ...string) string { return "" } -// newMiniMaxOpenAIClient uses the official OpenAI-compatible MiniMax surface only. -func newMiniMaxOpenAIClient(apiKey, baseURL string) client.Provider { +// 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) - return client.NewOpenAIClient(apiKey, openaiBase, &client.MiniMaxCompat) + 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.