diff --git a/catalog/live_enrich.go b/catalog/live_enrich.go index 519855c..4d7afd6 100644 --- a/catalog/live_enrich.go +++ b/catalog/live_enrich.go @@ -123,7 +123,7 @@ func FetchLiveModelEntriesForProvider(env map[string]string, providerID string) return nil, fmt.Errorf("catalog: provider %q has no live model list API", providerID) } env = registry.ScopedProviderEnv(spec, env) - if !registry.CredentialPresent(spec, env) { + if !spec.PublicModelCatalog && !registry.CredentialPresent(spec, env) { return nil, fmt.Errorf("catalog: set %s for %s", spec.CredentialEnv, providerID) } entries, err := live.Fetch(spec.LiveFetcherKey, env) diff --git a/catalog/registry/providers.go b/catalog/registry/providers.go index 4603703..cbbeb65 100644 --- a/catalog/registry/providers.go +++ b/catalog/registry/providers.go @@ -17,7 +17,7 @@ func providerSpecs() []ProviderSpec { return []ProviderSpec{ // ── Direct API providers ────────────────────────────────────────── { - ProviderID: "anthropic", DisplayName: "Anthropic", DeploymentID: "anthropic-direct", SortOrder: 1, ChatPreference: 2, + ProviderID: "anthropic", DisplayName: "Anthropic", DeploymentID: "anthropic-direct", SortOrder: 3, ChatPreference: 2, TransportKind: "anthropic", RequiresKey: true, CredentialEnv: "ANTHROPIC_API_KEY", CredentialAliases: []string{"CLAUDE_API_KEY"}, @@ -28,7 +28,7 @@ func providerSpecs() []ProviderSpec { DirectFallbacks: []string{"openai"}, }, { - ProviderID: "openai", DisplayName: "OpenAI", DeploymentID: "openai-direct", SortOrder: 2, ChatPreference: 1, + ProviderID: "openai", DisplayName: "OpenAI", DeploymentID: "openai-direct", SortOrder: 15, ChatPreference: 1, TransportKind: "openai", RequiresKey: true, CredentialEnv: "OPENAI_API_KEY", BaseURLEnv: []string{"OPENAI_BASE_URL", "OPENAI_API_BASE"}, @@ -38,7 +38,7 @@ func providerSpecs() []ProviderSpec { DirectFallbacks: []string{"anthropic"}, }, { - ProviderID: "gemini", DisplayName: "Gemini API", DeploymentID: "gemini-direct", SortOrder: 3, ChatPreference: 5, + ProviderID: "gemini", DisplayName: "Gemini API", DeploymentID: "gemini-direct", SortOrder: 9, ChatPreference: 5, RuntimeBaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", RequiresKey: true, CredentialEnv: "GEMINI_API_KEY", CredentialAliases: []string{"GOOGLE_API_KEY"}, @@ -48,7 +48,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "gemini-generate-content", AdapterID: "gemini", RuntimeProfileKey: "gemini", }, { - ProviderID: "deepseek", DisplayName: "DeepSeek", DeploymentID: "deepseek-direct", SortOrder: 4, ChatPreference: 11, + ProviderID: "deepseek", DisplayName: "DeepSeek", DeploymentID: "deepseek-direct", SortOrder: 8, ChatPreference: 11, RequiresKey: true, CredentialEnv: "DEEPSEEK_API_KEY", BaseURLEnv: []string{"DEEPSEEK_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.deepseek.com/v1", @@ -56,7 +56,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "deepseek", RuntimeProfileKey: "deepseek", }, { - ProviderID: "grok", DisplayName: "xAI", DeploymentID: "grok-direct", SortOrder: 5, ChatPreference: 4, + ProviderID: "grok", DisplayName: "xAI", DeploymentID: "grok-direct", SortOrder: 21, ChatPreference: 4, RequiresKey: true, CredentialEnv: "XAI_API_KEY", BaseURLEnv: []string{"XAI_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.x.ai/v1", @@ -64,7 +64,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "grok", RuntimeProfileKey: "grok", }, { - ProviderID: "kimi", DisplayName: "Kimi", DeploymentID: "kimi-direct", SortOrder: 6, ChatPreference: 14, + ProviderID: "kimi", DisplayName: "Kimi", DeploymentID: "kimi-direct", SortOrder: 11, ChatPreference: 14, RequiresKey: true, CredentialEnv: "MOONSHOT_API_KEY", BaseURLEnv: []string{"MOONSHOT_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.moonshot.ai/v1", @@ -72,7 +72,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "kimi", RuntimeProfileKey: "kimi", }, { - ProviderID: "zai_coding", DisplayName: "Z.AI — Coding Plan", DeploymentID: "zai_coding-direct", SortOrder: 7, ChatPreference: 8, + ProviderID: "zai_coding", DisplayName: "Z.AI — Coding Plan", DeploymentID: "zai_coding-direct", SortOrder: 24, ChatPreference: 8, RequiresKey: true, CredentialEnv: "ZAI_CODING_API_KEY", BaseURLEnv: []string{"ZAI_CODING_BASE_URL", "ZAI_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/coding/paas/v4", @@ -81,7 +81,7 @@ func providerSpecs() []ProviderSpec { PrepareCredentialEnv: true, }, { - ProviderID: "zai_payg", DisplayName: "Z.AI — Pay-as-you-go", DeploymentID: "zai_payg-direct", SortOrder: 8, ChatPreference: 9, + ProviderID: "zai_payg", DisplayName: "Z.AI — Pay-as-you-go", DeploymentID: "zai_payg-direct", SortOrder: 25, ChatPreference: 9, RequiresKey: true, CredentialEnv: "ZAI_API_KEY", BaseURLEnv: []string{"ZAI_BASE_URL", "ZAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/paas/v4", @@ -90,7 +90,7 @@ func providerSpecs() []ProviderSpec { PrepareCredentialEnv: true, }, { - ProviderID: "xiaomi_mimo_token_plan", DisplayName: "Xiaomi MiMo — Token Plan", DeploymentID: "xiaomi_mimo_token_plan-direct", SortOrder: 9, ChatPreference: 16, + ProviderID: "xiaomi_mimo_token_plan", DisplayName: "Xiaomi MiMo — Token Plan", DeploymentID: "xiaomi_mimo_token_plan-direct", SortOrder: 23, ChatPreference: 16, RequiresKey: true, CredentialEnv: "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", BaseURLEnv: []string{"XIAOMI_MIMO_TOKEN_PLAN_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "", @@ -99,7 +99,7 @@ func providerSpecs() []ProviderSpec { PrepareCredentialEnv: true, }, { - ProviderID: "xiaomi_mimo_payg", DisplayName: "Xiaomi MiMo — Pay-as-you-go", DeploymentID: "xiaomi_mimo_payg-direct", SortOrder: 10, ChatPreference: 15, + ProviderID: "xiaomi_mimo_payg", DisplayName: "Xiaomi MiMo — Pay-as-you-go", DeploymentID: "xiaomi_mimo_payg-direct", SortOrder: 22, ChatPreference: 15, RequiresKey: true, CredentialEnv: "XIAOMI_MIMO_PAYG_API_KEY", CredentialAliases: []string{"XIAOMI_MIMO_API_KEY"}, BaseURLEnv: []string{"XIAOMI_MIMO_PAYG_BASE_URL", "XIAOMI_BASE_URL"}, @@ -108,7 +108,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "xiaomi_mimo", RuntimeProfileKey: "xiaomi_mimo_payg", }, { - ProviderID: "minimax_token_plan", DisplayName: "MiniMax — Token Plan", DeploymentID: "minimax_token_plan-direct", SortOrder: 11, ChatPreference: 17, + ProviderID: "minimax_token_plan", DisplayName: "MiniMax — Token Plan", DeploymentID: "minimax_token_plan-direct", SortOrder: 14, ChatPreference: 17, RequiresKey: true, CredentialEnv: "MINIMAX_TOKEN_PLAN_API_KEY", BaseURLEnv: []string{"MINIMAX_TOKEN_PLAN_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.minimax.io/v1", @@ -116,7 +116,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "minimax_token_plan", }, { - ProviderID: "minimax_payg", DisplayName: "MiniMax — Pay-as-you-go", DeploymentID: "minimax_payg-direct", SortOrder: 12, ChatPreference: 18, + ProviderID: "minimax_payg", DisplayName: "MiniMax — Pay-as-you-go", DeploymentID: "minimax_payg-direct", SortOrder: 13, ChatPreference: 18, RequiresKey: true, CredentialEnv: "MINIMAX_PAYG_API_KEY", BaseURLEnv: []string{"MINIMAX_PAYG_BASE_URL", "MINIMAX_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.minimax.io/v1", @@ -126,7 +126,7 @@ func providerSpecs() []ProviderSpec { // ── Cloud platform providers ────────────────────────────────────── { - ProviderID: "azure", DisplayName: "Azure OpenAI", DeploymentID: "openai-azure", SortOrder: 13, ChatPreference: 12, + ProviderID: "azure", DisplayName: "Azure OpenAI", DeploymentID: "openai-azure", SortOrder: 4, ChatPreference: 12, TransportKind: "azure", RequiresKey: true, CredentialEnv: "AZURE_OPENAI_API_KEY", BaseURLEnv: []string{"AZURE_OPENAI_ENDPOINT"}, @@ -135,7 +135,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "openai-azure", RuntimeProfileKey: "azure", }, { - ProviderID: "bedrock", DisplayName: "Amazon Bedrock", DeploymentID: "anthropic-bedrock", SortOrder: 14, ChatPreference: 7, + ProviderID: "bedrock", DisplayName: "Amazon Bedrock", DeploymentID: "anthropic-bedrock", SortOrder: 2, ChatPreference: 7, TransportKind: "bedrock", RequiresKey: true, CredentialEnv: "AWS_SECRET_ACCESS_KEY", CredentialEnvFallbacks: []string{"AWS_ACCESS_KEY_ID", "AWS_SESSION_TOKEN"}, @@ -145,7 +145,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "anthropic-messages", AdapterID: "anthropic-bedrock", RuntimeProfileKey: "bedrock", }, { - ProviderID: "vertex", DisplayName: "Vertex AI", DeploymentID: "gemini-vertex", SortOrder: 15, ChatPreference: 6, + ProviderID: "vertex", DisplayName: "Vertex AI", DeploymentID: "gemini-vertex", SortOrder: 20, ChatPreference: 6, TransportKind: "vertex", RequiresKey: true, CredentialEnv: "VERTEX_ACCESS_TOKEN", CredentialEnvFallbacks: []string{"GOOGLE_OAUTH_ACCESS_TOKEN"}, @@ -157,7 +157,7 @@ func providerSpecs() []ProviderSpec { // ── Aggregators ─────────────────────────────────────────────────── { - ProviderID: "openrouter", DisplayName: "OpenRouter", DeploymentID: "openrouter", SortOrder: 16, ChatPreference: 3, + ProviderID: "openrouter", DisplayName: "OpenRouter", DeploymentID: "openrouter", SortOrder: 17, ChatPreference: 3, RequiresKey: true, CredentialEnv: "OPENROUTER_API_KEY", BaseURLEnv: []string{"OPENROUTER_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://openrouter.ai/api/v1", @@ -174,7 +174,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-responses", AdapterID: "concentrate-responses", RuntimeProfileKey: "concentrate", }, { - ProviderID: "stepfun", DisplayName: "StepFun", DeploymentID: "stepfun-direct", SortOrder: 2, ChatPreference: 27, + ProviderID: "stepfun", DisplayName: "StepFun", DeploymentID: "stepfun-direct", SortOrder: 26, ChatPreference: 27, RequiresKey: true, CredentialEnv: "STEP_API_KEY", BaseURLEnv: []string{"STEP_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.stepfun.ai/v1", @@ -200,7 +200,7 @@ func providerSpecs() []ProviderSpec { // ── Niche ───────────────────────────────────────────────────────── { - ProviderID: "canopywave", DisplayName: "CanopyWave", DeploymentID: "canopywave", SortOrder: 17, ChatPreference: 10, + ProviderID: "canopywave", DisplayName: "CanopyWave", DeploymentID: "canopywave", SortOrder: 5, ChatPreference: 10, RequiresKey: true, CredentialEnv: "CANOPYWAVE_API_KEY", BaseURLEnv: []string{"CANOPYWAVE_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://inference.canopywave.io/v1", @@ -208,7 +208,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "canopywave", RuntimeProfileKey: "canopywave", }, { - ProviderID: "poolside", DisplayName: "Poolside", DeploymentID: "poolside", SortOrder: 18, ChatPreference: 20, + ProviderID: "poolside", DisplayName: "Poolside", DeploymentID: "poolside", SortOrder: 19, ChatPreference: 20, RequiresKey: true, CredentialEnv: "POOLSIDE_API_KEY", BaseURLEnv: []string{"POOLSIDE_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://inference.poolside.ai/v1", @@ -216,7 +216,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "poolside", RuntimeProfileKey: "poolside", }, { - ProviderID: "groq", DisplayName: "Groq", DeploymentID: "groq-direct", SortOrder: 19, ChatPreference: 21, + ProviderID: "groq", DisplayName: "Groq", DeploymentID: "groq-direct", SortOrder: 10, ChatPreference: 21, RequiresKey: true, CredentialEnv: "GROQ_API_KEY", BaseURLEnv: []string{"GROQ_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.groq.com/openai/v1", @@ -224,7 +224,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "groq", RuntimeProfileKey: "groq", }, { - ProviderID: "clinepass", DisplayName: "ClinePass", DeploymentID: "clinepass", SortOrder: 20, ChatPreference: 22, + ProviderID: "clinepass", DisplayName: "ClinePass", DeploymentID: "clinepass", SortOrder: 6, ChatPreference: 22, RuntimeBaseURL: "https://api.cline.bot/api/v1", RequiresKey: true, CredentialEnv: "CLINE_API_KEY", BaseURLEnv: []string{"CLINE_API_BASE"}, @@ -233,7 +233,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "clinepass", RuntimeProfileKey: "clinepass", }, { - ProviderID: "opencodego", DisplayName: "OpenCode Go", DeploymentID: "opencodego", SortOrder: 21, ChatPreference: 13, + ProviderID: "opencodego", DisplayName: "OpenCode Go", DeploymentID: "opencodego", SortOrder: 16, ChatPreference: 13, RequiresKey: true, CredentialEnv: "OPENCODEGO_API_KEY", BaseURLEnv: []string{"OPENCODEGO_BASE_URL"}, ProbeKind: ProbeOpenAIModels, @@ -244,7 +244,7 @@ func providerSpecs() []ProviderSpec { // ── Local ───────────────────────────────────────────────────────── { - ProviderID: "ollama", DisplayName: "Ollama", DeploymentID: "ollama-local", SortOrder: 22, ChatPreference: 19, + ProviderID: "ollama", DisplayName: "Ollama", DeploymentID: "ollama-local", SortOrder: 18, ChatPreference: 19, RuntimeBaseURL: "http://localhost:11434/v1", RuntimeCredentialEnv: "OLLAMA_API_KEY", RequiresKey: false, CredentialEnv: "OLLAMA_BASE_URL", BaseURLEnv: []string{"OLLAMA_BASE_URL"}, diff --git a/catalog/zai/endpoints.go b/catalog/zai/endpoints.go index c7707d6..67040ba 100644 --- a/catalog/zai/endpoints.go +++ b/catalog/zai/endpoints.go @@ -86,6 +86,20 @@ func ResolveOpenAIBase(plan Plan, region Region, override string) (string, error } } +// Anthropic-compatible bases (fallback for /v1/messages protocol). +const ( + InternationalAnthropicBase = "https://api.z.ai/api/anthropic" + ChinaAnthropicBase = "https://open.bigmodel.cn/api/anthropic" +) + +// ResolveAnthropicBase returns the correct Anthropic-compat base for the region. +func ResolveAnthropicBase(region Region) string { + if region == RegionChina { + return ChinaAnthropicBase + } + return InternationalAnthropicBase +} + // KeyMismatchHint (kept for future key prefix detection). func KeyMismatchHint(plan Plan, secret string) string { secret = strings.TrimSpace(secret) diff --git a/client/adapters/anthropic.go b/client/adapters/anthropic.go index 13f714b..0147650 100644 --- a/client/adapters/anthropic.go +++ b/client/adapters/anthropic.go @@ -172,7 +172,13 @@ func resolveThinking(opts core.ChatOptions) *anthropicThinking { } return thinking default: - // Legacy behavior: if budget > 0, enable with budget + // Legacy behavior: ThinkingEnabled toggle wins, else budget > 0 enables with budget. + if opts.ThinkingEnabled != nil { + if *opts.ThinkingEnabled { + return thinkingAdaptive() + } + return thinkingDisabled() + } return thinkingForBudget(opts.ThinkingBudgetTokens) } } diff --git a/client/adapters/compat.go b/client/adapters/compat.go index 1990cd2..d439fbf 100644 --- a/client/adapters/compat.go +++ b/client/adapters/compat.go @@ -21,6 +21,14 @@ 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 suppresses the max_tokens field so the provider applies + // its own default. Useful for providers that pre-authorize the maximum + // token cost (e.g. Agnes AI). + OmitMaxTokens bool `json:"omit_max_tokens,omitempty"` + // DefaultDisableThinking sets thinking to disabled when no thinking + // preference is provided. Useful for providers that enable thinking + // by default but don't support it in all configurations. + DefaultDisableThinking bool `json:"default_disable_thinking,omitempty"` } // Per-provider compat configs. @@ -66,11 +74,15 @@ var ( MaxTokensField: "max_tokens", } KimiCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - SupportsCacheRole: true, + MaxTokensField: "max_tokens", + SupportsCacheRole: true, + ThinkingFormat: "kimi", + DefaultDisableThinking: true, } XiaomiCompat = OpenAICompatConfig{ - MaxTokensField: "max_completion_tokens", + MaxTokensField: "max_completion_tokens", + ThinkingFormat: "xiaomi", + DefaultDisableThinking: true, } AzureCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", @@ -81,12 +93,33 @@ var ( VertexCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", } + // AgnesCompat: OpenAI-compatible; pre-authorizes max token cost, so omit max_tokens. + AgnesCompat = OpenAICompatConfig{ + OmitMaxTokens: true, + ThinkingFormat: "agnes", + } + // LongCatCompat: OpenAI-compatible; enables thinking by default, so disable it. + LongCatCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + ThinkingFormat: "longcat", + DefaultDisableThinking: true, + StripReasoningFromInput: true, + } + // MiniMaxCompat: OpenAI-compatible; enables thinking by default, so disable it. + MiniMaxCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + ThinkingFormat: "minimax", + DefaultDisableThinking: true, + } // DeepSeekCompat: OpenAI-compatible with usage in streaming. // The provider rejects reasoning_content in input messages with HTTP 400, so we strip it. + // Enables thinking by default, so disable it. DeepSeekCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, StripReasoningFromInput: true, + ThinkingFormat: "deepseek", + DefaultDisableThinking: true, } ) diff --git a/client/adapters/deepseek_test.go b/client/adapters/deepseek_test.go index 8f2c219..bb7e410 100644 --- a/client/adapters/deepseek_test.go +++ b/client/adapters/deepseek_test.go @@ -15,8 +15,8 @@ import ( func TestNewDeepSeekClient_OpenAIOnly(t *testing.T) { t.Parallel() - client := NewDeepSeekClient("ds-key", "https://api.deepseek.com/v1", &DeepSeekCompat) - if client == nil || client.openai == nil { + client := NewDeepSeekClient("ds-key", "https://api.deepseek.com/v1", "", &DeepSeekCompat) + if client == nil || client.router.OpenAI == nil { t.Fatal("expected OpenAI client") } if client.Name() != "deepseek" { @@ -38,7 +38,7 @@ func TestDeepSeekClient_ChatUsesOpenAIPath(t *testing.T) { })) defer server.Close() - client := NewDeepSeekClient("key", server.URL, &DeepSeekCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + client := NewDeepSeekClient("key", server.URL, "", &DeepSeekCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "deepseek-v4-flash", MaxTokens: 16}) if err != nil { t.Fatal(err) @@ -58,7 +58,7 @@ func TestDeepSeekClient_Ping(t *testing.T) { _, _ = io.WriteString(w, `{"data":[]}`) })) defer server.Close() - client := NewDeepSeekClient("key", server.URL, &DeepSeekCompat, core.WithTimeout(2*time.Second)) + client := NewDeepSeekClient("key", server.URL, "", &DeepSeekCompat, core.WithTimeout(2*time.Second)) if err := client.Ping(context.Background()); err != nil { t.Fatal(err) } diff --git a/client/adapters/gemini_test.go b/client/adapters/gemini_test.go index f10c5ba..4faea87 100644 --- a/client/adapters/gemini_test.go +++ b/client/adapters/gemini_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "io" + "log/slog" "net/http" "os" "strings" @@ -13,6 +14,11 @@ import ( "github.com/GrayCodeAI/eyrie/types" ) +func testLogger(t *testing.T) *slog.Logger { + t.Helper() + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) +} + func TestNewGeminiClient(t *testing.T) { t.Parallel() c := NewGeminiClient("AIza-test", "https://custom.example/v1beta") diff --git a/client/adapters/openai_test.go b/client/adapters/openai_test.go index 81b4364..c93313b 100644 --- a/client/adapters/openai_test.go +++ b/client/adapters/openai_test.go @@ -586,3 +586,5 @@ func TestOpenAIClient_BuildOpenAIRequest(t *testing.T) { t.Error("expected non-nil body") } } + +func float64Ptr(f float64) *float64 { return &f } diff --git a/client/adapters/protocol_router.go b/client/adapters/protocol_router.go index 86cda44..b570d60 100644 --- a/client/adapters/protocol_router.go +++ b/client/adapters/protocol_router.go @@ -100,44 +100,6 @@ func (r ProtocolRouter) providers(primary ChatProtocol) (core.Provider, core.Pro 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) diff --git a/client/adapters/protocol_router_test.go b/client/adapters/protocol_router_test.go index bad6055..2b29248 100644 --- a/client/adapters/protocol_router_test.go +++ b/client/adapters/protocol_router_test.go @@ -10,23 +10,6 @@ import ( "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) diff --git a/client/adapters/zai_test.go b/client/adapters/zai_test.go index 9317244..f40cfac 100644 --- a/client/adapters/zai_test.go +++ b/client/adapters/zai_test.go @@ -15,8 +15,8 @@ import ( func TestNewZAIClient_OpenAIOnly(t *testing.T) { t.Parallel() - client := NewZAIClient("zai-key", "https://zai.example/paas/v4", &ZAICompat, "zai_payg") - if client == nil || client.openai == nil { + client := NewZAIClient("zai-key", "https://zai.example/paas/v4", "", &ZAICompat, "zai_payg") + if client == nil || client.router.OpenAI == nil { t.Fatal("expected OpenAI client") } if client.Name() == "" { @@ -38,7 +38,7 @@ func TestZAIClient_ChatUsesOpenAIPath(t *testing.T) { })) defer server.Close() - client := NewZAIClient("key", server.URL, &ZAICompat, "zai_payg", core.WithRetry(core.NewRetryConfig(0, 0, 0))) + client := NewZAIClient("key", server.URL, "", &ZAICompat, "zai_payg", core.WithRetry(core.NewRetryConfig(0, 0, 0))) resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "glm-5.1", MaxTokens: 16}) if err != nil { t.Fatal(err) @@ -58,7 +58,7 @@ func TestZAIClient_Ping(t *testing.T) { _, _ = io.WriteString(w, `{"data":[]}`) })) defer server.Close() - client := NewZAIClient("key", server.URL, &ZAICompat, "zai_coding", core.WithTimeout(2*time.Second)) + client := NewZAIClient("key", server.URL, "", &ZAICompat, "zai_coding", core.WithTimeout(2*time.Second)) if err := client.Ping(context.Background()); err != nil { t.Fatal(err) } diff --git a/client/aliases.go b/client/aliases.go index bdf6879..5d6ba58 100644 --- a/client/aliases.go +++ b/client/aliases.go @@ -274,6 +274,8 @@ type ( ZAIClient = adapters.ZAIClient // MiMoClient implements Provider for the Xiaomi MiMo API. MiMoClient = adapters.MiMoClient + // ConcentrateResponsesClient implements Provider for the Concentrate Responses API. + ConcentrateResponsesClient = adapters.ConcentrateResponsesClient // OpenCodeGoClient implements Provider for the OpenCode Go API. OpenCodeGoClient = adapters.OpenCodeGoClient // PoolsideClient implements Poolside reasoning-only stream recovery. @@ -333,6 +335,10 @@ func NewMiMoClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompa return adapters.NewMiMoClient(apiKey, openAIBase, anthropicBase, compat, providerID, opts...) } +func NewConcentrateResponsesClient(apiKey, baseURL string, opts ...ClientOption) *ConcentrateResponsesClient { + return adapters.NewConcentrateResponsesClient(apiKey, baseURL, opts...) +} + func NewOpenCodeGoClient(apiKey, baseURL string, opts ...ClientOption) *OpenCodeGoClient { return adapters.NewOpenCodeGoClient(apiKey, baseURL, opts...) } diff --git a/client/compat.go b/client/compat.go index d7adb90..1229bab 100644 --- a/client/compat.go +++ b/client/compat.go @@ -24,4 +24,7 @@ var ( BedrockCompat = adapters.BedrockCompat VertexCompat = adapters.VertexCompat DeepSeekCompat = adapters.DeepSeekCompat + AgnesCompat = adapters.AgnesCompat + LongCatCompat = adapters.LongCatCompat + MiniMaxCompat = adapters.MiniMaxCompat ) diff --git a/config/providers.go b/config/providers.go index f354ccb..7fb86eb 100644 --- a/config/providers.go +++ b/config/providers.go @@ -34,6 +34,7 @@ const ( DefaultKimiOpenAIBaseURL = "https://api.moonshot.ai/v1" DefaultXiaomiOpenAIBaseURL = "https://api.xiaomimimo.com/v1" DefaultMiniMaxOpenAIBaseURL = "https://api.minimax.io/v1" + DefaultMiniMaxAnthropicBaseURL = "https://api.minimax.io" DefaultGroqOpenAIBaseURL = "https://api.groq.com/openai/v1" DefaultPoolsideOpenAIBaseURL = "https://inference.poolside.ai/v1" DefaultClinePassOpenAIBaseURL = "https://api.cline.bot/api/v1" // #nosec G101 -- public API base URL, not a secret value diff --git a/config/xiaomi_profile.go b/config/xiaomi_profile.go index 2d354d9..91eea82 100644 --- a/config/xiaomi_profile.go +++ b/config/xiaomi_profile.go @@ -1,6 +1,8 @@ package config import ( + "strings" + "github.com/GrayCodeAI/eyrie/catalog/xiaomi" ) @@ -43,6 +45,16 @@ func ResolveXiaomiOpenAIBase(providerID string, cfg *ProviderConfig) (string, er return xiaomi.ResolveOpenAIBasePreferRegion(billing, region, override) } +// ResolveXiaomiAnthropicBase resolves the Anthropic-compat base for a MiMo gateway id. +func ResolveXiaomiAnthropicBase(providerID string, cfg *ProviderConfig) (string, error) { + openAIBase, err := ResolveXiaomiOpenAIBase(providerID, cfg) + if err != nil { + return "", err + } + // Anthropic base strips the /v1 suffix; same host, different protocol path. + return strings.TrimSuffix(strings.TrimRight(openAIBase, "/"), "/v1"), nil +} + // IsXiaomiMimoProvider reports whether id is a MiMo setup gateway (payg or token plan). func IsXiaomiMimoProvider(providerID string) bool { _, ok := xiaomi.BillingForProvider(providerID) diff --git a/config/zai_profile.go b/config/zai_profile.go index c0e2c55..d5f684d 100644 --- a/config/zai_profile.go +++ b/config/zai_profile.go @@ -28,6 +28,19 @@ func ResolveZAIOpenAIBase(providerID string, cfg *ProviderConfig) (string, error return zai.ResolveOpenAIBase(plan, region, override) } +// ResolveZAIAnthropicBase resolves the Anthropic-compat base for a Z.AI gateway config. +func ResolveZAIAnthropicBase(cfg *ProviderConfig) string { + regionStr := "" + if cfg != nil { + regionStr = cfg.ZAICodingRegion + if regionStr == "" { + regionStr = cfg.ZAIRegion + } + } + region, _ := zai.NormalizeRegion(regionStr) + return zai.ResolveAnthropicBase(region) +} + // IsZAIProvider reports whether id is a Z.AI setup gateway (payg or coding). func IsZAIProvider(providerID string) bool { _, ok := zai.PlanForProvider(providerID) diff --git a/conversation/engine_test.go b/conversation/engine_test.go index adc7277..dcb34c1 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,10 +258,8 @@ func TestConversationEngine_ContextCancelClosesStream(t *testing.T) { } // blockingMockProvider returns a StreamResult whose Events channel blocks -// until the context is cancelled. It signals via a channel when Close is called. -type blockingMockProvider struct { - closed chan struct{} -} +// until the context is cancelled. +type blockingMockProvider struct{} func (b *blockingMockProvider) Name() string { return "blocking-mock" diff --git a/engine/convert_test.go b/engine/convert_test.go index 68b649d..e3463ab 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/go.mod b/go.mod index aa9abf2..c881b58 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/GrayCodeAI/eyrie go 1.26.5 require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.8 + github.com/GrayCodeAI/hawk-core-contracts v0.1.11 github.com/google/uuid v1.6.0 github.com/tiktoken-go/tokenizer v0.8.0 github.com/zalando/go-keyring v0.2.8 diff --git a/go.sum b/go.sum index 78e324e..1d16f8b 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -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/GrayCodeAI/hawk-core-contracts v0.1.11 h1:qv6zIoi4InxYxAwgFZrmoberjvEBdB50nSKTd4qVyBE= +github.com/GrayCodeAI/hawk-core-contracts v0.1.11/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= diff --git a/operationsgraph/operations_graph.go b/operationsgraph/operations_graph.go index 3da70c1..7341c1b 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. @@ -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 87bd813..212eddd 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -340,6 +340,13 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c return nil, false } return newMiniMaxDualProtocolClient(apiKey, deployment.BaseURL), true + case "concentrate-payg": + apiKey := FirstNonEmpty(deployment.APIKey, lookup("CONCENTRATE_API_KEY")) + if apiKey == "" { + return nil, false + } + baseURL := FirstNonEmpty(deployment.BaseURL, config.DefaultConcentrateOpenAIBaseURL) + return client.NewConcentrateResponsesClient(apiKey, baseURL), true default: return nil, false } @@ -460,6 +467,8 @@ func DefaultDeploymentForProvider(provider string) string { return "minimax_token_plan-direct" case config.ProviderMiniMaxPayg: return "minimax_payg-direct" + case config.ProviderConcentrate: + return "concentrate-payg" default: return "" }