diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f99d448..6147740 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,7 +239,6 @@ jobs: - name: Run markdownlint-cli2 run: | npm install -g markdownlint-cli2 - printf '%s\n' '{"config":{"default":true,"line-length":false,"no-inline-html":false,"first-line-h1":false,"no-duplicate-heading":false,"no-emphasis-as-heading":false,"blanks-around-headings":false,"blanks-around-lists":false,"blanks-around-fences":false,"fenced-code-language":false,"table-column-style":false,"no-space-in-emphasis":false,"ol-prefix":false,"link-fragments":false,"blanks-around-tables":false,"table-column-count":false,"single-trailing-newline":false,"no-multiple-blanks":false,"ul-style":false}}' > .markdownlint-cli2.jsonc markdownlint-cli2 '**/*.md' # ------------------------------------------------------------------------- diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..cb2b785 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,23 @@ +{ + "config": { + "default": true, + "line-length": false, + "no-inline-html": false, + "first-line-h1": false, + "no-duplicate-heading": false, + "no-emphasis-as-heading": false, + "blanks-around-headings": false, + "blanks-around-lists": false, + "blanks-around-fences": false, + "fenced-code-language": false, + "table-column-style": false, + "no-space-in-emphasis": false, + "ol-prefix": false, + "link-fragments": false, + "blanks-around-tables": false, + "table-column-count": false, + "single-trailing-newline": false, + "no-multiple-blanks": false, + "ul-style": false + } +} diff --git a/catalog/testdata_test.go b/catalog/testdata_test.go index 15c76a9..7fa6cad 100644 --- a/catalog/testdata_test.go +++ b/catalog/testdata_test.go @@ -10,7 +10,7 @@ import ( // Run with EXPORT_HAWK_FIXTURE=1 to refresh hawk/internal/catalogtest/testdata/minimal_v1.json func TestExportHawkCatalogFixture(t *testing.T) { if os.Getenv("EXPORT_HAWK_FIXTURE") != "1" { - t.Skip("set EXPORT_HAWK_FIXTURE=1 to export") + t.Skip("set EXPORT_HAWK_FIXTURE=1 to export") // TODO: https://github.com/GrayCodeAI/eyrie/issues/30 } c := testLegacyCatalogV1() data, err := json.MarshalIndent(c, "", " ") diff --git a/catalog/user_catalog_dump_test.go b/catalog/user_catalog_dump_test.go index 56d045d..f2e8c2f 100644 --- a/catalog/user_catalog_dump_test.go +++ b/catalog/user_catalog_dump_test.go @@ -13,11 +13,11 @@ import ( func TestUserCatalog_GatewayCountsMatchDeploymentOfferings(t *testing.T) { home, err := os.UserHomeDir() if err != nil { - t.Skip(err) + t.Skip(err) // TODO: https://github.com/GrayCodeAI/eyrie/issues/31 } path := filepath.Join(home, ".eyrie", "model_catalog.json") if _, err := os.Stat(path); err != nil { - t.Skip("no user catalog") + t.Skip("no user catalog") // TODO: https://github.com/GrayCodeAI/eyrie/issues/31 } compiled, err := catalog.LoadCatalogV1(context.Background(), catalog.LoadCatalogV1Options{ CachePath: path, diff --git a/client/benchmarks_test.go b/client/benchmarks_test.go index cab1eab..eb1fb38 100644 --- a/client/benchmarks_test.go +++ b/client/benchmarks_test.go @@ -134,12 +134,12 @@ func BenchmarkCachedProvider_CacheHit(b *testing.B) { opts := ChatOptions{Model: "gpt-4"} // Prime the cache - _, _ = cp.Chat(context.TODO(), messages, opts) + _, _ = cp.Chat(context.Background(), messages, opts) b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - _, _ = cp.Chat(context.TODO(), messages, opts) + _, _ = cp.Chat(context.Background(), messages, opts) } } @@ -153,7 +153,7 @@ func BenchmarkCachedProvider_CacheMiss(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { messages := []EyrieMessage{{Role: "user", Content: "unique query"}} - _, _ = cp.Chat(context.TODO(), messages, opts) + _, _ = cp.Chat(context.Background(), messages, opts) } } diff --git a/client/client_test.go b/client/client_test.go index a30aab7..7aecebf 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -17,10 +17,10 @@ func TestDetectProvider(t *testing.T) { credentials.SetDefaultStore(store) t.Cleanup(func() { credentials.SetDefaultStore(nil) }) - for _, k := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "CANOPYWAVE_API_KEY", "ZAI_API_KEY", "OPENCODEGO_API_KEY", "OLLAMA_BASE_URL"} { + for _, k := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "CANOPYWAVE_API_KEY", "DEEPSEEK_API_KEY", "ZAI_API_KEY", "OPENCODEGO_API_KEY", "MOONSHOT_API_KEY", "XIAOMI_MIMO_PAYG_API_KEY", "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "OLLAMA_BASE_URL"} { _ = os.Unsetenv(k) } - credentials.ScrubProcessEnv([]string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "CANOPYWAVE_API_KEY", "ZAI_API_KEY", "OPENCODEGO_API_KEY"}) + credentials.ScrubProcessEnv([]string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "CANOPYWAVE_API_KEY", "DEEPSEEK_API_KEY", "ZAI_API_KEY", "OPENCODEGO_API_KEY", "MOONSHOT_API_KEY", "XIAOMI_MIMO_PAYG_API_KEY", "XIAOMI_MIMO_TOKEN_PLAN_API_KEY"}) ctx := context.Background() if p := DetectProvider(); p != "anthropic" { @@ -34,6 +34,32 @@ func TestDetectProvider(t *testing.T) { } } +func TestDetectProvider_AdditionalProviders(t *testing.T) { + tests := []struct { + name string + env string + want string + }{ + {name: "deepseek", env: "DEEPSEEK_API_KEY", want: "deepseek"}, + {name: "kimi", env: "MOONSHOT_API_KEY", want: "kimi"}, + {name: "xiaomi payg", env: "XIAOMI_MIMO_PAYG_API_KEY", want: "xiaomi_mimo_payg"}, + {name: "xiaomi token plan", env: "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", want: "xiaomi_mimo_token_plan"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + if err := store.Set(context.Background(), credentials.AccountForEnv(tc.env), "test"); err != nil { + t.Fatal(err) + } + if got := DetectProvider(); got != tc.want { + t.Fatalf("DetectProvider() = %q, want %q", got, tc.want) + } + }) + } +} + func TestParseCustomHeaders(t *testing.T) { _ = os.Setenv("GRAYCODE_CUSTOM_HEADERS", "X-Custom: value1\nX-Other: value2") defer func() { _ = os.Unsetenv("GRAYCODE_CUSTOM_HEADERS") }() diff --git a/client/opencodego.go b/client/opencodego.go new file mode 100644 index 0000000..7c61321 --- /dev/null +++ b/client/opencodego.go @@ -0,0 +1,186 @@ +package client + +import ( + "context" + "strings" +) + +// OpenCodeGoClient routes OpenCode Go models to the correct upstream API format. +// Most models use OpenAI-compatible /v1/chat/completions; MiniMax and Qwen3.x +// models require Anthropic-compatible /v1/messages (see opencode.ai/docs/go). +type OpenCodeGoClient struct { + openAI *OpenAIClient + anthropic *AnthropicClient +} + +// NewOpenCodeGoClient builds a dual-protocol OpenCode Go provider client. +func NewOpenCodeGoClient(apiKey, baseURL string, opts ...ClientOption) *OpenCodeGoClient { + openBase := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if openBase == "" { + openBase = "https://opencode.ai/zen/go/v1" + } + ocgOpts := append(append([]ClientOption{}, opts...), WithProviderName("opencodego")) + o := NewOpenAIClient(apiKey, openBase, &OpenCodeGoCompat, ocgOpts...) + // Anthropic /messages on OpenCode Go uses X-Api-Key (not Bearer, not MiMo api-key). + a := NewAnthropicClient(apiKey, openCodeGoAnthropicBase(openBase), ocgOpts...) + return &OpenCodeGoClient{openAI: o, anthropic: a} +} + +func (c *OpenCodeGoClient) Name() string { return "opencodego" } + +func (c *OpenCodeGoClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { + if openCodeGoUsesAnthropicMessages(opts.Model) { + return c.anthropic.Chat(ctx, messages, opts) + } + resp, err := c.openAI.Chat(ctx, messages, opts) + if err != nil || c.anthropic == nil || !openCodeGoMightNeedAnthropicFallback(opts.Model) { + return resp, err + } + if resp != nil && strings.TrimSpace(resp.Content) != "" { + return resp, nil + } + anthropicResp, anthropicErr := c.anthropic.Chat(ctx, messages, opts) + if anthropicErr == nil && anthropicResp != nil && strings.TrimSpace(anthropicResp.Content) != "" { + return anthropicResp, nil + } + return resp, err +} + +func (c *OpenCodeGoClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { + if openCodeGoUsesAnthropicMessages(opts.Model) { + return c.anthropic.StreamChat(ctx, messages, opts) + } + primary, err := c.openAI.StreamChat(ctx, messages, opts) + if err != nil || c.anthropic == nil || !openCodeGoMightNeedAnthropicFallback(opts.Model) { + return primary, err + } + return newOpenCodeGoFallbackStream(ctx, c.anthropic, messages, opts, primary), nil +} + +func (c *OpenCodeGoClient) Ping(ctx context.Context) error { + if err := c.openAI.Ping(ctx); err == nil { + return nil + } + return c.anthropic.Ping(ctx) +} + +// newOpenCodeGoFallbackStream watches an OpenAI-format stream; if it ends with +// reasoning tokens but no answer, transparently retries via /v1/messages. +func newOpenCodeGoFallbackStream(ctx context.Context, anthropic *AnthropicClient, messages []EyrieMessage, opts ChatOptions, primary *StreamResult) *StreamResult { + out := make(chan EyrieStreamEvent, streamChannelBuffer) + cancelCtx, cancel := context.WithCancel(ctx) + go func() { + defer close(out) + defer primary.Close() + defer cancel() + + var ( + sawReasoning bool + contentLen int + toolCalls int + buffered []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": + contentLen += len(ev.Content) + case "tool_call": + toolCalls++ + case "error": + streamErr = true + } + if contentLen > 0 || toolCalls > 0 { + flush() + select { + case out <- ev: + case <-cancelCtx.Done(): + return + } + continue + } + buffered = append(buffered, ev) + } + + health := DetectResponseHealth(ResponseSignals{ + SawReasoning: sawReasoning, + ContentLen: contentLen, + ToolCalls: toolCalls, + StreamEnded: true, + StreamErr: streamErr, + }) + if health != ResponseErrorOnlyReasoning { + flush() + return + } + + fallback, err := anthropic.StreamChat(cancelCtx, messages, opts) + if err != nil { + flush() + select { + case out <- EyrieStreamEvent{Type: "error", Error: err.Error()}: + case <-cancelCtx.Done(): + } + return + } + defer fallback.Close() + for ev := range fallback.Events { + select { + case out <- ev: + case <-cancelCtx.Done(): + return + } + } + }() + return NewStreamResult(out, cancel) +} + +// openCodeGoAnthropicBase strips the /v1 suffix so AnthropicClient posts to +// {base}/v1/messages (e.g. https://opencode.ai/zen/go/v1/messages). +func openCodeGoAnthropicBase(openAIBase string) string { + base := strings.TrimRight(strings.TrimSpace(openAIBase), "/") + if strings.HasSuffix(base, "/v1") { + return strings.TrimSuffix(base, "/v1") + } + return base +} + +// openCodeGoUsesAnthropicMessages reports whether a model must use the Anthropic +// Messages API on OpenCode Go (not OpenAI chat/completions). +func openCodeGoUsesAnthropicMessages(model string) bool { + model = openCodeGoNativeModel(model) + switch { + case strings.Contains(model, "minimax"): + return true + case strings.HasPrefix(model, "qwen3."): + return true + default: + return false + } +} + +func openCodeGoMightNeedAnthropicFallback(model string) bool { + return openCodeGoUsesAnthropicMessages(model) +} + +func openCodeGoNativeModel(model string) string { + model = strings.ToLower(strings.TrimSpace(model)) + if i := strings.LastIndex(model, "/"); i >= 0 { + model = model[i+1:] + } + return model +} + +var _ Provider = (*OpenCodeGoClient)(nil) diff --git a/client/opencodego_test.go b/client/opencodego_test.go new file mode 100644 index 0000000..df996fa --- /dev/null +++ b/client/opencodego_test.go @@ -0,0 +1,103 @@ +package client + +import ( + "context" + "net/http" + "strings" + "testing" +) + +func TestOpenCodeGoUsesAnthropicMessages(t *testing.T) { + tests := []struct { + model string + want bool + }{ + {"minimax-m2.5", true}, + {"opencodego/minimax-m2.5", true}, + {"minimax-m2.7", true}, + {"minimax-m3", true}, + {"MiniMax-M2.5-highspeed", true}, + {"qwen3.5-plus", true}, + {"qwen3.7-max", true}, + {"kimi-k2.5", false}, + {"glm-5", false}, + {"mimo-v2.5-pro", false}, + {"deepseek-v4-flash", false}, + } + for _, tc := range tests { + if got := openCodeGoUsesAnthropicMessages(tc.model); got != tc.want { + t.Errorf("openCodeGoUsesAnthropicMessages(%q) = %v, want %v", tc.model, got, tc.want) + } + } +} + +func TestOpenCodeGoAnthropicBase(t *testing.T) { + if got := openCodeGoAnthropicBase("https://opencode.ai/zen/go/v1"); got != "https://opencode.ai/zen/go" { + t.Fatalf("base = %q, want https://opencode.ai/zen/go", got) + } +} + +func TestOpenCodeGoClient_RoutesMiniMaxToAnthropic(t *testing.T) { + var gotPath string + var gotAuth string + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("X-Api-Key") + return jsonResponse(http.StatusOK, map[string]interface{}{ + "id": "msg_1", + "type": "message", + "role": "assistant", + "content": []map[string]string{{"type": "text", "text": "Hello!"}}, + "stop_reason": "end_turn", + "usage": map[string]int{"input_tokens": 1, "output_tokens": 2}, + }), nil + }) + + c := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") + c.anthropic.httpClient = &http.Client{Transport: transport} + resp, err := c.Chat(context.Background(), []EyrieMessage{{Role: "user", Content: "Hi"}}, ChatOptions{ + Model: "minimax-m2.5", + MaxTokens: 256, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Content != "Hello!" { + t.Fatalf("content = %q, want Hello!", resp.Content) + } + if !strings.HasSuffix(gotPath, "/v1/messages") { + t.Fatalf("path = %q, want suffix /v1/messages", gotPath) + } + if gotAuth != "ocg-test-key" { + t.Fatalf("X-Api-Key = %q, want ocg-test-key", gotAuth) + } +} + +func TestOpenCodeGoClient_RoutesKimiToOpenAI(t *testing.T) { + var gotPath string + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + gotPath = r.URL.Path + return jsonResponse(http.StatusOK, map[string]interface{}{ + "id": "chatcmpl-1", + "object": "chat.completion", + "choices": []map[string]interface{}{{"message": map[string]string{"role": "assistant", "content": "Hi there!"}, "finish_reason": "stop"}}, + "usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}, + }), nil + }) + + c := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") + c.openAI.httpClient = &http.Client{Transport: transport} + resp, err := c.Chat(context.Background(), []EyrieMessage{{Role: "user", Content: "Hi"}}, ChatOptions{ + Model: "kimi-k2.5", + MaxTokens: 256, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Content != "Hi there!" { + t.Fatalf("content = %q, want Hi there!", resp.Content) + } + if !strings.HasSuffix(gotPath, "/chat/completions") { + t.Fatalf("path = %q, want suffix /chat/completions", gotPath) + } +} diff --git a/client/provider_registry.go b/client/provider_registry.go index 38b50b3..a3f5579 100644 --- a/client/provider_registry.go +++ b/client/provider_registry.go @@ -188,6 +188,10 @@ func (c *EyrieClient) getOrCreateProvider(providerName string) (Provider, error) p = NewMiMoClient(apiKey, openAIBase, anthropicBase, info.Compat, providerName) break } + if providerName == "opencodego" { + p = NewOpenCodeGoClient(apiKey, baseURL) + break + } p = NewOpenAIClient(apiKey, baseURL, info.Compat) } @@ -208,7 +212,14 @@ func DetectProvider() string { "canopywave": func() bool { return credentials.HasSecret(ctx, "CANOPYWAVE_API_KEY") }, "openai": func() bool { return credentials.HasSecret(ctx, "OPENAI_API_KEY") }, "opencodego": func() bool { return credentials.HasSecret(ctx, "OPENCODEGO_API_KEY") }, - "ollama": func() bool { return resolveEnvSecret("OLLAMA_BASE_URL") != "" }, + "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") + }, + "xiaomi_mimo_token_plan": func() bool { + return credentials.HasSecret(ctx, config.EnvXiaomiTokenPlanAPIKey) + }, + "ollama": func() bool { return resolveEnvSecret("OLLAMA_BASE_URL") != "" }, "azure": func() bool { return credentials.HasSecret(ctx, "AZURE_OPENAI_API_KEY") && resolveEnvSecret("AZURE_OPENAI_ENDPOINT") != "" }, diff --git a/config/config_test.go b/config/config_test.go index 38bc164..86057be 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -56,6 +56,8 @@ func TestIsOpenAICompatibleRuntimeEnabled(t *testing.T) { store := &credentials.MapStore{} credentials.SetDefaultStore(store) t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + ClearProviderRuntimeEnv() + t.Cleanup(ClearProviderRuntimeEnv) if IsOpenAICompatibleRuntimeEnabled() { t.Error("expected false with no keys set") @@ -81,8 +83,8 @@ func TestNormalizeOllamaOpenAIBaseURL(t *testing.T) { } func TestProviderDetectionOrder(t *testing.T) { - if len(APIProviderDetectionOrder) != 15 { - t.Errorf("expected 15 providers in detection order, got %d", len(APIProviderDetectionOrder)) + if len(APIProviderDetectionOrder) != 16 { + t.Errorf("expected 16 providers in detection order, got %d", len(APIProviderDetectionOrder)) } if APIProviderDetectionOrder[0] != ProviderAnthropic { t.Error("expected anthropic first in detection order") diff --git a/config/profiles.go b/config/profiles.go index 88496f9..53dac26 100644 --- a/config/profiles.go +++ b/config/profiles.go @@ -8,6 +8,7 @@ const ( ProviderOpenAI APIProvider = "openai" ProviderAzure APIProvider = "azure" ProviderCanopyWave APIProvider = "canopywave" + ProviderDeepSeek APIProvider = "deepseek" ProviderZAI APIProvider = "z-ai" ProviderOpenRouter APIProvider = "openrouter" ProviderGrok APIProvider = "grok" @@ -110,6 +111,13 @@ var ( BaseURLEnv: []string{"CANOPYWAVE_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, APIKeys: []APIKeyDef{{Env: "CANOPYWAVE_API_KEY", Source: "canopywave"}, {Env: "OPENAI_API_KEY", Source: "openai"}}, } + DeepSeekRuntimeProfile = RuntimeProviderProfile{ + Mode: "openai", DefaultBaseURL: "https://api.deepseek.com/v1", DefaultModel: "deepseek-v4-flash", + DetectionEnv: []string{"DEEPSEEK_API_KEY"}, + ModelEnv: []string{"DEEPSEEK_MODEL", "OPENAI_MODEL"}, + BaseURLEnv: []string{"DEEPSEEK_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, + APIKeys: []APIKeyDef{{Env: "DEEPSEEK_API_KEY", Source: "deepseek"}}, + } OpenCodeGoRuntimeProfile = RuntimeProviderProfile{ Mode: "opencodego", DefaultBaseURL: DefaultOpenCodeGoBaseURL, DefaultModel: "kimi-k2.5", DetectionEnv: []string{"OPENCODEGO_API_KEY"}, @@ -143,7 +151,7 @@ var ( // APIProviderDetectionOrder is the priority order for provider detection. var APIProviderDetectionOrder = []APIProvider{ ProviderAnthropic, ProviderOpenRouter, ProviderGrok, ProviderGemini, - ProviderVertex, ProviderBedrock, ProviderZAI, ProviderCanopyWave, ProviderAzure, ProviderOpenAI, ProviderOpenCodeGo, + ProviderVertex, ProviderBedrock, ProviderZAI, ProviderCanopyWave, ProviderDeepSeek, ProviderAzure, ProviderOpenAI, ProviderOpenCodeGo, ProviderKimi, ProviderXiaomiMimoPayg, ProviderXiaomiMimoTokenPlan, ProviderOllama, } @@ -153,6 +161,7 @@ var ProviderModelEnvKeys = map[APIProvider][]string{ ProviderOpenAI: OpenAIRuntimeProfile.ModelEnv, ProviderAzure: AzureRuntimeProfile.ModelEnv, ProviderCanopyWave: CanopyWaveRuntimeProfile.ModelEnv, + ProviderDeepSeek: DeepSeekRuntimeProfile.ModelEnv, ProviderZAI: ZAIRuntimeProfile.ModelEnv, ProviderOpenRouter: OpenRouterRuntimeProfile.ModelEnv, ProviderGrok: GrokRuntimeProfile.ModelEnv, @@ -177,7 +186,7 @@ const ( // OpenAICompatibleRuntimeProfileOrder is the detection order for runtime profiles. var OpenAICompatibleRuntimeProfileOrder = []string{ - "openrouter", "grok", "gemini", "anthropic", "z-ai", "canopywave", "openai", "opencodego", "kimi", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", + "openrouter", "grok", "gemini", "anthropic", "z-ai", "canopywave", "deepseek", "openai", "opencodego", "kimi", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", } // OpenAICompatibleRuntimeProfiles maps profile key to its runtime profile. @@ -187,6 +196,7 @@ var OpenAICompatibleRuntimeProfiles = map[string]RuntimeProviderProfile{ "gemini": GeminiRuntimeProfile, "z-ai": ZAIRuntimeProfile, "canopywave": CanopyWaveRuntimeProfile, + "deepseek": DeepSeekRuntimeProfile, "openai": OpenAIRuntimeProfile, "openrouter": OpenRouterRuntimeProfile, "opencodego": OpenCodeGoRuntimeProfile, diff --git a/config/provider_env.go b/config/provider_env.go index 9c7c916..d80b4a2 100644 --- a/config/provider_env.go +++ b/config/provider_env.go @@ -20,6 +20,7 @@ type ProviderConfig struct { XAIAPIKey string `json:"xai_api_key,omitempty"` OpenAIAPIKey string `json:"openai_api_key,omitempty"` CanopyWaveAPIKey string `json:"canopywave_api_key,omitempty"` + DeepSeekAPIKey string `json:"deepseek_api_key,omitempty"` ZAIAPIKey string `json:"zai_api_key,omitempty"` OpenRouterAPIKey string `json:"openrouter_api_key,omitempty"` GeminiAPIKey string `json:"gemini_api_key,omitempty"` @@ -31,6 +32,7 @@ type ProviderConfig struct { XiaomiMimoTokenPlanAPIKey string `json:"xiaomi_mimo_token_plan_api_key,omitempty"` AnthropicBaseURL string `json:"anthropic_base_url,omitempty"` CanopyWaveBaseURL string `json:"canopywave_base_url,omitempty"` + DeepSeekBaseURL string `json:"deepseek_base_url,omitempty"` ZAIBaseURL string `json:"zai_base_url,omitempty"` GrokBaseURL string `json:"grok_base_url,omitempty"` XAIBaseURL string `json:"xai_base_url,omitempty"` @@ -46,6 +48,7 @@ type ProviderConfig struct { AnthropicModel string `json:"anthropic_model,omitempty"` OpenAIModel string `json:"openai_model,omitempty"` CanopyWaveModel string `json:"canopywave_model,omitempty"` + DeepSeekModel string `json:"deepseek_model,omitempty"` ZAIModel string `json:"zai_model,omitempty"` GrokModel string `json:"grok_model,omitempty"` XAIModel string `json:"xai_model,omitempty"` @@ -128,6 +131,11 @@ var providerFields = map[string]providerFieldMap{ Models: func(c *ProviderConfig) []string { return []string{c.CanopyWaveModel} }, BaseURL: func(c *ProviderConfig) string { return c.CanopyWaveBaseURL }, }, + ProviderDeepSeek: { + APIKeys: func(c *ProviderConfig) []string { return []string{c.DeepSeekAPIKey} }, + Models: func(c *ProviderConfig) []string { return []string{c.DeepSeekModel} }, + BaseURL: func(c *ProviderConfig) string { return c.DeepSeekBaseURL }, + }, ProviderZAI: { APIKeys: func(c *ProviderConfig) []string { return []string{c.ZAIAPIKey} }, Models: func(c *ProviderConfig) []string { return []string{c.ZAIModel} }, @@ -423,6 +431,7 @@ func ClearProviderRuntimeEnv() { "VERTEX_ACCESS_TOKEN", "GOOGLE_OAUTH_ACCESS_TOKEN", "VERTEX_PROJECT_ID", "VERTEX_REGION", "VERTEX_MODEL", "OPENROUTER_API_KEY", "OPENROUTER_MODEL", "OPENROUTER_BASE_URL", "CANOPYWAVE_API_KEY", "CANOPYWAVE_MODEL", "CANOPYWAVE_BASE_URL", + "DEEPSEEK_API_KEY", "DEEPSEEK_MODEL", "DEEPSEEK_BASE_URL", "ZAI_API_KEY", "ZAI_MODEL", "ZAI_BASE_URL", "ZAI_API_BASE", "XAI_API_KEY", "XAI_MODEL", "XAI_BASE_URL", "GEMINI_API_KEY", "GEMINI_MODEL", "GEMINI_BASE_URL", @@ -543,6 +552,17 @@ func ApplyProviderEnv(provider string, config *ProviderConfig, activeModel strin m = catalog.GetProviderDefaultModel("canopywave", cat) } collectOpenAICompatibleProvider(env, "CANOPYWAVE", apiKey, m, base, overwrite) + case ProviderDeepSeek: + apiKey := AsNonEmptyString(config.DeepSeekAPIKey) + base := firstNonEmpty(config.DeepSeekBaseURL, "https://api.deepseek.com/v1") + m := activeModel + if m == "" { + m = catalog.GetProviderDefaultModel("deepseek", cat) + } + if m == "" { + m = "deepseek-v4-flash" + } + collectOpenAICompatibleProvider(env, "DEEPSEEK", apiKey, m, base, overwrite) case ProviderZAI: apiKey := AsNonEmptyString(config.ZAIAPIKey) base := firstNonEmpty(config.ZAIBaseURL, DefaultZAIOpenAIBaseURL) diff --git a/config/provider_env_test.go b/config/provider_env_test.go index 3868eec..2baede5 100644 --- a/config/provider_env_test.go +++ b/config/provider_env_test.go @@ -195,6 +195,25 @@ func TestApplyProviderEnv_Gemini(t *testing.T) { } } +func TestApplyProviderEnv_DeepSeek(t *testing.T) { + cfg := &ProviderConfig{ + DeepSeekAPIKey: "deepseek-key-1234567890", + } + cat := testModelCatalog() + + env := ApplyProviderEnv(ProviderDeepSeek, cfg, "deepseek-v4-flash", true, &cat) + + if env["DEEPSEEK_API_KEY"] != "deepseek-key-1234567890" { + t.Errorf("expected DEEPSEEK_API_KEY, got %q", env["DEEPSEEK_API_KEY"]) + } + if env["DEEPSEEK_MODEL"] != "deepseek-v4-flash" { + t.Errorf("expected DEEPSEEK_MODEL 'deepseek-v4-flash', got %q", env["DEEPSEEK_MODEL"]) + } + if env["OPENAI_API_KEY"] != "deepseek-key-1234567890" { + t.Errorf("expected OPENAI_API_KEY set for deepseek compat, got %q", env["OPENAI_API_KEY"]) + } +} + func TestApplyProviderEnv_Ollama(t *testing.T) { cfg := &ProviderConfig{ OllamaBaseURL: "http://localhost:11434", diff --git a/config/runtime.go b/config/runtime.go index 98eb5b9..c0e6223 100644 --- a/config/runtime.go +++ b/config/runtime.go @@ -26,9 +26,9 @@ type ResolvedOpenAICompatibleRuntime struct { func IsOpenAICompatibleRuntimeEnabled() bool { keys := []string{ "OPENROUTER_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", - "ANTHROPIC_API_KEY", "CANOPYWAVE_API_KEY", "ZAI_API_KEY", "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", "CANOPYWAVE_API_KEY", "DEEPSEEK_API_KEY", "ZAI_API_KEY", "OPENAI_API_KEY", "OPENCODEGO_API_KEY", "OLLAMA_BASE_URL", - "MOONSHOT_API_KEY", "XIAOMI_MIMO_API_KEY", + "MOONSHOT_API_KEY", "XIAOMI_MIMO_API_KEY", "XIAOMI_MIMO_PAYG_API_KEY", "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", } for _, k := range keys { if envValue(k) != "" { diff --git a/config/runtime_test.go b/config/runtime_test.go index 8c1c8d6..252370b 100644 --- a/config/runtime_test.go +++ b/config/runtime_test.go @@ -17,6 +17,7 @@ func TestRuntimeProfileFields(t *testing.T) { "gemini": GeminiRuntimeProfile, "openrouter": OpenRouterRuntimeProfile, "canopywave": CanopyWaveRuntimeProfile, + "deepseek": DeepSeekRuntimeProfile, "z-ai": ZAIRuntimeProfile, "opencodego": OpenCodeGoRuntimeProfile, } @@ -52,6 +53,7 @@ func TestRuntimeProfileAPIKeys(t *testing.T) { "gemini": GeminiRuntimeProfile, "openrouter": OpenRouterRuntimeProfile, "canopywave": CanopyWaveRuntimeProfile, + "deepseek": DeepSeekRuntimeProfile, "z-ai": ZAIRuntimeProfile, "opencodego": OpenCodeGoRuntimeProfile, } @@ -76,6 +78,7 @@ func TestModelEnvKeysCorrectForEachProvider(t *testing.T) { ProviderAnthropic: "ANTHROPIC_MODEL", ProviderOpenAI: "OPENAI_MODEL", ProviderCanopyWave: "CANOPYWAVE_MODEL", + ProviderDeepSeek: "DEEPSEEK_MODEL", ProviderOpenRouter: "OPENROUTER_MODEL", ProviderGrok: "XAI_MODEL", ProviderGemini: "GEMINI_MODEL", @@ -101,7 +104,7 @@ func TestModelEnvKeysCorrectForEachProvider(t *testing.T) { func TestProviderModelEnvKeys_AllProvidersPresent(t *testing.T) { allProviders := []string{ - ProviderAnthropic, ProviderOpenAI, ProviderCanopyWave, + ProviderAnthropic, ProviderOpenAI, ProviderCanopyWave, ProviderDeepSeek, ProviderOpenRouter, ProviderGrok, ProviderGemini, ProviderOllama, ProviderOpenCodeGo, } @@ -155,8 +158,9 @@ func TestResolveOpenAICompatibleRuntime_GrokProvider(t *testing.T) { func TestResolveOpenAICompatibleRuntime_FallbackModel(t *testing.T) { clearKeys := []string{ "OPENROUTER_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", - "ANTHROPIC_API_KEY", "CANOPYWAVE_API_KEY", "ZAI_API_KEY", "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", "CANOPYWAVE_API_KEY", "DEEPSEEK_API_KEY", "ZAI_API_KEY", "OPENAI_API_KEY", "OPENCODEGO_API_KEY", "OLLAMA_BASE_URL", + "MOONSHOT_API_KEY", "XIAOMI_MIMO_PAYG_API_KEY", "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "OPENAI_MODEL", "OPENAI_BASE_URL", "OPENAI_API_BASE", } for _, k := range clearKeys { diff --git a/credentials/combined_store_test.go b/credentials/combined_store_test.go index c767e5e..f337000 100644 --- a/credentials/combined_store_test.go +++ b/credentials/combined_store_test.go @@ -152,7 +152,7 @@ func TestDiscoveryEnvKeys_ReturnsNonEmpty(t *testing.T) { } func TestDiscoveryEnvKeys_NilContext(t *testing.T) { - keys := discoveryEnvKeys(context.TODO()) + keys := discoveryEnvKeys(context.Background()) if len(keys) == 0 { t.Fatal("discoveryEnvKeys should return fallback keys") } diff --git a/credentials/migrate_test.go b/credentials/migrate_test.go index 8b7e535..44a81bb 100644 --- a/credentials/migrate_test.go +++ b/credentials/migrate_test.go @@ -193,7 +193,7 @@ func TestMigrateLegacyEnvFile_NilContext(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) - n, err := MigrateLegacyEnvFile(context.TODO()) + n, err := MigrateLegacyEnvFile(context.Background()) if err != nil { t.Fatalf("error: %v", err) } diff --git a/credentials/security_test.go b/credentials/security_test.go index 166fa93..65ae0e9 100644 --- a/credentials/security_test.go +++ b/credentials/security_test.go @@ -171,9 +171,9 @@ func TestLookupSecret_WhitespaceKeyReturnsEmpty(t *testing.T) { func TestLookupSecret_NilContextHandled(t *testing.T) { // Nil context should not panic. - result := LookupSecret(context.TODO(), "NONEXISTENT_KEY") + result := LookupSecret(context.Background(), "NONEXISTENT_KEY") if result != "" { - t.Errorf("LookupSecret(context.TODO(), 'NONEXISTENT_KEY') = %q, want empty", result) + t.Errorf("LookupSecret(context.Background(), 'NONEXISTENT_KEY') = %q, want empty", result) } } @@ -185,8 +185,8 @@ func TestHasSecret_EmptyKeyReturnsFalse(t *testing.T) { func TestHasSecret_NilContextHandled(t *testing.T) { // Should not panic. - if HasSecret(context.TODO(), "NONEXISTENT_KEY") { - t.Error("HasSecret(context.TODO(), 'NONEXISTENT_KEY') should return false") + if HasSecret(context.Background(), "NONEXISTENT_KEY") { + t.Error("HasSecret(context.Background(), 'NONEXISTENT_KEY') should return false") } } diff --git a/runtime/preflight_test.go b/runtime/preflight_test.go index fdc90d4..2b0add3 100644 --- a/runtime/preflight_test.go +++ b/runtime/preflight_test.go @@ -70,7 +70,7 @@ func TestPreflight_NilContext(t *testing.T) { setupPreflightEnv(t, "{}\n") // Should not panic with nil context (Preflight uses context.Background internally) - r := Preflight(context.TODO()) + r := Preflight(context.Background()) if len(r.Checks) == 0 { t.Fatal("expected at least one check") } @@ -187,7 +187,7 @@ func TestPreflight_NotReady_WhenCredentialsFail(t *testing.T) { } } // If creds check doesn't fail (edge case), skip - t.Skip("credentials check did not fail in this environment") + t.Skip("credentials check did not fail in this environment") // TODO: https://github.com/GrayCodeAI/eyrie/issues/28 } func TestPreflight_WithValidModel(t *testing.T) { @@ -402,7 +402,7 @@ func TestPreflight_MultipleFailures(t *testing.T) { } } if failCount == 0 { - t.Skip("no failures detected in this environment") + t.Skip("no failures detected in this environment") // TODO: https://github.com/GrayCodeAI/eyrie/issues/29 } if r.Ready { t.Fatal("expected not ready with failures") diff --git a/setup/deployment.go b/setup/deployment.go index 7d9c377..f965263 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -212,6 +212,12 @@ func ProviderForDeployment(id string, deployment config.DeploymentConfig) (clien return nil, false } return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultCanopyWaveOpenAIBaseURL), &client.CanopyWaveCompat), true + case "deepseek-direct": + apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("DEEPSEEK_API_KEY")) + if apiKey == "" { + return nil, false + } + return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, "https://api.deepseek.com/v1"), &client.DeepSeekCompat), true case "z-ai-direct": apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("ZAI_API_KEY")) if apiKey == "" { @@ -283,6 +289,8 @@ func DefaultDeploymentForProvider(provider string) string { return "openrouter" case config.ProviderCanopyWave: return "canopywave" + case config.ProviderDeepSeek: + return "deepseek-direct" case config.ProviderZAI: return "z-ai-direct" case config.ProviderOllama: @@ -318,6 +326,8 @@ func LegacyDeploymentConfig(cfg *config.ProviderConfig, provider string) config. return config.DeploymentConfig{APIKey: cfg.OpenRouterAPIKey, BaseURL: cfg.OpenRouterBaseURL} case config.ProviderCanopyWave: return config.DeploymentConfig{APIKey: cfg.CanopyWaveAPIKey, BaseURL: cfg.CanopyWaveBaseURL} + case config.ProviderDeepSeek: + return config.DeploymentConfig{APIKey: cfg.DeepSeekAPIKey, BaseURL: cfg.DeepSeekBaseURL} case config.ProviderZAI: return config.DeploymentConfig{APIKey: cfg.ZAIAPIKey, BaseURL: cfg.ZAIBaseURL} case config.ProviderOllama: diff --git a/setup/deployment_test.go b/setup/deployment_test.go index 3320f40..8976ba5 100644 --- a/setup/deployment_test.go +++ b/setup/deployment_test.go @@ -265,6 +265,7 @@ func TestDefaultDeploymentForProvider(t *testing.T) { {config.ProviderGemini, "gemini-direct"}, {config.ProviderOpenRouter, "openrouter"}, {config.ProviderCanopyWave, "canopywave"}, + {config.ProviderDeepSeek, "deepseek-direct"}, {config.ProviderZAI, "z-ai-direct"}, {config.ProviderOllama, "ollama-local"}, {config.ProviderOpenCodeGo, "opencodego"}, @@ -449,6 +450,16 @@ func TestProviderForDeployment_CanopyWave(t *testing.T) { } } +func TestProviderForDeployment_DeepSeekDirect(t *testing.T) { + p, ok := ProviderForDeployment("deepseek-direct", config.DeploymentConfig{APIKey: "test-key"}) + if !ok { + t.Fatal("expected deepseek-direct to be configured") + } + if p.Name() != "openai" { + t.Fatalf("provider name = %q, want openai", p.Name()) + } +} + func TestProviderForDeployment_ZAIDirect(t *testing.T) { p, ok := ProviderForDeployment("z-ai-direct", config.DeploymentConfig{APIKey: "test-key"}) if !ok {