diff --git a/catalog/discover/discover_fallback_test.go b/catalog/discover/discover_fallback_test.go index 4ee1991..e14147d 100644 --- a/catalog/discover/discover_fallback_test.go +++ b/catalog/discover/discover_fallback_test.go @@ -32,6 +32,7 @@ func TestDiscoverRun_RemoteFailureUsesCacheFallback(t *testing.T) { RefreshRemote: true, RemoteURL: failServer.URL, }, + DisableCredentialFallback: true, }) if err != nil { t.Fatalf("discover.Run: %v", err) @@ -61,6 +62,7 @@ func TestDiscoverRun_ConcurrentCallsSerialized(t *testing.T) { CachePath: cachePath, RefreshRemote: false, }, + DisableCredentialFallback: true, } done := make(chan error, 2) for i := 0; i < 2; i++ { diff --git a/catalog/live/fetchers.go b/catalog/live/fetchers.go index 003962b..23c11be 100644 --- a/catalog/live/fetchers.go +++ b/catalog/live/fetchers.go @@ -46,6 +46,7 @@ const ( DefaultKimiBaseURL = "https://api.moonshot.ai/v1" DefaultXiaomiBaseURL = "https://api.xiaomimimo.com/v1" DefaultMiniMaxBaseURL = "https://api.minimax.io/v1" + DefaultFireworksBaseURL = "https://api.fireworks.ai/inference/v1" ) // FetchFunc lists models from a live provider API. @@ -74,6 +75,7 @@ var Registry = map[string]FetchFunc{ "opengateway": FetchOpenGateway, "agnes": FetchAgnes, "longcat": FetchLongCat, + "fireworks": FetchFireworks, "canopywave": FetchCanopyWave, "opencodego": FetchOpenCodeGo, "kimi": FetchKimi, diff --git a/catalog/live/fetchers_providers.go b/catalog/live/fetchers_providers.go index 5da80ae..781b2a1 100644 --- a/catalog/live/fetchers_providers.go +++ b/catalog/live/fetchers_providers.go @@ -30,6 +30,15 @@ func FetchGrok(env map[string]string) ([]Entry, error) { return entries, nil } +// FetchFireworks lists models from Fireworks' OpenAI-compatible API. +func FetchFireworks(env map[string]string) ([]Entry, error) { + return fetchOpenAICompatModels( + context.Background(), + envOr(env, "FIREWORKS_BASE_URL", DefaultFireworksBaseURL), + env["FIREWORKS_API_KEY"], "Bearer", + ) +} + func FetchZAI(env map[string]string) ([]Entry, error) { entries, err := fetchOpenAICompatModels( context.Background(), diff --git a/catalog/live/fireworks_test.go b/catalog/live/fireworks_test.go new file mode 100644 index 0000000..5bba0e5 --- /dev/null +++ b/catalog/live/fireworks_test.go @@ -0,0 +1,43 @@ +package live + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestFetchFireworks_Mock(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/models" { + t.Fatalf("path = %q, want /models", r.URL.Path) + } + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + t.Fatalf("missing bearer authorization") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"accounts/fireworks/models/deepseek-v4-flash","owned_by":"fireworks"}]}`)) + })) + defer server.Close() + + entries, err := FetchFireworks(map[string]string{ + "FIREWORKS_API_KEY": "fw-test-key", + "FIREWORKS_BASE_URL": server.URL, + }) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].ID != "accounts/fireworks/models/deepseek-v4-flash" { + t.Fatalf("unexpected entries: %#v", entries) + } +} + +func TestFetchFireworks_NoKey(t *testing.T) { + entries, err := FetchFireworks(map[string]string{}) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("expected no entries, got %d", len(entries)) + } +} diff --git a/catalog/live_enrich.go b/catalog/live_enrich.go index 4d7afd6..99efc8c 100644 --- a/catalog/live_enrich.go +++ b/catalog/live_enrich.go @@ -73,19 +73,7 @@ func FetchLiveProviderCatalog(env map[string]string) (Catalog, []LiveProviderEnr name = entryID } - // If the native model ID already contains a "/" and the owner - // matches the provider's canonical form, keep it as-is. - canonicalID := entryID - if hasSlash(entryID) { - owner, _, hasOwner := splitOwner(entryID) - if hasOwner && owner == canonicalProviderID(providerID) { - canonicalID = entryID - } else if hasInputPricing(entry.RawJSON) { - canonicalID = providerID + "/" + entryID - } - } else if hasInputPricing(entry.RawJSON) { - canonicalID = providerID + "/" + entryID - } + canonicalID := canonicalModelIDForLiveEntry(providerID, entry) cat.Models[canonicalID] = Model{ ID: canonicalID, @@ -113,6 +101,24 @@ func FetchLiveProviderCatalog(env map[string]string) (Catalog, []LiveProviderEnr return cat, enrichment } +// canonicalModelIDForLiveEntry qualifies ownerless native IDs with the +// provider while preserving owner-qualified IDs. Gateway-priced IDs retain the +// gateway prefix so provider-specific pricing does not collide with a direct +// provider offering for the same underlying model. +func canonicalModelIDForLiveEntry(providerID string, entry live.Entry) string { + if !hasSlash(entry.ID) { + return providerID + "/" + entry.ID + } + owner, _, hasOwner := splitOwner(entry.ID) + if hasOwner && owner == canonicalProviderID(providerID) { + return entry.ID + } + if hasInputPricing(entry.RawJSON) { + return providerID + "/" + entry.ID + } + return entry.ID +} + // FetchLiveModelEntriesForProvider lists models from one provider's live API with full JSON metadata. func FetchLiveModelEntriesForProvider(env map[string]string, providerID string) ([]ModelCatalogEntry, error) { spec, ok := registry.SpecByProviderID(providerID) diff --git a/catalog/live_enrich_internal_test.go b/catalog/live_enrich_internal_test.go new file mode 100644 index 0000000..5e52bfd --- /dev/null +++ b/catalog/live_enrich_internal_test.go @@ -0,0 +1,56 @@ +package catalog + +import ( + "encoding/json" + "testing" + + "github.com/GrayCodeAI/eyrie/catalog/live" +) + +func TestCanonicalModelIDForLiveEntry(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + providerID string + entry live.Entry + want string + }{ + { + name: "qualifies ownerless Ollama tag without pricing", + providerID: "ollama", + entry: live.Entry{ID: "qwen3:4b"}, + want: "ollama/qwen3:4b", + }, + { + name: "preserves matching canonical owner", + providerID: "gemini", + entry: live.Entry{ID: "google/gemini-2.5-pro"}, + want: "google/gemini-2.5-pro", + }, + { + name: "preserves upstream owner without gateway pricing", + providerID: "openrouter", + entry: live.Entry{ID: "anthropic/claude-sonnet-4"}, + want: "anthropic/claude-sonnet-4", + }, + { + name: "qualifies gateway-priced upstream model", + providerID: "openrouter", + entry: live.Entry{ + ID: "anthropic/claude-sonnet-4", + RawJSON: json.RawMessage(`{"input_token_price_per_m": 3}`), + }, + want: "openrouter/anthropic/claude-sonnet-4", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := canonicalModelIDForLiveEntry(tt.providerID, tt.entry); got != tt.want { + t.Fatalf("canonicalModelIDForLiveEntry(%q, %q) = %q, want %q", tt.providerID, tt.entry.ID, got, tt.want) + } + }) + } +} diff --git a/catalog/provider_live_parity_test.go b/catalog/provider_live_parity_test.go index d5588c6..a07f7da 100644 --- a/catalog/provider_live_parity_test.go +++ b/catalog/provider_live_parity_test.go @@ -11,8 +11,8 @@ import ( func TestAllProviders_LiveFetchParity(t *testing.T) { t.Parallel() specs := registry.All() - if len(specs) != 27 { - t.Fatalf("expected 27 providers, got %d", len(specs)) + if len(specs) != 28 { + t.Fatalf("expected 28 providers, got %d", len(specs)) } for _, spec := range specs { t.Run(spec.ProviderID, func(t *testing.T) { diff --git a/catalog/registry/provider_spec_test.go b/catalog/registry/provider_spec_test.go index baaf223..df859f5 100644 --- a/catalog/registry/provider_spec_test.go +++ b/catalog/registry/provider_spec_test.go @@ -9,8 +9,8 @@ import ( func TestAllProviders_Count(t *testing.T) { t.Parallel() - if n := len(registry.All()); n != 27 { - t.Fatalf("expected 27 providers, got %d", n) + if n := len(registry.All()); n != 28 { + t.Fatalf("expected 28 providers, got %d", n) } } @@ -39,8 +39,8 @@ func TestProviderSpecs_AgnesOpenAIOnlyLongCatOpenAIPrimary(t *testing.T) { func TestLiveFetcherKeys_AllProviders(t *testing.T) { t.Parallel() keys := registry.LiveFetcherKeys() - if len(keys) != 27 { - t.Fatalf("expected 27 live fetcher keys, got %d", len(keys)) + if len(keys) != 28 { + t.Fatalf("expected 28 live fetcher keys, got %d", len(keys)) } } diff --git a/catalog/registry/providers.go b/catalog/registry/providers.go index 707267c..bfafa07 100644 --- a/catalog/registry/providers.go +++ b/catalog/registry/providers.go @@ -260,6 +260,15 @@ func providerSpecs() []ProviderSpec { ThinkingToggleSupported: true, DefaultThinkingDisabled: true, }, + { + ProviderID: "fireworks", DisplayName: "Fireworks AI", DeploymentID: "fireworks-direct", SortOrder: 28, ChatPreference: 23, + RequiresKey: true, CredentialEnv: "FIREWORKS_API_KEY", + BaseURLEnv: []string{"FIREWORKS_BASE_URL"}, + ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.fireworks.ai/inference/v1", + LiveFetcherKey: "fireworks", LiveCatalogKey: "fireworks", + ProtocolID: "openai-chat-completions", AdapterID: "fireworks", RuntimeProfileKey: "fireworks", + DNSHost: "api.fireworks.ai", + }, // ── Niche ───────────────────────────────────────────────────────── { diff --git a/catalog/v1_defaults.go b/catalog/v1_defaults.go index d0a6830..fa47110 100644 --- a/catalog/v1_defaults.go +++ b/catalog/v1_defaults.go @@ -28,6 +28,7 @@ func defaultProviders() map[string]Provider { "xiaomi_mimo_token_plan": {ID: "xiaomi_mimo_token_plan", Name: "Xiaomi MiMo (Token Plan)"}, "deepseek": {ID: "deepseek", Name: "DeepSeek"}, "stepfun": {ID: "stepfun", Name: "StepFun"}, + "fireworks": {ID: "fireworks", Name: "Fireworks AI"}, } } @@ -63,6 +64,7 @@ func defaultDeployments() map[string]Deployment { "xiaomi_mimo_token_plan-direct": deployment("xiaomi_mimo_token_plan-direct", "Xiaomi MiMo Token Plan", "xiaomi_mimo_token_plan", "openai-chat-completions", "xiaomi_mimo", NativeModelIDDiscovered), "deepseek-direct": deployment("deepseek-direct", "DeepSeek", "deepseek", "openai-chat-completions", "deepseek", NativeModelIDCatalogKnown), "stepfun-direct": deployment("stepfun-direct", "StepFun", "stepfun", "openai-chat-completions", "openai", NativeModelIDDiscovered), + "fireworks-direct": deployment("fireworks-direct", "Fireworks AI", "fireworks", "openai-chat-completions", "openai", NativeModelIDDiscovered), } } diff --git a/client/adapters/provider_registry.go b/client/adapters/provider_registry.go index 721f7da..65a05ca 100644 --- a/client/adapters/provider_registry.go +++ b/client/adapters/provider_registry.go @@ -75,51 +75,43 @@ func staticProviderMaps() (map[string]ProviderRegistryConfig, map[string]Provide // DetectProvider detects the active provider from the credential store (not process env). 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") }, - "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) - }, - "xiaomi_mimo_token_plan": func() bool { - return credentials.HasSecret(ctx, config.EnvXiaomiTokenPlanAPIKey) - }, - "minimax_token_plan": func() bool { - return credentials.HasSecret(ctx, "MINIMAX_TOKEN_PLAN_API_KEY") - }, - "minimax_payg": func() bool { - return credentials.HasSecret(ctx, "MINIMAX_PAYG_API_KEY") - }, - "ollama": func() bool { return ResolveEnvSecret("OLLAMA_BASE_URL") != "" }, - "azure": func() bool { - return credentials.HasSecret(ctx, "AZURE_OPENAI_API_KEY") && ResolveEnvSecret("AZURE_OPENAI_ENDPOINT") != "" - }, - "bedrock": func() bool { - return credentials.HasSecret(ctx, "AWS_ACCESS_KEY_ID") && credentials.HasSecret(ctx, "AWS_SECRET_ACCESS_KEY") - }, - "vertex": func() bool { - return credentials.HasSecret(ctx, "VERTEX_PROJECT_ID") && credentials.HasSecret(ctx, "VERTEX_ACCESS_TOKEN") - }, - } for _, p := range config.APIProviderDetectionOrder { - if fn, ok := checks[p]; ok && fn() { + if providerCredentialsPresent(ctx, p) { return p } } return "anthropic" } +// providerCredentialsPresent derives ordinary API-key checks from the +// authoritative runtime profile and keeps only providers with multi-field +// credentials explicit. This prevents new catalog providers from being +// silently omitted from automatic detection. +func providerCredentialsPresent(ctx context.Context, provider string) bool { + if provider == config.ProviderOllama { + return ResolveEnvSecret("OLLAMA_BASE_URL") != "" + } + profile, ok := config.RuntimeProfileByKey(provider) + if !ok { + return false + } + if provider == config.ProviderAzure { + return credentials.HasSecret(ctx, "AZURE_OPENAI_API_KEY") && ResolveEnvSecret("AZURE_OPENAI_ENDPOINT") != "" + } + if provider == config.ProviderBedrock { + return credentials.HasSecret(ctx, "AWS_ACCESS_KEY_ID") && credentials.HasSecret(ctx, "AWS_SECRET_ACCESS_KEY") + } + if provider == config.ProviderVertex { + return credentials.HasSecret(ctx, "VERTEX_PROJECT_ID") && credentials.HasSecret(ctx, "VERTEX_ACCESS_TOKEN") + } + for _, env := range profile.DetectionEnv { + if credentials.HasSecret(ctx, env) { + return true + } + } + return false +} + // ResolveProviderModelEnvOverride resolves the model env override for a provider. func ResolveProviderModelEnvOverride(provider string) string { if provider == "" { diff --git a/client/adapters/provider_registry_test.go b/client/adapters/provider_registry_test.go index 7e1fcab..57ad081 100644 --- a/client/adapters/provider_registry_test.go +++ b/client/adapters/provider_registry_test.go @@ -1,6 +1,7 @@ package adapters import ( + "context" "testing" "github.com/GrayCodeAI/eyrie/credentials" @@ -84,6 +85,33 @@ func TestDetectProvider_NoProvider(t *testing.T) { } } +func TestDetectProvider_AllProfileCredentialProviders(t *testing.T) { + for _, tc := range []struct { + provider string + env string + }{ + {"concentrate", "CONCENTRATE_API_KEY"}, + {"agnes", "AGNES_API_KEY"}, + {"longcat", "LONGCAT_API_KEY"}, + {"fireworks", "FIREWORKS_API_KEY"}, + {"stepfun", "STEP_API_KEY"}, + {"opengateway", "OPENGATEWAY_API_KEY"}, + {"clinepass", "CLINE_API_KEY"}, + } { + t.Run(tc.provider, 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-key"); err != nil { + t.Fatal(err) + } + if got := DetectProvider(); got != tc.provider { + t.Fatalf("DetectProvider() = %q, want %q", got, tc.provider) + } + }) + } +} + func TestDetectProvider_PriorityOrder(t *testing.T) { store := &credentials.MapStore{ Data: map[string]string{ diff --git a/client/client_test.go b/client/client_test.go index 2b07e8e..c06d419 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -41,6 +41,13 @@ func TestDetectProvider_AdditionalProviders(t *testing.T) { want string }{ {name: "deepseek", env: "DEEPSEEK_API_KEY", want: "deepseek"}, + {name: "concentrate", env: "CONCENTRATE_API_KEY", want: "concentrate"}, + {name: "agnes", env: "AGNES_API_KEY", want: "agnes"}, + {name: "longcat", env: "LONGCAT_API_KEY", want: "longcat"}, + {name: "fireworks", env: "FIREWORKS_API_KEY", want: "fireworks"}, + {name: "stepfun", env: "STEP_API_KEY", want: "stepfun"}, + {name: "opengateway", env: "OPENGATEWAY_API_KEY", want: "opengateway"}, + {name: "clinepass", env: "CLINE_API_KEY", want: "clinepass"}, {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"}, diff --git a/config/active_selection.go b/config/active_selection.go index c8be9bc..1888024 100644 --- a/config/active_selection.go +++ b/config/active_selection.go @@ -90,6 +90,8 @@ func SetProviderModel(cfg *ProviderConfig, provider, model string) { cfg.XiaomiModel = model case ProviderMiniMaxTokenPlan, ProviderMiniMaxPayg: cfg.MiniMaxModel = model + case ProviderFireworks: + cfg.FireworksModel = model default: // Unknown/custom provider: active_model + active_provider are enough. } diff --git a/config/config_test.go b/config/config_test.go index 68ac60e..ef381fa 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -68,6 +68,13 @@ func TestIsOpenAICompatibleRuntimeEnabled(t *testing.T) { if !IsOpenAICompatibleRuntimeEnabled() { t.Error("expected true with OPENAI_API_KEY in secure store") } + store = &credentials.MapStore{} + credentials.SetDefaultStore(store) + ClearProviderRuntimeEnv() + _ = store.Set(context.Background(), credentials.AccountForEnv("FIREWORKS_API_KEY"), "fireworks-test-key") + if !IsOpenAICompatibleRuntimeEnabled() { + t.Error("expected true with FIREWORKS_API_KEY in secure store") + } } func TestNormalizeOllamaOpenAIBaseURL(t *testing.T) { @@ -87,8 +94,8 @@ func TestNormalizeOllamaOpenAIBaseURL(t *testing.T) { func TestProviderDetectionOrder(t *testing.T) { t.Parallel() - if len(APIProviderDetectionOrder) != 25 { - t.Errorf("expected 25 providers in detection order, got %d", len(APIProviderDetectionOrder)) + if len(APIProviderDetectionOrder) != 28 { + t.Errorf("expected 28 providers in detection order, got %d", len(APIProviderDetectionOrder)) } if APIProviderDetectionOrder[0] != ProviderAnthropic { t.Error("expected anthropic first in detection order") diff --git a/config/discovery_env.go b/config/discovery_env.go index a818bb4..c380a7e 100644 --- a/config/discovery_env.go +++ b/config/discovery_env.go @@ -70,6 +70,7 @@ func mergeDiscoveryEnvFromConfig(env map[string]string, cfg *ProviderConfig, all setDiscoveryEnv(env, "POOLSIDE_BASE_URL", cfg.PoolsideBaseURL) setDiscoveryEnv(env, "GROQ_BASE_URL", cfg.GroqBaseURL) setDiscoveryEnv(env, "CLINE_API_BASE", cfg.ClinePassBaseURL) + setDiscoveryEnv(env, "FIREWORKS_BASE_URL", cfg.FireworksBaseURL) if ollamaBase := NormalizeOllamaOpenAIBaseURL(AsNonEmptyString(cfg.OllamaBaseURL)); ollamaBase != "" { env["OLLAMA_BASE_URL"] = ollamaBase } @@ -114,6 +115,7 @@ func mergeDeploymentBaseURL(env map[string]string, deploymentID, baseURL string) "clinepass": "CLINE_API_BASE", "opencodego": "OPENCODEGO_BASE_URL", "ollama-local": "OLLAMA_BASE_URL", + "fireworks-direct": "FIREWORKS_BASE_URL", } if key := keys[deploymentID]; key != "" { setDiscoveryEnv(env, key, baseURL) diff --git a/config/profiles.go b/config/profiles.go index e79f76f..1a1f64b 100644 --- a/config/profiles.go +++ b/config/profiles.go @@ -17,6 +17,8 @@ const ( ProviderConcentrate APIProvider = "concentrate" ProviderOpenGateway APIProvider = "opengateway" ProviderAgnes APIProvider = "agnes" + ProviderLongCat APIProvider = "longcat" + ProviderFireworks APIProvider = "fireworks" ProviderGrok APIProvider = "grok" ProviderGemini APIProvider = "gemini" ProviderBedrock APIProvider = "bedrock" @@ -67,6 +69,27 @@ var ( BaseURLEnv: []string{"OPENAI_BASE_URL", "OPENAI_API_BASE"}, APIKeys: []APIKeyDef{{Env: "OPENAI_API_KEY", Source: "openai"}}, } + AgnesRuntimeProfile = RuntimeProviderProfile{ + Mode: "openai", DefaultBaseURL: DefaultAgnesOpenAIBaseURL, + DetectionEnv: []string{"AGNES_API_KEY"}, + ModelEnv: []string{"AGNES_MODEL", "OPENAI_MODEL"}, + BaseURLEnv: []string{"AGNES_BASE_URL"}, + APIKeys: []APIKeyDef{{Env: "AGNES_API_KEY", Source: "agnes"}}, + } + LongCatRuntimeProfile = RuntimeProviderProfile{ + Mode: "openai", DefaultBaseURL: DefaultLongCatOpenAIBaseURL, + DetectionEnv: []string{"LONGCAT_API_KEY"}, + ModelEnv: []string{"LONGCAT_MODEL", "OPENAI_MODEL"}, + BaseURLEnv: []string{"LONGCAT_BASE_URL"}, + APIKeys: []APIKeyDef{{Env: "LONGCAT_API_KEY", Source: "longcat"}}, + } + FireworksRuntimeProfile = RuntimeProviderProfile{ + Mode: "openai", DefaultBaseURL: DefaultFireworksOpenAIBaseURL, + DetectionEnv: []string{"FIREWORKS_API_KEY"}, + ModelEnv: []string{"FIREWORKS_MODEL", "OPENAI_MODEL"}, + BaseURLEnv: []string{"FIREWORKS_BASE_URL"}, + APIKeys: []APIKeyDef{{Env: "FIREWORKS_API_KEY", Source: "fireworks"}}, + } GrokRuntimeProfile = RuntimeProviderProfile{ Mode: "grok", DefaultBaseURL: DefaultGrokOpenAIBaseURL, DefaultModel: "grok-2", DetectionEnv: []string{"XAI_API_KEY"}, @@ -232,7 +255,7 @@ var ( // APIProviderDetectionOrder is the priority order for provider detection. var APIProviderDetectionOrder = []APIProvider{ - ProviderAnthropic, ProviderConcentrate, ProviderOpenRouter, ProviderGrok, ProviderGemini, + ProviderAnthropic, ProviderConcentrate, ProviderAgnes, ProviderLongCat, ProviderFireworks, ProviderOpenRouter, ProviderGrok, ProviderGemini, ProviderVertex, ProviderBedrock, ProviderZAICoding, ProviderZAIPayg, ProviderCanopyWave, ProviderDeepSeek, ProviderPoolside, ProviderGroq, ProviderClinePass, ProviderAzure, ProviderOpenAI, ProviderOpenCodeGo, ProviderKimi, ProviderXiaomiMimoPayg, ProviderXiaomiMimoTokenPlan, ProviderMiniMaxTokenPlan, ProviderMiniMaxPayg, ProviderOllama, ProviderStepFun, ProviderOpenGateway, } @@ -264,6 +287,7 @@ var ProviderModelEnvKeys = map[APIProvider][]string{ ProviderMiniMaxPayg: {"MINIMAX_PAYG_MODEL", "MINIMAX_MODEL", "OPENAI_MODEL"}, ProviderStepFun: StepFunRuntimeProfile.ModelEnv, ProviderOpenGateway: OpenGatewayRuntimeProfile.ModelEnv, + ProviderFireworks: FireworksRuntimeProfile.ModelEnv, } const ( @@ -273,15 +297,16 @@ const ( // OpenAICompatibleRuntimeProfileOrder is the detection order for runtime profiles. var OpenAICompatibleRuntimeProfileOrder = []string{ - "concentrate", "agnes", "longcat", "openrouter", "grok", "gemini", "anthropic", "zai_coding", "zai_payg", "canopywave", "deepseek", "poolside", "groq", "clinepass", "openai", "opencodego", "kimi", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", "minimax_token_plan", "minimax_payg", "stepfun", "opengateway", + "concentrate", "agnes", "longcat", "fireworks", "openrouter", "grok", "gemini", "anthropic", "zai_coding", "zai_payg", "canopywave", "deepseek", "poolside", "groq", "clinepass", "openai", "opencodego", "kimi", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", "minimax_token_plan", "minimax_payg", "stepfun", "opengateway", } // OpenAICompatibleRuntimeProfiles maps profile key to its runtime profile. var OpenAICompatibleRuntimeProfiles = map[string]RuntimeProviderProfile{ "concentrate": ConcentrateRuntimeProfile, + "agnes": AgnesRuntimeProfile, + "longcat": LongCatRuntimeProfile, + "fireworks": FireworksRuntimeProfile, "opengateway": OpenGatewayRuntimeProfile, - "agnes": OpenAIRuntimeProfile, - "longcat": OpenAIRuntimeProfile, "anthropic": AnthropicRuntimeProfile, "grok": GrokRuntimeProfile, "gemini": GeminiRuntimeProfile, @@ -307,7 +332,9 @@ var OpenAICompatibleRuntimeProfiles = map[string]RuntimeProviderProfile{ var RuntimeProviderProfiles = map[string]RuntimeProviderProfile{ "anthropic": AnthropicRuntimeProfile, "openai": OpenAIRuntimeProfile, - "agnes": OpenAIRuntimeProfile, + "agnes": AgnesRuntimeProfile, + "longcat": LongCatRuntimeProfile, + "fireworks": FireworksRuntimeProfile, "grok": GrokRuntimeProfile, "gemini": GeminiRuntimeProfile, "vertex": VertexRuntimeProfile, diff --git a/config/provider_env.go b/config/provider_env.go index 09657cd..c520aa3 100644 --- a/config/provider_env.go +++ b/config/provider_env.go @@ -80,6 +80,9 @@ type ProviderConfig struct { AgnesAPIKey string `json:"agnes_api_key,omitempty"` AgnesBaseURL string `json:"agnes_base_url,omitempty"` AgnesModel string `json:"agnes_model,omitempty"` + FireworksAPIKey string `json:"fireworks_api_key,omitempty"` + FireworksBaseURL string `json:"fireworks_base_url,omitempty"` + FireworksModel string `json:"fireworks_model,omitempty"` DeepSeekModel string `json:"deepseek_model,omitempty"` ZAIModel string `json:"zai_model,omitempty"` GrokModel string `json:"grok_model,omitempty"` @@ -263,6 +266,11 @@ var providerFields = map[string]providerFieldMap{ Models: func(c *ProviderConfig) []string { return []string{c.AgnesModel} }, BaseURL: func(c *ProviderConfig) string { return c.AgnesBaseURL }, }, + ProviderFireworks: { + APIKeys: func(c *ProviderConfig) []string { return []string{c.FireworksAPIKey} }, + Models: func(c *ProviderConfig) []string { return []string{c.FireworksModel} }, + BaseURL: func(c *ProviderConfig) string { return c.FireworksBaseURL }, + }, ProviderMiniMaxTokenPlan: { APIKeys: func(c *ProviderConfig) []string { return []string{c.MiniMaxTokenPlanAPIKey} }, Models: func(c *ProviderConfig) []string { return []string{c.MiniMaxModel} }, @@ -622,6 +630,7 @@ func ClearProviderRuntimeEnv() { "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_REGION", "AWS_DEFAULT_REGION", "BEDROCK_MODEL", "VERTEX_ACCESS_TOKEN", "GOOGLE_OAUTH_ACCESS_TOKEN", "VERTEX_PROJECT_ID", "VERTEX_REGION", "VERTEX_MODEL", "OPENROUTER_API_KEY", "OPENROUTER_MODEL", "OPENROUTER_BASE_URL", + "FIREWORKS_API_KEY", "FIREWORKS_MODEL", "FIREWORKS_BASE_URL", "CONCENTRATE_API_KEY", "CONCENTRATE_MODEL", "CONCENTRATE_BASE_URL", "OPENGATEWAY_API_KEY", "OPENGATEWAY_MODEL", "OPENGATEWAY_BASE_URL", "CANOPYWAVE_API_KEY", "CANOPYWAVE_MODEL", "CANOPYWAVE_BASE_URL", @@ -823,6 +832,14 @@ func ApplyProviderEnv(provider string, config *ProviderConfig, activeModel strin m = catalog.GetProviderDefaultModel("openrouter", cat) } collectOpenAICompatibleProvider(env, "OPENROUTER", apiKey, m, base, overwrite) + case ProviderFireworks: + apiKey := AsNonEmptyString(config.FireworksAPIKey) + base := firstNonEmpty(config.FireworksBaseURL, DefaultFireworksOpenAIBaseURL) + m := activeModel + if m == "" { + m = catalog.GetProviderDefaultModel("fireworks", cat) + } + collectOpenAICompatibleProvider(env, "FIREWORKS", apiKey, m, base, overwrite) case ProviderOllama: m := activeModel if m == "" { diff --git a/config/provider_env_test.go b/config/provider_env_test.go index 1ca4b35..eb9fb41 100644 --- a/config/provider_env_test.go +++ b/config/provider_env_test.go @@ -293,6 +293,30 @@ func TestApplyProviderEnv_DeepSeek(t *testing.T) { } } +func TestApplyProviderEnv_Fireworks(t *testing.T) { + t.Parallel() + cfg := &ProviderConfig{ + FireworksAPIKey: "fireworks-key-1234567890", + FireworksBaseURL: "https://fireworks.example/v1", + } + cat := testModelCatalog() + + env := ApplyProviderEnv(ProviderFireworks, cfg, "accounts/fireworks/models/deepseek-v4-flash", true, &cat) + + if env["FIREWORKS_API_KEY"] != "fireworks-key-1234567890" { + t.Errorf("expected FIREWORKS_API_KEY, got %q", env["FIREWORKS_API_KEY"]) + } + if env["FIREWORKS_MODEL"] != "accounts/fireworks/models/deepseek-v4-flash" { + t.Errorf("expected FIREWORKS_MODEL, got %q", env["FIREWORKS_MODEL"]) + } + if env["FIREWORKS_BASE_URL"] != "https://fireworks.example/v1" { + t.Errorf("expected FIREWORKS_BASE_URL, got %q", env["FIREWORKS_BASE_URL"]) + } + if env["OPENAI_API_KEY"] != "fireworks-key-1234567890" || env["OPENAI_BASE_URL"] != "https://fireworks.example/v1" { + t.Errorf("expected OpenAI compatibility env, got key=%q base=%q", env["OPENAI_API_KEY"], env["OPENAI_BASE_URL"]) + } +} + func TestApplyProviderEnv_Ollama(t *testing.T) { t.Parallel() cfg := &ProviderConfig{ diff --git a/config/provider_secrets.go b/config/provider_secrets.go index ae03318..343ce47 100644 --- a/config/provider_secrets.go +++ b/config/provider_secrets.go @@ -138,6 +138,11 @@ var providerCredentialFields = []providerCredentialField{ value: func(c *ProviderConfig) string { return c.AgnesAPIKey }, clear: func(c *ProviderConfig) { c.AgnesAPIKey = "" }, }, + { + label: "fireworks_api_key", env: "FIREWORKS_API_KEY", + value: func(c *ProviderConfig) string { return c.FireworksAPIKey }, + clear: func(c *ProviderConfig) { c.FireworksAPIKey = "" }, + }, } // ProviderConfigContainsSecrets reports whether provider state contains @@ -300,6 +305,7 @@ var providerBaseURLEnv = map[string]func(*ProviderConfig) string{ "OPENGATEWAY_BASE_URL": func(c *ProviderConfig) string { return c.OpenGatewayBaseURL }, "STEP_BASE_URL": func(c *ProviderConfig) string { return c.StepFunBaseURL }, "AGNES_BASE_URL": func(c *ProviderConfig) string { return c.AgnesBaseURL }, + "FIREWORKS_BASE_URL": func(c *ProviderConfig) string { return c.FireworksBaseURL }, "CANOPYWAVE_BASE_URL": func(c *ProviderConfig) string { return c.CanopyWaveBaseURL }, "POOLSIDE_BASE_URL": func(c *ProviderConfig) string { return c.PoolsideBaseURL }, "GROQ_BASE_URL": func(c *ProviderConfig) string { return c.GroqBaseURL }, diff --git a/config/providers.go b/config/providers.go index 2afbd0e..81579cf 100644 --- a/config/providers.go +++ b/config/providers.go @@ -42,6 +42,7 @@ const ( DefaultAgnesOpenAIBaseURL = "https://apihub.agnes-ai.com/v1" DefaultLongCatOpenAIBaseURL = "https://api.longcat.chat/openai/v1" DefaultLongCatAnthropicBaseURL = "https://api.longcat.chat/anthropic" + DefaultFireworksOpenAIBaseURL = "https://api.fireworks.ai/inference/v1" ) // ProviderTransport is the transport type for provider requests. diff --git a/config/runtime.go b/config/runtime.go index d70efb7..96afc01 100644 --- a/config/runtime.go +++ b/config/runtime.go @@ -25,18 +25,16 @@ type ResolvedOpenAICompatibleRuntime struct { // IsOpenAICompatibleRuntimeEnabled checks if any provider API key is set. func IsOpenAICompatibleRuntimeEnabled() bool { - keys := []string{ - "OPENROUTER_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", - "ANTHROPIC_API_KEY", "CANOPYWAVE_API_KEY", "DEEPSEEK_API_KEY", "ZAI_API_KEY", "OPENAI_API_KEY", - "OPENCODEGO_API_KEY", "OLLAMA_BASE_URL", - "MOONSHOT_API_KEY", "XIAOMI_MIMO_PAYG_API_KEY", "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", - } - for _, k := range keys { - if envValue(k) != "" { - return true + // Derive detection keys from the registered runtime profiles so adding a + // provider cannot silently leave readiness checks out of sync. + for _, profile := range RuntimeProviderProfiles { + for _, key := range profile.DetectionEnv { + if envValue(key) != "" { + return true + } } } - return false + return envValue("OLLAMA_BASE_URL") != "" } func envValue(key string) string { diff --git a/config/runtime_test.go b/config/runtime_test.go index dafb92a..3e7a0ad 100644 --- a/config/runtime_test.go +++ b/config/runtime_test.go @@ -35,6 +35,7 @@ func TestRuntimeProfileFields(t *testing.T) { "grok": GrokRuntimeProfile, "gemini": GeminiRuntimeProfile, "openrouter": OpenRouterRuntimeProfile, + "fireworks": FireworksRuntimeProfile, "canopywave": CanopyWaveRuntimeProfile, "deepseek": DeepSeekRuntimeProfile, "zai_payg": ZAIPaygRuntimeProfile, @@ -70,6 +71,7 @@ func TestRuntimeProfileAPIKeys(t *testing.T) { "grok": GrokRuntimeProfile, "gemini": GeminiRuntimeProfile, "openrouter": OpenRouterRuntimeProfile, + "fireworks": FireworksRuntimeProfile, "canopywave": CanopyWaveRuntimeProfile, "deepseek": DeepSeekRuntimeProfile, "zai_payg": ZAIPaygRuntimeProfile, @@ -99,6 +101,7 @@ func TestModelEnvKeysCorrectForEachProvider(t *testing.T) { ProviderCanopyWave: "CANOPYWAVE_MODEL", ProviderDeepSeek: "DEEPSEEK_MODEL", ProviderOpenRouter: "OPENROUTER_MODEL", + ProviderFireworks: "FIREWORKS_MODEL", ProviderGrok: "XAI_MODEL", ProviderGemini: "GEMINI_MODEL", ProviderOllama: "OLLAMA_MODEL", @@ -126,6 +129,7 @@ func TestProviderModelEnvKeys_AllProvidersPresent(t *testing.T) { allProviders := []string{ ProviderAnthropic, ProviderOpenAI, ProviderCanopyWave, ProviderDeepSeek, ProviderOpenRouter, ProviderGrok, ProviderGemini, + ProviderFireworks, ProviderOllama, ProviderOpenCodeGo, } diff --git a/credentials/store.go b/credentials/store.go index 609569a..75183a7 100644 --- a/credentials/store.go +++ b/credentials/store.go @@ -99,6 +99,7 @@ func discoveryEnvKeys(ctx context.Context) []string { "GEMINI_API_KEY", "XAI_API_KEY", "MOONSHOT_API_KEY", "XIAOMI_MIMO_PAYG_API_KEY", "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "CANOPYWAVE_API_KEY", "OPENCODEGO_API_KEY", + "FIREWORKS_API_KEY", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "VERTEX_ACCESS_TOKEN", "GOOGLE_OAUTH_ACCESS_TOKEN", } diff --git a/setup/deployment.go b/setup/deployment.go index 854eef3..306af8b 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -274,6 +274,12 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c return nil, false } return client.NewOpenRouterClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultOpenRouterOpenAIBaseURL), &client.OpenRouterCompat), true + case "fireworks-direct": + apiKey := FirstNonEmpty(deployment.APIKey, lookup("FIREWORKS_API_KEY")) + if apiKey == "" { + return nil, false + } + return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultFireworksOpenAIBaseURL), &client.OpenAICompat), true case "canopywave": apiKey := FirstNonEmpty(deployment.APIKey, lookup("CANOPYWAVE_API_KEY")) if apiKey == "" { diff --git a/setup/deployment_test.go b/setup/deployment_test.go index 278e443..03423ce 100644 --- a/setup/deployment_test.go +++ b/setup/deployment_test.go @@ -493,6 +493,13 @@ func TestProviderForDeployment_OpenAIDirect(t *testing.T) { } } +func TestProviderForDeployment_Fireworks(t *testing.T) { + p, ok := ProviderForDeployment("fireworks-direct", config.DeploymentConfig{APIKey: "fw-test-key"}) + if !ok || p == nil { + t.Fatal("expected Fireworks provider") + } +} + func TestProviderForDeployment_OpenAIDirectRequiresKey(t *testing.T) { store := &credentials.MapStore{} credentials.SetDefaultStore(store)