Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions catalog/discover/discover_fallback_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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++ {
Expand Down
2 changes: 2 additions & 0 deletions catalog/live/fetchers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -74,6 +75,7 @@ var Registry = map[string]FetchFunc{
"opengateway": FetchOpenGateway,
"agnes": FetchAgnes,
"longcat": FetchLongCat,
"fireworks": FetchFireworks,
"canopywave": FetchCanopyWave,
"opencodego": FetchOpenCodeGo,
"kimi": FetchKimi,
Expand Down
9 changes: 9 additions & 0 deletions catalog/live/fetchers_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
43 changes: 43 additions & 0 deletions catalog/live/fireworks_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
32 changes: 19 additions & 13 deletions catalog/live_enrich.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,19 +73,7 @@
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,
Expand Down Expand Up @@ -113,6 +101,24 @@
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)
Expand All @@ -137,6 +143,6 @@
}

// LiveDiscoverableDeploymentIDs returns provider IDs that have live model-list APIs.
func LiveDiscoverableDeploymentIDs() []string {

Check failure on line 146 in catalog/live_enrich.go

View workflow job for this annotation

GitHub Actions / deadcode

unreachable func: LiveDiscoverableDeploymentIDs
return registry.LiveFetcherKeys()
}
56 changes: 56 additions & 0 deletions catalog/live_enrich_internal_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
4 changes: 2 additions & 2 deletions catalog/provider_live_parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions catalog/registry/provider_spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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))
}
}

Expand Down
9 changes: 9 additions & 0 deletions catalog/registry/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────
{
Expand Down
2 changes: 2 additions & 0 deletions catalog/v1_defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"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"},
}
}

Expand Down Expand Up @@ -63,6 +64,7 @@
"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),
}
}

Expand Down Expand Up @@ -126,7 +128,7 @@
}

// DefaultOfferingTemplates returns offering templates for Azure deployments (model mappings required).
func DefaultOfferingTemplates(generatedAt time.Time) []ModelOfferingTemplate {

Check failure on line 131 in catalog/v1_defaults.go

View workflow job for this annotation

GitHub Actions / deadcode

unreachable func: DefaultOfferingTemplates
var out []ModelOfferingTemplate
for _, model := range seedOpenAIModels {
modelID := "openai/" + model.ID
Expand Down
68 changes: 30 additions & 38 deletions client/adapters/provider_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
28 changes: 28 additions & 0 deletions client/adapters/provider_registry_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package adapters

import (
"context"
"testing"

"github.com/GrayCodeAI/eyrie/credentials"
Expand Down Expand Up @@ -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{
Expand Down
7 changes: 7 additions & 0 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
Loading
Loading