diff --git a/catalog/bootstrap.go b/catalog/bootstrap.go index b546b57..0d978fd 100644 --- a/catalog/bootstrap.go +++ b/catalog/bootstrap.go @@ -9,21 +9,21 @@ func BootstrapSource() string { return bootstrapSource } -// BootstrapCatalogV1 returns deployment/provider wiring only — no chat models. +// BootstrapCatalog returns deployment/provider wiring only — no chat models. // Chat models come from the published catalog cache and live provider discovery. -func BootstrapCatalogV1() CatalogV1 { +func BootstrapCatalog() Catalog { generatedAt := time.Now().UTC().Truncate(time.Second) - c := CatalogV1{ - SchemaVersion: CatalogV1SchemaVersion, + c := Catalog{ + SchemaVersion: CatalogSchemaVersion, GeneratedAt: generatedAt, StaleAfter: generatedAt.Add(24 * time.Hour), - Providers: defaultProvidersV1(), - APIProtocols: defaultAPIProtocolsV1(), - Deployments: defaultDeploymentsV1(), - Models: map[string]ModelV1{}, + Providers: defaultProviders(), + Protocols: defaultProtocols(), + Deployments: defaultDeployments(), + Models: map[string]Model{}, Aliases: map[string]string{}, Offerings: nil, - Provenance: &CatalogProvenanceV1{Source: bootstrapSource, ObservedAt: generatedAt}, + Provenance: &Provenance{Source: bootstrapSource, ObservedAt: generatedAt}, } EnsureDeploymentEnvFallbacks(&c) EnsureCredentialRegistryInCatalog(&c) @@ -31,6 +31,6 @@ func BootstrapCatalogV1() CatalogV1 { } // IsBootstrapCatalog reports whether c is the empty wiring-only catalog. -func IsBootstrapCatalog(c *CatalogV1) bool { +func IsBootstrapCatalog(c *Catalog) bool { return c != nil && c.Provenance != nil && c.Provenance.Source == bootstrapSource } diff --git a/catalog/catalog_list_test.go b/catalog/catalog_list_test.go index 5bf7c88..52468ce 100644 --- a/catalog/catalog_list_test.go +++ b/catalog/catalog_list_test.go @@ -89,18 +89,18 @@ func TestServerToolsFromOffering(t *testing.T) { t.Parallel() tests := []struct { name string - offering ModelOfferingV1 + offering ModelOffering want []string }{ { name: "nil_server_tools", - offering: ModelOfferingV1{}, + offering: ModelOffering{}, want: nil, }, { name: "supported_tool", - offering: ModelOfferingV1{ - Capabilities: CapabilitySetV1{ + offering: ModelOffering{ + Capabilities: CapabilitySet{ ServerTools: map[string]CapabilityState{"web_search": CapabilitySupported}, }, }, @@ -108,8 +108,8 @@ func TestServerToolsFromOffering(t *testing.T) { }, { name: "unsupported_tool_filtered", - offering: ModelOfferingV1{ - Capabilities: CapabilitySetV1{ + offering: ModelOffering{ + Capabilities: CapabilitySet{ ServerTools: map[string]CapabilityState{"web_search": CapabilityUnsupported}, }, }, @@ -117,8 +117,8 @@ func TestServerToolsFromOffering(t *testing.T) { }, { name: "mixed_tools", - offering: ModelOfferingV1{ - Capabilities: CapabilitySetV1{ + offering: ModelOffering{ + Capabilities: CapabilitySet{ ServerTools: map[string]CapabilityState{ "web_search": CapabilitySupported, "code_interp": CapabilityUnsupported, @@ -130,8 +130,8 @@ func TestServerToolsFromOffering(t *testing.T) { }, { name: "empty_tool_name_filtered", - offering: ModelOfferingV1{ - Capabilities: CapabilitySetV1{ + offering: ModelOffering{ + Capabilities: CapabilitySet{ ServerTools: map[string]CapabilityState{ "": CapabilitySupported, "web_search": CapabilitySupported, @@ -162,8 +162,8 @@ func TestModelEntryFromOffering(t *testing.T) { t.Parallel() tests := []struct { name string - model ModelV1 - offering ModelOfferingV1 + model Model + offering ModelOffering wantID string wantContext int wantMaxOut int @@ -172,28 +172,28 @@ func TestModelEntryFromOffering(t *testing.T) { }{ { name: "uses_native_model_id", - model: ModelV1{ID: "anthropic/claude-sonnet-4-6", Name: "Sonnet 4.6", ContextWindow: 200000, MaxOutput: 32000}, - offering: ModelOfferingV1{ + model: Model{ID: "anthropic/claude-sonnet-4-6", Name: "Sonnet 4.6", ContextWindow: 200000, MaxOutput: 32000}, + offering: ModelOffering{ NativeModelID: "claude-sonnet-4-6", - Pricing: PricingV1{RatesPer1M: map[string]float64{"input_tokens": 3, "output_tokens": 15}}, + Pricing: Pricing{RatesPer1M: map[string]float64{"input_tokens": 3, "output_tokens": 15}}, }, wantID: "claude-sonnet-4-6", wantContext: 200000, wantMaxOut: 32000, wantInPrice: 3, wantOutPrice: 15, }, { name: "empty_native_falls_back_to_model_id", - model: ModelV1{ID: "openai/gpt-4o", Name: "GPT-4o"}, - offering: ModelOfferingV1{ + model: Model{ID: "openai/gpt-4o", Name: "GPT-4o"}, + offering: ModelOffering{ NativeModelID: "", - Pricing: PricingV1{Status: PricingUnknown}, + Pricing: Pricing{Status: PricingUnknown}, }, wantID: "openai/gpt-4o", }, { name: "nil_rates_zeroes_prices", - model: ModelV1{ID: "x/model", Name: "Model"}, - offering: ModelOfferingV1{ + model: Model{ID: "x/model", Name: "Model"}, + offering: ModelOffering{ NativeModelID: "model", - Pricing: PricingV1{Status: PricingUnknown}, + Pricing: Pricing{Status: PricingUnknown}, }, wantID: "model", wantInPrice: 0, wantOutPrice: 0, }, @@ -232,8 +232,8 @@ func TestModelEntriesForProvider_NilCompiled(t *testing.T) { func TestModelEntriesForProvider_EmptyProvider(t *testing.T) { t.Parallel() - compiled := &CompiledCatalogV1{ - ModelsByID: map[string]ModelV1{ + compiled := &CompiledCatalog{ + ModelsByID: map[string]Model{ "anthropic/claude-sonnet-4-6": {ID: "anthropic/claude-sonnet-4-6", Name: "Sonnet", ProviderID: "anthropic"}, }, } @@ -245,11 +245,11 @@ func TestModelEntriesForProvider_EmptyProvider(t *testing.T) { func TestModelEntriesForProvider_DeduplicatesByNativeID(t *testing.T) { t.Parallel() - compiled := &CompiledCatalogV1{ - ModelsByID: map[string]ModelV1{ + compiled := &CompiledCatalog{ + ModelsByID: map[string]Model{ "anthropic/claude-sonnet-4-6": {ID: "anthropic/claude-sonnet-4-6", Name: "Sonnet", ProviderID: "anthropic"}, }, - OfferingsByDeployment: map[string][]ModelOfferingV1{ + OfferingsByDeployment: map[string][]ModelOffering{ "anthropic-direct": { {CanonicalModelID: "anthropic/claude-sonnet-4-6", DeploymentID: "anthropic-direct", NativeModelID: "claude-sonnet-4-6"}, }, @@ -267,7 +267,7 @@ func TestDiscoveryEnvKeysFromCatalog(t *testing.T) { t.Parallel() tests := []struct { name string - compiled *CompiledCatalogV1 + compiled *CompiledCatalog wantNil bool }{ { @@ -277,7 +277,7 @@ func TestDiscoveryEnvKeysFromCatalog(t *testing.T) { }, { name: "nil_catalog", - compiled: &CompiledCatalogV1{}, + compiled: &CompiledCatalog{}, wantNil: true, }, } @@ -293,8 +293,8 @@ func TestDiscoveryEnvKeysFromCatalog(t *testing.T) { func TestDiscoveryEnvKeysFromCatalog_ReturnsUniqueKeys(t *testing.T) { t.Parallel() - c := testLegacyCatalogV1() - compiled, err := CompileCatalogV1(&c) + c := SeedCatalog() + compiled, err := CompileCatalog(&c) if err != nil { t.Fatal(err) } @@ -318,8 +318,8 @@ func TestDiscoveryEnvKeysFromCatalog_ReturnsUniqueKeys(t *testing.T) { func TestAPIKeyEnvsForProvider(t *testing.T) { t.Parallel() - c := testLegacyCatalogV1() - compiled, err := CompileCatalogV1(&c) + c := SeedCatalog() + compiled, err := CompileCatalog(&c) if err != nil { t.Fatal(err) } @@ -378,8 +378,8 @@ func TestPrimaryAPIKeyEnvForDeployment(t *testing.T) { func TestPrimaryAPIKeyEnvForDeployment_WithCompiled(t *testing.T) { t.Parallel() - c := testLegacyCatalogV1() - compiled, err := CompileCatalogV1(&c) + c := SeedCatalog() + compiled, err := CompileCatalog(&c) if err != nil { t.Fatal(err) } diff --git a/catalog/catalog_test.go b/catalog/catalog_test.go index 79bfee0..ecfe7ff 100644 --- a/catalog/catalog_test.go +++ b/catalog/catalog_test.go @@ -64,7 +64,7 @@ func TestGetModelDeprecationWarning(t *testing.T) { func TestModelsForProvider(t *testing.T) { t.Parallel() - cat := testLegacyModelCatalog() + cat := testModelCatalog() models := cat.Providers["anthropic"] if len(models) == 0 { t.Error("expected anthropic models in default catalog") diff --git a/catalog/compiled_list.go b/catalog/compiled_list.go index 05ce263..3676ce2 100644 --- a/catalog/compiled_list.go +++ b/catalog/compiled_list.go @@ -10,7 +10,7 @@ import ( // ModelEntriesForProvider lists models from a compiled v1 catalog for one provider. // New models appear here automatically when the eyrie catalog is updated — hosts must not hardcode IDs. -func ModelEntriesForProvider(compiled *CompiledCatalogV1, provider string) []ModelCatalogEntry { +func ModelEntriesForProvider(compiled *CompiledCatalog, provider string) []ModelCatalogEntry { if compiled == nil { return nil } @@ -24,7 +24,7 @@ func ModelEntriesForProvider(compiled *CompiledCatalogV1, provider string) []Mod return modelEntriesByProviderID(compiled, provider) } -func modelEntriesByProviderID(compiled *CompiledCatalogV1, provider string) []ModelCatalogEntry { +func modelEntriesByProviderID(compiled *CompiledCatalog, provider string) []ModelCatalogEntry { seen := map[string]bool{} var out []ModelCatalogEntry ids := make([]string, 0, len(compiled.ModelsByID)) @@ -48,7 +48,7 @@ func modelEntriesByProviderID(compiled *CompiledCatalogV1, provider string) []Mo // CanonicalModelForProviderNative maps a picker native id to the canonical model for that provider's // deployment, without using global catalog aliases (e.g. mimo-v2.5-pro → xiaomi, not opencodego). -func CanonicalModelForProviderNative(compiled *CompiledCatalogV1, providerID, modelID string) (string, bool) { +func CanonicalModelForProviderNative(compiled *CompiledCatalog, providerID, modelID string) (string, bool) { if compiled == nil { return "", false } @@ -70,7 +70,7 @@ func CanonicalModelForProviderNative(compiled *CompiledCatalogV1, providerID, mo return "", false } -func modelEntriesForDeployment(compiled *CompiledCatalogV1, deploymentID string) []ModelCatalogEntry { +func modelEntriesForDeployment(compiled *CompiledCatalog, deploymentID string) []ModelCatalogEntry { if compiled == nil || deploymentID == "" { return nil } @@ -95,7 +95,7 @@ func modelEntriesForDeployment(compiled *CompiledCatalogV1, deploymentID string) return out } -func modelEntryFromOffering(model ModelV1, offering ModelOfferingV1) ModelCatalogEntry { +func modelEntryFromOffering(model Model, offering ModelOffering) ModelCatalogEntry { id := strings.TrimSpace(model.ID) if native := strings.TrimSpace(offering.NativeModelID); native != "" { id = native @@ -132,14 +132,14 @@ func descriptionFromLiveMetadata(raw json.RawMessage) string { return strings.TrimSpace(meta.Description) } -func modelOwnerFromOffering(offering ModelOfferingV1) string { +func modelOwnerFromOffering(offering ModelOffering) string { if o := ownerFromLiveMetadata(offering.LiveMetadata); o != "" { return o } return ownerFromModelID(offering.NativeModelID) } -func serverToolsFromOffering(offering ModelOfferingV1) []string { +func serverToolsFromOffering(offering ModelOffering) []string { if offering.Capabilities.ServerTools == nil { return nil } @@ -153,10 +153,10 @@ func serverToolsFromOffering(offering ModelOfferingV1) []string { return out } -func firstOfferingForModel(compiled *CompiledCatalogV1, canonicalModelID string) ModelOfferingV1 { +func firstOfferingForModel(compiled *CompiledCatalog, canonicalModelID string) ModelOffering { offerings := compiled.OfferingsByCanonicalModel[canonicalModelID] if len(offerings) == 0 { - return ModelOfferingV1{} + return ModelOffering{} } sort.SliceStable(offerings, func(i, j int) bool { return offerings[i].DeploymentID < offerings[j].DeploymentID diff --git a/catalog/compiled_list_test.go b/catalog/compiled_list_test.go index 81f8e32..6f90def 100644 --- a/catalog/compiled_list_test.go +++ b/catalog/compiled_list_test.go @@ -5,11 +5,11 @@ import "testing" func TestModelEntriesForProvider_OpenRouterUsesOfferings(t *testing.T) { t.Parallel() raw := []byte(`{"id":"anthropic/claude-sonnet-4-6","architecture":{"modality":"text"}}`) - compiled := &CompiledCatalogV1{ - ModelsByID: map[string]ModelV1{ + compiled := &CompiledCatalog{ + ModelsByID: map[string]Model{ "anthropic/claude-sonnet-4-6": {ID: "anthropic/claude-sonnet-4-6", Name: "Sonnet", ProviderID: "anthropic"}, }, - OfferingsByDeployment: map[string][]ModelOfferingV1{ + OfferingsByDeployment: map[string][]ModelOffering{ "openrouter": {{ CanonicalModelID: "anthropic/claude-sonnet-4-6", DeploymentID: "openrouter", @@ -30,11 +30,11 @@ func TestModelEntriesForProvider_OpenRouterUsesOfferings(t *testing.T) { func TestModelEntriesForProvider_CanopyWaveUsesDeploymentOfferings(t *testing.T) { t.Parallel() raw := []byte(`{"id":"moonshotai/kimi-k2.6","name":"Kimi K2.6","owned_by":"moonshotai"}`) - compiled := &CompiledCatalogV1{ - ModelsByID: map[string]ModelV1{ + compiled := &CompiledCatalog{ + ModelsByID: map[string]Model{ "moonshotai/kimi-k2.6": {ID: "moonshotai/kimi-k2.6", Name: "Kimi K2.6", ProviderID: "moonshotai"}, }, - OfferingsByDeployment: map[string][]ModelOfferingV1{ + OfferingsByDeployment: map[string][]ModelOffering{ "canopywave": {{ CanonicalModelID: "moonshotai/kimi-k2.6", DeploymentID: "canopywave", @@ -54,13 +54,13 @@ func TestModelEntriesForProvider_CanopyWaveUsesDeploymentOfferings(t *testing.T) func TestModelEntriesForProvider_GeminiUsesDirectDeploymentOfferings(t *testing.T) { t.Parallel() - compiled := &CompiledCatalogV1{ - ModelsByID: map[string]ModelV1{ + compiled := &CompiledCatalog{ + ModelsByID: map[string]Model{ "gemini-flash": {ID: "gemini-flash", Name: "Flash", ProviderID: "google"}, "gemini-pro": {ID: "gemini-pro", Name: "Pro", ProviderID: "google"}, "other-model": {ID: "other-model", Name: "Other", ProviderID: "google"}, }, - OfferingsByDeployment: map[string][]ModelOfferingV1{ + OfferingsByDeployment: map[string][]ModelOffering{ "gemini-direct": { {CanonicalModelID: "gemini-flash", DeploymentID: "gemini-direct", NativeModelID: "gemini-flash"}, {CanonicalModelID: "gemini-pro", DeploymentID: "gemini-direct", NativeModelID: "gemini-pro"}, @@ -75,17 +75,17 @@ func TestModelEntriesForProvider_GeminiUsesDirectDeploymentOfferings(t *testing. func TestCanonicalModelForProviderNative_PrefersDeploymentOverGlobalAlias(t *testing.T) { t.Parallel() - compiled := &CompiledCatalogV1{ - Catalog: &CatalogV1{ + compiled := &CompiledCatalog{ + Catalog: &Catalog{ Aliases: map[string]string{ "mimo-v2.5-pro": "opencodego/mimo-v2.5-pro", }, }, - ModelsByID: map[string]ModelV1{ + ModelsByID: map[string]Model{ "opencodego/mimo-v2.5-pro": {ID: "opencodego/mimo-v2.5-pro", ProviderID: "opencodego"}, "xiaomi_mimo_token_plan/mimo-v2.5-pro": {ID: "xiaomi_mimo_token_plan/mimo-v2.5-pro", ProviderID: "xiaomi_mimo_token_plan"}, }, - OfferingsByDeployment: map[string][]ModelOfferingV1{ + OfferingsByDeployment: map[string][]ModelOffering{ "xiaomi_mimo_token_plan-direct": {{ CanonicalModelID: "xiaomi_mimo_token_plan/mimo-v2.5-pro", DeploymentID: "xiaomi_mimo_token_plan-direct", @@ -101,12 +101,12 @@ func TestCanonicalModelForProviderNative_PrefersDeploymentOverGlobalAlias(t *tes func TestModelEntriesForProvider_AnthropicUsesDirectDeploymentOfferings(t *testing.T) { t.Parallel() - compiled := &CompiledCatalogV1{ - ModelsByID: map[string]ModelV1{ + compiled := &CompiledCatalog{ + ModelsByID: map[string]Model{ "anthropic/claude-sonnet-4-6": {ID: "anthropic/claude-sonnet-4-6", Name: "Sonnet", ProviderID: "anthropic"}, "openai/gpt-4o": {ID: "openai/gpt-4o", Name: "GPT-4o", ProviderID: "openai"}, }, - OfferingsByDeployment: map[string][]ModelOfferingV1{ + OfferingsByDeployment: map[string][]ModelOffering{ "anthropic-direct": {{ CanonicalModelID: "anthropic/claude-sonnet-4-6", DeploymentID: "anthropic-direct", diff --git a/catalog/credential_registry.go b/catalog/credential_registry.go index 2643335..3a01e92 100644 --- a/catalog/credential_registry.go +++ b/catalog/credential_registry.go @@ -7,27 +7,27 @@ import ( ) // EnsureCredentialRegistryInCatalog merges registry providers/deployments into catalog v1. -func EnsureCredentialRegistryInCatalog(c *CatalogV1) { +func EnsureCredentialRegistryInCatalog(c *Catalog) { if c == nil { return } if c.Providers == nil { - c.Providers = map[string]ProviderV1{} + c.Providers = map[string]Provider{} } if c.Deployments == nil { - c.Deployments = map[string]DeploymentV1{} + c.Deployments = map[string]Deployment{} } for _, spec := range registry.DefaultRegistry.All() { pid := CanonicalProviderID(spec.ProviderID) if c.Providers[pid].ID == "" { - c.Providers[pid] = ProviderV1{ID: pid, Name: spec.DisplayName} + c.Providers[pid] = Provider{ID: pid, Name: spec.DisplayName} } if c.Deployments[spec.DeploymentID].ID == "" { - c.Deployments[spec.DeploymentID] = DeploymentV1{ + c.Deployments[spec.DeploymentID] = Deployment{ ID: spec.DeploymentID, Name: spec.DisplayName, ProviderID: pid, - APIProtocolID: spec.APIProtocolID, + APIProtocolID: spec.ProtocolID, AdapterConstructor: spec.AdapterID, NativeModelIDSource: NativeModelIDDiscovered, } diff --git a/catalog/default_catalog_test.go b/catalog/default_catalog_test.go index 24eed77..8734dbe 100644 --- a/catalog/default_catalog_test.go +++ b/catalog/default_catalog_test.go @@ -1,24 +1,25 @@ package catalog import ( + "strings" "testing" ) -func TestDefaultCatalogV1_ReturnsBootstrap(t *testing.T) { +func TestSeedCatalog_ReturnsCatalog(t *testing.T) { t.Parallel() - c := DefaultCatalogV1() - if c.SchemaVersion != CatalogV1SchemaVersion { + c := SeedCatalog() + if c.SchemaVersion != CatalogSchemaVersion { t.Fatalf("schema_version = %q", c.SchemaVersion) } - if c.Provenance == nil || c.Provenance.Source != bootstrapSource { - t.Fatalf("provenance source = %v, want %q", c.Provenance, bootstrapSource) + if c.Provenance == nil || c.Provenance.Source != "test" { + t.Fatalf("provenance source = %v, want test", c.Provenance) } } -func TestDefaultCatalogV1_HasProviders(t *testing.T) { +func TestSeedCatalog_HasProviders(t *testing.T) { t.Parallel() - c := DefaultCatalogV1() - expected := []string{"anthropic", "openai", "google", "xai", "openrouter", "ollama"} + c := SeedCatalog() + expected := []string{"anthropic", "openai", "google", "xai", "openrouter", "ollama", "opencodego", "canopywave"} for _, id := range expected { if c.Providers[id].ID == "" { t.Errorf("missing provider %q in default catalog", id) @@ -26,12 +27,13 @@ func TestDefaultCatalogV1_HasProviders(t *testing.T) { } } -func TestDefaultCatalogV1_HasDeployments(t *testing.T) { +func TestSeedCatalog_HasDeployments(t *testing.T) { t.Parallel() - c := DefaultCatalogV1() + c := SeedCatalog() expected := []string{ - "anthropic-direct", "openai-direct", "gemini-direct", + "anthropic-direct", "openai-direct", "grok-direct", "openrouter", "ollama-local", + "opencodego", "canopywave", } for _, id := range expected { if c.Deployments[id].ID == "" { @@ -40,114 +42,91 @@ func TestDefaultCatalogV1_HasDeployments(t *testing.T) { } } -func TestDefaultCatalogV1_HasAPIProtocols(t *testing.T) { +func TestSeedCatalog_HasModels(t *testing.T) { t.Parallel() - c := DefaultCatalogV1() - expected := []string{"anthropic-messages", "openai-chat-completions", "gemini-generate-content"} - for _, id := range expected { - if c.APIProtocols[id].ID == "" { - t.Errorf("missing api_protocol %q in default catalog", id) - } - } -} - -func TestDefaultCatalogV1_NoModels(t *testing.T) { - t.Parallel() - c := DefaultCatalogV1() - if len(c.Models) != 0 { - t.Fatalf("bootstrap catalog should have no models, got %d", len(c.Models)) - } - if len(c.Offerings) != 0 { - t.Fatalf("bootstrap catalog should have no offerings, got %d", len(c.Offerings)) + c := SeedCatalog() + if len(c.Models) == 0 { + t.Fatal("seed catalog should have models") } } -func TestDefaultCatalogV1_DeploymentsReferenceProviders(t *testing.T) { +func TestSeedCatalog_DeploymentsReferenceProviders(t *testing.T) { t.Parallel() - c := DefaultCatalogV1() + c := SeedCatalog() for id, dep := range c.Deployments { if c.Providers[dep.ProviderID].ID == "" { t.Errorf("deployment %q references unknown provider %q", id, dep.ProviderID) } - if c.APIProtocols[dep.APIProtocolID].ID == "" { - t.Errorf("deployment %q references unknown api_protocol %q", id, dep.APIProtocolID) + if c.Protocols[dep.APIProtocolID].ID == "" { + t.Errorf("deployment %q references unknown protocol %q", id, dep.APIProtocolID) } } } -func TestDefaultCatalogV1_Validates(t *testing.T) { +func TestSeedCatalog_Validates(t *testing.T) { t.Parallel() - c := DefaultCatalogV1() - if err := ValidateCatalogV1(&c); err != nil { - t.Fatalf("default catalog should validate: %v", err) + c := SeedCatalog() + if err := ValidateCatalog(&c); err != nil { + t.Fatalf("seed catalog should validate: %v", err) } } -func TestDefaultCatalogV1_Compiles(t *testing.T) { +func TestSeedCatalog_Compiles(t *testing.T) { t.Parallel() - c := DefaultCatalogV1() - compiled, err := CompileCatalogV1(&c) + c := SeedCatalog() + compiled, err := CompileCatalog(&c) if err != nil { - t.Fatalf("default catalog should compile: %v", err) + t.Fatalf("seed catalog should compile: %v", err) } if compiled == nil { t.Fatal("compiled should not be nil") } } -func TestIsBootstrapCatalog(t *testing.T) { +func TestSeedCatalog_NoDuplicateOfferings(t *testing.T) { t.Parallel() - bootstrap := BootstrapCatalogV1() - if !IsBootstrapCatalog(&bootstrap) { - t.Error("BootstrapCatalogV1 should be identified as bootstrap") - } - - legacy := testLegacyCatalogV1() - if IsBootstrapCatalog(&legacy) { - t.Error("legacy-sourced catalog should not be bootstrap") - } - - if IsBootstrapCatalog(nil) { - t.Error("nil should not be bootstrap") + c := SeedCatalog() + seen := map[string]bool{} + for _, o := range c.Offerings { + if seen[o.ID] { + t.Fatalf("duplicate offering %q", o.ID) + } + seen[o.ID] = true } } -func TestBootstrapCatalogV1_HasEnvFallbacks(t *testing.T) { +func TestBootstrapCatalog_HasEnvFallbacks(t *testing.T) { t.Parallel() - c := BootstrapCatalogV1() - anthDep, ok := c.Deployments["anthropic-direct"] - if !ok { - t.Fatal("missing anthropic-direct") - } - if len(anthDep.EnvFallbacks) == 0 { - t.Error("bootstrap anthropic-direct should have env fallbacks") + c := SeedCatalog() + for id, dep := range c.Deployments { + if len(dep.EnvFallbacks) > 0 { + continue + } + if dep.AdapterConstructor == "" { + continue + } + if dep.ModelMappingsRequired { + continue + } + if dep.NativeModelIDSource == NativeModelIDUserConfigured { + continue + } + if strings.HasSuffix(id, "-direct") || id == "openrouter" || id == "canopywave" || id == "opencodego" || id == "ollama-local" { + if len(dep.EnvFallbacks) == 0 { + t.Errorf("deployment %q should have env fallbacks", id) + } + } } } -func TestBootstrapCatalogV1_HasCredentialProviders(t *testing.T) { +func TestIsBootstrapCatalog(t *testing.T) { t.Parallel() - c := BootstrapCatalogV1() - // Ollama is local, should not require key - ollamaDep, ok := c.Deployments["ollama-local"] - if !ok { - t.Fatal("missing ollama-local") + // SeedCatalog is NOT bootstrap (it's a full catalog) + c := SeedCatalog() + if IsBootstrapCatalog(&c) { + t.Error("SeedCatalog should not be bootstrap") } - if ollamaDep.Local != true { - t.Error("ollama-local should be marked local") - } - - // Anthropic requires key - anthDep, ok := c.Deployments["anthropic-direct"] - if !ok { - t.Fatal("missing anthropic-direct") - } - hasAPIKey := false - for _, fb := range anthDep.EnvFallbacks { - if fb.Field == "api_key" { - hasAPIKey = true - } - } - if !hasAPIKey { - t.Error("anthropic-direct should have api_key in env fallbacks") + if IsBootstrapCatalog(nil) { + t.Error("nil should not be bootstrap") } } diff --git a/catalog/deployment_env.go b/catalog/deployment_env.go index f22b4cb..76589fd 100644 --- a/catalog/deployment_env.go +++ b/catalog/deployment_env.go @@ -7,7 +7,7 @@ import ( // extraDeploymentEnvFallbacks are env fallbacks for deployments that have no // corresponding ProviderSpec (proxy/gateway deployments like Bedrock, Vertex, Azure). // All provider-spec-derived deployments come from registry.DeploymentEnvFallbacks. -var extraDeploymentEnvFallbacks = map[string][]EnvFallbackV1{ +var extraDeploymentEnvFallbacks = map[string][]EnvFallback{ "anthropic-bedrock": { {Field: "access_key_id", Env: []string{"AWS_ACCESS_KEY_ID"}}, {Field: "secret_access_key", Env: []string{"AWS_SECRET_ACCESS_KEY"}}, @@ -32,12 +32,12 @@ var extraDeploymentEnvFallbacks = map[string][]EnvFallbackV1{ } // DefaultDeploymentEnvFallbacks seeds env_fallbacks per deployment until the published catalog includes them. -var DefaultDeploymentEnvFallbacks = func() map[string][]EnvFallbackV1 { - result := make(map[string][]EnvFallbackV1, len(registry.DeploymentEnvFallbacks())+len(extraDeploymentEnvFallbacks)) +var DefaultDeploymentEnvFallbacks = func() map[string][]EnvFallback { + result := make(map[string][]EnvFallback, len(registry.DeploymentEnvFallbacks())+len(extraDeploymentEnvFallbacks)) for id, fbs := range registry.DeploymentEnvFallbacks() { - var converted []EnvFallbackV1 + var converted []EnvFallback for _, fb := range fbs { - converted = append(converted, EnvFallbackV1{Field: fb.Field, Env: fb.Env}) + converted = append(converted, EnvFallback{Field: fb.Field, Env: fb.Env}) } result[id] = converted } @@ -51,7 +51,7 @@ var DefaultDeploymentEnvFallbacks = func() map[string][]EnvFallbackV1 { // EnsureDeploymentEnvFallbacks fills missing env_fallbacks from the embedded seed. // Published catalogs with env_fallbacks set are left unchanged. -func EnsureDeploymentEnvFallbacks(c *CatalogV1) { +func EnsureDeploymentEnvFallbacks(c *Catalog) { if c == nil || c.Deployments == nil { return } @@ -68,7 +68,7 @@ func EnsureDeploymentEnvFallbacks(c *CatalogV1) { // DiscoveryEnvKeysFromCatalog returns env var names needed for catalog discovery // (API keys, base URLs) from deployment env_fallbacks in the compiled catalog. -func DiscoveryEnvKeysFromCatalog(compiled *CompiledCatalogV1) []string { +func DiscoveryEnvKeysFromCatalog(compiled *CompiledCatalog) []string { if compiled == nil || compiled.Catalog == nil { return nil } diff --git a/catalog/deployment_env_test.go b/catalog/deployment_env_test.go index aebdb8e..91d4ed5 100644 --- a/catalog/deployment_env_test.go +++ b/catalog/deployment_env_test.go @@ -73,8 +73,8 @@ func TestDefaultDeploymentEnvFallbacks_ZAIHasZAIAPIBase(t *testing.T) { func TestEnsureDeploymentEnvFallbacks(t *testing.T) { t.Parallel() - c := &CatalogV1{ - Deployments: map[string]DeploymentV1{ + c := &Catalog{ + Deployments: map[string]Deployment{ "anthropic-direct": {ID: "anthropic-direct"}, "unknown-dep": {ID: "unknown-dep"}, }, diff --git a/catalog/discover/discover.go b/catalog/discover/discover.go index 96dc6b0..686f078 100644 --- a/catalog/discover/discover.go +++ b/catalog/discover/discover.go @@ -6,13 +6,37 @@ import ( "time" "github.com/GrayCodeAI/eyrie/catalog" - "github.com/GrayCodeAI/eyrie/catalog/registry" eyriecfg "github.com/GrayCodeAI/eyrie/config" ) +func appendSourceSuffix(source, suffix string) string { + if source == "embedded" || source == catalog.BootstrapSource() || source == "cache-fallback" { + if source == "cache-fallback" { + return "cache-fallback+" + suffix + } + return suffix + } + return source + "+" + suffix +} + +// liveDeploymentIDs returns the set of deployment IDs referenced by +// the offerings in a live-discovered catalog, so the caller can +// replace them in the base catalog. +func liveDeploymentIDs(v1Catalog catalog.Catalog) []string { + var ids []string + seen := map[string]bool{} + for _, o := range v1Catalog.Offerings { + if o.DeploymentID != "" && !seen[o.DeploymentID] { + seen[o.DeploymentID] = true + ids = append(ids, o.DeploymentID) + } + } + return ids +} + // Options configures catalog discovery: published catalog + live provider APIs via API keys. type Options struct { - catalog.LoadCatalogV1Options + catalog.LoadCatalogOptions Credentials catalog.Credentials } @@ -30,22 +54,22 @@ func run(ctx context.Context, opts Options) (*catalog.RefreshResult, error) { opts.CachePath = catalog.DefaultCachePath() } - var base *catalog.CatalogV1 + var base *catalog.Catalog source := "embedded" refreshed := false remoteRefreshed := false var liveProviders []catalog.LiveProviderEnrichment if opts.RefreshRemote { - loadOpts := opts.LoadCatalogV1Options + loadOpts := opts.LoadCatalogOptions loadOpts.RemoteURL = catalog.ResolvedRemoteCatalogURL(opts.RemoteURL) - remote, err := catalog.FetchRemoteCatalogV1(ctx, loadOpts) + remote, err := catalog.FetchRemoteCatalog(ctx, loadOpts) if err != nil { if compiled, ok := catalog.LoadValidCatalogCache(opts.CachePath); ok && compiled.Catalog != nil { base = compiled.Catalog source = "cache-fallback" } else { - bootstrap := catalog.BootstrapCatalogV1() + bootstrap := catalog.BootstrapCatalog() base = &bootstrap source = catalog.BootstrapSource() } @@ -57,7 +81,7 @@ func run(ctx context.Context, opts Options) (*catalog.RefreshResult, error) { opts.RemoteURL = loadOpts.RemoteURL } } else { - compiled, err := catalog.LoadCatalogV1(ctx, opts.LoadCatalogV1Options) + compiled, err := catalog.LoadCatalog(ctx, opts.LoadCatalogOptions) if err != nil { return nil, fmt.Errorf("catalog discover: load: %w", err) } @@ -68,7 +92,7 @@ func run(ctx context.Context, opts Options) (*catalog.RefreshResult, error) { } if base == nil { - bootstrap := catalog.BootstrapCatalogV1() + bootstrap := catalog.BootstrapCatalog() base = &bootstrap source = catalog.BootstrapSource() } @@ -80,51 +104,28 @@ func run(ctx context.Context, opts Options) (*catalog.RefreshResult, error) { env = eyriecfg.DiscoveryCredentials(ctx).Env() } if len(env) > 0 { - legacy, enrichment := catalog.FetchLiveProviderCatalog(env) + v1Catalog, enrichment := catalog.FetchLiveProviderCatalog(env) liveProviders = enrichment - if len(legacy.Providers) > 0 { - enriched := catalog.CatalogV1FromLegacy(legacy) - var replaceDeps []string - var preferProviders []string - for _, item := range enrichment { - if item.Error != "" || item.ModelCount <= 0 { - continue - } - if dep := catalog.DeploymentIDForLiveCatalogKey(item.Provider); dep != "" { - replaceDeps = append(replaceDeps, dep) - } - if spec, ok := registry.SpecForLiveFetcher(item.Provider); ok { - preferProviders = append(preferProviders, catalog.CanonicalProviderID(spec.ProviderID)) - } - } - base = MergeCatalogV1WithPolicy(base, &enriched, MergePolicy{ - PreferLive: len(preferProviders) > 0, - PreferLiveProviders: preferProviders, - ReplaceDeploymentOfferings: replaceDeps, + if len(v1Catalog.Models) > 0 { + base = MergeCatalogWithPolicy(base, &v1Catalog, MergePolicy{ + PreferLive: true, + ReplaceDeploymentOfferings: liveDeploymentIDs(v1Catalog), }) now := time.Now().UTC().Truncate(time.Second) base.GeneratedAt = now - base.StaleAfter = now.Add(catalog.LiveCatalogStaleDuration) + base.StaleAfter = now.Add(catalog.LiveStaleDuration) if base.Provenance == nil { - base.Provenance = &catalog.CatalogProvenanceV1{} + base.Provenance = &catalog.Provenance{} } base.Provenance.ObservedAt = now - } - if source == "embedded" || source == catalog.BootstrapSource() || source == "cache-fallback" { - if source == "cache-fallback" { - source = "cache-fallback+providers" - } else { - source = "providers" - } - } else { - source += "+providers" + source = appendSourceSuffix(source, "providers") } } - if err := catalog.WriteCatalogV1Cache(opts.CachePath, base); err != nil { + if err := catalog.WriteCatalogCache(opts.CachePath, base); err != nil { return nil, fmt.Errorf("catalog discover: write cache: %w", err) } - compiled, err := catalog.CompileCatalogV1(base) + compiled, err := catalog.CompileCatalog(base) if err != nil { return nil, fmt.Errorf("catalog discover: compile: %w", err) } diff --git a/catalog/discover/discover_fallback_test.go b/catalog/discover/discover_fallback_test.go index a37ce2d..4ee1991 100644 --- a/catalog/discover/discover_fallback_test.go +++ b/catalog/discover/discover_fallback_test.go @@ -14,8 +14,8 @@ import ( func TestDiscoverRun_RemoteFailureUsesCacheFallback(t *testing.T) { cachePath := filepath.Join(t.TempDir(), "model_catalog.json") - base := catalog.TestSeedCatalogV1() - if err := catalog.WriteCatalogV1Cache(cachePath, &base); err != nil { + base := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(cachePath, &base); err != nil { t.Fatalf("seed cache: %v", err) } @@ -27,7 +27,7 @@ func TestDiscoverRun_RemoteFailureUsesCacheFallback(t *testing.T) { t.Setenv("EYRIE_MODEL_CATALOG_URL", failServer.URL) result, err := discover.Run(context.Background(), discover.Options{ - LoadCatalogV1Options: catalog.LoadCatalogV1Options{ + LoadCatalogOptions: catalog.LoadCatalogOptions{ CachePath: cachePath, RefreshRemote: true, RemoteURL: failServer.URL, @@ -52,12 +52,12 @@ func TestDiscoverRun_RemoteFailureUsesCacheFallback(t *testing.T) { func TestDiscoverRun_ConcurrentCallsSerialized(t *testing.T) { cachePath := filepath.Join(t.TempDir(), "model_catalog.json") - base := catalog.TestSeedCatalogV1() - if err := catalog.WriteCatalogV1Cache(cachePath, &base); err != nil { + base := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(cachePath, &base); err != nil { t.Fatalf("seed cache: %v", err) } opts := discover.Options{ - LoadCatalogV1Options: catalog.LoadCatalogV1Options{ + LoadCatalogOptions: catalog.LoadCatalogOptions{ CachePath: cachePath, RefreshRemote: false, }, diff --git a/catalog/discover/discover_test.go b/catalog/discover/discover_test.go index 1445585..b7ee07c 100644 --- a/catalog/discover/discover_test.go +++ b/catalog/discover/discover_test.go @@ -25,12 +25,12 @@ func TestDiscoverCatalog_MergesProviderModelsWithAPIKey(t *testing.T) { defer orServer.Close() cachePath := filepath.Join(t.TempDir(), "model_catalog.json") - base := catalog.TestSeedCatalogV1() - if err := catalog.WriteCatalogV1Cache(cachePath, &base); err != nil { + base := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(cachePath, &base); err != nil { t.Fatalf("seed cache: %v", err) } result, err := discover.Run(context.Background(), discover.Options{ - LoadCatalogV1Options: catalog.LoadCatalogV1Options{ + LoadCatalogOptions: catalog.LoadCatalogOptions{ CachePath: cachePath, RefreshRemote: false, }, diff --git a/catalog/discover/merge.go b/catalog/discover/merge.go index 86c7111..a794dd2 100644 --- a/catalog/discover/merge.go +++ b/catalog/discover/merge.go @@ -23,13 +23,13 @@ func (p MergePolicy) preferLiveForProvider(providerID string) bool { return providerID != "" && slices.Contains(p.PreferLiveProviders, providerID) } -// MergeCatalogV1 merges models, offerings, providers, deployments, and aliases from src into dst. -func MergeCatalogV1(dst, src *catalog.CatalogV1) *catalog.CatalogV1 { - return MergeCatalogV1WithPolicy(dst, src, MergePolicy{}) +// MergeCatalog merges models, offerings, providers, deployments, and aliases from src into dst. +func MergeCatalog(dst, src *catalog.Catalog) *catalog.Catalog { + return MergeCatalogWithPolicy(dst, src, MergePolicy{}) } -// MergeCatalogV1WithPolicy merges with live replacement for prefer-live providers. -func MergeCatalogV1WithPolicy(dst, src *catalog.CatalogV1, policy MergePolicy) *catalog.CatalogV1 { +// MergeCatalogWithPolicy merges with live replacement for prefer-live providers. +func MergeCatalogWithPolicy(dst, src *catalog.Catalog, policy MergePolicy) *catalog.Catalog { if dst == nil { return src } @@ -37,23 +37,23 @@ func MergeCatalogV1WithPolicy(dst, src *catalog.CatalogV1, policy MergePolicy) * return dst } if dst.Providers == nil { - dst.Providers = map[string]catalog.ProviderV1{} + dst.Providers = map[string]catalog.Provider{} } for id, p := range src.Providers { if dst.Providers[id].ID == "" { dst.Providers[id] = p } } - if dst.APIProtocols == nil { - dst.APIProtocols = map[string]catalog.APIProtocolV1{} + if dst.Protocols == nil { + dst.Protocols = map[string]catalog.Protocol{} } - for id, p := range src.APIProtocols { - if dst.APIProtocols[id].ID == "" { - dst.APIProtocols[id] = p + for id, p := range src.Protocols { + if dst.Protocols[id].ID == "" { + dst.Protocols[id] = p } } if dst.Deployments == nil { - dst.Deployments = map[string]catalog.DeploymentV1{} + dst.Deployments = map[string]catalog.Deployment{} } for id, d := range src.Deployments { if dst.Deployments[id].ID == "" { @@ -61,7 +61,7 @@ func MergeCatalogV1WithPolicy(dst, src *catalog.CatalogV1, policy MergePolicy) * } } if dst.Models == nil { - dst.Models = map[string]catalog.ModelV1{} + dst.Models = map[string]catalog.Model{} } if len(policy.ReplaceDeploymentOfferings) > 0 { remove := map[string]bool{} @@ -119,7 +119,7 @@ func MergeCatalogV1WithPolicy(dst, src *catalog.CatalogV1, policy MergePolicy) * return dst } -func providerIDForOffering(c *catalog.CatalogV1, offering catalog.ModelOfferingV1) string { +func providerIDForOffering(c *catalog.Catalog, offering catalog.ModelOffering) string { if c == nil { return "" } @@ -130,7 +130,7 @@ func providerIDForOffering(c *catalog.CatalogV1, offering catalog.ModelOfferingV return "" } -func mergeOfferingV1(existing, live catalog.ModelOfferingV1, providerID string, policy MergePolicy) catalog.ModelOfferingV1 { +func mergeOfferingV1(existing, live catalog.ModelOffering, providerID string, policy MergePolicy) catalog.ModelOffering { if strings.TrimSpace(live.CanonicalModelID) != "" { existing.CanonicalModelID = live.CanonicalModelID } @@ -153,7 +153,7 @@ func mergeOfferingV1(existing, live catalog.ModelOfferingV1, providerID string, return existing } -func mergeCapabilities(existing, live catalog.CapabilitySetV1) catalog.CapabilitySetV1 { +func mergeCapabilities(existing, live catalog.CapabilitySet) catalog.CapabilitySet { if len(live.ServerTools) > 0 { if existing.ServerTools == nil { existing.ServerTools = map[string]catalog.CapabilityState{} @@ -202,7 +202,7 @@ func mergeCapabilities(existing, live catalog.CapabilitySetV1) catalog.Capabilit return existing } -func shouldReplacePricing(existing, live catalog.PricingV1) bool { +func shouldReplacePricing(existing, live catalog.Pricing) bool { if live.Status == "" { return false } diff --git a/catalog/discover/merge_test.go b/catalog/discover/merge_test.go index 4a60973..a664779 100644 --- a/catalog/discover/merge_test.go +++ b/catalog/discover/merge_test.go @@ -9,19 +9,25 @@ import ( "github.com/GrayCodeAI/eyrie/catalog/discover" ) -func TestMergeCatalogV1WithPolicy_ReplacesDeploymentOfferings(t *testing.T) { +func TestMergeCatalogWithPolicy_ReplacesDeploymentOfferings(t *testing.T) { t.Parallel() - dst := catalog.TestSeedCatalogV1() - dst.Offerings = append(dst.Offerings, catalog.ModelOfferingV1{ + dst := catalog.SeedCatalog() + dst.Offerings = append(dst.Offerings, catalog.ModelOffering{ ID: "canopywave:old-model", CanonicalModelID: "z-ai/old", DeploymentID: "canopywave", - NativeModelID: "old-model", Pricing: catalog.PricingV1{Status: catalog.PricingUnknown}, + NativeModelID: "old-model", Pricing: catalog.Pricing{Status: catalog.PricingUnknown}, }) - src := catalog.CatalogV1FromLegacy(catalog.ModelCatalog{ - Providers: map[string][]catalog.ModelCatalogEntry{ - "canopywave": {{ID: "moonshotai/kimi-k2.6"}}, - }, + src := catalog.SeedCatalog() + src.Providers["canopywave"] = catalog.Provider{ID: "canopywave", Name: "CanopyWave"} + src.Deployments["canopywave"] = catalog.Deployment{ID: "canopywave"} + src.Models["moonshotai/kimi-k2.6"] = catalog.Model{ + ID: "moonshotai/kimi-k2.6", ProviderID: "canopywave", Name: "Kimi K2.6", + } + src.Offerings = append(src.Offerings, catalog.ModelOffering{ + ID: "canopywave:moonshotai/kimi-k2.6", CanonicalModelID: "moonshotai/kimi-k2.6", + DeploymentID: "canopywave", NativeModelID: "moonshotai/kimi-k2.6", + Pricing: catalog.Pricing{Status: catalog.PricingUnknown}, }) - out := discover.MergeCatalogV1WithPolicy(&dst, &src, discover.MergePolicy{ + out := discover.MergeCatalogWithPolicy(&dst, &src, discover.MergePolicy{ PreferLive: true, ReplaceDeploymentOfferings: []string{"canopywave"}, }) @@ -42,23 +48,23 @@ func TestMergeCatalogV1WithPolicy_ReplacesDeploymentOfferings(t *testing.T) { } } -func TestMergeCatalogV1WithPolicy_PreferLiveReplacesExistingModel(t *testing.T) { +func TestMergeCatalogWithPolicy_PreferLiveReplacesExistingModel(t *testing.T) { t.Parallel() - dst := catalog.TestSeedCatalogV1() - dst.Models["anthropic/claude-sonnet-4-6"] = catalog.ModelV1{ + dst := catalog.SeedCatalog() + dst.Models["anthropic/claude-sonnet-4-6"] = catalog.Model{ ID: "anthropic/claude-sonnet-4-6", ProviderID: "anthropic", Name: "Claude Sonnet", ContextWindow: 100000, MaxOutput: 4096, Aliases: []string{"claude-sonnet"}, } - src := &catalog.CatalogV1{ - Models: map[string]catalog.ModelV1{ + src := &catalog.Catalog{ + Models: map[string]catalog.Model{ "anthropic/claude-sonnet-4-6": { ID: "anthropic/claude-sonnet-4-6", ProviderID: "anthropic", Name: "Claude Sonnet 4.6", ContextWindow: 200000, MaxOutput: 8192, Aliases: []string{"sonnet-4-6"}, - Provenance: &catalog.CatalogProvenanceV1{Source: "live", ObservedAt: time.Now().UTC()}, + Provenance: &catalog.Provenance{Source: "live", ObservedAt: time.Now().UTC()}, }, }, } - out := discover.MergeCatalogV1WithPolicy(&dst, src, discover.MergePolicy{ + out := discover.MergeCatalogWithPolicy(&dst, src, discover.MergePolicy{ PreferLiveProviders: []string{"anthropic"}, }) got := out.Models["anthropic/claude-sonnet-4-6"] @@ -73,46 +79,46 @@ func TestMergeCatalogV1WithPolicy_PreferLiveReplacesExistingModel(t *testing.T) } } -func TestMergeCatalogV1WithPolicy_PreferLiveUpdatesExistingOffering(t *testing.T) { +func TestMergeCatalogWithPolicy_PreferLiveUpdatesExistingOffering(t *testing.T) { t.Parallel() - dst := catalog.TestSeedCatalogV1() - dst.Models["anthropic/claude-sonnet-4-6"] = catalog.ModelV1{ + dst := catalog.SeedCatalog() + dst.Models["anthropic/claude-sonnet-4-6"] = catalog.Model{ ID: "anthropic/claude-sonnet-4-6", ProviderID: "anthropic", Name: "Claude Sonnet", } - dst.Offerings = []catalog.ModelOfferingV1{{ + dst.Offerings = []catalog.ModelOffering{{ ID: "anthropic-direct:claude-sonnet-4-6", CanonicalModelID: "anthropic/claude-sonnet-4-6", DeploymentID: "anthropic-direct", NativeModelID: "claude-sonnet-4-6", - Pricing: catalog.PricingV1{Status: catalog.PricingUnknown}, + Pricing: catalog.Pricing{Status: catalog.PricingUnknown}, }} liveMeta, _ := json.Marshal(map[string]any{"mode": "live"}) - src := &catalog.CatalogV1{ - Models: map[string]catalog.ModelV1{ + src := &catalog.Catalog{ + Models: map[string]catalog.Model{ "anthropic/claude-sonnet-4-6": { ID: "anthropic/claude-sonnet-4-6", ProviderID: "anthropic", Name: "Claude Sonnet 4.6", }, }, - Offerings: []catalog.ModelOfferingV1{{ + Offerings: []catalog.ModelOffering{{ ID: "anthropic-direct:claude-sonnet-4-6", CanonicalModelID: "anthropic/claude-sonnet-4-6", DeploymentID: "anthropic-direct", NativeModelID: "claude-sonnet-4-6", - Capabilities: catalog.CapabilitySetV1{ + Capabilities: catalog.CapabilitySet{ FunctionCalling: "supported", ExplicitThinkingBudget: "supported", }, - Pricing: catalog.PricingV1{ + Pricing: catalog.Pricing{ Status: catalog.PricingKnown, Currency: "USD", RatesPer1M: map[string]float64{"input_tokens": 3, "output_tokens": 15}, Source: "live", }, LiveMetadata: liveMeta, - Provenance: &catalog.CatalogProvenanceV1{Source: "live", ObservedAt: time.Now().UTC()}, + Provenance: &catalog.Provenance{Source: "live", ObservedAt: time.Now().UTC()}, }}, } - out := discover.MergeCatalogV1WithPolicy(&dst, src, discover.MergePolicy{ + out := discover.MergeCatalogWithPolicy(&dst, src, discover.MergePolicy{ PreferLiveProviders: []string{"anthropic"}, }) if len(out.Offerings) != 1 { @@ -130,22 +136,22 @@ func TestMergeCatalogV1WithPolicy_PreferLiveUpdatesExistingOffering(t *testing.T } } -func TestMergeCatalogV1WithPolicy_PreferLiveFullReplace(t *testing.T) { +func TestMergeCatalogWithPolicy_PreferLiveFullReplace(t *testing.T) { t.Parallel() - dst := catalog.TestSeedCatalogV1() - dst.Models["openrouter/model-a"] = catalog.ModelV1{ + dst := catalog.SeedCatalog() + dst.Models["openrouter/model-a"] = catalog.Model{ ID: "openrouter/model-a", ProviderID: "openrouter", Name: "Model A (old)", ContextWindow: 100000, MaxOutput: 4096, } - src := &catalog.CatalogV1{ - Models: map[string]catalog.ModelV1{ + src := &catalog.Catalog{ + Models: map[string]catalog.Model{ "openrouter/model-a": { ID: "openrouter/model-a", ProviderID: "openrouter", Name: "Model A (live)", ContextWindow: 0, MaxOutput: 0, }, }, } - out := discover.MergeCatalogV1WithPolicy(&dst, src, discover.MergePolicy{ + out := discover.MergeCatalogWithPolicy(&dst, src, discover.MergePolicy{ PreferLiveProviders: []string{"openrouter"}, }) got := out.Models["openrouter/model-a"] @@ -160,34 +166,34 @@ func TestMergeCatalogV1WithPolicy_PreferLiveFullReplace(t *testing.T) { } } -func TestMergeCatalogV1WithPolicy_PreferLiveUnconditionalPricing(t *testing.T) { +func TestMergeCatalogWithPolicy_PreferLiveUnconditionalPricing(t *testing.T) { t.Parallel() - dst := catalog.TestSeedCatalogV1() - dst.Models["openrouter/model-a"] = catalog.ModelV1{ + dst := catalog.SeedCatalog() + dst.Models["openrouter/model-a"] = catalog.Model{ ID: "openrouter/model-a", ProviderID: "openrouter", } - dst.Offerings = []catalog.ModelOfferingV1{{ + dst.Offerings = []catalog.ModelOffering{{ ID: "openrouter:model-a", CanonicalModelID: "openrouter/model-a", DeploymentID: "openrouter", NativeModelID: "model-a", - Pricing: catalog.PricingV1{ + Pricing: catalog.Pricing{ Status: catalog.PricingKnown, Currency: "USD", RatesPer1M: map[string]float64{"input_tokens": 5, "output_tokens": 15}, }, }} - src := &catalog.CatalogV1{ - Models: map[string]catalog.ModelV1{ + src := &catalog.Catalog{ + Models: map[string]catalog.Model{ "openrouter/model-a": {ID: "openrouter/model-a", ProviderID: "openrouter"}, }, - Offerings: []catalog.ModelOfferingV1{{ + Offerings: []catalog.ModelOffering{{ ID: "openrouter:model-a", CanonicalModelID: "openrouter/model-a", DeploymentID: "openrouter", NativeModelID: "model-a", - Pricing: catalog.PricingV1{ + Pricing: catalog.Pricing{ Status: catalog.PricingKnown, Currency: "EUR", RatesPer1M: map[string]float64{"input_tokens": 3, "output_tokens": 10}, }, }}, } - out := discover.MergeCatalogV1WithPolicy(&dst, src, discover.MergePolicy{ + out := discover.MergeCatalogWithPolicy(&dst, src, discover.MergePolicy{ PreferLiveProviders: []string{"openrouter"}, }) if len(out.Offerings) != 1 { @@ -202,22 +208,22 @@ func TestMergeCatalogV1WithPolicy_PreferLiveUnconditionalPricing(t *testing.T) { } } -func TestMergeCatalogV1WithPolicy_PreferLiveZeroContextOverwrites(t *testing.T) { +func TestMergeCatalogWithPolicy_PreferLiveZeroContextOverwrites(t *testing.T) { t.Parallel() - dst := catalog.TestSeedCatalogV1() - dst.Models["anthropic/claude-sonnet-4-6"] = catalog.ModelV1{ + dst := catalog.SeedCatalog() + dst.Models["anthropic/claude-sonnet-4-6"] = catalog.Model{ ID: "anthropic/claude-sonnet-4-6", ProviderID: "anthropic", ContextWindow: 200000, MaxOutput: 8192, } - src := &catalog.CatalogV1{ - Models: map[string]catalog.ModelV1{ + src := &catalog.Catalog{ + Models: map[string]catalog.Model{ "anthropic/claude-sonnet-4-6": { ID: "anthropic/claude-sonnet-4-6", ProviderID: "anthropic", ContextWindow: 0, MaxOutput: 0, }, }, } - out := discover.MergeCatalogV1WithPolicy(&dst, src, discover.MergePolicy{ + out := discover.MergeCatalogWithPolicy(&dst, src, discover.MergePolicy{ PreferLiveProviders: []string{"anthropic"}, }) got := out.Models["anthropic/claude-sonnet-4-6"] @@ -229,22 +235,22 @@ func TestMergeCatalogV1WithPolicy_PreferLiveZeroContextOverwrites(t *testing.T) } } -func TestMergeCatalogV1WithPolicy_NonPreferLivePreservesExisting(t *testing.T) { +func TestMergeCatalogWithPolicy_NonPreferLivePreservesExisting(t *testing.T) { t.Parallel() - dst := catalog.TestSeedCatalogV1() - dst.Models["anthropic/claude-sonnet-4-6"] = catalog.ModelV1{ + dst := catalog.SeedCatalog() + dst.Models["anthropic/claude-sonnet-4-6"] = catalog.Model{ ID: "anthropic/claude-sonnet-4-6", ProviderID: "anthropic", ContextWindow: 200000, MaxOutput: 8192, Name: "Old", } - src := &catalog.CatalogV1{ - Models: map[string]catalog.ModelV1{ + src := &catalog.Catalog{ + Models: map[string]catalog.Model{ "anthropic/claude-sonnet-4-6": { ID: "anthropic/claude-sonnet-4-6", ProviderID: "anthropic", ContextWindow: 0, MaxOutput: 0, Name: "New", }, }, } - out := discover.MergeCatalogV1WithPolicy(&dst, src, discover.MergePolicy{}) + out := discover.MergeCatalogWithPolicy(&dst, src, discover.MergePolicy{}) got := out.Models["anthropic/claude-sonnet-4-6"] if got.ContextWindow != 200000 { t.Fatalf("context = %d (expected preserved without live policy)", got.ContextWindow) diff --git a/catalog/discover/provider_refresh.go b/catalog/discover/provider_refresh.go index 16a4f69..671a5f4 100644 --- a/catalog/discover/provider_refresh.go +++ b/catalog/discover/provider_refresh.go @@ -2,6 +2,7 @@ package discover import ( "context" + "encoding/json" "fmt" "time" @@ -29,16 +30,16 @@ func refreshProvider(ctx context.Context, providerID string, creds catalog.Crede } cachePath := catalog.DefaultCachePath() - var base *catalog.CatalogV1 + var base *catalog.Catalog source := "cache" - if compiled, err := catalog.LoadCatalogV1(ctx, catalog.LoadCatalogV1Options{ + if compiled, err := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{ CachePath: cachePath, }); err == nil && compiled != nil && compiled.Catalog != nil { base = compiled.Catalog } else if compiled, ok := catalog.LoadValidCatalogCache(cachePath); ok && compiled.Catalog != nil { base = compiled.Catalog } else { - bootstrap := catalog.BootstrapCatalogV1() + bootstrap := catalog.BootstrapCatalog() base = &bootstrap source = catalog.BootstrapSource() } @@ -61,36 +62,88 @@ func refreshProvider(ctx context.Context, providerID string, creds catalog.Crede return nil, fmt.Errorf("catalog discover: live API returned no models for %q", providerID) } - legacy := catalog.ModelCatalog{ - UpdatedAt: time.Now().UTC().Format(time.RFC3339), - Source: "live-providers", - Providers: map[string][]catalog.ModelCatalogEntry{ - spec.LiveCatalogKey: catalog.LiveEntriesToCatalog(entries), - }, + generatedAt := time.Now().UTC().Truncate(time.Second) + cat := catalog.SeedCatalog() + cat.SchemaVersion = catalog.CatalogSchemaVersion + cat.GeneratedAt = generatedAt + cat.StaleAfter = generatedAt.Add(catalog.LiveStaleDuration) + catalog.EnsureDeploymentEnvFallbacks(&cat) + catalog.EnsureCredentialRegistryInCatalog(&cat) + cat.Provenance = &catalog.Provenance{Source: "live-providers", ObservedAt: generatedAt} + + provID := spec.ProviderID + if _, ok := cat.Providers[provID]; !ok { + cat.Providers[provID] = catalog.Provider{ID: provID, Name: provID} + } + depID := spec.DeploymentID + if _, ok := cat.Deployments[depID]; !ok { + cat.Deployments[depID] = catalog.Deployment{ + ID: depID, + Name: provID, + ProviderID: provID, + APIProtocolID: "openai-chat-completions", + AdapterConstructor: "openai", + NativeModelIDSource: catalog.NativeModelIDDiscovered, + ModelMappingsRequired: false, + } + } + + for _, entry := range entries { + entryID := entry.ID + if entryID == "" { + continue + } + name := entry.DisplayName + if name == "" { + name = entryID + } + canonicalID := entryID + var liveMeta map[string]interface{} + if json.Unmarshal(entry.RawJSON, &liveMeta) == nil { + if inputPrice, ok := liveMeta["input_token_price_per_m"]; ok { + if _, ok := inputPrice.(float64); ok { + canonicalID = provID + "/" + entryID + } + } + } + cat.Models[canonicalID] = catalog.Model{ + ID: canonicalID, + ProviderID: provID, + Name: name, + ContextWindow: entry.ContextWindow, + MaxOutput: entry.MaxOutput, + } + cat.Aliases[entryID] = canonicalID + offeringID := depID + ":" + entryID + cat.Offerings = append(cat.Offerings, catalog.ModelOffering{ + ID: offeringID, + CanonicalModelID: canonicalID, + DeploymentID: depID, + NativeModelID: entryID, + Capabilities: catalog.CapabilitySetFromEntry(entry), + Pricing: catalog.PricingFromEntry(entry), + LiveMetadata: entry.RawJSON, + }) } - enriched := catalog.CatalogV1FromLegacy(legacy) - base = MergeCatalogV1WithPolicy(base, &enriched, MergePolicy{ + + base = MergeCatalogWithPolicy(base, &cat, MergePolicy{ PreferLive: true, PreferLiveProviders: []string{catalog.CanonicalProviderID(spec.ProviderID)}, ReplaceDeploymentOfferings: []string{spec.DeploymentID}, }) now := time.Now().UTC().Truncate(time.Second) base.GeneratedAt = now - base.StaleAfter = now.Add(catalog.LiveCatalogStaleDuration) + base.StaleAfter = now.Add(catalog.LiveStaleDuration) if base.Provenance == nil { - base.Provenance = &catalog.CatalogProvenanceV1{} + base.Provenance = &catalog.Provenance{} } base.Provenance.ObservedAt = now - if source == catalog.BootstrapSource() { - source = "providers" - } else { - source += "+providers" - } + source = appendSourceSuffix(source, "providers") - if err := catalog.WriteCatalogV1Cache(cachePath, base); err != nil { + if err := catalog.WriteCatalogCache(cachePath, base); err != nil { return nil, fmt.Errorf("catalog discover: write cache: %w", err) } - compiled, err := catalog.CompileCatalogV1(base) + compiled, err := catalog.CompileCatalog(base) if err != nil { return nil, fmt.Errorf("catalog discover: compile: %w", err) } diff --git a/catalog/discover/provider_refresh_test.go b/catalog/discover/provider_refresh_test.go index 52575c2..2711581 100644 --- a/catalog/discover/provider_refresh_test.go +++ b/catalog/discover/provider_refresh_test.go @@ -24,8 +24,8 @@ func TestRefreshProvider_MergesLiveModelsIntoCache(t *testing.T) { defer srv.Close() cachePath := filepath.Join(t.TempDir(), "model_catalog.json") - base := catalog.TestSeedCatalogV1() - if err := catalog.WriteCatalogV1Cache(cachePath, &base); err != nil { + base := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(cachePath, &base); err != nil { t.Fatal(err) } diff --git a/catalog/discovery_load.go b/catalog/discovery_load.go index d4648dd..9b92327 100644 --- a/catalog/discovery_load.go +++ b/catalog/discovery_load.go @@ -3,18 +3,18 @@ package catalog import "context" // LoadCatalogForDiscovery returns the cached catalog or bootstrap wiring (no network). -func LoadCatalogForDiscovery(ctx context.Context) (*CompiledCatalogV1, error) { +func LoadCatalogForDiscovery(ctx context.Context) (*CompiledCatalog, error) { if ctx == nil { ctx = context.Background() } - compiled, err := LoadCatalogV1(ctx, LoadCatalogV1Options{ + compiled, err := LoadCatalog(ctx, LoadCatalogOptions{ CachePath: DefaultCachePath(), }) if err == nil && compiled != nil { return compiled, nil } - bootstrap := BootstrapCatalogV1() - return CompileCatalogV1(&bootstrap) + bootstrap := BootstrapCatalog() + return CompileCatalog(&bootstrap) } // DiscoveryEnvKeyNames returns env var names used for credential discovery from the catalog. diff --git a/catalog/gateway.go b/catalog/gateway.go index e7830ef..22256bc 100644 --- a/catalog/gateway.go +++ b/catalog/gateway.go @@ -17,7 +17,7 @@ func IsSetupGateway(providerID string) bool { } // GatewayForModel returns the setup gateway that serves a model (e.g. openrouter for openrouter/auto). -func GatewayForModel(compiled *CompiledCatalogV1, modelID string) string { +func GatewayForModel(compiled *CompiledCatalog, modelID string) string { modelID = strings.TrimSpace(modelID) if modelID == "" { return "" diff --git a/catalog/legacy/deprecated_catalog.go b/catalog/legacy/deprecated_catalog.go deleted file mode 100644 index 42d35c3..0000000 --- a/catalog/legacy/deprecated_catalog.go +++ /dev/null @@ -1,73 +0,0 @@ -package legacy - -import ( - "encoding/json" - "os" - "path/filepath" - - "github.com/GrayCodeAI/eyrie/catalog" -) - -var defaultModelCatalog = catalog.ModelCatalog{ - UpdatedAt: "2026-04-09T00:00:00.000Z", - Source: "bootstrap", - Providers: map[string][]catalog.ModelCatalogEntry{}, -} - -// DefaultModelCatalog returns the embedded default catalog. -// -// Deprecated: use catalog.LoadCatalogV1 instead. -func DefaultModelCatalog() catalog.ModelCatalog { - return defaultModelCatalog -} - -// LoadModelCatalogSync loads a catalog from a cache file, falling back to embedded. -// -// Deprecated: use catalog.LoadCatalogV1 instead. -func LoadModelCatalogSync(cachePath string) catalog.ModelCatalog { - if cachePath != "" { - data, err := os.ReadFile(cachePath) - if err == nil { - var cat catalog.ModelCatalog - if json.Unmarshal(data, &cat) == nil && cat.Providers != nil { - return cat - } - } - } - return DefaultModelCatalog() -} - -// FetchModelCatalog fetches catalog from live provider APIs. -// -// Deprecated: use setup.DiscoverModelCatalog instead. -func FetchModelCatalog(cachePath string, env map[string]string) (catalog.ModelCatalog, error) { - cat, _ := FetchModelCatalogWithEnrichment(cachePath, env) - return cat, nil -} - -// FetchModelCatalogWithEnrichment returns live provider catalog data and per-provider fetch status. -// -// Deprecated: use setup.DiscoverModelCatalog instead. -func FetchModelCatalogWithEnrichment(cachePath string, env map[string]string) (catalog.ModelCatalog, []catalog.LiveProviderEnrichment) { - cat, enrichment := catalog.FetchLiveProviderCatalog(env) - - if cachePath != "" { - data, err := json.MarshalIndent(cat, "", " ") - if err == nil { - _ = os.MkdirAll(filepath.Dir(cachePath), 0o755) - _ = os.WriteFile(cachePath, append(data, '\n'), 0o644) - } - } - - return cat, enrichment -} - -// ModelsForProvider returns catalog entries for a given provider in a legacy ModelCatalog. -// -// Deprecated: use catalog.ModelEntriesForProvider with catalog.CompiledCatalogV1 instead. -func ModelsForProvider(cat *catalog.ModelCatalog, provider string) []catalog.ModelCatalogEntry { - if cat == nil || cat.Providers == nil { - return nil - } - return cat.Providers[provider] -} diff --git a/catalog/live/clinepass_test.go b/catalog/live/clinepass_test.go new file mode 100644 index 0000000..299d153 --- /dev/null +++ b/catalog/live/clinepass_test.go @@ -0,0 +1,54 @@ +package live + +import ( + "testing" +) + +func TestFetchClinePass_ReturnsStaticList(t *testing.T) { + t.Parallel() + entries, err := FetchClinePass(map[string]string{ + "CLINE_API_KEY": "cp-test123", + }) + if err != nil { + t.Fatal(err) + } + if len(entries) != 11 { + t.Fatalf("expected 11 curated models, got %d", len(entries)) + } + expected := []string{ + "cline-pass/deepseek-v4-pro", + "cline-pass/deepseek-v4-flash", + "cline-pass/glm-5.2", + "cline-pass/kimi-k2.7-code", + "cline-pass/kimi-k2.6", + "cline-pass/minimax-m3", + "cline-pass/mimo-v2.5-pro", + "cline-pass/mimo-v2.5", + "cline-pass/qwen3.7-max", + "cline-pass/qwen3.7-plus", + "cline-pass/poolside-laguna-m.1-free", + } + for i, e := range entries { + if e.ID != expected[i] { + t.Errorf("entry[%d].ID = %q, want %q", i, e.ID, expected[i]) + } + } + // Verify OpenRouter pricing (poolside is free). + if entries[0].InputPricePer1M != 1.74 || entries[0].OutputPricePer1M != 3.48 { + t.Errorf("deepseek-v4-pro: in=%f out=%f, want 1.74/3.48", entries[0].InputPricePer1M, entries[0].OutputPricePer1M) + } + if entries[10].InputPricePer1M != 0 || entries[10].OutputPricePer1M != 0 { + t.Errorf("poolside: in=%f out=%f, want 0/0", entries[10].InputPricePer1M, entries[10].OutputPricePer1M) + } +} + +func TestFetchClinePass_NoKey(t *testing.T) { + t.Parallel() + entries, err := FetchClinePass(map[string]string{}) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("expected 0 entries with no key, got %d", len(entries)) + } +} diff --git a/catalog/live/fetchers.go b/catalog/live/fetchers.go index 462d893..58ee8ff 100644 --- a/catalog/live/fetchers.go +++ b/catalog/live/fetchers.go @@ -50,7 +50,12 @@ const ( // FetchFunc lists models from a live provider API. type FetchFunc func(env map[string]string) ([]Entry, error) -const DefaultDeepSeekBaseURL = "https://api.deepseek.com/v1" +const ( + DefaultDeepSeekBaseURL = "https://api.deepseek.com/v1" + DefaultPoolsideBaseURL = "https://inference.poolside.ai/v1" + DefaultGroqBaseURL = "https://api.groq.com/openai/v1" + DefaultClinePassBaseURL = "https://api.cline.bot/api/v1" // #nosec G101 -- public API base URL, not a secret value +) // Registry maps fetcher keys to implementations. var Registry = map[string]FetchFunc{ @@ -73,6 +78,9 @@ var Registry = map[string]FetchFunc{ "minimax_payg": FetchMiniMaxPayg, "ollama": FetchOllama, "deepseek": FetchDeepSeek, + "poolside": FetchPoolside, + "groq": FetchGroq, + "clinepass": FetchClinePass, } // Fetch runs a registered live fetcher. diff --git a/catalog/live/fetchers_providers.go b/catalog/live/fetchers_providers.go index 32467ac..b69855c 100644 --- a/catalog/live/fetchers_providers.go +++ b/catalog/live/fetchers_providers.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/GrayCodeAI/eyrie/catalog/opencodego" "github.com/GrayCodeAI/eyrie/catalog/xiaomi" @@ -578,3 +579,62 @@ func FetchDeepSeek(env map[string]string) ([]Entry, error) { enrichFromOpenRouter(entries, "deepseek/") return entries, nil } + +// FetchPoolside lists models from the Poolside OpenAI-compatible API. +func FetchPoolside(env map[string]string) ([]Entry, error) { + return fetchOpenAICompatModels( + context.Background(), + envOr(env, "POOLSIDE_BASE_URL", DefaultPoolsideBaseURL), + env["POOLSIDE_API_KEY"], "Bearer", + ) +} + +// FetchGroq lists models from the Groq OpenAI-compatible API. +func FetchGroq(env map[string]string) ([]Entry, error) { + return fetchOpenAICompatModels( + context.Background(), + envOr(env, "GROQ_BASE_URL", DefaultGroqBaseURL), + env["GROQ_API_KEY"], "Bearer", + ) +} + +// FetchClinePass returns a curated list of known ClinePass models. +// ClinePass does not expose a GET /models endpoint, so we return a +// static list based on official documentation at docs.cline.bot. +func FetchClinePass(env map[string]string) ([]Entry, error) { + apiKey := strings.TrimSpace(env["CLINE_API_KEY"]) + if apiKey == "" { + return nil, nil + } + now := time.Now().Unix() + models := []struct { + id, name, owner string + ctx, maxOut int + inPrice, outPrice float64 // per 1M tokens (ClinePass reference pricing), 0 = free + }{ + {"cline-pass/deepseek-v4-pro", "DeepSeek V4 Pro", "deepseek", 131072, 8192, 1.74, 3.48}, + {"cline-pass/deepseek-v4-flash", "DeepSeek V4 Flash", "deepseek", 131072, 8192, 0.14, 0.28}, + {"cline-pass/glm-5.2", "GLM 5.2", "zhipu", 131072, 8192, 1.40, 4.40}, + {"cline-pass/kimi-k2.7-code", "Kimi K2.7 Code", "moonshot", 131072, 8192, 0.95, 4.00}, + {"cline-pass/kimi-k2.6", "Kimi K2.6", "moonshot", 131072, 8192, 0.95, 4.00}, + {"cline-pass/minimax-m3", "MiniMax M3", "minimax", 131072, 8192, 0.30, 1.20}, + {"cline-pass/mimo-v2.5-pro", "MiMo V2.5 Pro", "xiaomi", 131072, 8192, 1.74, 3.48}, + {"cline-pass/mimo-v2.5", "MiMo V2.5", "xiaomi", 131072, 8192, 0.14, 0.28}, + {"cline-pass/qwen3.7-max", "Qwen 3.7 Max", "qwen", 131072, 8192, 2.50, 7.50}, + {"cline-pass/qwen3.7-plus", "Qwen 3.7 Plus", "qwen", 131072, 8192, 0.40, 1.60}, + {"cline-pass/poolside-laguna-m.1-free", "Poolside Laguna M.1 (Free)", "poolside", 262144, 32768, 0, 0}, + } + var entries []Entry + for _, m := range models { + entry, _ := entryFromOpenAICompatJSON(json.RawMessage(fmt.Sprintf( + `{"id":%q,"name":%q,"owned_by":%q,"context_length":%d,"max_completion_tokens":%d,"created":%d}`, + m.id, m.name, m.owner, m.ctx, m.maxOut, now, + ))) + if entry.ID != "" { + entry.InputPricePer1M = m.inPrice + entry.OutputPricePer1M = m.outPrice + entries = append(entries, entry) + } + } + return entries, nil +} diff --git a/catalog/live/groq_test.go b/catalog/live/groq_test.go new file mode 100644 index 0000000..9d53ac8 --- /dev/null +++ b/catalog/live/groq_test.go @@ -0,0 +1,73 @@ +package live + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestFetchGroq_MockHTTPServer(t *testing.T) { + t.Parallel() + body, err := os.ReadFile("testdata/groq_models.json") + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/models" { + http.NotFound(w, r) + return + } + if r.Header.Get("Authorization") == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write(body) + })) + defer srv.Close() + + entries, err := FetchGroq(map[string]string{ + "GROQ_API_KEY": "gsk-test123", + "GROQ_BASE_URL": srv.URL, + }) + if err != nil { + t.Fatal(err) + } + if len(entries) != 3 { + t.Fatalf("expected 3 models, got %d", len(entries)) + } +} + +func TestFetchGroq_NoKey(t *testing.T) { + t.Parallel() + entries, err := FetchGroq(map[string]string{}) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("expected 0 entries with no key, got %d", len(entries)) + } +} + +func TestFetchGroq_ParsesProviderFields(t *testing.T) { + t.Parallel() + raw := json.RawMessage(`{ + "id": "llama-3.3-70b-versatile", + "owned_by": "meta-llama", + "display_name": "Llama 3.3 70B Versatile" + }`) + entry, ok := entryFromOpenAICompatJSON(raw) + if !ok { + t.Fatal("expected parse ok") + } + if entry.ID != "llama-3.3-70b-versatile" { + t.Fatalf("id = %q", entry.ID) + } + if entry.OwnedBy != "meta-llama" { + t.Fatalf("owned_by = %q", entry.OwnedBy) + } + if entry.DisplayName != "Llama 3.3 70B Versatile" { + t.Fatalf("display_name = %q", entry.DisplayName) + } +} diff --git a/catalog/live/poolside_test.go b/catalog/live/poolside_test.go new file mode 100644 index 0000000..2c27d4d --- /dev/null +++ b/catalog/live/poolside_test.go @@ -0,0 +1,81 @@ +package live + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestFetchPoolside_MockHTTPServer(t *testing.T) { + t.Parallel() + body, err := os.ReadFile("testdata/poolside_models.json") + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/models" { + http.NotFound(w, r) + return + } + if r.Header.Get("Authorization") == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write(body) + })) + defer srv.Close() + + entries, err := FetchPoolside(map[string]string{ + "POOLSIDE_API_KEY": "ps-test123", + "POOLSIDE_BASE_URL": srv.URL, + }) + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 models, got %d", len(entries)) + } +} + +func TestFetchPoolside_NoKey(t *testing.T) { + t.Parallel() + entries, err := FetchPoolside(map[string]string{}) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("expected 0 entries with no key, got %d", len(entries)) + } +} + +func TestFetchPoolside_ParsesProviderFields(t *testing.T) { + t.Parallel() + raw := json.RawMessage(`{ + "id": "poolside/laguna-m.1", + "owned_by": "poolside", + "display_name": "Poolside: Laguna M.1", + "context_length": 262144, + "max_completion_tokens": 32768 + }`) + entry, ok := entryFromOpenAICompatJSON(raw) + if !ok { + t.Fatal("expected parse ok") + } + if entry.ID != "poolside/laguna-m.1" { + t.Fatalf("id = %q", entry.ID) + } + if entry.OwnedBy != "poolside" { + t.Fatalf("owned_by = %q", entry.OwnedBy) + } + if entry.DisplayName != "Poolside: Laguna M.1" { + t.Fatalf("display_name = %q", entry.DisplayName) + } + if entry.ContextWindow != 262144 { + t.Fatalf("context_window = %d", entry.ContextWindow) + } + if entry.MaxOutput != 32768 { + t.Fatalf("max_output = %d", entry.MaxOutput) + } +} diff --git a/catalog/live/testdata/groq_models.json b/catalog/live/testdata/groq_models.json new file mode 100644 index 0000000..78cf96c --- /dev/null +++ b/catalog/live/testdata/groq_models.json @@ -0,0 +1,23 @@ +{ + "object": "list", + "data": [ + { + "id": "llama-3.3-70b-versatile", + "object": "model", + "created": 1730000000, + "owned_by": "meta-llama" + }, + { + "id": "mixtral-8x7b-32768", + "object": "model", + "created": 1730000001, + "owned_by": "mistralai" + }, + { + "id": "deepseek-r1-distill-llama-70b", + "object": "model", + "created": 1730000002, + "owned_by": "deepseek" + } + ] +} diff --git a/catalog/live/testdata/poolside_models.json b/catalog/live/testdata/poolside_models.json new file mode 100644 index 0000000..92d19a4 --- /dev/null +++ b/catalog/live/testdata/poolside_models.json @@ -0,0 +1,52 @@ +{ + "object": "list", + "data": [ + { + "id": "poolside/laguna-m.1", + "object": "model", + "created": 1773321358, + "owned_by": "poolside", + "name": "Poolside: Laguna M.1", + "description": "Poolside's most capable coding agent model", + "context_length": 262144, + "max_completion_tokens": 32768, + "pricing": { + "prompt": "0", + "completion": "0", + "image": "0", + "request": "0", + "input_cache_read": "0" + }, + "supported_sampling_parameters": ["temperature"], + "supported_features": ["tools", "reasoning"], + "input_modalities": ["text"], + "output_modalities": ["text"], + "is_free": true + }, + { + "id": "poolside/laguna-xs.2", + "object": "model", + "created": 1776776074, + "owned_by": "poolside", + "name": "Poolside: Laguna XS.2", + "description": "Poolside's second-generation coding agent model in the XS size class", + "context_length": 262144, + "max_completion_tokens": 32768, + "pricing": { + "prompt": "0", + "completion": "0", + "image": "0", + "request": "0", + "input_cache_read": "0" + }, + "supported_sampling_parameters": ["temperature"], + "supported_features": ["tools", "reasoning"], + "input_modalities": ["text"], + "output_modalities": ["text"], + "hugging_face_id": "poolside/Laguna-XS.2", + "is_free": true, + "discount_to_user": 0.0, + "deprecation_date": "2026-07-09" + } + ] +} diff --git a/catalog/live_catalog.go b/catalog/live_catalog.go index 84a0c58..aa0e3ff 100644 --- a/catalog/live_catalog.go +++ b/catalog/live_catalog.go @@ -2,14 +2,10 @@ package catalog import ( "sort" - "time" "github.com/GrayCodeAI/eyrie/catalog/registry" ) -// LiveCatalogStaleDuration is how long a cache remains fresh after live provider APIs were merged. -const LiveCatalogStaleDuration = 24 * time.Hour - // IsLiveOnlyProvider reports whether a provider uses live API discovery only. // All providers are now fully dynamic. func IsLiveOnlyProvider(providerID string) bool { @@ -27,7 +23,7 @@ func DeploymentIDForLiveCatalogKey(catalogKey string) string { } // FirstModelForProvider returns the first canonical model ID for a provider from compiled catalog. -func FirstModelForProvider(compiled *CompiledCatalogV1, providerID string) string { +func FirstModelForProvider(compiled *CompiledCatalog, providerID string) string { if compiled == nil { return "" } diff --git a/catalog/live_catalog_test.go b/catalog/live_catalog_test.go index cdbdc3a..3c82026 100644 --- a/catalog/live_catalog_test.go +++ b/catalog/live_catalog_test.go @@ -22,9 +22,9 @@ func TestIsLiveOnlyProvider(t *testing.T) { func TestFirstModelForProvider(t *testing.T) { t.Parallel() - c := catalog.TestSeedCatalogV1() - c.Models["zai_payg/glm-5.1"] = catalog.ModelV1{ID: "zai_payg/glm-5.1", ProviderID: "zai_payg", Name: "GLM-5.1"} - compiled, err := catalog.CompileCatalogV1(&c) + c := catalog.SeedCatalog() + c.Models["zai_payg/glm-5.1"] = catalog.Model{ID: "zai_payg/glm-5.1", ProviderID: "zai_payg", Name: "GLM-5.1"} + compiled, err := catalog.CompileCatalog(&c) if err != nil { t.Fatal(err) } diff --git a/catalog/live_enrich.go b/catalog/live_enrich.go index 3108494..686be8e 100644 --- a/catalog/live_enrich.go +++ b/catalog/live_enrich.go @@ -8,13 +8,14 @@ import ( "github.com/GrayCodeAI/eyrie/catalog/registry" ) -// FetchLiveProviderCatalog enriches catalog from live provider list APIs. -func FetchLiveProviderCatalog(env map[string]string) (ModelCatalog, []LiveProviderEnrichment) { - cat := ModelCatalog{ - UpdatedAt: time.Now().UTC().Format(time.RFC3339), - Source: "live-providers", - Providers: make(map[string][]ModelCatalogEntry), - } +// FetchLiveProviderCatalog discovers models from all registered live provider APIs +// that have API keys available in env. Returns a catalog with enriched model listings +// and a list of per-provider fetch statuses. +func FetchLiveProviderCatalog(env map[string]string) (Catalog, []LiveProviderEnrichment) { + cat := SeedCatalog() + EnsureDeploymentEnvFallbacks(&cat) + EnsureCredentialRegistryInCatalog(&cat) + var enrichment []LiveProviderEnrichment for _, fetcherKey := range registry.LiveFetcherKeys() { spec, ok := registry.SpecForLiveFetcher(fetcherKey) @@ -42,7 +43,70 @@ func FetchLiveProviderCatalog(env map[string]string) (ModelCatalog, []LiveProvid enrichment = append(enrichment, LiveProviderEnrichment{Provider: catalogKey, Error: "no models returned", DurationMs: duration}) continue } - cat.Providers[catalogKey] = LiveEntriesToCatalog(models) + + providerID := spec.ProviderID + if _, ok := cat.Providers[providerID]; !ok { + cat.Providers[providerID] = Provider{ID: providerID, Name: providerID} + } + + deploymentID := spec.DeploymentID + if _, ok := cat.Deployments[deploymentID]; !ok { + cat.Deployments[deploymentID] = Deployment{ + ID: deploymentID, + Name: providerID, + ProviderID: providerID, + APIProtocolID: "openai-chat-completions", + AdapterConstructor: "openai", + NativeModelIDSource: NativeModelIDDiscovered, + ModelMappingsRequired: false, + } + } + + for _, entry := range models { + entryID := entry.ID + if entryID == "" { + continue + } + name := entry.DisplayName + if name == "" { + 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 + } + + cat.Models[canonicalID] = Model{ + ID: canonicalID, + ProviderID: providerID, + Name: name, + ContextWindow: entry.ContextWindow, + MaxOutput: entry.MaxOutput, + } + cat.Aliases[entryID] = canonicalID + + offeringID := deploymentID + ":" + entryID + cat.Offerings = append(cat.Offerings, ModelOffering{ + ID: offeringID, + CanonicalModelID: canonicalID, + DeploymentID: deploymentID, + NativeModelID: entryID, + Capabilities: CapabilitySetFromEntry(entry), + Pricing: PricingFromEntry(entry), + LiveMetadata: entry.RawJSON, + }) + } + enrichment = append(enrichment, LiveProviderEnrichment{Provider: catalogKey, ModelCount: len(models), DurationMs: duration}) } return cat, enrichment @@ -58,9 +122,6 @@ func FetchLiveModelEntriesForProvider(env map[string]string, providerID string) return nil, fmt.Errorf("catalog: provider %q has no live model list API", providerID) } if !registry.CredentialPresent(spec, env) { - if !spec.RequiresKey { - return nil, fmt.Errorf("catalog: set %s for %s", spec.CredentialEnv, providerID) - } return nil, fmt.Errorf("catalog: set %s for %s", spec.CredentialEnv, providerID) } entries, err := live.Fetch(spec.LiveFetcherKey, env) @@ -73,7 +134,7 @@ func FetchLiveModelEntriesForProvider(env map[string]string, providerID string) return LiveEntriesToCatalog(entries), nil } -// LiveDiscoverableDeploymentIDs returns provider keys with live model-list APIs. +// LiveDiscoverableDeploymentIDs returns provider IDs that have live model-list APIs. func LiveDiscoverableDeploymentIDs() []string { return registry.LiveFetcherKeys() } diff --git a/catalog/live_enrich_test.go b/catalog/live_enrich_test.go index 32f2c08..f58d226 100644 --- a/catalog/live_enrich_test.go +++ b/catalog/live_enrich_test.go @@ -11,11 +11,11 @@ import ( func TestFetchLiveProviderCatalog_SkipsProvidersWithoutCredentials(t *testing.T) { t.Parallel() cat, enrichment := catalog.FetchLiveProviderCatalog(map[string]string{}) - if len(cat.Providers) != 0 { - t.Fatalf("expected no providers without creds, got %d", len(cat.Providers)) + if len(cat.Models) == 0 { + t.Fatal("expected seed models in catalog") } if len(enrichment) != len(registry.All()) { - t.Fatalf("expected skipped status for all %d providers, got %d", len(registry.All()), len(enrichment)) + t.Fatalf("expected enrichment for all %d providers, got %d", len(registry.All()), len(enrichment)) } for _, item := range enrichment { if !strings.HasPrefix(item.Error, "skipped") { diff --git a/catalog/live_metadata_test.go b/catalog/live_metadata_test.go index acfbf19..1fa9f0d 100644 --- a/catalog/live_metadata_test.go +++ b/catalog/live_metadata_test.go @@ -20,13 +20,15 @@ func TestLiveEntriesToCatalog_PreservesFullJSONInOffering(t *testing.T) { if string(entries[0].LiveMetadata) != string(raw) { t.Fatalf("metadata = %s", entries[0].LiveMetadata) } - c := catalog.CatalogV1FromLegacy(catalog.ModelCatalog{ - Source: "test", - Providers: map[string][]catalog.ModelCatalogEntry{ - "canopywave": entries, + c := &catalog.Catalog{ + Models: map[string]catalog.Model{ + "canopywave/moonshotai-kimi-k2.6": {ID: "canopywave/moonshotai-kimi-k2.6", ProviderID: "canopywave", Name: "Kimi K2.6"}, }, - }) - var offering catalog.ModelOfferingV1 + Offerings: []catalog.ModelOffering{ + {ID: "canopywave:moonshotai/kimi-k2.6", CanonicalModelID: "canopywave/moonshotai-kimi-k2.6", DeploymentID: "canopywave", NativeModelID: "moonshotai/kimi-k2.6", LiveMetadata: raw}, + }, + } + var offering catalog.ModelOffering for _, o := range c.Offerings { if o.DeploymentID == "canopywave" && o.NativeModelID == "moonshotai/kimi-k2.6" { offering = o diff --git a/catalog/model_policy.go b/catalog/model_policy.go index 61cbd64..acd800a 100644 --- a/catalog/model_policy.go +++ b/catalog/model_policy.go @@ -37,8 +37,8 @@ type ModelRoleAssignments struct { Commit string `json:"commit,omitempty"` } -// ProviderDefaultModelV1 returns the provider's first catalog model. -func ProviderDefaultModelV1(compiled *CompiledCatalogV1, provider, fallback string) string { +// ProviderDefaultModel returns the provider's first catalog model. +func ProviderDefaultModel(compiled *CompiledCatalog, provider, fallback string) string { models := ModelEntriesForProvider(compiled, provider) if len(models) == 0 { return fallback @@ -49,21 +49,21 @@ func ProviderDefaultModelV1(compiled *CompiledCatalogV1, provider, fallback stri return fallback } -// PreferredProviderModelV1 returns the preferred model for provider and tier. -func PreferredProviderModelV1(compiled *CompiledCatalogV1, provider string, tier ModelTier, fallback string) string { +// PreferredProviderModel returns the preferred model for provider and tier. +func PreferredProviderModel(compiled *CompiledCatalog, provider string, tier ModelTier, fallback string) string { switch tier { case TierHaiku: - return CheapestModelForProviderV1(compiled, provider, fallback) + return CheapestModelForProvider(compiled, provider, fallback) case TierOpus: - return MostExpensiveModelForProviderV1(compiled, provider, fallback) + return MostExpensiveModelForProvider(compiled, provider, fallback) default: - return middleModelForProviderV1(compiled, provider, fallback) + return middleModelForProvider(compiled, provider, fallback) } } -// PreferredModelsForTierV1 returns unique preferred models for a tier, starting +// PreferredModelsForTier returns unique preferred models for a tier, starting // with primaryProvider and then following the registry chat preference order. -func PreferredModelsForTierV1(compiled *CompiledCatalogV1, primaryProvider string, tier ModelTier, limit int) []string { +func PreferredModelsForTier(compiled *CompiledCatalog, primaryProvider string, tier ModelTier, limit int) []string { seenProviders := map[string]bool{} seenModels := map[string]bool{} providers := make([]string, 0, len(registry.ChatProviderPreferenceOrder())+1) @@ -71,7 +71,7 @@ func PreferredModelsForTierV1(compiled *CompiledCatalogV1, primaryProvider strin providers = append(providers, primary) } providers = append(providers, registry.ChatProviderPreferenceOrder()...) - providers = append(providers, AllModelProvidersV1(compiled)...) + providers = append(providers, AllModelProviders(compiled)...) models := make([]string, 0, len(providers)) for _, provider := range providers { @@ -80,7 +80,7 @@ func PreferredModelsForTierV1(compiled *CompiledCatalogV1, primaryProvider strin continue } seenProviders[provider] = true - model := PreferredProviderModelV1(compiled, provider, tier, "") + model := PreferredProviderModel(compiled, provider, tier, "") if model == "" || seenModels[model] { continue } @@ -93,8 +93,8 @@ func PreferredModelsForTierV1(compiled *CompiledCatalogV1, primaryProvider strin return models } -// CheapestModelForProviderV1 returns the lowest known input-priced model for a provider. -func CheapestModelForProviderV1(compiled *CompiledCatalogV1, provider, fallback string) string { +// CheapestModelForProvider returns the lowest known input-priced model for a provider. +func CheapestModelForProvider(compiled *CompiledCatalog, provider, fallback string) string { models := ModelEntriesForProvider(compiled, provider) if len(models) == 0 { return fallback @@ -119,8 +119,8 @@ func CheapestModelForProviderV1(compiled *CompiledCatalogV1, provider, fallback return fallback } -// MostExpensiveModelForProviderV1 returns the highest known input-priced model for a provider. -func MostExpensiveModelForProviderV1(compiled *CompiledCatalogV1, provider, fallback string) string { +// MostExpensiveModelForProvider returns the highest known input-priced model for a provider. +func MostExpensiveModelForProvider(compiled *CompiledCatalog, provider, fallback string) string { models := ModelEntriesForProvider(compiled, provider) if len(models) == 0 { return fallback @@ -141,7 +141,7 @@ func MostExpensiveModelForProviderV1(compiled *CompiledCatalogV1, provider, fall } // ModelCostTierOf resolves a model's cost tier from catalog family and pricing data. -func ModelCostTierOf(compiled *CompiledCatalogV1, modelName string) ModelCostTier { +func ModelCostTierOf(compiled *CompiledCatalog, modelName string) ModelCostTier { if tier, ok := costTierFromCatalogFamily(compiled, modelName); ok { return mapModelTierToCostTier(tier) } @@ -151,8 +151,8 @@ func ModelCostTierOf(compiled *CompiledCatalogV1, modelName string) ModelCostTie return costTierFromModelName(modelName) } -// ProviderForModelV1 returns the canonical owner provider for modelName. -func ProviderForModelV1(compiled *CompiledCatalogV1, modelName string) string { +// ProviderForModel returns the canonical owner provider for modelName. +func ProviderForModel(compiled *CompiledCatalog, modelName string) string { if compiled == nil { return "" } @@ -164,8 +164,8 @@ func ProviderForModelV1(compiled *CompiledCatalogV1, modelName string) string { return CanonicalProviderID(model.ProviderID) } -// AllModelProvidersV1 lists canonical model owner providers in the catalog. -func AllModelProvidersV1(compiled *CompiledCatalogV1) []string { +// AllModelProviders lists canonical model owner providers in the catalog. +func AllModelProviders(compiled *CompiledCatalog) []string { if compiled == nil { return nil } @@ -185,11 +185,11 @@ func AllModelProvidersV1(compiled *CompiledCatalogV1) []string { // DefaultModelRolesV1 uses the primary model for interactive roles and the cheapest // same-provider model for commit/summarization work when catalog data is available. -func DefaultModelRolesV1(compiled *CompiledCatalogV1, primaryModel string) ModelRoleAssignments { +func DefaultModelRolesV1(compiled *CompiledCatalog, primaryModel string) ModelRoleAssignments { primaryModel = strings.TrimSpace(primaryModel) commit := primaryModel - if provider := ProviderForModelV1(compiled, primaryModel); provider != "" { - commit = CheapestModelForProviderV1(compiled, provider, primaryModel) + if provider := ProviderForModel(compiled, primaryModel); provider != "" { + commit = CheapestModelForProvider(compiled, provider, primaryModel) } return ModelRoleAssignments{ Planner: primaryModel, @@ -200,7 +200,7 @@ func DefaultModelRolesV1(compiled *CompiledCatalogV1, primaryModel string) Model } // ModelForRoleV1 resolves a role assignment with coder then catalog fallback. -func ModelForRoleV1(compiled *CompiledCatalogV1, roles ModelRoleAssignments, role ModelRole) string { +func ModelForRoleV1(compiled *CompiledCatalog, roles ModelRoleAssignments, role ModelRole) string { var model string switch role { case ModelRolePlanner: @@ -218,25 +218,25 @@ func ModelForRoleV1(compiled *CompiledCatalogV1, roles ModelRoleAssignments, rol if strings.TrimSpace(roles.Coder) != "" { return roles.Coder } - return PrimaryModelV1(compiled) + return PrimaryModel(compiled) } -// PrimaryModelV1 returns a stable best-effort model from chat-preferred providers. -func PrimaryModelV1(compiled *CompiledCatalogV1) string { +// PrimaryModel returns a stable best-effort model from chat-preferred providers. +func PrimaryModel(compiled *CompiledCatalog) string { for _, provider := range registry.ChatProviderPreferenceOrder() { - if model := ProviderDefaultModelV1(compiled, provider, ""); model != "" { + if model := ProviderDefaultModel(compiled, provider, ""); model != "" { return model } } - for _, provider := range AllModelProvidersV1(compiled) { - if model := ProviderDefaultModelV1(compiled, provider, ""); model != "" { + for _, provider := range AllModelProviders(compiled) { + if model := ProviderDefaultModel(compiled, provider, ""); model != "" { return model } } return "" } -func middleModelForProviderV1(compiled *CompiledCatalogV1, provider, fallback string) string { +func middleModelForProvider(compiled *CompiledCatalog, provider, fallback string) string { models := ModelEntriesForProvider(compiled, provider) if len(models) == 0 { return fallback @@ -248,7 +248,7 @@ func middleModelForProviderV1(compiled *CompiledCatalogV1, provider, fallback st } } if len(priced) == 0 { - return ProviderDefaultModelV1(compiled, provider, fallback) + return ProviderDefaultModel(compiled, provider, fallback) } sort.SliceStable(priced, func(i, j int) bool { if priced[i].InputPricePer1M == priced[j].InputPricePer1M { @@ -259,7 +259,7 @@ func middleModelForProviderV1(compiled *CompiledCatalogV1, provider, fallback st return priced[len(priced)/2].ID } -func costTierFromCatalogFamily(compiled *CompiledCatalogV1, modelName string) (ModelTier, bool) { +func costTierFromCatalogFamily(compiled *CompiledCatalog, modelName string) (ModelTier, bool) { if compiled == nil { return "", false } @@ -283,7 +283,7 @@ func costTierFromCatalogFamily(compiled *CompiledCatalogV1, modelName string) (M } } -func costTierFromCatalogPricing(compiled *CompiledCatalogV1, modelName string) (ModelCostTier, bool) { +func costTierFromCatalogPricing(compiled *CompiledCatalog, modelName string) (ModelCostTier, bool) { if compiled == nil { return 0, false } diff --git a/catalog/model_policy_test.go b/catalog/model_policy_test.go index 6fa9d13..b8f69c5 100644 --- a/catalog/model_policy_test.go +++ b/catalog/model_policy_test.go @@ -5,20 +5,20 @@ import ( "time" ) -func testPolicyCatalogV1(t *testing.T) *CompiledCatalogV1 { +func testPolicyCatalog(t *testing.T) *CompiledCatalog { t.Helper() now := time.Now().UTC() - cat := &CatalogV1{ - SchemaVersion: CatalogV1SchemaVersion, + cat := &Catalog{ + SchemaVersion: CatalogSchemaVersion, GeneratedAt: now, StaleAfter: now.Add(time.Hour), - Providers: map[string]ProviderV1{ + Providers: map[string]Provider{ "anthropic": {ID: "anthropic", Name: "Anthropic"}, }, - APIProtocols: map[string]APIProtocolV1{ + Protocols: map[string]Protocol{ "anthropic-messages": {ID: "anthropic-messages", Name: "Anthropic Messages"}, }, - Deployments: map[string]DeploymentV1{ + Deployments: map[string]Deployment{ "anthropic-direct": { ID: "anthropic-direct", Name: "Anthropic", @@ -29,7 +29,7 @@ func testPolicyCatalogV1(t *testing.T) *CompiledCatalogV1 { ModelMappingsRequired: false, }, }, - Models: map[string]ModelV1{ + Models: map[string]Model{ "anthropic/claude-haiku": { ID: "anthropic/claude-haiku", ProviderID: "anthropic", @@ -49,7 +49,7 @@ func testPolicyCatalogV1(t *testing.T) *CompiledCatalogV1 { Family: "opus", }, }, - Offerings: []ModelOfferingV1{ + Offerings: []ModelOffering{ testPolicyOffering("anthropic/claude-haiku", "claude-haiku", 0.25, 1), testPolicyOffering("anthropic/claude-sonnet", "claude-sonnet", 3, 15), testPolicyOffering("anthropic/claude-opus", "claude-opus", 15, 75), @@ -60,20 +60,20 @@ func testPolicyCatalogV1(t *testing.T) *CompiledCatalogV1 { "claude-opus": "anthropic/claude-opus", }, } - compiled, err := CompileCatalogV1(cat) + compiled, err := CompileCatalog(cat) if err != nil { - t.Fatalf("CompileCatalogV1 failed: %v", err) + t.Fatalf("CompileCatalog failed: %v", err) } return compiled } -func testPolicyOffering(canonicalModelID, nativeModelID string, input, output float64) ModelOfferingV1 { - return ModelOfferingV1{ +func testPolicyOffering(canonicalModelID, nativeModelID string, input, output float64) ModelOffering { + return ModelOffering{ ID: "anthropic-direct:" + nativeModelID, CanonicalModelID: canonicalModelID, DeploymentID: "anthropic-direct", NativeModelID: nativeModelID, - Pricing: PricingV1{ + Pricing: Pricing{ Status: PricingKnown, Currency: "USD", RatesPer1M: map[string]float64{"input_tokens": input, "output_tokens": output}, @@ -81,9 +81,9 @@ func testPolicyOffering(canonicalModelID, nativeModelID string, input, output fl } } -func TestModelPolicyPreferredProviderModelV1(t *testing.T) { +func TestModelPolicyPreferredProviderModel(t *testing.T) { t.Parallel() - compiled := testPolicyCatalogV1(t) + compiled := testPolicyCatalog(t) tests := []struct { tier ModelTier @@ -94,26 +94,26 @@ func TestModelPolicyPreferredProviderModelV1(t *testing.T) { {TierOpus, "claude-opus"}, } for _, tt := range tests { - got := PreferredProviderModelV1(compiled, "anthropic", tt.tier, "") + got := PreferredProviderModel(compiled, "anthropic", tt.tier, "") if got != tt.want { - t.Fatalf("PreferredProviderModelV1(%s) = %q, want %q", tt.tier, got, tt.want) + t.Fatalf("PreferredProviderModel(%s) = %q, want %q", tt.tier, got, tt.want) } } } -func TestModelPolicyPreferredModelsForTierV1(t *testing.T) { +func TestModelPolicyPreferredModelsForTier(t *testing.T) { t.Parallel() - compiled := testPolicyCatalogV1(t) + compiled := testPolicyCatalog(t) - got := PreferredModelsForTierV1(compiled, "anthropic", TierHaiku, 3) + got := PreferredModelsForTier(compiled, "anthropic", TierHaiku, 3) if len(got) != 1 || got[0] != "claude-haiku" { - t.Fatalf("PreferredModelsForTierV1 = %v, want [claude-haiku]", got) + t.Fatalf("PreferredModelsForTier = %v, want [claude-haiku]", got) } } func TestModelPolicyCostTierOf(t *testing.T) { t.Parallel() - compiled := testPolicyCatalogV1(t) + compiled := testPolicyCatalog(t) tests := []struct { model string @@ -134,7 +134,7 @@ func TestModelPolicyCostTierOf(t *testing.T) { func TestModelPolicyDefaultRolesV1(t *testing.T) { t.Parallel() - compiled := testPolicyCatalogV1(t) + compiled := testPolicyCatalog(t) roles := DefaultModelRolesV1(compiled, "claude-opus") if roles.Planner != "claude-opus" || roles.Coder != "claude-opus" || roles.Reviewer != "claude-opus" { diff --git a/catalog/pricing_sanitize_test.go b/catalog/pricing_sanitize_test.go index c06097e..9f3485c 100644 --- a/catalog/pricing_sanitize_test.go +++ b/catalog/pricing_sanitize_test.go @@ -5,9 +5,9 @@ import ( "time" ) -func TestSanitizePricingV1_negativeInputRemoved(t *testing.T) { +func TestSanitizePricing_negativeInputRemoved(t *testing.T) { t.Parallel() - p := sanitizePricingV1(PricingV1{ + p := sanitizePricing(Pricing{ Status: PricingKnown, Currency: "USD", RatesPer1M: map[string]float64{"input_tokens": -1, "output_tokens": 2}, @@ -26,7 +26,7 @@ func TestPricingFromLegacy_negativeBecomesUnknown(t *testing.T) { ID: "openrouter/auto", InputPricePer1M: -5, OutputPricePer1M: 1, - }, time.Now().UTC(), "test") + }, time.Now().UTC()) if p.Status != PricingUnknown { t.Fatalf("status = %q, want unknown", p.Status) } diff --git a/catalog/provider_credentials.go b/catalog/provider_credentials.go index 7c25f0b..d876a17 100644 --- a/catalog/provider_credentials.go +++ b/catalog/provider_credentials.go @@ -3,7 +3,7 @@ package catalog import "strings" // ProviderIDsFromCompiled lists provider IDs from catalog providers and deployments. -func ProviderIDsFromCompiled(compiled *CompiledCatalogV1) []string { +func ProviderIDsFromCompiled(compiled *CompiledCatalog) []string { if compiled == nil || compiled.Catalog == nil { return nil } @@ -27,7 +27,7 @@ func ProviderIDsFromCompiled(compiled *CompiledCatalogV1) []string { } // PrimaryAPIKeyEnvForProvider returns the preferred API key env var for a provider. -func PrimaryAPIKeyEnvForProvider(compiled *CompiledCatalogV1, providerID string) string { +func PrimaryAPIKeyEnvForProvider(compiled *CompiledCatalog, providerID string) string { providerID = canonicalProviderID(providerID) if providerID == "" || compiled == nil || compiled.Catalog == nil { return "" @@ -49,7 +49,7 @@ func PrimaryAPIKeyEnvForProvider(compiled *CompiledCatalogV1, providerID string) return "" } -func apiKeyEnvFromDeployment(dep DeploymentV1) string { +func apiKeyEnvFromDeployment(dep Deployment) string { for _, fb := range dep.EnvFallbacks { if fb.Field == "api_key" && len(fb.Env) > 0 { return fb.Env[0] @@ -60,7 +60,7 @@ func apiKeyEnvFromDeployment(dep DeploymentV1) string { // CredentialStatusForProvider reports whether a provider needs an API key (local vs required). // For set/empty status use hawk config.EnvKeyStatus or credentials.HasSecret — catalog does not read env. -func CredentialStatusForProvider(compiled *CompiledCatalogV1, providerID string) string { +func CredentialStatusForProvider(compiled *CompiledCatalog, providerID string) string { providerID = canonicalProviderID(providerID) if providerID == "" { return "empty" @@ -73,12 +73,12 @@ func CredentialStatusForProvider(compiled *CompiledCatalogV1, providerID string) } // APIKeyEnvsForProvider lists API key env var names for a provider from deployment env_fallbacks. -func APIKeyEnvsForProvider(compiled *CompiledCatalogV1, providerID string) []string { +func APIKeyEnvsForProvider(compiled *CompiledCatalog, providerID string) []string { return apiKeyEnvsForProvider(compiled, canonicalProviderID(providerID)) } // PrimaryAPIKeyEnvForDeployment returns the primary API key env var for a deployment ID. -func PrimaryAPIKeyEnvForDeployment(compiled *CompiledCatalogV1, deploymentID string) string { +func PrimaryAPIKeyEnvForDeployment(compiled *CompiledCatalog, deploymentID string) string { if compiled != nil && compiled.Catalog != nil { if dep, ok := compiled.Catalog.Deployments[deploymentID]; ok { if env := apiKeyEnvFromDeployment(dep); env != "" { @@ -94,7 +94,7 @@ func PrimaryAPIKeyEnvForDeployment(compiled *CompiledCatalogV1, deploymentID str return "" } -func apiKeyEnvsForProvider(compiled *CompiledCatalogV1, providerID string) []string { +func apiKeyEnvsForProvider(compiled *CompiledCatalog, providerID string) []string { if compiled == nil || compiled.Catalog == nil { return nil } diff --git a/catalog/provider_credentials_test.go b/catalog/provider_credentials_test.go index f9969a3..cd57d42 100644 --- a/catalog/provider_credentials_test.go +++ b/catalog/provider_credentials_test.go @@ -4,8 +4,8 @@ import "testing" func TestPrimaryAPIKeyEnvForProvider(t *testing.T) { t.Parallel() - bootstrap := BootstrapCatalogV1() - compiled, err := CompileCatalogV1(&bootstrap) + bootstrap := BootstrapCatalog() + compiled, err := CompileCatalog(&bootstrap) if err != nil { t.Fatal(err) } @@ -28,8 +28,8 @@ func TestPrimaryAPIKeyEnvForProvider(t *testing.T) { func TestCredentialStatusForProvider_OllamaLocal(t *testing.T) { t.Parallel() - bootstrap := BootstrapCatalogV1() - compiled, err := CompileCatalogV1(&bootstrap) + bootstrap := BootstrapCatalog() + compiled, err := CompileCatalog(&bootstrap) if err != nil { t.Fatal(err) } @@ -42,8 +42,8 @@ func TestCredentialStatusForProvider_OllamaLocal(t *testing.T) { func TestProviderIDsFromCompiled_Bootstrap(t *testing.T) { t.Parallel() - bootstrap := BootstrapCatalogV1() - compiled, err := CompileCatalogV1(&bootstrap) + bootstrap := BootstrapCatalog() + compiled, err := CompileCatalog(&bootstrap) if err != nil { t.Fatal(err) } diff --git a/catalog/provider_live_parity_test.go b/catalog/provider_live_parity_test.go index 784972e..f0929fb 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) != 19 { - t.Fatalf("expected 19 providers, got %d", len(specs)) + if len(specs) != 22 { + t.Fatalf("expected 22 providers, got %d", len(specs)) } for _, spec := range specs { t.Run(spec.ProviderID, func(t *testing.T) { @@ -47,7 +47,7 @@ func TestAllProviders_AllReturnEmptyWithoutCatalog(t *testing.T) { func TestAllProviders_FirstModelFromCompiledCache(t *testing.T) { t.Parallel() - base := catalog.TestSeedCatalogV1() + base := catalog.SeedCatalog() for _, spec := range registry.All() { native := "live-" + spec.ProviderID + "-model" owner := catalog.CanonicalProviderID(spec.ProviderID) @@ -65,13 +65,13 @@ func TestAllProviders_FirstModelFromCompiledCache(t *testing.T) { } // Ensure provider exists in catalog if _, ok := base.Providers[owner]; !ok { - base.Providers[owner] = catalog.ProviderV1{ID: owner, Name: owner} + base.Providers[owner] = catalog.Provider{ID: owner, Name: owner} } - base.Models[canonical] = catalog.ModelV1{ + base.Models[canonical] = catalog.Model{ ID: canonical, ProviderID: owner, Name: native, } } - compiled, err := catalog.CompileCatalogV1(&base) + compiled, err := catalog.CompileCatalog(&base) if err != nil { t.Fatal(err) } diff --git a/catalog/provider_registration_test.go b/catalog/provider_registration_test.go index 45d450b..4666064 100644 --- a/catalog/provider_registration_test.go +++ b/catalog/provider_registration_test.go @@ -108,10 +108,10 @@ func TestProviderDisplayName(t *testing.T) { func TestEnsureCredentialRegistryInCatalog_AddsMissingProviders(t *testing.T) { t.Parallel() - c := &CatalogV1{ - Providers: map[string]ProviderV1{}, - Deployments: map[string]DeploymentV1{}, - APIProtocols: map[string]APIProtocolV1{}, + c := &Catalog{ + Providers: map[string]Provider{}, + Deployments: map[string]Deployment{}, + Protocols: map[string]Protocol{}, } EnsureCredentialRegistryInCatalog(c) for _, spec := range registry.DefaultRegistry.All() { @@ -127,14 +127,14 @@ func TestEnsureCredentialRegistryInCatalog_AddsMissingProviders(t *testing.T) { func TestEnsureCredentialRegistryInCatalog_PreservesExisting(t *testing.T) { t.Parallel() - c := &CatalogV1{ - Providers: map[string]ProviderV1{ + c := &Catalog{ + Providers: map[string]Provider{ "anthropic": {ID: "anthropic", Name: "Custom Anthropic"}, }, - Deployments: map[string]DeploymentV1{ + Deployments: map[string]Deployment{ "anthropic-direct": {ID: "anthropic-direct", Name: "Custom Anthropic Direct"}, }, - APIProtocols: map[string]APIProtocolV1{}, + Protocols: map[string]Protocol{}, } EnsureCredentialRegistryInCatalog(c) if c.Providers["anthropic"].Name != "Custom Anthropic" { @@ -152,8 +152,8 @@ func TestEnsureCredentialRegistryInCatalog_NilCatalog(t *testing.T) { func TestProviderIDsFromCompiled_LegacyCatalog(t *testing.T) { t.Parallel() - c := testLegacyCatalogV1() - compiled, _ := CompileCatalogV1(&c) + c := SeedCatalog() + compiled, _ := CompileCatalog(&c) ids := ProviderIDsFromCompiled(compiled) if len(ids) == 0 { t.Fatal("expected provider IDs") @@ -180,8 +180,8 @@ func TestProviderIDsFromCompiled_ReturnsNilForNil(t *testing.T) { func TestPrimaryAPIKeyEnvForProvider_LegacyCatalog(t *testing.T) { t.Parallel() - c := testLegacyCatalogV1() - compiled, _ := CompileCatalogV1(&c) + c := SeedCatalog() + compiled, _ := CompileCatalog(&c) tests := []struct { provider, wantEnv string }{ @@ -206,8 +206,8 @@ func TestPrimaryAPIKeyEnvForProvider_ReturnsEmptyForNilCompiled(t *testing.T) { func TestCredentialStatusForProvider_LegacyCatalog(t *testing.T) { t.Parallel() - c := testLegacyCatalogV1() - compiled, _ := CompileCatalogV1(&c) + c := SeedCatalog() + compiled, _ := CompileCatalog(&c) if got := CredentialStatusForProvider(compiled, "anthropic"); got != "required" { t.Errorf("anthropic status = %q, want required", got) } diff --git a/catalog/refresh.go b/catalog/refresh.go index 68386b0..05ddbf1 100644 --- a/catalog/refresh.go +++ b/catalog/refresh.go @@ -11,7 +11,7 @@ import ( // RefreshResult summarizes a strict remote catalog refresh (eyrie published catalog). type RefreshResult struct { - Compiled *CompiledCatalogV1 + Compiled *CompiledCatalog CachePath string Source string // remote, cache, embedded, remote+providers RemoteURL string @@ -47,22 +47,22 @@ func CacheInfo(cachePath string) (exists bool, modTime time.Time, size int64, er return true, info.ModTime(), info.Size(), nil } -// RefreshCatalogV1 fetches the published catalog, validates it, and writes the cache. -// Unlike LoadCatalogV1 with RefreshRemote, this fails when the remote fetch fails so +// RefreshCatalog fetches the published catalog, validates it, and writes the cache. +// Unlike LoadCatalog with RefreshRemote, this fails when the remote fetch fails so // callers never treat a stale cache as a successful refresh. -func RefreshCatalogV1(ctx context.Context, opts LoadCatalogV1Options) (*RefreshResult, error) { +func RefreshCatalog(ctx context.Context, opts LoadCatalogOptions) (*RefreshResult, error) { if opts.CachePath == "" { opts.CachePath = DefaultCachePath() } opts.RemoteURL = ResolvedRemoteCatalogURL(opts.RemoteURL) - remote, err := FetchRemoteCatalogV1(ctx, opts) + remote, err := FetchRemoteCatalog(ctx, opts) if err != nil { return nil, fmt.Errorf("catalog refresh: %w", err) } - if err := WriteCatalogV1Cache(opts.CachePath, remote); err != nil { + if err := WriteCatalogCache(opts.CachePath, remote); err != nil { return nil, fmt.Errorf("catalog refresh: write cache: %w", err) } - compiled, err := CompileCatalogV1(remote) + compiled, err := CompileCatalog(remote) if err != nil { return nil, fmt.Errorf("catalog refresh: compile: %w", err) } diff --git a/catalog/refresh_test.go b/catalog/refresh_test.go index 4795cfc..f3ee13b 100644 --- a/catalog/refresh_test.go +++ b/catalog/refresh_test.go @@ -8,8 +8,8 @@ import ( func TestRefreshResult_DiscoverReport(t *testing.T) { t.Parallel() - def := testLegacyCatalogV1() - compiled, err := CompileCatalogV1(&def) + def := SeedCatalog() + compiled, err := CompileCatalog(&def) if err != nil { t.Fatalf("compile: %v", err) } @@ -41,8 +41,8 @@ func TestRefreshResult_DiscoverReport(t *testing.T) { func TestRefreshResult_DiscoverReport_NoLiveAPIs(t *testing.T) { t.Parallel() - def := testLegacyCatalogV1() - compiled, err := CompileCatalogV1(&def) + def := SeedCatalog() + compiled, err := CompileCatalog(&def) if err != nil { t.Fatalf("compile: %v", err) } diff --git a/catalog/registry/provider_spec_test.go b/catalog/registry/provider_spec_test.go index 4d8c431..da1aeeb 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 != 19 { - t.Fatalf("expected 19 providers, got %d", n) + if n := len(registry.All()); n != 22 { + t.Fatalf("expected 22 providers, got %d", n) } } @@ -24,8 +24,8 @@ func TestCredentialRegistry_MatchesAll(t *testing.T) { func TestLiveFetcherKeys_AllProviders(t *testing.T) { t.Parallel() keys := registry.LiveFetcherKeys() - if len(keys) != 19 { - t.Fatalf("expected 19 live fetcher keys, got %d", len(keys)) + if len(keys) != 22 { + t.Fatalf("expected 22 live fetcher keys, got %d", len(keys)) } } diff --git a/catalog/registry/providers.go b/catalog/registry/providers.go index a98aa44..98c3df1 100644 --- a/catalog/registry/providers.go +++ b/catalog/registry/providers.go @@ -23,7 +23,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"ANTHROPIC_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeAnthropic, LiveFetcherKey: "anthropic", LiveCatalogKey: "anthropic", - APIProtocolID: "anthropic-messages", AdapterID: "anthropic", RuntimeProfileKey: "anthropic", + ProtocolID: "anthropic-messages", AdapterID: "anthropic", RuntimeProfileKey: "anthropic", DirectFallbacks: []string{"openai"}, }, { @@ -32,7 +32,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.openai.com/v1", LiveFetcherKey: "openai", LiveCatalogKey: "openai", - APIProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "openai", + ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "openai", DirectFallbacks: []string{"anthropic"}, }, { @@ -42,7 +42,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"GEMINI_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeGemini, LiveFetcherKey: "gemini", LiveCatalogKey: "gemini", - APIProtocolID: "gemini-generate-content", AdapterID: "gemini", RuntimeProfileKey: "gemini", + ProtocolID: "gemini-generate-content", AdapterID: "gemini", RuntimeProfileKey: "gemini", }, { ProviderID: "deepseek", DisplayName: "DeepSeek", DeploymentID: "deepseek-direct", SortOrder: 4, ChatPreference: 11, @@ -50,7 +50,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"DEEPSEEK_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.deepseek.com/v1", LiveFetcherKey: "deepseek", LiveCatalogKey: "deepseek", - APIProtocolID: "openai-chat-completions", AdapterID: "deepseek", RuntimeProfileKey: "deepseek", + ProtocolID: "openai-chat-completions", AdapterID: "deepseek", RuntimeProfileKey: "deepseek", }, { ProviderID: "grok", DisplayName: "xAI", DeploymentID: "grok-direct", SortOrder: 5, ChatPreference: 4, @@ -58,7 +58,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"XAI_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.x.ai/v1", LiveFetcherKey: "grok", LiveCatalogKey: "grok", - APIProtocolID: "openai-chat-completions", AdapterID: "grok", RuntimeProfileKey: "grok", + ProtocolID: "openai-chat-completions", AdapterID: "grok", RuntimeProfileKey: "grok", }, { ProviderID: "kimi", DisplayName: "Kimi", DeploymentID: "kimi-direct", SortOrder: 6, ChatPreference: 14, @@ -66,7 +66,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"MOONSHOT_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.moonshot.ai/v1", LiveFetcherKey: "kimi", LiveCatalogKey: "kimi", - APIProtocolID: "openai-chat-completions", AdapterID: "kimi", RuntimeProfileKey: "kimi", + ProtocolID: "openai-chat-completions", AdapterID: "kimi", RuntimeProfileKey: "kimi", }, { ProviderID: "zai_coding", DisplayName: "Z.AI — Coding Plan", DeploymentID: "zai_coding-direct", SortOrder: 7, ChatPreference: 8, @@ -74,7 +74,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"ZAI_CODING_BASE_URL", "ZAI_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/coding/paas/v4", LiveFetcherKey: "zai_coding", LiveCatalogKey: "zai_coding", - APIProtocolID: "openai-chat-completions", AdapterID: "zai_coding", RuntimeProfileKey: "zai_coding", + ProtocolID: "openai-chat-completions", AdapterID: "zai_coding", RuntimeProfileKey: "zai_coding", PrepareCredentialEnv: true, }, { @@ -83,7 +83,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"ZAI_BASE_URL", "ZAI_API_BASE", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/paas/v4", LiveFetcherKey: "zai_payg", LiveCatalogKey: "zai_payg", - APIProtocolID: "openai-chat-completions", AdapterID: "zai_payg", RuntimeProfileKey: "zai_payg", + ProtocolID: "openai-chat-completions", AdapterID: "zai_payg", RuntimeProfileKey: "zai_payg", PrepareCredentialEnv: true, }, { @@ -92,7 +92,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"XIAOMI_MIMO_TOKEN_PLAN_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "", LiveFetcherKey: "xiaomi_mimo_token_plan", LiveCatalogKey: "xiaomi_mimo_token_plan", - APIProtocolID: "openai-chat-completions", AdapterID: "xiaomi_mimo", RuntimeProfileKey: "xiaomi_mimo_token_plan", + ProtocolID: "openai-chat-completions", AdapterID: "xiaomi_mimo", RuntimeProfileKey: "xiaomi_mimo_token_plan", PrepareCredentialEnv: true, }, { @@ -102,7 +102,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"XIAOMI_MIMO_PAYG_BASE_URL", "XIAOMI_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.xiaomimimo.com/v1", LiveFetcherKey: "xiaomi_mimo_payg", LiveCatalogKey: "xiaomi_mimo_payg", - APIProtocolID: "openai-chat-completions", AdapterID: "xiaomi_mimo", RuntimeProfileKey: "xiaomi_mimo_payg", + 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, @@ -110,7 +110,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"MINIMAX_TOKEN_PLAN_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.minimax.io/v1", LiveFetcherKey: "minimax_token_plan", LiveCatalogKey: "minimax_token_plan", - APIProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "minimax_token_plan", + 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, @@ -118,7 +118,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"MINIMAX_PAYG_BASE_URL", "MINIMAX_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.minimax.io/v1", LiveFetcherKey: "minimax_payg", LiveCatalogKey: "minimax_payg", - APIProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "minimax_payg", + ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "minimax_payg", }, // ── Cloud platform providers ────────────────────────────────────── @@ -128,7 +128,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"AZURE_OPENAI_ENDPOINT"}, ProbeKind: ProbeNone, LiveFetcherKey: "azure", LiveCatalogKey: "azure", - APIProtocolID: "openai-chat-completions", AdapterID: "openai-azure", RuntimeProfileKey: "azure", + ProtocolID: "openai-chat-completions", AdapterID: "openai-azure", RuntimeProfileKey: "azure", }, { ProviderID: "bedrock", DisplayName: "Amazon Bedrock", DeploymentID: "anthropic-bedrock", SortOrder: 14, ChatPreference: 7, @@ -137,7 +137,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"AWS_REGION", "AWS_DEFAULT_REGION"}, ProbeKind: ProbeNone, LiveFetcherKey: "bedrock", LiveCatalogKey: "bedrock", - APIProtocolID: "anthropic-messages", AdapterID: "anthropic-bedrock", RuntimeProfileKey: "bedrock", + ProtocolID: "anthropic-messages", AdapterID: "anthropic-bedrock", RuntimeProfileKey: "bedrock", }, { ProviderID: "vertex", DisplayName: "Vertex AI", DeploymentID: "gemini-vertex", SortOrder: 15, ChatPreference: 6, @@ -146,7 +146,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"VERTEX_PROJECT_ID", "VERTEX_REGION"}, ProbeKind: ProbeNone, LiveFetcherKey: "vertex", LiveCatalogKey: "vertex", - APIProtocolID: "gemini-generate-content", AdapterID: "gemini-vertex", RuntimeProfileKey: "vertex", + ProtocolID: "gemini-generate-content", AdapterID: "gemini-vertex", RuntimeProfileKey: "vertex", }, // ── Aggregators ─────────────────────────────────────────────────── @@ -156,7 +156,7 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"OPENROUTER_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://openrouter.ai/api/v1", LiveFetcherKey: "openrouter", LiveCatalogKey: "openrouter", - APIProtocolID: "openai-chat-completions", AdapterID: "openrouter", RuntimeProfileKey: "openrouter", + ProtocolID: "openai-chat-completions", AdapterID: "openrouter", RuntimeProfileKey: "openrouter", }, // ── Niche ───────────────────────────────────────────────────────── @@ -166,26 +166,50 @@ func providerSpecs() []ProviderSpec { BaseURLEnv: []string{"CANOPYWAVE_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://inference.canopywave.io/v1", LiveFetcherKey: "canopywave", LiveCatalogKey: "canopywave", - APIProtocolID: "openai-chat-completions", AdapterID: "canopywave", RuntimeProfileKey: "canopywave", + ProtocolID: "openai-chat-completions", AdapterID: "canopywave", RuntimeProfileKey: "canopywave", }, { - ProviderID: "opencodego", DisplayName: "OpenCode Go", DeploymentID: "opencodego", SortOrder: 18, ChatPreference: 13, + ProviderID: "poolside", DisplayName: "Poolside", DeploymentID: "poolside", SortOrder: 18, ChatPreference: 20, + RequiresKey: true, CredentialEnv: "POOLSIDE_API_KEY", + BaseURLEnv: []string{"POOLSIDE_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, + ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://inference.poolside.ai/v1", + LiveFetcherKey: "poolside", LiveCatalogKey: "poolside", + ProtocolID: "openai-chat-completions", AdapterID: "poolside", RuntimeProfileKey: "poolside", + }, + { + ProviderID: "groq", DisplayName: "Groq", DeploymentID: "groq-direct", SortOrder: 19, ChatPreference: 21, + RequiresKey: true, CredentialEnv: "GROQ_API_KEY", + BaseURLEnv: []string{"GROQ_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, + ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.groq.com/openai/v1", + LiveFetcherKey: "groq", LiveCatalogKey: "groq", + ProtocolID: "openai-chat-completions", AdapterID: "groq", RuntimeProfileKey: "groq", + }, + { + ProviderID: "clinepass", DisplayName: "ClinePass", DeploymentID: "clinepass", SortOrder: 20, ChatPreference: 22, + RequiresKey: true, CredentialEnv: "CLINE_API_KEY", + BaseURLEnv: []string{"CLINE_API_BASE", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, + ProbeKind: ProbeNone, + LiveFetcherKey: "clinepass", LiveCatalogKey: "clinepass", + ProtocolID: "openai-chat-completions", AdapterID: "clinepass", RuntimeProfileKey: "clinepass", + }, + { + ProviderID: "opencodego", DisplayName: "OpenCode Go", DeploymentID: "opencodego", SortOrder: 21, ChatPreference: 13, RequiresKey: true, CredentialEnv: "OPENCODEGO_API_KEY", BaseURLEnv: []string{"OPENCODEGO_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: opencodego.DefaultBaseURL, LiveFetcherKey: "opencodego", LiveCatalogKey: "opencodego", - APIProtocolID: "openai-chat-completions", AdapterID: "opencodego", RuntimeProfileKey: "opencodego", + ProtocolID: "openai-chat-completions", AdapterID: "opencodego", RuntimeProfileKey: "opencodego", }, // ── Local ───────────────────────────────────────────────────────── { - ProviderID: "ollama", DisplayName: "Ollama", DeploymentID: "ollama-local", SortOrder: 19, ChatPreference: 19, + ProviderID: "ollama", DisplayName: "Ollama", DeploymentID: "ollama-local", SortOrder: 21, ChatPreference: 19, RequiresKey: false, CredentialEnv: "OLLAMA_BASE_URL", BaseURLEnv: []string{"OLLAMA_BASE_URL"}, ProbeKind: ProbeOllama, LiveFetcherKey: "ollama", LiveCatalogKey: "ollama", - APIProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "ollama", + ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "ollama", IsLocal: true, RetryConfig: &RetryConfig{ BaseDelayMs: 2000, MaxDelayMs: 10000, MaxRetries: 3, diff --git a/catalog/registry/spec.go b/catalog/registry/spec.go index ac422ab..34b1402 100644 --- a/catalog/registry/spec.go +++ b/catalog/registry/spec.go @@ -38,7 +38,7 @@ type ProviderSpec struct { ProbeBaseURL string LiveFetcherKey string // key in catalog/live registry LiveCatalogKey string // legacy provider key in ModelCatalog.Providers map - APIProtocolID string + ProtocolID string AdapterID string RuntimeProfileKey string DirectFallbacks []string diff --git a/catalog/spec_parse_test.go b/catalog/spec_parse_test.go index 892ad2c..5d78d6f 100644 --- a/catalog/spec_parse_test.go +++ b/catalog/spec_parse_test.go @@ -6,19 +6,29 @@ import ( "time" ) -// --- ParseCatalogV1 tests --- +// --- ParseCatalog tests --- -func TestParseCatalogV1_V1Format(t *testing.T) { - c := testLegacyCatalogV1() +func TestParseCatalog_V1Format(t *testing.T) { + c := SeedCatalog() + _ = c data, err := json.Marshal(c) if err != nil { t.Fatal(err) } - parsed, err := ParseCatalogV1(data) + // Debug: show offerings count + if len(c.Offerings) == 0 { + t.Log("DEBUG: SeedCatalog has 0 offerings") + } else { + t.Logf("DEBUG: SeedCatalog has %d offerings", len(c.Offerings)) + } + if err != nil { + t.Fatal(err) + } + parsed, err := ParseCatalog(data) if err != nil { - t.Fatalf("ParseCatalogV1 failed: %v", err) + t.Fatalf("ParseCatalog failed: %v", err) } - if parsed.SchemaVersion != CatalogV1SchemaVersion { + if parsed.SchemaVersion != CatalogSchemaVersion { t.Fatalf("schema_version = %q", parsed.SchemaVersion) } if len(parsed.Models) == 0 { @@ -26,32 +36,26 @@ func TestParseCatalogV1_V1Format(t *testing.T) { } } -func TestParseCatalogV1_LegacyFormat(t *testing.T) { - legacy := testLegacyModelCatalog() +func TestParseCatalog_LegacyFormatRejected(t *testing.T) { + legacy := testModelCatalog() data, err := json.Marshal(legacy) if err != nil { t.Fatal(err) } - parsed, err := ParseCatalogV1(data) - if err != nil { - t.Fatalf("ParseCatalogV1 legacy failed: %v", err) - } - if parsed.SchemaVersion != CatalogV1SchemaVersion { - t.Fatalf("schema_version = %q, want %q", parsed.SchemaVersion, CatalogV1SchemaVersion) - } - if len(parsed.Offerings) == 0 { - t.Fatal("expected offerings from legacy conversion") + _, err = ParseCatalog(data) + if err == nil { + t.Fatal("expected error for legacy ModelCatalog format") } } -func TestParseCatalogV1_InvalidJSON(t *testing.T) { - _, err := ParseCatalogV1([]byte(`not json`)) +func TestParseCatalog_InvalidJSON(t *testing.T) { + _, err := ParseCatalog([]byte(`not json`)) if err == nil { t.Fatal("expected error for invalid JSON") } } -func TestParseCatalogV1_StrictV1RejectsUnknownFields(t *testing.T) { +func TestParseCatalog_StrictV1RejectsUnknownFields(t *testing.T) { data := []byte(`{ "schema_version": "model-catalog/v1", "unknown_field": true, @@ -63,56 +67,56 @@ func TestParseCatalogV1_StrictV1RejectsUnknownFields(t *testing.T) { "models": {}, "offerings": [] }`) - _, err := ParseCatalogV1(data) + _, err := ParseCatalog(data) if err == nil { t.Fatal("expected error for unknown fields in v1 format") } } -// --- ValidateCatalogV1 tests --- +// --- ValidateCatalog tests --- -func TestValidateCatalogV1_NilCatalog(t *testing.T) { - if err := ValidateCatalogV1(nil); err == nil { +func TestValidateCatalog_NilCatalog(t *testing.T) { + if err := ValidateCatalog(nil); err == nil { t.Fatal("expected error for nil catalog") } } -func TestValidateCatalogV1_BadSchemaVersion(t *testing.T) { - c := testLegacyCatalogV1() +func TestValidateCatalog_BadSchemaVersion(t *testing.T) { + c := SeedCatalog() c.SchemaVersion = "wrong" - if err := ValidateCatalogV1(&c); err == nil { + if err := ValidateCatalog(&c); err == nil { t.Fatal("expected error for wrong schema_version") } } -func TestValidateCatalogV1_MissingGeneratedAt(t *testing.T) { - c := testLegacyCatalogV1() +func TestValidateCatalog_MissingGeneratedAt(t *testing.T) { + c := SeedCatalog() c.GeneratedAt = time.Time{} - if err := ValidateCatalogV1(&c); err == nil { + if err := ValidateCatalog(&c); err == nil { t.Fatal("expected error for zero generated_at") } } -func TestValidateCatalogV1_StaleAfterBeforeGeneratedAt(t *testing.T) { - c := testLegacyCatalogV1() +func TestValidateCatalog_StaleAfterBeforeGeneratedAt(t *testing.T) { + c := SeedCatalog() c.GeneratedAt = time.Now().UTC() c.StaleAfter = c.GeneratedAt.Add(-time.Hour) - if err := ValidateCatalogV1(&c); err == nil { + if err := ValidateCatalog(&c); err == nil { t.Fatal("expected error when stale_after < generated_at") } } -func TestValidateCatalogV1_BadProviderIDMismatch(t *testing.T) { - c := testLegacyCatalogV1() - c.Providers["bad"] = ProviderV1{ID: "mismatch", Name: "Bad"} - if err := ValidateCatalogV1(&c); err == nil { +func TestValidateCatalog_BadProviderIDMismatch(t *testing.T) { + c := SeedCatalog() + c.Providers["bad"] = Provider{ID: "mismatch", Name: "Bad"} + if err := ValidateCatalog(&c); err == nil { t.Fatal("expected error for provider ID mismatch") } } -func TestValidateCatalogV1_BadDeploymentProviderRef(t *testing.T) { - c := testLegacyCatalogV1() - c.Deployments["bad-dep"] = DeploymentV1{ +func TestValidateCatalog_BadDeploymentProviderRef(t *testing.T) { + c := SeedCatalog() + c.Deployments["bad-dep"] = Deployment{ ID: "bad-dep", Name: "Bad", ProviderID: "nonexistent", @@ -120,14 +124,14 @@ func TestValidateCatalogV1_BadDeploymentProviderRef(t *testing.T) { AdapterConstructor: "openai", NativeModelIDSource: NativeModelIDCatalogKnown, } - if err := ValidateCatalogV1(&c); err == nil { + if err := ValidateCatalog(&c); err == nil { t.Fatal("expected error for bad provider reference in deployment") } } -func TestValidateCatalogV1_BadDeploymentProtocolRef(t *testing.T) { - c := testLegacyCatalogV1() - c.Deployments["bad-dep"] = DeploymentV1{ +func TestValidateCatalog_BadDeploymentProtocolRef(t *testing.T) { + c := SeedCatalog() + c.Deployments["bad-dep"] = Deployment{ ID: "bad-dep", Name: "Bad", ProviderID: "openai", @@ -135,85 +139,85 @@ func TestValidateCatalogV1_BadDeploymentProtocolRef(t *testing.T) { AdapterConstructor: "openai", NativeModelIDSource: NativeModelIDCatalogKnown, } - if err := ValidateCatalogV1(&c); err == nil { + if err := ValidateCatalog(&c); err == nil { t.Fatal("expected error for bad api_protocol reference in deployment") } } -func TestValidateCatalogV1_BadModelProviderRef(t *testing.T) { - c := testLegacyCatalogV1() - c.Models["bad/model"] = ModelV1{ +func TestValidateCatalog_BadModelProviderRef(t *testing.T) { + c := SeedCatalog() + c.Models["bad/model"] = Model{ ID: "bad/model", ProviderID: "nonexistent", Name: "Bad Model", } - if err := ValidateCatalogV1(&c); err == nil { + if err := ValidateCatalog(&c); err == nil { t.Fatal("expected error for bad provider reference in model") } } -func TestValidateCatalogV1_DuplicateOffering(t *testing.T) { - c := testLegacyCatalogV1() +func TestValidateCatalog_DuplicateOffering(t *testing.T) { + c := SeedCatalog() c.Offerings = append(c.Offerings, c.Offerings[0]) - if err := ValidateCatalogV1(&c); err == nil { + if err := ValidateCatalog(&c); err == nil { t.Fatal("expected error for duplicate offering") } } -func TestValidateCatalogV1_OfferingBadModelRef(t *testing.T) { - c := testLegacyCatalogV1() - c.Offerings = append(c.Offerings, ModelOfferingV1{ +func TestValidateCatalog_OfferingBadModelRef(t *testing.T) { + c := SeedCatalog() + c.Offerings = append(c.Offerings, ModelOffering{ ID: "anthropic-direct:fake", CanonicalModelID: "nonexistent/model", DeploymentID: "anthropic-direct", NativeModelID: "fake", - Pricing: PricingV1{Status: PricingUnknown}, + Pricing: Pricing{Status: PricingUnknown}, }) - if err := ValidateCatalogV1(&c); err == nil { + if err := ValidateCatalog(&c); err == nil { t.Fatal("expected error for offering referencing unknown model") } } -func TestValidateCatalogV1_InvalidPricingStatus(t *testing.T) { - c := testLegacyCatalogV1() - c.Offerings[0].Pricing = PricingV1{Status: "invalid_status"} - if err := ValidateCatalogV1(&c); err == nil { +func TestValidateCatalog_InvalidPricingStatus(t *testing.T) { + c := SeedCatalog() + c.Offerings[0].Pricing = Pricing{Status: "invalid_status"} + if err := ValidateCatalog(&c); err == nil { t.Fatal("expected error for invalid pricing status") } } -func TestValidateCatalogV1_InvalidCapabilityState(t *testing.T) { - c := testLegacyCatalogV1() - c.Offerings[0].Capabilities = CapabilitySetV1{ +func TestValidateCatalog_InvalidCapabilityState(t *testing.T) { + c := SeedCatalog() + c.Offerings[0].Capabilities = CapabilitySet{ FunctionCalling: "invalid_state", } - if err := ValidateCatalogV1(&c); err == nil { + if err := ValidateCatalog(&c); err == nil { t.Fatal("expected error for invalid capability state") } } -func TestValidateCatalogV1_ValidBootstrapCatalog(t *testing.T) { - c := BootstrapCatalogV1() - if err := ValidateCatalogV1(&c); err != nil { +func TestValidateCatalog_ValidBootstrapCatalog(t *testing.T) { + c := BootstrapCatalog() + if err := ValidateCatalog(&c); err != nil { t.Fatalf("bootstrap catalog should validate: %v", err) } } -// --- SplitOfferingIDV1 tests --- +// --- SplitOfferingID tests --- -func TestSplitOfferingIDV1_Valid(t *testing.T) { - dep, native, ok := SplitOfferingIDV1("anthropic-direct:claude-sonnet-4-6") +func TestSplitOfferingID_Valid(t *testing.T) { + dep, native, ok := SplitOfferingID("anthropic-direct:claude-sonnet-4-6") if !ok || dep != "anthropic-direct" || native != "claude-sonnet-4-6" { t.Fatalf("got (%q, %q, %v)", dep, native, ok) } } -func TestSplitOfferingIDV1_Invalid(t *testing.T) { +func TestSplitOfferingID_Invalid(t *testing.T) { tests := []string{"", "nocolon", ":empty-left", "empty-right:"} for _, id := range tests { - _, _, ok := SplitOfferingIDV1(id) + _, _, ok := SplitOfferingID(id) if ok { - t.Errorf("SplitOfferingIDV1(%q) should return ok=false", id) + t.Errorf("SplitOfferingID(%q) should return ok=false", id) } } } @@ -258,25 +262,6 @@ func TestValidNativeModelIDSource(t *testing.T) { } } -// --- canonicalModelID tests --- - -func TestCanonicalModelID(t *testing.T) { - tests := []struct { - provider, native, want string - }{ - {"anthropic", "claude-sonnet-4-6", "anthropic/claude-sonnet-4-6"}, - {"openai", "gpt-4o", "openai/gpt-4o"}, - {"openrouter", "anthropic/claude-sonnet-4-6", "openrouter/anthropic/claude-sonnet-4-6"}, - {"zai_payg", "zai/glm-5.1", "zai_payg/glm-5.1"}, - } - for _, tt := range tests { - got := canonicalModelID(tt.provider, tt.native) - if got != tt.want { - t.Errorf("canonicalModelID(%q, %q) = %q, want %q", tt.provider, tt.native, got, tt.want) - } - } -} - // --- canonicalProviderID tests --- func TestCanonicalProviderID(t *testing.T) { @@ -302,110 +287,15 @@ func TestCanonicalProviderID(t *testing.T) { } } -// --- CatalogV1FromLegacy additional tests --- - -func TestCatalogV1FromLegacy_ProducesValidCatalog(t *testing.T) { - legacy := testLegacyModelCatalog() - c := CatalogV1FromLegacy(legacy) - if err := ValidateCatalogV1(&c); err != nil { - t.Fatalf("CatalogV1FromLegacy produced invalid catalog: %v", err) - } -} - -func TestCatalogV1FromLegacy_PreservesTimestamp(t *testing.T) { - legacy := testLegacyModelCatalog() - legacy.UpdatedAt = "2026-01-15T12:00:00Z" - c := CatalogV1FromLegacy(legacy) - ts, err := time.Parse(time.RFC3339, "2026-01-15T12:00:00Z") - if err != nil { - t.Fatal(err) - } - if !c.GeneratedAt.Equal(ts) { - t.Fatalf("generated_at = %v, want %v", c.GeneratedAt, ts) - } -} - -func TestCatalogV1FromLegacy_SkipsEmptyNativeIDs(t *testing.T) { - legacy := testLegacyModelCatalog() - legacy.Providers["anthropic"] = append( - legacy.Providers["anthropic"], - ModelCatalogEntry{ID: "", DisplayName: "Empty"}, - ModelCatalogEntry{ID: " ", DisplayName: "Whitespace"}, - ) - c := CatalogV1FromLegacy(legacy) - for _, o := range c.Offerings { - if o.NativeModelID == "" || o.NativeModelID == " " { - t.Fatalf("should skip empty native IDs, found %q", o.NativeModelID) - } - } -} - -func TestCatalogV1FromLegacy_SkipsUnknownProviders(t *testing.T) { - legacy := testLegacyModelCatalog() - legacy.Providers["unknown_provider"] = []ModelCatalogEntry{ - {ID: "some-model"}, - } - c := CatalogV1FromLegacy(legacy) - for _, o := range c.Offerings { - if o.DeploymentID == "" { - t.Fatal("should skip unknown providers") - } - } -} - -// --- pricingFromLegacy tests --- - -func TestPricingFromLegacy_KnownPricing(t *testing.T) { - entry := ModelCatalogEntry{InputPricePer1M: 3, OutputPricePer1M: 15} - now := time.Now().UTC() - p := pricingFromLegacy(entry, now, "test") - if p.Status != PricingKnown { - t.Fatalf("status = %q, want known", p.Status) - } - if p.RatesPer1M["input_tokens"] != 3 || p.RatesPer1M["output_tokens"] != 15 { - t.Fatalf("rates = %v", p.RatesPer1M) - } -} - -func TestPricingFromLegacy_NegativePricing(t *testing.T) { - entry := ModelCatalogEntry{InputPricePer1M: -1, OutputPricePer1M: 0} - now := time.Now().UTC() - p := pricingFromLegacy(entry, now, "test") - if p.Status != PricingUnknown { - t.Fatalf("status = %q, want unknown", p.Status) - } -} - -func TestPricingFromLegacy_ZeroPricing(t *testing.T) { - entry := ModelCatalogEntry{InputPricePer1M: 0, OutputPricePer1M: 0} - now := time.Now().UTC() - p := pricingFromLegacy(entry, now, "test") - if p.Status != PricingUnknown { - t.Fatalf("status = %q, want unknown", p.Status) - } - if p.RatesPer1M != nil { - t.Fatal("zero pricing should have nil rates") - } -} - -func TestPricingFromLegacy_FreeModel(t *testing.T) { - entry := ModelCatalogEntry{ID: "model:free", InputPricePer1M: 0, OutputPricePer1M: 0} - now := time.Now().UTC() - p := pricingFromLegacy(entry, now, "test") - if p.Status != PricingFree { - t.Fatalf("status = %q, want free", p.Status) - } -} - -// --- sanitizePricingV1 tests --- +// --- sanitizePricing tests --- -func TestSanitizePricingV1_DropsNegativeRates(t *testing.T) { - p := PricingV1{ +func TestSanitizePricing_DropsNegativeRates(t *testing.T) { + p := Pricing{ Status: PricingKnown, Currency: "USD", RatesPer1M: map[string]float64{"input_tokens": 3, "output_tokens": -1}, } - cleaned := sanitizePricingV1(p) + cleaned := sanitizePricing(p) if _, ok := cleaned.RatesPer1M["output_tokens"]; ok { t.Error("negative rate should be dropped") } @@ -414,13 +304,13 @@ func TestSanitizePricingV1_DropsNegativeRates(t *testing.T) { } } -func TestSanitizePricingV1_AllNegativeBecomesUnknown(t *testing.T) { - p := PricingV1{ +func TestSanitizePricing_AllNegativeBecomesUnknown(t *testing.T) { + p := Pricing{ Status: PricingKnown, Currency: "USD", RatesPer1M: map[string]float64{"input_tokens": -1, "output_tokens": -2}, } - cleaned := sanitizePricingV1(p) + cleaned := sanitizePricing(p) if cleaned.Status != PricingUnknown { t.Fatalf("status = %q, want unknown", cleaned.Status) } @@ -429,13 +319,13 @@ func TestSanitizePricingV1_AllNegativeBecomesUnknown(t *testing.T) { } } -func TestSanitizePricingV1_EmptyRatesMapReturnsUnchanged(t *testing.T) { - p := PricingV1{ +func TestSanitizePricing_EmptyRatesMapReturnsUnchanged(t *testing.T) { + p := Pricing{ Status: PricingKnown, Currency: "USD", RatesPer1M: map[string]float64{}, } - cleaned := sanitizePricingV1(p) + cleaned := sanitizePricing(p) // Empty rates map returns early (len == 0) without modifying status if cleaned.Status != PricingKnown { t.Fatalf("status = %q, want known (empty map returns unchanged)", cleaned.Status) @@ -457,13 +347,13 @@ func TestUniqueNonEmpty(t *testing.T) { } } -// --- CompileCatalogV1 tests --- +// --- CompileCatalog tests --- -func TestCompileCatalogV1_BuildsIndexes(t *testing.T) { - c := testLegacyCatalogV1() - compiled, err := CompileCatalogV1(&c) +func TestCompileCatalog_BuildsIndexes(t *testing.T) { + c := SeedCatalog() + compiled, err := CompileCatalog(&c) if err != nil { - t.Fatalf("CompileCatalogV1: %v", err) + t.Fatalf("CompileCatalog: %v", err) } if len(compiled.OfferingsByID) != len(c.Offerings) { t.Fatalf("OfferingsByID len = %d, want %d", len(compiled.OfferingsByID), len(c.Offerings)) @@ -481,26 +371,26 @@ func TestCompileCatalogV1_BuildsIndexes(t *testing.T) { } } -func TestCompileCatalogV1_AppliesEnvFallbacks(t *testing.T) { - c := testLegacyCatalogV1() +func TestCompileCatalog_AppliesEnvFallbacks(t *testing.T) { + c := SeedCatalog() // Remove env fallbacks to test that compile adds them for id, dep := range c.Deployments { dep.EnvFallbacks = nil c.Deployments[id] = dep } - compiled, err := CompileCatalogV1(&c) + compiled, err := CompileCatalog(&c) if err != nil { - t.Fatalf("CompileCatalogV1: %v", err) + t.Fatalf("CompileCatalog: %v", err) } anthDep := compiled.DeploymentsByID["anthropic-direct"] if len(anthDep.EnvFallbacks) == 0 { - t.Error("CompileCatalogV1 should apply env fallbacks") + t.Error("CompileCatalog should apply env fallbacks") } } -func TestCompileCatalogV1_InvalidCatalogFails(t *testing.T) { - c := CatalogV1{} // missing required fields - _, err := CompileCatalogV1(&c) +func TestCompileCatalog_InvalidCatalogFails(t *testing.T) { + c := Catalog{} // missing required fields + _, err := CompileCatalog(&c) if err == nil { t.Fatal("expected error for invalid catalog") } @@ -509,8 +399,8 @@ func TestCompileCatalogV1_InvalidCatalogFails(t *testing.T) { // --- CanonicalModelForAliasOrID tests --- func TestCanonicalModelForAliasOrID_ByDirectID(t *testing.T) { - c := testLegacyCatalogV1() - compiled, _ := CompileCatalogV1(&c) + c := SeedCatalog() + compiled, _ := CompileCatalog(&c) canonical, ok := compiled.CanonicalModelForAliasOrID("anthropic/claude-sonnet-4-6") if !ok || canonical != "anthropic/claude-sonnet-4-6" { t.Fatalf("got (%q, %v)", canonical, ok) @@ -518,8 +408,8 @@ func TestCanonicalModelForAliasOrID_ByDirectID(t *testing.T) { } func TestCanonicalModelForAliasOrID_ByAlias(t *testing.T) { - c := testLegacyCatalogV1() - compiled, _ := CompileCatalogV1(&c) + c := SeedCatalog() + compiled, _ := CompileCatalog(&c) canonical, ok := compiled.CanonicalModelForAliasOrID("claude-sonnet-4-6") if !ok || canonical != "anthropic/claude-sonnet-4-6" { t.Fatalf("got (%q, %v)", canonical, ok) @@ -527,8 +417,8 @@ func TestCanonicalModelForAliasOrID_ByAlias(t *testing.T) { } func TestCanonicalModelForAliasOrID_NotFound(t *testing.T) { - c := testLegacyCatalogV1() - compiled, _ := CompileCatalogV1(&c) + c := SeedCatalog() + compiled, _ := CompileCatalog(&c) _, ok := compiled.CanonicalModelForAliasOrID("nonexistent-model") if ok { t.Fatal("expected not found") @@ -536,7 +426,7 @@ func TestCanonicalModelForAliasOrID_NotFound(t *testing.T) { } func TestCanonicalModelForAliasOrID_NilCompiled(t *testing.T) { - var c *CompiledCatalogV1 + var c *CompiledCatalog _, ok := c.CanonicalModelForAliasOrID("anything") if ok { t.Fatal("nil compiled should return false") @@ -546,8 +436,8 @@ func TestCanonicalModelForAliasOrID_NilCompiled(t *testing.T) { // --- OfferingForDeployment tests --- func TestOfferingForDeployment_Found(t *testing.T) { - c := testLegacyCatalogV1() - compiled, _ := CompileCatalogV1(&c) + c := SeedCatalog() + compiled, _ := CompileCatalog(&c) offering, ok := compiled.OfferingForDeployment("anthropic/claude-sonnet-4-6", "anthropic-direct") if !ok { t.Fatal("expected offering") @@ -558,8 +448,8 @@ func TestOfferingForDeployment_Found(t *testing.T) { } func TestOfferingForDeployment_NotFound(t *testing.T) { - c := testLegacyCatalogV1() - compiled, _ := CompileCatalogV1(&c) + c := SeedCatalog() + compiled, _ := CompileCatalog(&c) _, ok := compiled.OfferingForDeployment("anthropic/claude-sonnet-4-6", "nonexistent") if ok { t.Fatal("expected not found") @@ -579,8 +469,8 @@ func TestResolvedRemoteCatalogURL_Default(t *testing.T) { // Clear env var if set t.Setenv("EYRIE_MODEL_CATALOG_URL", "") got := ResolvedRemoteCatalogURL("") - if got != DefaultCatalogV1URL { - t.Fatalf("got %q, want %q", got, DefaultCatalogV1URL) + if got != SeedCatalogURL { + t.Fatalf("got %q, want %q", got, SeedCatalogURL) } } diff --git a/catalog/testdata_test.go b/catalog/testdata_test.go index a6fb709..02421e7 100644 --- a/catalog/testdata_test.go +++ b/catalog/testdata_test.go @@ -13,7 +13,7 @@ func TestExportHawkCatalogFixture(t *testing.T) { if os.Getenv("EXPORT_HAWK_FIXTURE") != "1" { t.Skip("set EXPORT_HAWK_FIXTURE=1 to export") // TODO: https://github.com/GrayCodeAI/eyrie/issues/30 } - c := testLegacyCatalogV1() + c := SeedCatalog() data, err := json.MarshalIndent(c, "", " ") if err != nil { t.Fatal(err) diff --git a/catalog/testfixtures.go b/catalog/testfixtures.go index 268a5f2..cd77e6d 100644 --- a/catalog/testfixtures.go +++ b/catalog/testfixtures.go @@ -1,64 +1,156 @@ package catalog -// Test fixture model slices — not used in production (cache/discovery only). +import "time" -var testAnthropicModels = []ModelCatalogEntry{ - {ID: "claude-opus-4-6", InputPricePer1M: 15, OutputPricePer1M: 75, ContextWindow: 200000, MaxOutput: 32000, ServerTools: []string{"web_search"}}, - {ID: "claude-sonnet-4-6", InputPricePer1M: 3, OutputPricePer1M: 15, ContextWindow: 200000, MaxOutput: 32000, ServerTools: []string{"web_search"}}, - {ID: "claude-haiku-4-5-20251001", InputPricePer1M: 1, OutputPricePer1M: 5, ContextWindow: 200000, MaxOutput: 16000, ServerTools: []string{"web_search"}}, +// deploymentsForProvider returns all deployment IDs belonging to the given provider. +func deploymentsForProvider(cat *Catalog, providerID string) []string { + var ids []string + for id, dep := range cat.Deployments { + if dep.ProviderID == providerID { + ids = append(ids, id) + } + } + return ids +} + +// ModelID constants for well-known models used across tests. +const ( + ClaudeOpusV4_6 = "claude-opus-4-6" + ClaudeSonnetV4_6 = "claude-sonnet-4-6" + ClaudeHaikuV4_5 = "claude-haiku-4-5-20251001" + GPT_4o = "gpt-4o" + GPT_4oMini = "gpt-4o-mini" + Grokk_2 = "grok-2" + Glm51 = "glm-5.1" +) + +// Fixture model lists — used exclusively by SeedCatalog to validate catalog +// construction and model registry. All provider model lists are defined here so +// that producers and consumers stay in sync. + +var seedAnthropicModels = []ModelCatalogEntry{ + {ID: ClaudeOpusV4_6, InputPricePer1M: 15, OutputPricePer1M: 75, ContextWindow: 200000, MaxOutput: 32000, DisplayName: "Claude Opus 4.6", ServerTools: []string{"web_search"}}, + {ID: ClaudeSonnetV4_6, InputPricePer1M: 3, OutputPricePer1M: 15, ContextWindow: 200000, MaxOutput: 32000, DisplayName: "Claude Sonnet 4.6", ServerTools: []string{"web_search"}}, + {ID: ClaudeHaikuV4_5, InputPricePer1M: 1, OutputPricePer1M: 5, ContextWindow: 200000, MaxOutput: 16000, DisplayName: "Claude Haiku 4.5", ServerTools: []string{"web_search"}}, } -var testOpenAIModels = []ModelCatalogEntry{ - {ID: "gpt-4o", InputPricePer1M: 5, OutputPricePer1M: 15, ContextWindow: 128000, MaxOutput: 16000, ServerTools: []string{"web_search"}}, - {ID: "gpt-4o-mini", InputPricePer1M: 0.15, OutputPricePer1M: 0.6, ContextWindow: 128000, MaxOutput: 16000, ServerTools: []string{"web_search"}}, +var seedOpenAIModels = []ModelCatalogEntry{ + {ID: GPT_4o, InputPricePer1M: 5, OutputPricePer1M: 15, ContextWindow: 128000, MaxOutput: 16000, DisplayName: "GPT-4o", ServerTools: []string{"web_search"}}, + {ID: GPT_4oMini, InputPricePer1M: 0.15, OutputPricePer1M: 0.6, ContextWindow: 128000, MaxOutput: 16000, DisplayName: "GPT-4o Mini", ServerTools: []string{"web_search"}}, } -var testGrokModels = []ModelCatalogEntry{ - {ID: "grok-2", InputPricePer1M: 2, OutputPricePer1M: 10, ContextWindow: 128000, MaxOutput: 8000, ServerTools: []string{"web_search"}}, +var seedGrokModels = []ModelCatalogEntry{ + {ID: Grokk_2, InputPricePer1M: 2, OutputPricePer1M: 10, ContextWindow: 128000, MaxOutput: 8000, DisplayName: "Grokk 2", ServerTools: []string{"web_search"}}, } -var testGeminiModels = []ModelCatalogEntry{ - {ID: "gemini-2.5-pro-preview-03-25", InputPricePer1M: 1.25, OutputPricePer1M: 5, ContextWindow: 1000000, MaxOutput: 65536, ServerTools: []string{"web_search"}}, - {ID: "gemini-2.0-flash", InputPricePer1M: 0.1, OutputPricePer1M: 0.4, ContextWindow: 1000000, MaxOutput: 8192, ServerTools: []string{"web_search"}}, - {ID: "gemini-2.0-flash-lite", InputPricePer1M: 0.075, OutputPricePer1M: 0.3, ContextWindow: 1000000, MaxOutput: 8192, ServerTools: []string{"web_search"}}, +var seedGeminiModels = []ModelCatalogEntry{ + {ID: "gemini-2.5-pro-preview-03-25", InputPricePer1M: 1.25, OutputPricePer1M: 5, ContextWindow: 1000000, MaxOutput: 65536, DisplayName: "Gemini 2.5 Pro", ServerTools: []string{"web_search"}}, + {ID: "gemini-2.0-flash", InputPricePer1M: 0.1, OutputPricePer1M: 0.4, ContextWindow: 1000000, MaxOutput: 8192, DisplayName: "Gemini 2.0 Flash", ServerTools: []string{"web_search"}}, + {ID: "gemini-2.0-flash-lite", InputPricePer1M: 0.075, OutputPricePer1M: 0.3, ContextWindow: 1000000, MaxOutput: 8192, DisplayName: "Gemini 2.0 Flash Lite", ServerTools: []string{"web_search"}}, } -var testOpenRouterModels []ModelCatalogEntry +var seedOpenRouterModels []ModelCatalogEntry -var testCanopyWaveModels []ModelCatalogEntry +var seedCanopyWaveModels []ModelCatalogEntry -var testOpenCodeGoModels = []ModelCatalogEntry{ - {ID: "glm-5.1", InputPricePer1M: 5, OutputPricePer1M: 15, ContextWindow: 128000, MaxOutput: 8000, DisplayName: "GLM-5.1"}, +var seedOpenCodeGoModels = []ModelCatalogEntry{ + {ID: Glm51, InputPricePer1M: 5, OutputPricePer1M: 15, ContextWindow: 128000, MaxOutput: 8000, DisplayName: "GLM-5.1"}, } -func testLegacyModelCatalog() ModelCatalog { +// SeedModelCatalog returns the embedded test model catalog. +func SeedModelCatalog() ModelCatalog { return ModelCatalog{ - UpdatedAt: "2026-04-09T00:00:00.000Z", - Source: "test", + Source: "test", Providers: map[string][]ModelCatalogEntry{ - "anthropic": testAnthropicModels, - "openai": testOpenAIModels, - "grok": testGrokModels, - "gemini": testGeminiModels, - "openrouter": testOpenRouterModels, - "canopywave": testCanopyWaveModels, + "anthropic": seedAnthropicModels, + "openai": seedOpenAIModels, + "grok": seedGrokModels, + "gemini": seedGeminiModels, + "openrouter": seedOpenRouterModels, + "canopywave": seedCanopyWaveModels, "ollama": nil, - "opencodego": testOpenCodeGoModels, + "opencodego": seedOpenCodeGoModels, }, } } -func testLegacyCatalogV1() CatalogV1 { - return CatalogV1FromLegacy(testLegacyModelCatalog()) +// seedCatalogFromDefault returns a Catalog built from the embedded test fixtures, +// using the current default provider/deployment/protocol registries. +func seedCatalogFromDefault() Catalog { + now := time.Now().UTC() + cat := Catalog{ + SchemaVersion: CatalogSchemaVersion, + GeneratedAt: now, + StaleAfter: now.Add(30 * 24 * time.Hour), + Providers: defaultProviders(), + Protocols: defaultProtocols(), + Deployments: defaultDeployments(), + Models: map[string]Model{}, + Aliases: map[string]string{}, + Provenance: &Provenance{Source: "test", ObservedAt: now}, + } + EnsureDeploymentEnvFallbacks(&cat) + EnsureCredentialRegistryInCatalog(&cat) + + for _, e := range seedAnthropicModels { + addModel(&cat, "anthropic", e) + } + for _, e := range seedOpenAIModels { + addModel(&cat, "openai", e) + } + for _, e := range seedGrokModels { + addModel(&cat, "xai", e) + } + for _, e := range seedGeminiModels { + addModel(&cat, "google", e) + } + for _, e := range seedOpenRouterModels { + addModel(&cat, "openrouter", e) + } + for _, e := range seedCanopyWaveModels { + addModel(&cat, "canopywave", e) + } + for _, e := range seedOpenCodeGoModels { + addModel(&cat, "opencodego", e) + } + + return cat +} + +func addModel(cat *Catalog, providerID string, e ModelCatalogEntry) { + modelID := providerID + "/" + e.ID + cat.Models[modelID] = Model{ + ID: modelID, + ProviderID: providerID, + Name: e.DisplayName, + ContextWindow: e.ContextWindow, + MaxOutput: e.MaxOutput, + } + cat.Aliases[e.ID] = modelID + for _, deploymentID := range deploymentsForProvider(cat, providerID) { + cat.Offerings = append(cat.Offerings, ModelOffering{ + ID: deploymentID + ":" + e.ID, + CanonicalModelID: modelID, + DeploymentID: deploymentID, + NativeModelID: e.ID, + Capabilities: capabilitySetFromLegacy(e), + Pricing: pricingFromLegacy(e, time.Now().UTC()), + }) + } +} + +// SeedCatalog returns a catalog built from the embedded test fixtures. +func SeedCatalog() Catalog { + return seedCatalogFromDefault() } -// TestSeedCatalogV1 returns a v1 catalog built from embedded test fixtures. -func TestSeedCatalogV1() CatalogV1 { - return testLegacyCatalogV1() +// CompileTestCatalog builds a compiled catalog from the embedded test fixtures. +func CompileTestCatalog() (*CompiledCatalog, error) { + c := SeedCatalog() + return CompileCatalog(&c) } -// CompileTestCatalog builds a compiled catalog from built-in provider model lists (tests and dev fixtures). -func CompileTestCatalog() (*CompiledCatalogV1, error) { - c := testLegacyCatalogV1() - return CompileCatalogV1(&c) +// testModelCatalog is an alias for backward compatibility. +func testModelCatalog() ModelCatalog { + return SeedModelCatalog() } diff --git a/catalog/user_catalog_dump_test.go b/catalog/user_catalog_dump_test.go index f54b9e1..52ad9a9 100644 --- a/catalog/user_catalog_dump_test.go +++ b/catalog/user_catalog_dump_test.go @@ -20,7 +20,7 @@ func TestUserCatalog_GatewayCountsMatchDeploymentOfferings(t *testing.T) { if _, err := os.Stat(path); err != nil { t.Skip("no user catalog") // TODO: https://github.com/GrayCodeAI/eyrie/issues/31 } - compiled, err := catalog.LoadCatalogV1(context.Background(), catalog.LoadCatalogV1Options{ + compiled, err := catalog.LoadCatalog(context.Background(), catalog.LoadCatalogOptions{ CachePath: path, RequireCache: true, }) diff --git a/catalog/v1.go b/catalog/v1.go index ac259c2..1f17240 100644 --- a/catalog/v1.go +++ b/catalog/v1.go @@ -11,17 +11,8 @@ import ( "sort" "strings" "time" -) - -// Default catalog construction, legacy conversion, and sanitization helpers -// live in v1_defaults.go. -const ( - CatalogV1SchemaVersion = "model-catalog/v1" - // DefaultCatalogV1URL is the published model-catalog/v1 document. - // Override with EYRIE_MODEL_CATALOG_URL or LoadCatalogV1Options.RemoteURL. - DefaultCatalogV1URL = "https://langdag.com/model-catalog/v1/catalog.json" - EnvModelCatalogURL = "EYRIE_MODEL_CATALOG_URL" + "github.com/GrayCodeAI/eyrie/catalog/live" ) type CapabilityState string @@ -50,97 +41,106 @@ const ( NativeModelIDCatalogOrUser NativeModelIDSource = "catalog_or_user_configured" ) -// CatalogV1 separates model ownership from the API protocol and deployment used +const ( + CatalogSchemaVersion = "model-catalog/v1" + // SeedCatalogURL is the published model-catalog/v1 document. + SeedCatalogURL = "https://langdag.com/model-catalog/v1/catalog.json" + EnvCatalogURL = "EYRIE_MODEL_CATALOG_URL" + // LiveStaleDuration is how long a cache remains fresh after live provider APIs were merged. + LiveStaleDuration = 24 * time.Hour +) + +// Catalog separates model ownership from the API protocol and deployment used // to call the model. It is intentionally data-only; adapters remain code. -type CatalogV1 struct { - SchemaVersion string `json:"schema_version"` - GeneratedAt time.Time `json:"generated_at"` - StaleAfter time.Time `json:"stale_after"` - Providers map[string]ProviderV1 `json:"providers"` - APIProtocols map[string]APIProtocolV1 `json:"api_protocols"` - Deployments map[string]DeploymentV1 `json:"deployments"` - Models map[string]ModelV1 `json:"models"` - Offerings []ModelOfferingV1 `json:"offerings"` - OfferingTemplates []ModelOfferingTemplateV1 `json:"offering_templates,omitempty"` - Aliases map[string]string `json:"aliases,omitempty"` - Provenance *CatalogProvenanceV1 `json:"provenance,omitempty"` -} - -type ProviderV1 struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - HomepageURL string `json:"homepage_url,omitempty"` - Aliases []string `json:"aliases,omitempty"` - Provenance *CatalogProvenanceV1 `json:"provenance,omitempty"` -} - -type APIProtocolV1 struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Provenance *CatalogProvenanceV1 `json:"provenance,omitempty"` -} - -type DeploymentV1 struct { - ID string `json:"id"` - Name string `json:"name"` - ProviderID string `json:"provider_id"` - APIProtocolID string `json:"api_protocol_id"` - AdapterConstructor string `json:"adapter_constructor"` - CredentialRequirements []CredentialV1 `json:"credential_requirements,omitempty"` - EnvFallbacks []EnvFallbackV1 `json:"env_fallbacks,omitempty"` - NativeModelIDSource NativeModelIDSource `json:"native_model_id_source"` - ModelMappingsRequired bool `json:"model_mappings_required,omitempty"` - Local bool `json:"local,omitempty"` - Provenance *CatalogProvenanceV1 `json:"provenance,omitempty"` -} - -type CredentialV1 struct { +type Catalog struct { + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` + StaleAfter time.Time `json:"stale_after"` + Providers map[string]Provider `json:"providers"` + Protocols map[string]Protocol `json:"api_protocols"` + Deployments map[string]Deployment `json:"deployments"` + Models map[string]Model `json:"models"` + Offerings []ModelOffering `json:"offerings"` + OfferingTemplates []ModelOfferingTemplate `json:"offering_templates,omitempty"` + Aliases map[string]string `json:"aliases,omitempty"` + Provenance *Provenance `json:"provenance,omitempty"` +} + +type Provider struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + HomepageURL string `json:"homepage_url,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Provenance *Provenance `json:"provenance,omitempty"` +} + +type Protocol struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Provenance *Provenance `json:"provenance,omitempty"` +} + +type Deployment struct { + ID string `json:"id"` + Name string `json:"name"` + ProviderID string `json:"provider_id"` + APIProtocolID string `json:"api_protocol_id"` + AdapterConstructor string `json:"adapter_constructor"` + CredentialRequirements []Credential `json:"credential_requirements,omitempty"` + EnvFallbacks []EnvFallback `json:"env_fallbacks,omitempty"` + NativeModelIDSource NativeModelIDSource `json:"native_model_id_source"` + ModelMappingsRequired bool `json:"model_mappings_required,omitempty"` + Local bool `json:"local,omitempty"` + Provenance *Provenance `json:"provenance,omitempty"` +} + +type Credential struct { Field string `json:"field"` Secret bool `json:"secret,omitempty"` Required bool `json:"required,omitempty"` } -type EnvFallbackV1 struct { +type EnvFallback struct { Field string `json:"field"` Env []string `json:"env"` } -type ModelV1 struct { - ID string `json:"id"` - ProviderID string `json:"provider_id"` - Name string `json:"name"` - Family string `json:"family,omitempty"` - ContextWindow int `json:"context_window,omitempty"` - MaxOutput int `json:"max_output,omitempty"` - Aliases []string `json:"aliases,omitempty"` - Provenance *CatalogProvenanceV1 `json:"provenance,omitempty"` -} - -type ModelOfferingV1 struct { - ID string `json:"id"` - CanonicalModelID string `json:"canonical_model_id"` - DeploymentID string `json:"deployment_id"` - NativeModelID string `json:"native_model_id"` - Capabilities CapabilitySetV1 `json:"capabilities,omitempty"` - Pricing PricingV1 `json:"pricing"` - LiveMetadata json.RawMessage `json:"live_metadata,omitempty"` - Provenance *CatalogProvenanceV1 `json:"provenance,omitempty"` -} - -type ModelOfferingTemplateV1 struct { - ID string `json:"id"` - CanonicalModelID string `json:"canonical_model_id"` - DeploymentID string `json:"deployment_id"` - NativeModelIDSource NativeModelIDSource `json:"native_model_id_source"` - MappingRequired bool `json:"mapping_required"` - Capabilities CapabilitySetV1 `json:"capabilities,omitempty"` - Pricing PricingV1 `json:"pricing"` - Provenance *CatalogProvenanceV1 `json:"provenance,omitempty"` -} - -type CapabilitySetV1 struct { +type Model struct { + ID string `json:"id"` + ProviderID string `json:"provider_id"` + Name string `json:"name"` + Family string `json:"family,omitempty"` + ContextWindow int `json:"context_window,omitempty"` + MaxOutput int `json:"max_output,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Provenance *Provenance `json:"provenance,omitempty"` +} + +type ModelOffering struct { + ID string `json:"id"` + CanonicalModelID string `json:"canonical_model_id"` + DeploymentID string `json:"deployment_id"` + NativeModelID string `json:"native_model_id"` + Capabilities CapabilitySet `json:"capabilities,omitempty"` + Pricing Pricing `json:"pricing"` + LiveMetadata json.RawMessage `json:"live_metadata,omitempty"` + Provenance *Provenance `json:"provenance,omitempty"` +} + +type ModelOfferingTemplate struct { + ID string `json:"id"` + CanonicalModelID string `json:"canonical_model_id"` + DeploymentID string `json:"deployment_id"` + NativeModelIDSource NativeModelIDSource `json:"native_model_id_source"` + MappingRequired bool `json:"mapping_required"` + Capabilities CapabilitySet `json:"capabilities,omitempty"` + Pricing Pricing `json:"pricing"` + Provenance *Provenance `json:"provenance,omitempty"` +} + +type CapabilitySet struct { ServerTools map[string]CapabilityState `json:"server_tools,omitempty"` FunctionCalling CapabilityState `json:"function_calling,omitempty"` ExplicitThinkingBudget CapabilityState `json:"explicit_thinking_budget,omitempty"` @@ -157,7 +157,7 @@ type CapabilitySetV1 struct { EffortLevels []string `json:"effort_levels,omitempty"` } -type PricingV1 struct { +type Pricing struct { Status PricingStatus `json:"status"` Currency string `json:"currency,omitempty"` EffectiveAt time.Time `json:"effective_at,omitempty"` @@ -167,38 +167,86 @@ type PricingV1 struct { Source string `json:"source,omitempty"` } -type CatalogProvenanceV1 struct { +// CapabilitySetFromEntry builds a CapabilitySet from a live entry's features. +func CapabilitySetFromEntry(e live.Entry) CapabilitySet { + set := CapabilitySet{ServerTools: map[string]CapabilityState{}} + for _, feat := range e.Features { + switch feat { + case "web_search": + set.ServerTools[feat] = CapabilitySupported + case "image_generation": + set.ServerTools[feat] = CapabilitySupported + case "code_interpreter": + set.ServerTools[feat] = CapabilitySupported + case "function_calling": + set.FunctionCalling = CapabilitySupported + } + } + if len(set.ServerTools) == 0 && len(e.Features) > 0 { + for _, feat := range e.Features { + set.ServerTools[feat] = CapabilitySupported + } + } + if e.MaxOutput > 0 { + set.MaxOutputTokens = e.MaxOutput + } + if e.StructuredOutput { + set.StructuredOutput = CapabilitySupported + } + if e.CodeExecution { + set.CodeExecution = CapabilitySupported + } + return set +} + +// PricingFromEntry builds a Pricing from a live entry's per-token rates. +func PricingFromEntry(e live.Entry) Pricing { + in := e.InputPricePer1M + out := e.OutputPricePer1M + if in < 0 || out < 0 { + return Pricing{Status: PricingUnknown, Currency: "USD", Source: "live"} + } + pricing := Pricing{ + Status: PricingKnown, + Currency: "USD", + Source: "live", + RatesPer1M: map[string]float64{"input_tokens": in, "output_tokens": out}, + } + if in == 0 && out == 0 { + pricing.Status = PricingFree + } + return pricing +} + +type Provenance struct { Source string `json:"source"` SourceURL string `json:"source_url,omitempty"` ObservedAt time.Time `json:"observed_at,omitempty"` } -type CatalogDiagnosticV1 struct { +type CatalogDiagnostic struct { Code string `json:"code"` Message string `json:"message"` } -type CompiledCatalogV1 struct { - Catalog *CatalogV1 - ProvidersByID map[string]ProviderV1 - APIProtocolsByID map[string]APIProtocolV1 - DeploymentsByID map[string]DeploymentV1 - ModelsByID map[string]ModelV1 - OfferingsByID map[string]ModelOfferingV1 - OfferingsByCanonicalModel map[string][]ModelOfferingV1 - OfferingsByDeployment map[string][]ModelOfferingV1 - TemplatesByCanonicalModel map[string][]ModelOfferingTemplateV1 - Diagnostics []CatalogDiagnosticV1 +type CompiledCatalog struct { + Catalog *Catalog + ProvidersByID map[string]Provider + ProtocolsByID map[string]Protocol + DeploymentsByID map[string]Deployment + ModelsByID map[string]Model + OfferingsByID map[string]ModelOffering + OfferingsByCanonicalModel map[string][]ModelOffering + OfferingsByDeployment map[string][]ModelOffering + TemplatesByCanonicalModel map[string][]ModelOfferingTemplate + Diagnostics []CatalogDiagnostic } // CapabilitiesForModel returns the capability set for a model on a deployment. -// If deploymentID is empty, returns capabilities from the first offering found. -// Returns empty CapabilitySetV1 if the model is not in the catalog. -func (c *CompiledCatalogV1) CapabilitiesForModel(modelID, deploymentID string) CapabilitySetV1 { +func (c *CompiledCatalog) CapabilitiesForModel(modelID, deploymentID string) CapabilitySet { if c == nil { - return CapabilitySetV1{} + return CapabilitySet{} } - // Try by canonical model ID if offerings, ok := c.OfferingsByCanonicalModel[modelID]; ok { for _, offering := range offerings { if deploymentID == "" || offering.DeploymentID == deploymentID { @@ -206,7 +254,6 @@ func (c *CompiledCatalogV1) CapabilitiesForModel(modelID, deploymentID string) C } } } - // Try by native model ID across all deployments for _, offerings := range c.OfferingsByDeployment { for _, offering := range offerings { if offering.NativeModelID == modelID { @@ -214,125 +261,122 @@ func (c *CompiledCatalogV1) CapabilitiesForModel(modelID, deploymentID string) C } } } - return CapabilitySetV1{} + return CapabilitySet{} } -type LoadCatalogV1Options struct { - CachePath string - RemoteURL string - RefreshRemote bool - // RequireCache fails when no valid cache file exists (production hawk/eyrie path). - RequireCache bool - HTTPClient *http.Client - Timeout time.Duration -} - -// DefaultCatalogV1 returns the bootstrap catalog (deployments only, no models). -func DefaultCatalogV1() CatalogV1 { - return BootstrapCatalogV1() -} - -func CatalogV1FromLegacy(legacy ModelCatalog) CatalogV1 { - generatedAt := time.Now().UTC().Truncate(time.Second) - if ts, err := time.Parse(time.RFC3339, legacy.UpdatedAt); err == nil { - generatedAt = ts - } - c := CatalogV1{ - SchemaVersion: CatalogV1SchemaVersion, - GeneratedAt: generatedAt, - StaleAfter: generatedAt.Add(30 * 24 * time.Hour), - Providers: defaultProvidersV1(), - APIProtocols: defaultAPIProtocolsV1(), - Deployments: defaultDeploymentsV1(), - Models: map[string]ModelV1{}, - Aliases: map[string]string{}, - Provenance: &CatalogProvenanceV1{Source: legacy.Source, ObservedAt: generatedAt}, - } - for legacyProvider, entries := range legacy.Providers { - deploymentID, ownerProviderID := legacyDeploymentAndOwner(legacyProvider) - if deploymentID == "" { - continue - } - for _, entry := range entries { - nativeID := strings.TrimSpace(entry.ID) - if nativeID == "" { - continue - } - modelProviderID := ownerProviderID - if deploymentID == "openrouter" || deploymentID == "canopywave" { - if owner, _, ok := strings.Cut(nativeID, "/"); ok && owner != "" { - modelProviderID = canonicalProviderID(owner) - if c.Providers[modelProviderID].ID == "" { - c.Providers[modelProviderID] = ProviderV1{ID: modelProviderID, Name: modelProviderID} - } - } - } - canonicalID := canonicalModelID(modelProviderID, nativeID) - if c.Models[canonicalID].ID == "" { - name := entry.DisplayName - if name == "" { - name = nativeID - } - c.Models[canonicalID] = ModelV1{ - ID: canonicalID, - ProviderID: modelProviderID, - Name: name, - ContextWindow: entry.ContextWindow, - MaxOutput: entry.MaxOutput, - Aliases: uniqueNonEmpty(nativeID, entry.DisplayName), - } - } - if c.Aliases[nativeID] == "" { - c.Aliases[nativeID] = canonicalID +func (c *CompiledCatalog) CanonicalModelForAliasOrID(value string) (string, bool) { + if c == nil || value == "" { + return "", false + } + if c.ModelsByID[value].ID != "" { + return value, true + } + if target := c.Catalog.Aliases[value]; c.ModelsByID[target].ID != "" { + return target, true + } + for _, model := range c.ModelsByID { + for _, alias := range model.Aliases { + if alias == value { + return model.ID, true } - c.Offerings = append(c.Offerings, ModelOfferingV1{ - ID: deploymentID + ":" + nativeID, - CanonicalModelID: canonicalID, - DeploymentID: deploymentID, - NativeModelID: nativeID, - Capabilities: capabilitySetFromLegacy(entry), - Pricing: pricingFromLegacy(entry, generatedAt, legacy.Source), - LiveMetadata: entry.LiveMetadata, - }) } } - c.Offerings = appendDerivedDeploymentOfferings(c.Offerings) - c.OfferingTemplates = defaultOfferingTemplatesV1(generatedAt) - return c + return "", false +} + +func (c *CompiledCatalog) OfferingForDeployment(canonicalModelID, deploymentID string) (ModelOffering, bool) { + if c == nil { + return ModelOffering{}, false + } + for _, offering := range c.OfferingsByCanonicalModel[canonicalModelID] { + if offering.DeploymentID == deploymentID { + return offering, true + } + } + return ModelOffering{}, false } -func ParseCatalogV1(data []byte) (*CatalogV1, error) { +func (c *CompiledCatalog) FirstModelForProvider(providerID string) (string, bool) { + if c == nil { + return "", false + } + providerID = canonicalProviderID(providerID) + for modelID, model := range c.ModelsByID { + if canonicalProviderID(model.ProviderID) == providerID && model.Name != "" { + return modelID, true + } + } + return "", false +} + +// ModelIDsForProvider returns all model IDs for a given provider, sorted. +func (c *CompiledCatalog) ModelIDsForProvider(providerID string) []string { + if c == nil { + return nil + } + providerID = canonicalProviderID(providerID) + var ids []string + seen := map[string]bool{} + for modelID, model := range c.ModelsByID { + if canonicalProviderID(model.ProviderID) == providerID && model.Name != "" && !seen[modelID] { + ids = append(ids, modelID) + seen[modelID] = true + } + } + sort.Strings(ids) + return ids +} + +func (c *CompiledCatalog) ProviderNames() []string { + if c == nil { + return nil + } + var names []string + seen := map[string]bool{} + for _, p := range c.ProvidersByID { + if p.Name != "" && !seen[p.ID] { + names = append(names, p.ID) + seen[p.ID] = true + } + } + sort.Strings(names) + return names +} + +func SplitOfferingID(id string) (deploymentID, nativeModelID string, ok bool) { + left, right, found := strings.Cut(id, ":") + return left, right, found && left != "" && right != "" +} + +// --- Catalog helpers --- + +func ParseCatalog(data []byte) (*Catalog, error) { var envelope struct { SchemaVersion string `json:"schema_version"` } if err := json.Unmarshal(data, &envelope); err != nil { - return nil, fmt.Errorf("catalog v1: decode envelope: %w", err) + return nil, fmt.Errorf("catalog: decode envelope: %w", err) } if envelope.SchemaVersion == "" { - var legacy ModelCatalog - if err := json.Unmarshal(data, &legacy); err != nil { - return nil, fmt.Errorf("catalog v1: decode legacy catalog: %w", err) - } - c := CatalogV1FromLegacy(legacy) - return &c, ValidateCatalogV1(&c) + return nil, fmt.Errorf("catalog: legacy format is no longer supported") } - var c CatalogV1 + var c Catalog dec := json.NewDecoder(strings.NewReader(string(data))) dec.DisallowUnknownFields() if err := dec.Decode(&c); err != nil { - return nil, fmt.Errorf("catalog v1: decode: %w", err) + return nil, fmt.Errorf("catalog: decode: %w", err) } - return &c, ValidateCatalogV1(&c) + return &c, ValidateCatalog(&c) } -func ValidateCatalogV1(c *CatalogV1) error { +func ValidateCatalog(c *Catalog) error { var problems []string add := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) } if c == nil { - return fmt.Errorf("catalog v1: nil catalog") + return fmt.Errorf("catalog: nil catalog") } - if c.SchemaVersion != CatalogV1SchemaVersion { - add("schema_version must be %q", CatalogV1SchemaVersion) + if c.SchemaVersion != CatalogSchemaVersion { + add("schema_version must be %q", CatalogSchemaVersion) } if c.GeneratedAt.IsZero() { add("generated_at is required") @@ -345,7 +389,7 @@ func ValidateCatalogV1(c *CatalogV1) error { if len(c.Providers) == 0 { add("providers is required") } - if len(c.APIProtocols) == 0 { + if len(c.Protocols) == 0 { add("api_protocols is required") } if len(c.Deployments) == 0 { @@ -362,7 +406,7 @@ func ValidateCatalogV1(c *CatalogV1) error { add("provider %q must have matching non-empty id and name", id) } } - for id, protocol := range c.APIProtocols { + for id, protocol := range c.Protocols { if id == "" || protocol.ID != id || protocol.Name == "" { add("api_protocol %q must have matching non-empty id and name", id) } @@ -374,7 +418,7 @@ func ValidateCatalogV1(c *CatalogV1) error { if c.Providers[deployment.ProviderID].ID == "" { add("deployment %q references unknown provider %q", id, deployment.ProviderID) } - if c.APIProtocols[deployment.APIProtocolID].ID == "" { + if c.Protocols[deployment.APIProtocolID].ID == "" { add("deployment %q references unknown api_protocol %q", id, deployment.APIProtocolID) } if deployment.AdapterConstructor == "" { @@ -406,7 +450,7 @@ func ValidateCatalogV1(c *CatalogV1) error { continue } seenOfferings[offering.ID] = true - deploymentID, nativeID, ok := SplitOfferingIDV1(offering.ID) + deploymentID, nativeID, ok := SplitOfferingID(offering.ID) if !ok || deploymentID != offering.DeploymentID || nativeID != offering.NativeModelID { add("offering %q must be deployment_id:native_model_id", offering.ID) } @@ -441,30 +485,30 @@ func ValidateCatalogV1(c *CatalogV1) error { } if len(problems) > 0 { sort.Strings(problems) - return fmt.Errorf("catalog v1 validation failed: %s", strings.Join(problems, "; ")) + return fmt.Errorf("catalog validation failed: %s", strings.Join(problems, "; ")) } return nil } -func CompileCatalogV1(c *CatalogV1) (*CompiledCatalogV1, error) { +func CompileCatalog(c *Catalog) (*CompiledCatalog, error) { EnsureDeploymentEnvFallbacks(c) - SanitizeCatalogV1Pricing(c) - if err := ValidateCatalogV1(c); err != nil { + SanitizePricing(c) + if err := ValidateCatalog(c); err != nil { return nil, err } - compiled := &CompiledCatalogV1{ + compiled := &CompiledCatalog{ Catalog: c, ProvidersByID: cloneMap(c.Providers), - APIProtocolsByID: cloneMap(c.APIProtocols), + ProtocolsByID: cloneMap(c.Protocols), DeploymentsByID: cloneMap(c.Deployments), ModelsByID: cloneMap(c.Models), - OfferingsByID: map[string]ModelOfferingV1{}, - OfferingsByCanonicalModel: map[string][]ModelOfferingV1{}, - OfferingsByDeployment: map[string][]ModelOfferingV1{}, - TemplatesByCanonicalModel: map[string][]ModelOfferingTemplateV1{}, + OfferingsByID: map[string]ModelOffering{}, + OfferingsByCanonicalModel: map[string][]ModelOffering{}, + OfferingsByDeployment: map[string][]ModelOffering{}, + TemplatesByCanonicalModel: map[string][]ModelOfferingTemplate{}, } if time.Now().UTC().After(c.StaleAfter) { - compiled.Diagnostics = append(compiled.Diagnostics, CatalogDiagnosticV1{Code: "stale_catalog", Message: "catalog is stale"}) + compiled.Diagnostics = append(compiled.Diagnostics, CatalogDiagnostic{Code: "stale_catalog", Message: "catalog is stale"}) } for _, offering := range c.Offerings { compiled.OfferingsByID[offering.ID] = offering @@ -477,77 +521,82 @@ func CompileCatalogV1(c *CatalogV1) (*CompiledCatalogV1, error) { return compiled, nil } -func LoadCatalogV1(ctx context.Context, opts LoadCatalogV1Options) (*CompiledCatalogV1, error) { +func LoadCatalog(ctx context.Context, opts LoadCatalogOptions) (*CompiledCatalog, error) { if opts.CachePath == "" { opts.CachePath = DefaultCachePath() } if opts.RefreshRemote { - remote, err := FetchRemoteCatalogV1(ctx, opts) + remote, err := FetchRemoteCatalog(ctx, opts) if err != nil { return nil, fmt.Errorf("catalog remote: %w", err) } if opts.CachePath != "" { - _ = WriteCatalogV1Cache(opts.CachePath, remote) + _ = WriteCatalogCache(opts.CachePath, remote) } - return CompileCatalogV1(remote) + return CompileCatalog(remote) } - if compiled, ok := loadValidCatalogCache(opts.CachePath); ok { + if compiled, ok := LoadValidCatalogCache(opts.CachePath); ok { return compiled, nil } if opts.RequireCache { return nil, fmt.Errorf("%w (%s missing or invalid; run: hawk models refresh)", ErrCatalogCacheRequired, opts.CachePath) } - bootstrap := BootstrapCatalogV1() - compiled, err := CompileCatalogV1(&bootstrap) + bootstrap := BootstrapCatalog() + compiled, err := CompileCatalog(&bootstrap) if err != nil { return nil, err } - compiled.Diagnostics = append(compiled.Diagnostics, CatalogDiagnosticV1{ + compiled.Diagnostics = append(compiled.Diagnostics, CatalogDiagnostic{ Code: "bootstrap_only", Message: "no model catalog cache; run hawk models refresh or eyrie catalog discover", }) return compiled, nil } -func loadValidCatalogCache(cachePath string) (*CompiledCatalogV1, bool) { - return LoadValidCatalogCache(cachePath) -} - -// LoadValidCatalogCache reads and compiles a non-bootstrap catalog cache from disk. -func LoadValidCatalogCache(cachePath string) (*CompiledCatalogV1, bool) { +func LoadValidCatalogCache(cachePath string) (*CompiledCatalog, bool) { if cachePath == "" { return nil, false } - data, err := os.ReadFile(cachePath) + data, err := os.ReadFile(cachePath) // #nosec G304 -- cachePath is an operator-supplied local cache file path, not untrusted input if err != nil { return nil, false } - c, err := ParseCatalogV1(data) + c, err := ParseCatalog(data) if err != nil { return nil, false } if IsBootstrapCatalog(c) || len(c.Models) == 0 { return nil, false } - compiled, err := CompileCatalogV1(c) + compiled, err := CompileCatalog(c) if err != nil { return nil, false } return compiled, true } -// ResolvedRemoteCatalogURL returns explicit URL, else EYRIE_MODEL_CATALOG_URL, else DefaultCatalogV1URL. +// --- Remote catalog --- + +type LoadCatalogOptions struct { + CachePath string + RemoteURL string + RefreshRemote bool + RequireCache bool + HTTPClient *http.Client + Timeout time.Duration +} + func ResolvedRemoteCatalogURL(explicit string) string { if u := strings.TrimSpace(explicit); u != "" { return u } - if u := strings.TrimSpace(os.Getenv(EnvModelCatalogURL)); u != "" { + if u := strings.TrimSpace(os.Getenv(EnvCatalogURL)); u != "" { return u } - return DefaultCatalogV1URL + return SeedCatalogURL } -func FetchRemoteCatalogV1(ctx context.Context, opts LoadCatalogV1Options) (*CatalogV1, error) { +func FetchRemoteCatalog(ctx context.Context, opts LoadCatalogOptions) (*Catalog, error) { url := ResolvedRemoteCatalogURL(opts.RemoteURL) timeout := opts.Timeout if timeout == 0 { @@ -564,77 +613,40 @@ func FetchRemoteCatalogV1(ctx context.Context, opts LoadCatalogV1Options) (*Cata return nil, err } req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "eyrie-model-catalog/1.0") + req.Header.Set("User-Agent", "eyrie/1.0") resp, err := client.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("catalog v1: remote returned HTTP %d", resp.StatusCode) + return nil, fmt.Errorf("catalog: remote returned HTTP %d", resp.StatusCode) } - body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10 MiB max + body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) if err != nil { return nil, err } - return ParseCatalogV1(body) + return ParseCatalog(body) } -func WriteCatalogV1Cache(cachePath string, c *CatalogV1) error { +func WriteCatalogCache(cachePath string, c *Catalog) error { if cachePath == "" { return nil } - SanitizeCatalogV1Pricing(c) - if err := ValidateCatalogV1(c); err != nil { + SanitizePricing(c) + if err := ValidateCatalog(c); err != nil { return err } data, err := json.MarshalIndent(c, "", " ") if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil { return err } tmpPath := cachePath + ".tmp" - if err := os.WriteFile(tmpPath, append(data, '\n'), 0o600); err != nil { + if err := os.WriteFile(tmpPath, append(data, 0x0a), 0o600); err != nil { return err } return os.Rename(tmpPath, cachePath) } - -func (c *CompiledCatalogV1) CanonicalModelForAliasOrID(value string) (string, bool) { - if c == nil || value == "" { - return "", false - } - if c.ModelsByID[value].ID != "" { - return value, true - } - if target := c.Catalog.Aliases[value]; c.ModelsByID[target].ID != "" { - return target, true - } - for _, model := range c.ModelsByID { - for _, alias := range model.Aliases { - if alias == value { - return model.ID, true - } - } - } - return "", false -} - -func (c *CompiledCatalogV1) OfferingForDeployment(canonicalModelID, deploymentID string) (ModelOfferingV1, bool) { - if c == nil { - return ModelOfferingV1{}, false - } - for _, offering := range c.OfferingsByCanonicalModel[canonicalModelID] { - if offering.DeploymentID == deploymentID { - return offering, true - } - } - return ModelOfferingV1{}, false -} - -func SplitOfferingIDV1(id string) (deploymentID, nativeModelID string, ok bool) { - left, right, found := strings.Cut(id, ":") - return left, right, found && left != "" && right != "" -} diff --git a/catalog/v1_defaults.go b/catalog/v1_defaults.go index 8445395..562f6d1 100644 --- a/catalog/v1_defaults.go +++ b/catalog/v1_defaults.go @@ -1,15 +1,15 @@ package catalog import ( + "encoding/json" "fmt" "strings" "time" ) -// Default catalog construction, legacy ModelCatalog conversion, and pricing/ -// capability sanitization+validation helpers. Split out of v1.go for clarity. -func defaultProvidersV1() map[string]ProviderV1 { - return map[string]ProviderV1{ +// defaultProviders registers all known API providers with their display names. +func defaultProviders() map[string]Provider { + return map[string]Provider{ "anthropic": {ID: "anthropic", Name: "Anthropic"}, "openai": {ID: "openai", Name: "OpenAI"}, "google": {ID: "google", Name: "Google"}, @@ -28,23 +28,23 @@ func defaultProvidersV1() map[string]ProviderV1 { } } -func defaultAPIProtocolsV1() map[string]APIProtocolV1 { - return map[string]APIProtocolV1{ - "anthropic-messages": {ID: "anthropic-messages", Name: "Anthropic Messages"}, +// defaultProtocols registers the known API protocol types. +func defaultProtocols() map[string]Protocol { + return map[string]Protocol{ "openai-chat-completions": {ID: "openai-chat-completions", Name: "OpenAI Chat Completions"}, - "gemini-generate-content": {ID: "gemini-generate-content", Name: "Gemini generateContent"}, } } -func defaultDeploymentsV1() map[string]DeploymentV1 { - return map[string]DeploymentV1{ - "anthropic-direct": deployment("anthropic-direct", "Anthropic", "anthropic", "anthropic-messages", "anthropic", NativeModelIDCatalogKnown), - "anthropic-bedrock": deployment("anthropic-bedrock", "Anthropic on Bedrock", "anthropic", "anthropic-messages", "anthropic-bedrock", NativeModelIDCatalogKnown), - "anthropic-vertex": deployment("anthropic-vertex", "Anthropic on Vertex", "anthropic", "anthropic-messages", "anthropic-vertex", NativeModelIDCatalogKnown), +// defaultDeployments registers the known API deployment configurations. +func defaultDeployments() map[string]Deployment { + return map[string]Deployment{ + "anthropic-direct": deployment("anthropic-direct", "Anthropic", "anthropic", "openai-chat-completions", "anthropic", NativeModelIDCatalogKnown), + "anthropic-bedrock": deployment("anthropic-bedrock", "Anthropic on Bedrock", "anthropic", "openai-chat-completions", "anthropic-bedrock", NativeModelIDCatalogKnown), + "anthropic-vertex": deployment("anthropic-vertex", "Anthropic on Vertex", "anthropic", "openai-chat-completions", "anthropic-vertex", NativeModelIDCatalogKnown), "openai-direct": deployment("openai-direct", "OpenAI", "openai", "openai-chat-completions", "openai", NativeModelIDCatalogKnown), "openai-azure": azureDeployment(), - "gemini-direct": deployment("gemini-direct", "Gemini", "google", "gemini-generate-content", "gemini", NativeModelIDCatalogKnown), - "gemini-vertex": deployment("gemini-vertex", "Gemini on Vertex", "google", "gemini-generate-content", "gemini-vertex", NativeModelIDCatalogKnown), + "gemini-direct": deployment("gemini-direct", "Gemini", "google", "openai-chat-completions", "gemini", NativeModelIDCatalogKnown), + "gemini-vertex": deployment("gemini-vertex", "Gemini on Vertex", "google", "openai-chat-completions", "gemini-vertex", NativeModelIDCatalogKnown), "grok-direct": deployment("grok-direct", "Grok", "xai", "openai-chat-completions", "grok", NativeModelIDCatalogKnown), "openrouter": deployment("openrouter", "OpenRouter", "openrouter", "openai-chat-completions", "openrouter", NativeModelIDDiscovered), "zai_payg-direct": deployment("zai_payg-direct", "Z.AI Pay-as-you-go", "zai_payg", "openai-chat-completions", "zai_payg", NativeModelIDCatalogKnown), @@ -59,120 +59,23 @@ func defaultDeploymentsV1() map[string]DeploymentV1 { } } -func deployment(id, name, providerID, protocolID, adapter string, source NativeModelIDSource) DeploymentV1 { - return DeploymentV1{ID: id, Name: name, ProviderID: providerID, APIProtocolID: protocolID, AdapterConstructor: adapter, NativeModelIDSource: source} +func deployment(id, name, providerID, protocolID, adapter string, source NativeModelIDSource) Deployment { + return Deployment{ID: id, Name: name, ProviderID: providerID, APIProtocolID: protocolID, AdapterConstructor: adapter, NativeModelIDSource: source} } -func azureDeployment() DeploymentV1 { +func azureDeployment() Deployment { d := deployment("openai-azure", "Azure OpenAI", "openai", "openai-chat-completions", "openai-azure", NativeModelIDUserConfigured) d.ModelMappingsRequired = true return d } -func localDeployment() DeploymentV1 { +func localDeployment() Deployment { d := deployment("ollama-local", "Ollama local", "ollama", "openai-chat-completions", "ollama", NativeModelIDDiscovered) d.Local = true return d } -func defaultOfferingTemplatesV1(generatedAt time.Time) []ModelOfferingTemplateV1 { - var out []ModelOfferingTemplateV1 - for _, model := range testOpenAIModels { - canonical := canonicalModelID("openai", model.ID) - out = append(out, ModelOfferingTemplateV1{ - ID: "openai-azure:" + canonical, - CanonicalModelID: canonical, - DeploymentID: "openai-azure", - NativeModelIDSource: NativeModelIDUserConfigured, - MappingRequired: true, - Capabilities: capabilitySetFromLegacy(model), - Pricing: pricingFromLegacy(model, generatedAt, "embedded"), - }) - } - return out -} - -func appendDerivedDeploymentOfferings(offerings []ModelOfferingV1) []ModelOfferingV1 { - seen := make(map[string]bool, len(offerings)) - for _, offering := range offerings { - seen[offering.ID] = true - } - addCopy := func(source ModelOfferingV1, deploymentID string) { - copied := source - copied.DeploymentID = deploymentID - copied.ID = deploymentID + ":" + source.NativeModelID - if !seen[copied.ID] { - seen[copied.ID] = true - offerings = append(offerings, copied) - } - } - for _, offering := range append([]ModelOfferingV1(nil), offerings...) { - switch offering.DeploymentID { - case "anthropic-direct": - addCopy(offering, "anthropic-bedrock") - addCopy(offering, "anthropic-vertex") - case "gemini-direct": - addCopy(offering, "gemini-vertex") - } - } - return offerings -} - -func legacyDeploymentAndOwner(provider string) (deploymentID, ownerProviderID string) { - switch provider { - case "anthropic": - return "anthropic-direct", "anthropic" - case "openai": - return "openai-direct", "openai" - case "azure": - return "openai-azure", "openai" - case "grok": - return "grok-direct", "xai" - case "gemini": - return "gemini-direct", "google" - case "bedrock": - return "anthropic-bedrock", "anthropic" - case "vertex": - return "gemini-vertex", "google" - case "openrouter": - return "openrouter", "openrouter" - case "zai_payg": - return "zai_payg-direct", "zai_payg" - case "zai_coding": - return "zai_coding-direct", "zai_coding" - case "canopywave": - return "canopywave", "canopywave" - case "ollama": - return "ollama-local", "ollama" - case "opencodego": - return "opencodego", "opencodego" - case "kimi", "moonshotai": - return "kimi-direct", "kimi" - case "xiaomi_mimo", "xiaomi_mimo_payg": - return "xiaomi_mimo_payg-direct", "xiaomi_mimo_payg" - case "xiaomi_mimo_token_plan": - return "xiaomi_mimo_token_plan-direct", "xiaomi_mimo_token_plan" - case "deepseek": - return "deepseek-direct", "deepseek" - default: - return "", "" - } -} - -func canonicalModelID(ownerProviderID, nativeID string) string { - if strings.Contains(nativeID, "/") { - owner, _, _ := strings.Cut(nativeID, "/") - if owner != "" && ownerProviderID == canonicalProviderID(owner) { - return nativeID - } - } - if ownerProviderID == "zai_payg" && strings.HasPrefix(nativeID, "zai/") { - return "zai_payg/" + strings.TrimPrefix(nativeID, "zai/") - } - return ownerProviderID + "/" + nativeID -} - -// CanonicalProviderID normalizes legacy provider aliases (e.g. gemini -> google). +// CanonicalProviderID normalizes legacy provider aliases (e.g. gemini -> google, cline-pass -> clinepass). func CanonicalProviderID(providerID string) string { return canonicalProviderID(providerID) } @@ -183,9 +86,10 @@ func canonicalProviderID(providerID string) string { return "google" case "grok": return "xai" - // No legacy aliases — zai_payg and zai_coding are the only valid IDs. case "moonshotai": return "moonshotai" + case "cline-pass": + return "clinepass" case "xiaomi-mimo", "xiaomi_mimo", "xiaomi-mimo-payg": return "xiaomi_mimo_payg" case "xiaomi-mimo-token-plan": @@ -195,8 +99,82 @@ func canonicalProviderID(providerID string) string { } } -func capabilitySetFromLegacy(entry ModelCatalogEntry) CapabilitySetV1 { - set := CapabilitySetV1{ +func hasSlash(s string) bool { + _, _, ok := strings.Cut(s, "/") + return ok +} + +func splitOwner(s string) (owner, rest string, ok bool) { + owner, rest, ok = strings.Cut(s, "/") + return +} + +func hasInputPricing(raw json.RawMessage) bool { + var m map[string]interface{} + if err := json.Unmarshal(raw, &m); err != nil { + return false + } + _, ok := m["input_token_price_per_m"] + return ok +} + +// DefaultOfferingTemplates returns offering templates for Azure deployments (model mappings required). +func DefaultOfferingTemplates(generatedAt time.Time) []ModelOfferingTemplate { + var out []ModelOfferingTemplate + for _, model := range seedOpenAIModels { + modelID := "openai/" + model.ID + out = append(out, ModelOfferingTemplate{ + ID: "openai-azure:" + modelID, + CanonicalModelID: modelID, + DeploymentID: "openai-azure", + NativeModelIDSource: NativeModelIDUserConfigured, + MappingRequired: true, + Capabilities: capabilitySetFromLegacy(model), + Pricing: pricingFromLegacy(model, generatedAt), + }) + } + return out +} + +// SanitizePricing drops invalid rate dimensions (e.g. negative prices). +func SanitizePricing(c *Catalog) { + if c == nil { + return + } + for i := range c.Offerings { + c.Offerings[i].Pricing = sanitizePricing(c.Offerings[i].Pricing) + } + for i := range c.OfferingTemplates { + c.OfferingTemplates[i].Pricing = sanitizePricing(c.OfferingTemplates[i].Pricing) + } +} + +func sanitizePricing(p Pricing) Pricing { + if len(p.RatesPer1M) == 0 { + return p + } + clean := make(map[string]float64, len(p.RatesPer1M)) + for dim, rate := range p.RatesPer1M { + if dim == "" || rate < 0 { + continue + } + clean[dim] = rate + } + if len(clean) == 0 { + p.Status = PricingUnknown + p.RatesPer1M = nil + return p + } + p.RatesPer1M = clean + if p.Status == PricingKnown && (p.Currency == "" || len(p.RatesPer1M) == 0) { + p.Status = PricingUnknown + p.RatesPer1M = nil + } + return p +} + +func capabilitySetFromLegacy(entry ModelCatalogEntry) CapabilitySet { + set := CapabilitySet{ ServerTools: map[string]CapabilityState{}, MaxInputTokens: entry.ContextWindow, MaxOutputTokens: entry.MaxOutput, @@ -243,23 +221,17 @@ func capabilitySetFromLegacy(entry ModelCatalogEntry) CapabilitySetV1 { return set } -func pricingFromLegacy(entry ModelCatalogEntry, effectiveAt time.Time, source string) PricingV1 { +func pricingFromLegacy(entry ModelCatalogEntry, effectiveAt time.Time) Pricing { in := entry.InputPricePer1M out := entry.OutputPricePer1M if in < 0 || out < 0 { - return PricingV1{ - Status: PricingUnknown, - Currency: "USD", - EffectiveAt: effectiveAt, - Source: source, - } + return Pricing{Status: PricingUnknown, Currency: "USD", EffectiveAt: effectiveAt} } - pricing := PricingV1{ + pricing := Pricing{ Status: PricingKnown, Currency: "USD", EffectiveAt: effectiveAt, RatesPer1M: map[string]float64{"input_tokens": in, "output_tokens": out}, - Source: source, } if in == 0 && out == 0 { pricing.Status = PricingUnknown @@ -272,62 +244,6 @@ func pricingFromLegacy(entry ModelCatalogEntry, effectiveAt time.Time, source st return pricing } -// SanitizeCatalogV1Pricing drops invalid rate dimensions (e.g. negative OpenRouter prices). -func SanitizeCatalogV1Pricing(c *CatalogV1) { - if c == nil { - return - } - for i := range c.Offerings { - c.Offerings[i].Pricing = sanitizePricingV1(c.Offerings[i].Pricing) - } - for i := range c.OfferingTemplates { - c.OfferingTemplates[i].Pricing = sanitizePricingV1(c.OfferingTemplates[i].Pricing) - } -} - -func sanitizePricingV1(p PricingV1) PricingV1 { - if len(p.RatesPer1M) == 0 { - return p - } - clean := make(map[string]float64, len(p.RatesPer1M)) - for dim, rate := range p.RatesPer1M { - if dim == "" || rate < 0 { - continue - } - clean[dim] = rate - } - if len(clean) == 0 { - p.Status = PricingUnknown - p.RatesPer1M = nil - return p - } - p.RatesPer1M = clean - if p.Status == PricingKnown && (p.Currency == "" || len(p.RatesPer1M) == 0) { - p.Status = PricingUnknown - p.RatesPer1M = nil - } - return p -} - -func uniqueNonEmpty(values ...string) []string { - seen := map[string]bool{} - var out []string - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" || seen[value] { - continue - } - seen[value] = true - out = append(out, value) - } - return out -} - -func looksCanonicalModelID(value string) bool { - owner, model, ok := strings.Cut(value, "/") - return ok && owner != "" && model != "" && !strings.ContainsAny(value, " \t\r\n") -} - func validNativeModelIDSource(source NativeModelIDSource) bool { switch source { case NativeModelIDCatalogKnown, NativeModelIDDiscovered, NativeModelIDUserConfigured, NativeModelIDCatalogOrUser: @@ -337,7 +253,12 @@ func validNativeModelIDSource(source NativeModelIDSource) bool { } } -func validatePricing(problems *[]string, id string, pricing PricingV1) { +func looksCanonicalModelID(value string) bool { + owner, model, ok := strings.Cut(value, "/") + return ok && owner != "" && model != "" && !strings.ContainsAny(value, " \t\r\n") +} + +func validatePricing(problems *[]string, id string, pricing Pricing) { switch pricing.Status { case PricingKnown, PricingPartial: if pricing.Currency == "" || len(pricing.RatesPer1M) == 0 { @@ -361,7 +282,7 @@ func validatePricing(problems *[]string, id string, pricing PricingV1) { } } -func validateCapabilities(problems *[]string, id string, capabilities CapabilitySetV1) { +func validateCapabilities(problems *[]string, id string, capabilities CapabilitySet) { valid := func(state CapabilityState) bool { return state == "" || state == CapabilitySupported || state == CapabilityUnsupported || state == CapabilityUnknown } @@ -378,6 +299,21 @@ func validateCapabilities(problems *[]string, id string, capabilities Capability } } +// uniqueNonEmpty returns unique non-empty values from the input slice. +func uniqueNonEmpty(values ...string) []string { + seen := map[string]bool{} + var out []string + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + func cloneMap[T any](in map[string]T) map[string]T { out := make(map[string]T, len(in)) for key, value := range in { diff --git a/catalog/v1_test.go b/catalog/v1_test.go index 60883f7..167928c 100644 --- a/catalog/v1_test.go +++ b/catalog/v1_test.go @@ -11,12 +11,12 @@ import ( "time" ) -func TestCatalogV1FromLegacyCompiles(t *testing.T) { +func TestCatalogFromLegacyCompiles(t *testing.T) { t.Parallel() - c := testLegacyCatalogV1() - compiled, err := CompileCatalogV1(&c) + c := SeedCatalog() + compiled, err := CompileCatalog(&c) if err != nil { - t.Fatalf("CompileCatalogV1 failed: %v", err) + t.Fatalf("CompileCatalog failed: %v", err) } if compiled.ModelsByID["anthropic/claude-sonnet-4-6"].ID == "" { t.Fatal("expected canonical anthropic model") @@ -33,59 +33,28 @@ func TestCatalogV1FromLegacyCompiles(t *testing.T) { } } -func TestCatalogV1FromLegacyZAIDirectModels(t *testing.T) { +func TestValidateCatalogRejectsBadReferences(t *testing.T) { t.Parallel() - legacy := testLegacyModelCatalog() - legacy.Providers["zai_payg"] = []ModelCatalogEntry{{ID: "glm-5.1", DisplayName: "GLM-5.1"}} - c := CatalogV1FromLegacy(legacy) - compiled, err := CompileCatalogV1(&c) - if err != nil { - t.Fatalf("CompileCatalogV1 failed: %v", err) - } - if _, ok := compiled.OfferingForDeployment("zai_payg/glm-5.1", "zai_payg-direct"); !ok { - t.Fatal("expected zai_payg-direct offering on zai_payg/glm-5.1") - } -} - -func TestCatalogV1FromLegacyCanopyWaveNamespacedModels(t *testing.T) { - t.Parallel() - legacy := testLegacyModelCatalog() - legacy.Providers["canopywave"] = append( - legacy.Providers["canopywave"], - ModelCatalogEntry{ID: "moonshotai/kimi-k2.6", DisplayName: "Kimi K2.6"}, - ) - c := CatalogV1FromLegacy(legacy) - compiled, err := CompileCatalogV1(&c) - if err != nil { - t.Fatalf("CompileCatalogV1 failed: %v", err) - } - if _, ok := compiled.OfferingForDeployment("moonshotai/kimi-k2.6", "canopywave"); !ok { - t.Fatal("expected canopywave offering on moonshotai/kimi-k2.6") - } -} - -func TestValidateCatalogV1RejectsBadReferences(t *testing.T) { - t.Parallel() - c := testLegacyCatalogV1() - c.Offerings = append(c.Offerings, ModelOfferingV1{ + c := SeedCatalog() + c.Offerings = append(c.Offerings, ModelOffering{ ID: "missing:model", CanonicalModelID: "anthropic/claude-sonnet-4-6", DeploymentID: "missing", NativeModelID: "model", - Pricing: PricingV1{Status: PricingUnknown}, + Pricing: Pricing{Status: PricingUnknown}, }) - if err := ValidateCatalogV1(&c); err == nil { + if err := ValidateCatalog(&c); err == nil { t.Fatal("expected validation failure") } } -func TestLoadCatalogV1UsesValidCacheBeforeRemote(t *testing.T) { +func TestLoadCatalogUsesValidCacheBeforeRemote(t *testing.T) { t.Parallel() dir := t.TempDir() cachePath := filepath.Join(dir, "catalog.json") - c := testLegacyCatalogV1() + c := SeedCatalog() c.SourceForTest("cache") - if err := WriteCatalogV1Cache(cachePath, &c); err != nil { + if err := WriteCatalogCache(cachePath, &c); err != nil { t.Fatalf("write cache: %v", err) } calls := 0 @@ -94,12 +63,12 @@ func TestLoadCatalogV1UsesValidCacheBeforeRemote(t *testing.T) { w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() - compiled, err := LoadCatalogV1(context.Background(), LoadCatalogV1Options{ + compiled, err := LoadCatalog(context.Background(), LoadCatalogOptions{ CachePath: cachePath, RemoteURL: srv.URL, }) if err != nil { - t.Fatalf("LoadCatalogV1 failed: %v", err) + t.Fatalf("LoadCatalog failed: %v", err) } if compiled.Catalog.Provenance == nil || compiled.Catalog.Provenance.Source != "cache" { t.Fatalf("expected cached catalog, got %#v", compiled.Catalog.Provenance) @@ -109,16 +78,16 @@ func TestLoadCatalogV1UsesValidCacheBeforeRemote(t *testing.T) { } } -func TestLoadCatalogV1RefreshRemoteOverridesValidCache(t *testing.T) { +func TestLoadCatalogRefreshRemoteOverridesValidCache(t *testing.T) { t.Parallel() dir := t.TempDir() cachePath := filepath.Join(dir, "catalog.json") - cached := testLegacyCatalogV1() + cached := SeedCatalog() cached.SourceForTest("cache") - if err := WriteCatalogV1Cache(cachePath, &cached); err != nil { + if err := WriteCatalogCache(cachePath, &cached); err != nil { t.Fatalf("write cache: %v", err) } - remote := testLegacyCatalogV1() + remote := SeedCatalog() remote.SourceForTest("remote") data, err := json.Marshal(remote) if err != nil { @@ -131,13 +100,13 @@ func TestLoadCatalogV1RefreshRemoteOverridesValidCache(t *testing.T) { _, _ = w.Write(data) })) defer srv.Close() - compiled, err := LoadCatalogV1(context.Background(), LoadCatalogV1Options{ + compiled, err := LoadCatalog(context.Background(), LoadCatalogOptions{ CachePath: cachePath, RemoteURL: srv.URL, RefreshRemote: true, }) if err != nil { - t.Fatalf("LoadCatalogV1 failed: %v", err) + t.Fatalf("LoadCatalog failed: %v", err) } if compiled.Catalog.Provenance == nil || compiled.Catalog.Provenance.Source != "remote" { t.Fatalf("expected remote catalog, got %#v", compiled.Catalog.Provenance) @@ -147,7 +116,7 @@ func TestLoadCatalogV1RefreshRemoteOverridesValidCache(t *testing.T) { } } -func TestLoadCatalogV1RejectsInvalidRemote(t *testing.T) { +func TestLoadCatalogRejectsInvalidRemote(t *testing.T) { t.Parallel() dir := t.TempDir() cachePath := filepath.Join(dir, "missing.json") @@ -155,7 +124,7 @@ func TestLoadCatalogV1RejectsInvalidRemote(t *testing.T) { _, _ = w.Write([]byte(`{"schema_version":"model-catalog/v1"}`)) })) defer srv.Close() - _, err := LoadCatalogV1(context.Background(), LoadCatalogV1Options{ + _, err := LoadCatalog(context.Background(), LoadCatalogOptions{ CachePath: cachePath, RemoteURL: srv.URL, RefreshRemote: true, @@ -168,9 +137,9 @@ func TestLoadCatalogV1RejectsInvalidRemote(t *testing.T) { } } -func TestFetchRemoteCatalogV1StrictValidation(t *testing.T) { +func TestFetchRemoteCatalogStrictValidation(t *testing.T) { t.Parallel() - c := testLegacyCatalogV1() + c := SeedCatalog() c.GeneratedAt = time.Now().UTC() c.StaleAfter = c.GeneratedAt.Add(time.Hour) data, err := json.Marshal(c) @@ -182,18 +151,18 @@ func TestFetchRemoteCatalogV1StrictValidation(t *testing.T) { _, _ = w.Write(data) })) defer srv.Close() - fetched, err := FetchRemoteCatalogV1(context.Background(), LoadCatalogV1Options{RemoteURL: srv.URL}) + fetched, err := FetchRemoteCatalog(context.Background(), LoadCatalogOptions{RemoteURL: srv.URL}) if err != nil { - t.Fatalf("FetchRemoteCatalogV1 failed: %v", err) + t.Fatalf("FetchRemoteCatalog failed: %v", err) } - if fetched.SchemaVersion != CatalogV1SchemaVersion { + if fetched.SchemaVersion != CatalogSchemaVersion { t.Fatalf("schema version = %q", fetched.SchemaVersion) } } -func (c *CatalogV1) SourceForTest(source string) { +func (c *Catalog) SourceForTest(source string) { if c.Provenance == nil { - c.Provenance = &CatalogProvenanceV1{} + c.Provenance = &Provenance{} } c.Provenance.Source = source } diff --git a/catalog/xiaomi/endpoints.go b/catalog/xiaomi/endpoints.go index 3c2dea3..57b7903 100644 --- a/catalog/xiaomi/endpoints.go +++ b/catalog/xiaomi/endpoints.go @@ -26,19 +26,19 @@ const ( const ( PayAsYouGoOpenAIBase = "https://api.xiaomimimo.com/v1" PayAsYouGoAnthropicBase = "https://api.xiaomimimo.com/anthropic" - TokenPlanCNOpenAIBase = "https://token-plan-cn.xiaomimimo.com/v1" - TokenPlanCNAnthropicBase = "https://token-plan-cn.xiaomimimo.com/anthropic" - TokenPlanSGPOpenAIBase = "https://token-plan-sgp.xiaomimimo.com/v1" - TokenPlanSGPAnthropicBase = "https://token-plan-sgp.xiaomimimo.com/anthropic" - TokenPlanAMSOpenAIBase = "https://token-plan-ams.xiaomimimo.com/v1" - TokenPlanAMSAnthropicBase = "https://token-plan-ams.xiaomimimo.com/anthropic" + TokenPlanCNOpenAIBase = "https://token-plan-cn.xiaomimimo.com/v1" // #nosec G101 -- public API base URL, not a secret value + TokenPlanCNAnthropicBase = "https://token-plan-cn.xiaomimimo.com/anthropic" // #nosec G101 -- public API base URL, not a secret value + TokenPlanSGPOpenAIBase = "https://token-plan-sgp.xiaomimimo.com/v1" // #nosec G101 -- public API base URL, not a secret value + TokenPlanSGPAnthropicBase = "https://token-plan-sgp.xiaomimimo.com/anthropic" // #nosec G101 -- public API base URL, not a secret value + TokenPlanAMSOpenAIBase = "https://token-plan-ams.xiaomimimo.com/v1" // #nosec G101 -- public API base URL, not a secret value + TokenPlanAMSAnthropicBase = "https://token-plan-ams.xiaomimimo.com/anthropic" // #nosec G101 -- public API base URL, not a secret value ) // ProviderPayAsYouGo is the registry / setup gateway id for pay-as-you-go. const ProviderPayAsYouGo = "xiaomi_mimo_payg" // ProviderTokenPlan is the registry / setup gateway id for Token Plan. -const ProviderTokenPlan = "xiaomi_mimo_token_plan" +const ProviderTokenPlan = "xiaomi_mimo_token_plan" // #nosec G101 -- provider id string, not a secret value // NormalizeRegion parses a region id (cn, sgp, ams). func NormalizeRegion(region string) (Region, error) { diff --git a/client/cassette.go b/client/cassette.go index 449c56a..2ca1d63 100644 --- a/client/cassette.go +++ b/client/cassette.go @@ -42,7 +42,7 @@ type RecordedResponse struct { // LoadCassette reads a cassette from a JSON file at path. func LoadCassette(path string) (*Cassette, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- path is an operator-supplied local cassette file, not untrusted input if err != nil { return nil, fmt.Errorf("cassette: failed to read %s: %w", path, err) } @@ -61,7 +61,7 @@ func SaveCassette(c *Cassette, path string) error { } dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o750); err != nil { return fmt.Errorf("cassette: failed to create directory %s: %w", dir, err) } diff --git a/client/client.go b/client/client.go index eb6b20e..b378d51 100644 --- a/client/client.go +++ b/client/client.go @@ -11,14 +11,12 @@ import ( "github.com/GrayCodeAI/eyrie/catalog" ) -// Version is exported here for backwards compatibility. Callers should prefer -// the canonical eyrie.Version (which is sourced from the repo-root VERSION -// file). This variable is initialised by the root package via SetVersion to -// avoid a circular import. +// Version is set by the root eyrie package's init() from the VERSION file. +// Default is "dev" until the root package initialises. var Version = "dev" // SetVersion is called by the root eyrie package's init to wire the canonical -// version into this sub-package without creating an import cycle. +// version from the VERSION file into this sub-package. func SetVersion(v string) { Version = v } // userAgent returns the User-Agent string for HTTP requests. @@ -254,7 +252,7 @@ func ParseCustomHeaders() map[string]string { } var ( - cachedCatalog *catalog.CompiledCatalogV1 + cachedCatalog *catalog.CompiledCatalog catalogLoadOnce sync.Once ) @@ -337,7 +335,7 @@ func NewAudioMessageWithText(text, base64Data, format string) EyrieMessage { // ResolveDefaultModel resolves the default model for a provider from the catalog. func ResolveDefaultModel(provider string) string { catalogLoadOnce.Do(func() { - cat, err := catalog.LoadCatalogV1(context.Background(), catalog.LoadCatalogV1Options{}) + cat, err := catalog.LoadCatalog(context.Background(), catalog.LoadCatalogOptions{}) if err == nil { cachedCatalog = cat } diff --git a/client/compat.go b/client/compat.go index fe84b1b..37f0dab 100644 --- a/client/compat.go +++ b/client/compat.go @@ -56,6 +56,15 @@ var ( ThinkingFormat: "openrouter", StripReasoningFromInput: true, } + PoolsideCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + } + GroqCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + } + ClinePassCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + } KimiCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsCacheRole: true, @@ -110,6 +119,18 @@ func init() { p.Compat = &CanopyWaveCompat OpenAICompatibleProviders["canopywave"] = p } + if p, ok := OpenAICompatibleProviders["poolside"]; ok { + p.Compat = &PoolsideCompat + OpenAICompatibleProviders["poolside"] = p + } + if p, ok := OpenAICompatibleProviders["groq"]; ok { + p.Compat = &GroqCompat + OpenAICompatibleProviders["groq"] = p + } + if p, ok := OpenAICompatibleProviders["clinepass"]; ok { + p.Compat = &ClinePassCompat + OpenAICompatibleProviders["clinepass"] = p + } if p, ok := OpenAICompatibleProviders["ollama"]; ok { p.Compat = &OllamaCompat OpenAICompatibleProviders["ollama"] = p diff --git a/client/compat_test.go b/client/compat_test.go index 4dc6317..bb7f75d 100644 --- a/client/compat_test.go +++ b/client/compat_test.go @@ -221,8 +221,12 @@ func TestCompatDeprecationCheckerNilSafety(t *testing.T) { // 3. Feature availability per provider // --------------------------------------------------------------------------- +// The tests in this section read or write the package-level cachedCatalog +// (directly, or transitively via Get/Supports) and run sequentially (no +// t.Parallel()): mutating shared state via save/restore races under the +// parallel test runner. See also client/features_test.go. + func TestCompatFeatureMatrixAllProviders(t *testing.T) { - t.Parallel() // With no catalog loaded, all providers return zero-value FeatureSet orig := cachedCatalog defer func() { cachedCatalog = orig }() @@ -245,7 +249,6 @@ func TestCompatFeatureMatrixAllProviders(t *testing.T) { } func TestCompatSupportsAllFeatureAliases(t *testing.T) { - t.Parallel() pf := NewProviderFeatures() // Verify that feature name aliases resolve identically. @@ -268,7 +271,6 @@ func TestCompatSupportsAllFeatureAliases(t *testing.T) { } func TestCompatSupportsUnknownFeatureReturnsFalse(t *testing.T) { - t.Parallel() pf := NewProviderFeatures() if pf.Supports("anthropic", "nonexistent_feature_xyz") { t.Error("unknown feature should return false") @@ -276,7 +278,6 @@ func TestCompatSupportsUnknownFeatureReturnsFalse(t *testing.T) { } func TestCompatSupportsCaseInsensitiveFeatureNames(t *testing.T) { - t.Parallel() pf := NewProviderFeatures() // "Thinking" vs "thinking" should resolve the same. if pf.Supports("anthropic", "Thinking") != pf.Supports("anthropic", "thinking") { diff --git a/client/features.go b/client/features.go index 6330477..e6423d8 100644 --- a/client/features.go +++ b/client/features.go @@ -90,7 +90,7 @@ func featureSetFromCatalog(modelOrProvider string) *FeatureSet { } // featureSetFromCapabilities converts a catalog CapabilitySetV1 to a client FeatureSet. -func featureSetFromCapabilities(caps catalog.CapabilitySetV1) *FeatureSet { +func featureSetFromCapabilities(caps catalog.CapabilitySet) *FeatureSet { return &FeatureSet{ Thinking: caps.ExplicitThinkingBudget == catalog.CapabilitySupported, AdaptiveThinking: caps.AdaptiveThinking == catalog.CapabilitySupported, diff --git a/client/features_test.go b/client/features_test.go index 25a2876..3b72694 100644 --- a/client/features_test.go +++ b/client/features_test.go @@ -6,8 +6,11 @@ import ( "github.com/GrayCodeAI/eyrie/catalog" ) +// Tests below that read or write the package-level cachedCatalog run +// sequentially (no t.Parallel()): they mutate shared state via save/restore, +// which races under the parallel test runner. + func TestFeatureDefaultProviders_NoCatalog(t *testing.T) { - t.Parallel() orig := cachedCatalog defer func() { cachedCatalog = orig }() cachedCatalog = nil @@ -31,7 +34,6 @@ func TestFeatureDefaultProviders_NoCatalog(t *testing.T) { } func TestFeatureSupportsFeatureChecks_NoCatalog(t *testing.T) { - t.Parallel() orig := cachedCatalog defer func() { cachedCatalog = orig }() cachedCatalog = nil @@ -60,7 +62,6 @@ func TestFeatureSupportsFeatureChecks_NoCatalog(t *testing.T) { } func TestFeatureUnknownProviderDefaults(t *testing.T) { - t.Parallel() orig := cachedCatalog defer func() { cachedCatalog = orig }() cachedCatalog = nil @@ -81,7 +82,6 @@ func TestFeatureUnknownProviderDefaults(t *testing.T) { } func TestFeatureCaseInsensitiveProvider(t *testing.T) { - t.Parallel() pf := NewProviderFeatures() // Provider lookup should be case-insensitive @@ -113,20 +113,19 @@ func TestFeatureDeprecationChecker(t *testing.T) { } func TestFeatureSetFromCatalog_OverridesHardcoded(t *testing.T) { - t.Parallel() // Save and restore the global cachedCatalog orig := cachedCatalog defer func() { cachedCatalog = orig }() // Inject a mock catalog with per-model capabilities - cachedCatalog = &catalog.CompiledCatalogV1{ - OfferingsByDeployment: map[string][]catalog.ModelOfferingV1{ + cachedCatalog = &catalog.CompiledCatalog{ + OfferingsByDeployment: map[string][]catalog.ModelOffering{ "anthropic-direct": { { CanonicalModelID: "anthropic/claude-haiku-4-5", NativeModelID: "claude-haiku-4-5-20251001", DeploymentID: "anthropic-direct", - Capabilities: catalog.CapabilitySetV1{ + Capabilities: catalog.CapabilitySet{ ExplicitThinkingBudget: catalog.CapabilitySupported, AdaptiveThinking: catalog.CapabilitySupported, FunctionCalling: catalog.CapabilitySupported, @@ -139,7 +138,7 @@ func TestFeatureSetFromCatalog_OverridesHardcoded(t *testing.T) { CanonicalModelID: "anthropic/claude-opus-4-8", NativeModelID: "claude-opus-4-8", DeploymentID: "anthropic-direct", - Capabilities: catalog.CapabilitySetV1{ + Capabilities: catalog.CapabilitySet{ ExplicitThinkingBudget: catalog.CapabilitySupported, AdaptiveThinking: catalog.CapabilitySupported, FunctionCalling: catalog.CapabilitySupported, @@ -153,13 +152,13 @@ func TestFeatureSetFromCatalog_OverridesHardcoded(t *testing.T) { }, }, }, - OfferingsByCanonicalModel: map[string][]catalog.ModelOfferingV1{ + OfferingsByCanonicalModel: map[string][]catalog.ModelOffering{ "anthropic/claude-haiku-4-5": { { CanonicalModelID: "anthropic/claude-haiku-4-5", NativeModelID: "claude-haiku-4-5-20251001", DeploymentID: "anthropic-direct", - Capabilities: catalog.CapabilitySetV1{ + Capabilities: catalog.CapabilitySet{ ExplicitThinkingBudget: catalog.CapabilitySupported, MaxInputTokens: 200000, MaxOutputTokens: 64000, @@ -200,7 +199,6 @@ func TestFeatureSetFromCatalog_OverridesHardcoded(t *testing.T) { } func TestFeatureSetFromCatalog_FallsBackWhenNil(t *testing.T) { - t.Parallel() orig := cachedCatalog defer func() { cachedCatalog = orig }() cachedCatalog = nil @@ -218,7 +216,7 @@ func TestFeatureSetFromCatalog_FallsBackWhenNil(t *testing.T) { func TestFeatureSetFromCapabilities(t *testing.T) { t.Parallel() - caps := catalog.CapabilitySetV1{ + caps := catalog.CapabilitySet{ ExplicitThinkingBudget: catalog.CapabilitySupported, AdaptiveThinking: catalog.CapabilitySupported, FunctionCalling: catalog.CapabilitySupported, diff --git a/client/image.go b/client/image.go index 820adb1..0af7847 100644 --- a/client/image.go +++ b/client/image.go @@ -67,7 +67,7 @@ func normalizeImageSource(src string) (mediaType, data string, isBase64 bool, er // Not a path with a known image extension → assume raw base64. return "", src, false, nil } - raw, readErr := os.ReadFile(src) + raw, readErr := os.ReadFile(src) // #nosec G304 -- src is a caller-supplied local image path, an intentional API input, not untrusted request data if readErr != nil { return "", "", false, fmt.Errorf("eyrie: reading image file %q: %w", src, readErr) } diff --git a/client/provider_registry.go b/client/provider_registry.go index 0e03ad2..6b7162c 100644 --- a/client/provider_registry.go +++ b/client/provider_registry.go @@ -59,6 +59,9 @@ var OpenAICompatibleProviders = map[string]ProviderRegistryConfig{ "zai_payg": {Name: "zai_payg", Type: ProviderTypeOpenAICompatible, BaseURL: "https://api.z.ai/api/paas/v4", EnvKey: "ZAI_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, "zai_coding": {Name: "zai_coding", Type: ProviderTypeOpenAICompatible, BaseURL: "https://api.z.ai/api/coding/paas/v4", EnvKey: "ZAI_CODING_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, "canopywave": {Name: "canopywave", Type: ProviderTypeOpenAICompatible, BaseURL: "https://inference.canopywave.io/v1", EnvKey: "CANOPYWAVE_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, + "poolside": {Name: "poolside", Type: ProviderTypeOpenAICompatible, BaseURL: config.DefaultPoolsideOpenAIBaseURL, EnvKey: "POOLSIDE_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, + "groq": {Name: "groq", Type: ProviderTypeOpenAICompatible, BaseURL: config.DefaultGroqOpenAIBaseURL, EnvKey: "GROQ_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, + "clinepass": {Name: "clinepass", Type: ProviderTypeOpenAICompatible, BaseURL: config.DefaultClinePassOpenAIBaseURL, EnvKey: "CLINE_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, "gemini": {Name: "gemini", Type: ProviderTypeOpenAICompatible, BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", EnvKey: "GEMINI_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, "ollama": {Name: "ollama", Type: ProviderTypeOpenAICompatible, BaseURL: "http://localhost:11434/v1", EnvKey: "OLLAMA_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: false}, "opencodego": {Name: "opencodego", Type: ProviderTypeOpenAICompatible, BaseURL: config.DefaultOpenCodeGoBaseURL, EnvKey: "OPENCODEGO_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, @@ -233,6 +236,8 @@ func DetectProvider() string { "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") }, diff --git a/client/weighted.go b/client/weighted.go index e67ff69..d6ddd92 100644 --- a/client/weighted.go +++ b/client/weighted.go @@ -89,7 +89,7 @@ func NewWeightedProvider(configs []WeightedProviderConfig) (*WeightedProvider, e return &WeightedProvider{ configs: normalized, - rng: randv2.New(randv2.NewPCG(s1, s2)), + rng: randv2.New(randv2.NewPCG(s1, s2)), // #nosec G404 -- non-cryptographic weighted provider selection, not a security decision stats: stats, }, nil } diff --git a/config/active_selection.go b/config/active_selection.go index 4cc3731..c8be9bc 100644 --- a/config/active_selection.go +++ b/config/active_selection.go @@ -67,6 +67,10 @@ func SetProviderModel(cfg *ProviderConfig, provider, model string) { cfg.OpenAIModel = model case ProviderCanopyWave: cfg.CanopyWaveModel = model + case ProviderPoolside: + cfg.PoolsideModel = model + case ProviderGroq: + cfg.GroqModel = model case ProviderZAIPayg, ProviderZAICoding: cfg.ZAIModel = model case ProviderOpenRouter: diff --git a/config/agent_routing.go b/config/agent_routing.go index b8c82a6..c435a78 100644 --- a/config/agent_routing.go +++ b/config/agent_routing.go @@ -45,14 +45,14 @@ func LoadAgentRouting() (*AgentRoutingConfig, error) { // SaveAgentRouting persists the agent routing config. func SaveAgentRouting(cfg *AgentRoutingConfig) error { dir := filepath.Dir(AgentRoutingPath()) - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o750); err != nil { return fmt.Errorf("create config directory: %w", err) } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return fmt.Errorf("marshal agent routing: %w", err) } - return os.WriteFile(AgentRoutingPath(), data, 0o644) + return os.WriteFile(AgentRoutingPath(), data, 0o600) } // DefaultAgentRouting returns the default routing config. diff --git a/config/category.go b/config/category.go index 188919b..3f79091 100644 --- a/config/category.go +++ b/config/category.go @@ -126,7 +126,7 @@ func (r *CategoryRegistry) loadOverrides() { configDir = filepath.Join(dir, "hawk") } path := filepath.Join(configDir, "categories.json") - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- path is built from os.UserConfigDir(), not untrusted input if err != nil { return // file not found is fine } diff --git a/config/config_package_test.go b/config/config_package_test.go index bbf920b..97e96cc 100644 --- a/config/config_package_test.go +++ b/config/config_package_test.go @@ -758,88 +758,7 @@ func TestParseRoute_TableDriven(t *testing.T) { } // --------------------------------------------------------------------------- -// 8. Migration (EnsureDeploymentConfigV2) -// --------------------------------------------------------------------------- - -func TestEnsureDeploymentConfigV2_NilConfig(t *testing.T) { - if got := EnsureDeploymentConfigV2(nil); got != nil { - t.Errorf("EnsureDeploymentConfigV2(nil) = %v, want nil", got) - } -} - -func TestEnsureDeploymentConfigV2_AlreadyV2(t *testing.T) { - cfg := &ProviderConfig{ - ConfigVersion: 2, - Deployments: map[string]DeploymentConfig{"anthropic-direct": {APIKey: "key"}}, - } - out := EnsureDeploymentConfigV2(cfg) - if out.ConfigVersion != 2 { - t.Errorf("ConfigVersion = %d, want 2", out.ConfigVersion) - } -} - -func TestEnsureDeploymentConfigV2_UpgradesLegacy(t *testing.T) { - tests := []struct { - name string - cfg *ProviderConfig - wantDeploymentKey string - }{ - { - name: "anthropic legacy", - cfg: &ProviderConfig{AnthropicAPIKey: "sk-ant-test-1234567890"}, - wantDeploymentKey: "anthropic-direct", - }, - { - name: "openai legacy", - cfg: &ProviderConfig{OpenAIAPIKey: "sk-openai-test-1234567890"}, - wantDeploymentKey: "openai-direct", - }, - { - name: "gemini legacy", - cfg: &ProviderConfig{GeminiAPIKey: "gemini-test-key-1234567890"}, - wantDeploymentKey: "gemini-direct", - }, - { - name: "openrouter legacy", - cfg: &ProviderConfig{OpenRouterAPIKey: "or-test-key-1234567890"}, - wantDeploymentKey: "openrouter", - }, - { - name: "ollama legacy", - cfg: &ProviderConfig{OllamaBaseURL: "http://localhost:11434"}, - wantDeploymentKey: "ollama-local", - }, - { - name: "opencodego legacy", - cfg: &ProviderConfig{OpenCodeGoAPIKey: "ocg-test-key-1234567890"}, - wantDeploymentKey: "opencodego", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - out := EnsureDeploymentConfigV2(tt.cfg) - if out.ConfigVersion != 2 { - t.Errorf("ConfigVersion = %d, want 2", out.ConfigVersion) - } - if _, ok := out.Deployments[tt.wantDeploymentKey]; !ok { - t.Errorf("missing deployment %q, got %v", tt.wantDeploymentKey, out.Deployments) - } - }) - } -} - -func TestEnsureDeploymentConfigV2_NoConfiguredProviders(t *testing.T) { - cfg := &ProviderConfig{} // no keys or URLs set - out := EnsureDeploymentConfigV2(cfg) - // Should not upgrade if nothing is configured - if out.ConfigVersion == 2 { - t.Error("should not upgrade to v2 with no configured providers") - } -} - -// --------------------------------------------------------------------------- -// 9. Provider env application +// 8. Provider env application // --------------------------------------------------------------------------- func TestApplyProviderEnv_AllProviders(t *testing.T) { diff --git a/config/config_test.go b/config/config_test.go index 306c4ef..ae79237 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -87,8 +87,8 @@ func TestNormalizeOllamaOpenAIBaseURL(t *testing.T) { func TestProviderDetectionOrder(t *testing.T) { t.Parallel() - if len(APIProviderDetectionOrder) != 19 { - t.Errorf("expected 19 providers in detection order, got %d", len(APIProviderDetectionOrder)) + if len(APIProviderDetectionOrder) != 22 { + t.Errorf("expected 22 providers in detection order, got %d", len(APIProviderDetectionOrder)) } if APIProviderDetectionOrder[0] != ProviderAnthropic { t.Error("expected anthropic first in detection order") diff --git a/config/deployment_env_sync.go b/config/deployment_env_sync.go index d01170b..1006758 100644 --- a/config/deployment_env_sync.go +++ b/config/deployment_env_sync.go @@ -2,13 +2,12 @@ package config import ( "sort" - "strings" "github.com/GrayCodeAI/eyrie/catalog" ) // DeploymentConfigFromEnv builds deployment credentials from catalog env_fallbacks and env values. -func DeploymentConfigFromEnv(dep catalog.DeploymentV1, env map[string]string) DeploymentConfig { +func DeploymentConfigFromEnv(dep catalog.Deployment, env map[string]string) DeploymentConfig { var dc DeploymentConfig for _, fb := range dep.EnvFallbacks { val := firstEnvFromMap(env, fb.Env) @@ -39,10 +38,10 @@ func DeploymentConfigFromEnv(dep catalog.DeploymentV1, env map[string]string) De } // DeploymentConfigured reports whether env supplies enough credentials for this deployment. -func DeploymentConfigured(deploymentID string, dep catalog.DeploymentV1, dc DeploymentConfig) bool { +func DeploymentConfigured(deploymentID string, dep catalog.Deployment, dc DeploymentConfig) bool { switch deploymentID { case "ollama-local": - return strings.TrimSpace(dc.BaseURL) != "" + return dc.BaseURL != "" default: return deploymentHasLiveCredentials(deploymentID, dc) } @@ -51,19 +50,19 @@ func DeploymentConfigured(deploymentID string, dep catalog.DeploymentV1, dc Depl func deploymentHasLiveCredentials(deploymentID string, dc DeploymentConfig) bool { switch deploymentID { case "anthropic-bedrock": - return strings.TrimSpace(dc.AccessKeyID) != "" && strings.TrimSpace(dc.SecretAccessKey) != "" + return dc.AccessKeyID != "" && dc.SecretAccessKey != "" case "anthropic-vertex", "gemini-vertex": - return strings.TrimSpace(dc.ProjectID) != "" && strings.TrimSpace(dc.Region) != "" && - (strings.TrimSpace(dc.Token) != "" || strings.TrimSpace(dc.APIKey) != "") + return dc.ProjectID != "" && dc.Region != "" && + (dc.Token != "" || dc.APIKey != "") default: - return strings.TrimSpace(dc.APIKey) != "" || strings.TrimSpace(dc.Token) != "" || - strings.TrimSpace(dc.AccessKeyID) != "" + return dc.APIKey != "" || dc.Token != "" || + dc.AccessKeyID != "" } } func firstEnvFromMap(env map[string]string, keys []string) string { for _, k := range keys { - if v := strings.TrimSpace(env[k]); v != "" { + if v := env[k]; v != "" { return v } } @@ -71,7 +70,7 @@ func firstEnvFromMap(env map[string]string, keys []string) string { } // SyncProviderConfigFromCatalog merges catalog + env into provider.json deployments and routing. -func SyncProviderConfigFromCatalog(compiled *catalog.CompiledCatalogV1, env map[string]string) *ProviderConfig { +func SyncProviderConfigFromCatalog(compiled *catalog.CompiledCatalog, env map[string]string) *ProviderConfig { cfg := LoadProviderConfig("") if cfg == nil { cfg = &ProviderConfig{} diff --git a/config/deployment_env_sync_test.go b/config/deployment_env_sync_test.go index 8689936..477ff6b 100644 --- a/config/deployment_env_sync_test.go +++ b/config/deployment_env_sync_test.go @@ -57,8 +57,8 @@ func TestBuildRoutingPolicyFromDeployments_CanopyWave(t *testing.T) { func TestSyncProviderConfigFromCatalog(t *testing.T) { t.Parallel() - bootstrap := catalog.BootstrapCatalogV1() - compiled, err := catalog.CompileCatalogV1(&bootstrap) + bootstrap := catalog.BootstrapCatalog() + compiled, err := catalog.CompileCatalog(&bootstrap) if err != nil { t.Fatal(err) } diff --git a/config/migrate.go b/config/migrate.go deleted file mode 100644 index 7edbb0c..0000000 --- a/config/migrate.go +++ /dev/null @@ -1,190 +0,0 @@ -package config - -import "strings" - -// EnsureDeploymentConfigV2 upgrades legacy flat provider.json to deployment-aware v2. -func EnsureDeploymentConfigV2(cfg *ProviderConfig) *ProviderConfig { - if cfg == nil { - return nil - } - MigrateLegacyXiaomiProvider(cfg) - if cfg.ConfigVersion >= 2 || len(cfg.Deployments) > 0 || cfg.Routing != nil { - if cfg.ConfigVersion < 2 && (len(cfg.Deployments) > 0 || cfg.Routing != nil) { - cfg.ConfigVersion = 2 - } - return cfg - } - deployments := map[string]DeploymentConfig{} - legacy := []struct { - provider string - id string - }{ - {ProviderAnthropic, "anthropic-direct"}, - {ProviderOpenAI, "openai-direct"}, - {ProviderGrok, "grok-direct"}, - {ProviderGemini, "gemini-direct"}, - {ProviderOpenRouter, "openrouter"}, - {ProviderZAIPayg, "zai_payg-direct"}, - {ProviderZAICoding, "zai_coding-direct"}, - {ProviderCanopyWave, "canopywave"}, - {ProviderOllama, "ollama-local"}, - {ProviderOpenCodeGo, "opencodego"}, - {ProviderKimi, "kimi-direct"}, - {ProviderXiaomiMimoPayg, "xiaomi_mimo_payg-direct"}, - {ProviderXiaomiMimoTokenPlan, "xiaomi_mimo_token_plan-direct"}, - } - for _, item := range legacy { - dep := legacyDeploymentConfig(cfg, item.provider) - if legacyDeploymentConfigured(dep, item.provider) { - deployments[item.id] = dep - } - } - if len(deployments) == 0 { - return cfg - } - cfg.Deployments = deployments - cfg.ConfigVersion = 2 - if cfg.Routing == nil { - cfg.Routing = defaultRoutingPolicyV2(deployments) - } - return cfg -} - -func legacyDeploymentConfig(cfg *ProviderConfig, provider string) DeploymentConfig { - if cfg == nil { - return DeploymentConfig{} - } - switch provider { - case ProviderAnthropic: - return DeploymentConfig{APIKey: cfg.AnthropicAPIKey, BaseURL: cfg.AnthropicBaseURL} - case ProviderOpenAI: - return DeploymentConfig{APIKey: cfg.OpenAIAPIKey, BaseURL: cfg.OpenAIBaseURL} - case ProviderGrok: - return DeploymentConfig{APIKey: legacyFirstNonEmpty(cfg.GrokAPIKey, cfg.XAIAPIKey), BaseURL: legacyFirstNonEmpty(cfg.GrokBaseURL, cfg.XAIBaseURL)} - case ProviderGemini: - return DeploymentConfig{APIKey: cfg.GeminiAPIKey, BaseURL: cfg.GeminiBaseURL} - case ProviderOpenRouter: - return DeploymentConfig{APIKey: cfg.OpenRouterAPIKey, BaseURL: cfg.OpenRouterBaseURL} - case ProviderCanopyWave: - return DeploymentConfig{APIKey: cfg.CanopyWaveAPIKey, BaseURL: cfg.CanopyWaveBaseURL} - case ProviderZAIPayg: - return DeploymentConfig{APIKey: cfg.ZAIAPIKey, BaseURL: cfg.ZAIBaseURL} - case ProviderZAICoding: - return DeploymentConfig{APIKey: cfg.ZAICodingAPIKey, BaseURL: cfg.ZAICodingBaseURL} - case ProviderOllama: - return DeploymentConfig{BaseURL: cfg.OllamaBaseURL} - case ProviderOpenCodeGo: - return DeploymentConfig{APIKey: cfg.OpenCodeGoAPIKey, BaseURL: cfg.OpenCodeGoBaseURL} - case ProviderKimi: - return DeploymentConfig{APIKey: cfg.MoonshotAPIKey, BaseURL: cfg.MoonshotBaseURL} - case ProviderXiaomiMimoPayg: - return DeploymentConfig{ - APIKey: cfg.XiaomiMimoPaygAPIKey, - BaseURL: cfg.XiaomiMimoPaygBaseURL, - } - case ProviderXiaomiMimoTokenPlan: - base, _ := ResolveXiaomiOpenAIBase(ProviderXiaomiMimoTokenPlan, cfg) - return DeploymentConfig{APIKey: cfg.XiaomiMimoTokenPlanAPIKey, BaseURL: base} - default: - return DeploymentConfig{} - } -} - -func legacyDeploymentConfigured(dep DeploymentConfig, provider string) bool { - switch provider { - case ProviderOllama: - return strings.TrimSpace(dep.BaseURL) != "" - default: - return strings.TrimSpace(dep.APIKey) != "" || - strings.TrimSpace(dep.Token) != "" || - strings.TrimSpace(dep.AccessKeyID) != "" - } -} - -func defaultRoutingPolicyV2(deployments map[string]DeploymentConfig) *RoutingPolicy { - byProvider := map[string][]RoutingStage{} - for id := range deployments { - switch id { - case "anthropic-direct": - byProvider["anthropic"] = anthropicRoutingStagesV2(deployments) - case "openai-direct": - byProvider["openai"] = []RoutingStage{{ - Deployments: []DeploymentChoice{{DeploymentID: "openai-direct", Weight: 100}}, - Retries: 1, - }} - default: - provider := deploymentOwnerProviderID(id) - if provider == "" { - continue - } - byProvider[provider] = []RoutingStage{{ - Deployments: []DeploymentChoice{{DeploymentID: id, Weight: 100}}, - Retries: 1, - }} - } - } - return &RoutingPolicy{Providers: byProvider} -} - -func anthropicRoutingStagesV2(deployments map[string]DeploymentConfig) []RoutingStage { - var primary []DeploymentChoice - if _, ok := deployments["anthropic-direct"]; ok { - primary = append(primary, DeploymentChoice{DeploymentID: "anthropic-direct", Weight: 100}) - } - if len(primary) == 0 { - return nil - } - stages := []RoutingStage{{Deployments: primary, Retries: 1}} - var fallback []DeploymentChoice - for _, id := range []string{"anthropic-vertex", "anthropic-bedrock"} { - if _, ok := deployments[id]; ok { - fallback = append(fallback, DeploymentChoice{DeploymentID: id, Weight: 100}) - } - } - if len(fallback) > 0 { - stages = append(stages, RoutingStage{Deployments: fallback, Retries: 1}) - } - return stages -} - -func deploymentOwnerProviderID(deploymentID string) string { - switch deploymentID { - case "anthropic-direct", "anthropic-bedrock", "anthropic-vertex": - return "anthropic" - case "openai-direct", "openai-azure": - return "openai" - case "gemini-direct", "gemini-vertex": - return "google" - case "grok-direct": - return "xai" - case "openrouter": - return "openrouter" - case "canopywave": - return "canopywave" - case "zai_payg-direct": - return "zai_payg" - case "zai_coding-direct": - return "zai_coding" - case "ollama-local": - return "ollama" - case "opencodego": - return "opencodego" - case "kimi-direct": - return "kimi" - case "xiaomi_mimo_payg-direct", "xiaomi_mimo-direct": - return "xiaomi_mimo_payg" - case "xiaomi_mimo_token_plan-direct": - return "xiaomi_mimo_token_plan" - default: - return "" - } -} - -func legacyFirstNonEmpty(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} diff --git a/config/migrate_test.go b/config/migrate_test.go deleted file mode 100644 index 734e6b1..0000000 --- a/config/migrate_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package config - -import "testing" - -func TestEnsureDeploymentConfigV2FromLegacyAnthropic(t *testing.T) { - t.Parallel() - cfg := &ProviderConfig{AnthropicAPIKey: "sk-test-1234567890"} - out := EnsureDeploymentConfigV2(cfg) - if out.ConfigVersion != 2 { - t.Fatalf("config_version = %d, want 2", out.ConfigVersion) - } - if _, ok := out.Deployments["anthropic-direct"]; !ok { - t.Fatal("expected anthropic-direct deployment") - } -} diff --git a/config/profiles.go b/config/profiles.go index 4b726ca..b79cbcd 100644 --- a/config/profiles.go +++ b/config/profiles.go @@ -22,9 +22,12 @@ const ( ProviderOpenCodeGo APIProvider = "opencodego" ProviderKimi APIProvider = "kimi" ProviderXiaomiMimoPayg APIProvider = "xiaomi_mimo_payg" - ProviderXiaomiMimoTokenPlan APIProvider = "xiaomi_mimo_token_plan" + ProviderXiaomiMimoTokenPlan APIProvider = "xiaomi_mimo_token_plan" // #nosec G101 -- provider id string, not a secret value ProviderMiniMaxTokenPlan APIProvider = "minimax_token_plan" ProviderMiniMaxPayg APIProvider = "minimax_payg" + ProviderPoolside APIProvider = "poolside" + ProviderGroq APIProvider = "groq" + ProviderClinePass APIProvider = "clinepass" ) // RuntimeProviderProfile defines how a provider is detected and configured at runtime. @@ -179,12 +182,33 @@ var ( BaseURLEnv: []string{"OLLAMA_BASE_URL"}, APIKeys: []APIKeyDef{{Env: "OLLAMA_API_KEY", Source: "ollama"}}, } + PoolsideRuntimeProfile = RuntimeProviderProfile{ + Mode: "openai", DefaultBaseURL: DefaultPoolsideOpenAIBaseURL, + DetectionEnv: []string{"POOLSIDE_API_KEY"}, + ModelEnv: []string{"POOLSIDE_MODEL", "OPENAI_MODEL"}, + BaseURLEnv: []string{"POOLSIDE_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, + APIKeys: []APIKeyDef{{Env: "POOLSIDE_API_KEY", Source: "poolside"}}, + } + GroqRuntimeProfile = RuntimeProviderProfile{ + Mode: "openai", DefaultBaseURL: "https://api.groq.com/openai/v1", + DetectionEnv: []string{"GROQ_API_KEY"}, + ModelEnv: []string{"GROQ_MODEL", "OPENAI_MODEL"}, + BaseURLEnv: []string{"GROQ_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, + APIKeys: []APIKeyDef{{Env: "GROQ_API_KEY", Source: "groq"}}, + } + ClinePassRuntimeProfile = RuntimeProviderProfile{ + Mode: "openai", DefaultBaseURL: DefaultClinePassOpenAIBaseURL, + DetectionEnv: []string{"CLINE_API_KEY"}, + ModelEnv: []string{"CLINE_MODEL", "OPENAI_MODEL"}, + BaseURLEnv: []string{"CLINE_API_BASE", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, + APIKeys: []APIKeyDef{{Env: "CLINE_API_KEY", Source: "clinepass"}}, + } ) // APIProviderDetectionOrder is the priority order for provider detection. var APIProviderDetectionOrder = []APIProvider{ ProviderAnthropic, ProviderOpenRouter, ProviderGrok, ProviderGemini, - ProviderVertex, ProviderBedrock, ProviderZAICoding, ProviderZAIPayg, ProviderCanopyWave, ProviderDeepSeek, ProviderAzure, ProviderOpenAI, ProviderOpenCodeGo, + ProviderVertex, ProviderBedrock, ProviderZAICoding, ProviderZAIPayg, ProviderCanopyWave, ProviderDeepSeek, ProviderPoolside, ProviderGroq, ProviderClinePass, ProviderAzure, ProviderOpenAI, ProviderOpenCodeGo, ProviderKimi, ProviderXiaomiMimoPayg, ProviderXiaomiMimoTokenPlan, ProviderMiniMaxTokenPlan, ProviderMiniMaxPayg, ProviderOllama, } @@ -195,6 +219,9 @@ var ProviderModelEnvKeys = map[APIProvider][]string{ ProviderAzure: AzureRuntimeProfile.ModelEnv, ProviderCanopyWave: CanopyWaveRuntimeProfile.ModelEnv, ProviderDeepSeek: DeepSeekRuntimeProfile.ModelEnv, + ProviderPoolside: PoolsideRuntimeProfile.ModelEnv, + ProviderGroq: GroqRuntimeProfile.ModelEnv, + ProviderClinePass: ClinePassRuntimeProfile.ModelEnv, ProviderZAIPayg: ZAIPaygRuntimeProfile.ModelEnv, ProviderZAICoding: ZAICodingRuntimeProfile.ModelEnv, ProviderOpenRouter: OpenRouterRuntimeProfile.ModelEnv, @@ -218,7 +245,7 @@ const ( // OpenAICompatibleRuntimeProfileOrder is the detection order for runtime profiles. var OpenAICompatibleRuntimeProfileOrder = []string{ - "openrouter", "grok", "gemini", "anthropic", "zai_coding", "zai_payg", "canopywave", "deepseek", "openai", "opencodego", "kimi", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", "minimax_token_plan", "minimax_payg", + "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", } // OpenAICompatibleRuntimeProfiles maps profile key to its runtime profile. @@ -230,6 +257,9 @@ var OpenAICompatibleRuntimeProfiles = map[string]RuntimeProviderProfile{ "zai_coding": ZAICodingRuntimeProfile, "canopywave": CanopyWaveRuntimeProfile, "deepseek": DeepSeekRuntimeProfile, + "poolside": PoolsideRuntimeProfile, + "groq": GroqRuntimeProfile, + "clinepass": ClinePassRuntimeProfile, "openai": OpenAIRuntimeProfile, "openrouter": OpenRouterRuntimeProfile, "opencodego": OpenCodeGoRuntimeProfile, @@ -254,6 +284,9 @@ var RuntimeProviderProfiles = map[string]RuntimeProviderProfile{ "zai_coding": ZAICodingRuntimeProfile, "canopywave": CanopyWaveRuntimeProfile, "deepseek": DeepSeekRuntimeProfile, + "poolside": PoolsideRuntimeProfile, + "groq": GroqRuntimeProfile, + "clinepass": ClinePassRuntimeProfile, "opencodego": OpenCodeGoRuntimeProfile, "kimi": KimiRuntimeProfile, "xiaomi_mimo_payg": XiaomiPaygRuntimeProfile, diff --git a/config/provider_env.go b/config/provider_env.go index 0ec48dd..d31c587 100644 --- a/config/provider_env.go +++ b/config/provider_env.go @@ -53,6 +53,15 @@ type ProviderConfig struct { XiaomiMimoTokenPlanRegion string `json:"xiaomi_mimo_token_plan_region,omitempty"` MiniMaxTokenPlanBaseURL string `json:"minimax_token_plan_base_url,omitempty"` MiniMaxPaygBaseURL string `json:"minimax_payg_base_url,omitempty"` + PoolsideAPIKey string `json:"poolside_api_key,omitempty"` + PoolsideBaseURL string `json:"poolside_base_url,omitempty"` + PoolsideModel string `json:"poolside_model,omitempty"` + GroqAPIKey string `json:"groq_api_key,omitempty"` + GroqBaseURL string `json:"groq_base_url,omitempty"` + GroqModel string `json:"groq_model,omitempty"` + ClinePassAPIKey string `json:"clinepass_api_key,omitempty"` + ClinePassBaseURL string `json:"clinepass_base_url,omitempty"` + ClinePassModel string `json:"clinepass_model,omitempty"` MiniMaxModel string `json:"minimax_model,omitempty"` AnthropicModel string `json:"anthropic_model,omitempty"` OpenAIModel string `json:"openai_model,omitempty"` @@ -165,6 +174,21 @@ var providerFields = map[string]providerFieldMap{ Models: func(c *ProviderConfig) []string { return []string{c.GrokModel, c.XAIModel} }, BaseURL: func(c *ProviderConfig) string { return firstNonEmpty(c.GrokBaseURL, c.XAIBaseURL) }, }, + ProviderPoolside: { + APIKeys: func(c *ProviderConfig) []string { return []string{c.PoolsideAPIKey} }, + Models: func(c *ProviderConfig) []string { return []string{c.PoolsideModel} }, + BaseURL: func(c *ProviderConfig) string { return c.PoolsideBaseURL }, + }, + ProviderGroq: { + APIKeys: func(c *ProviderConfig) []string { return []string{c.GroqAPIKey} }, + Models: func(c *ProviderConfig) []string { return []string{c.GroqModel} }, + BaseURL: func(c *ProviderConfig) string { return c.GroqBaseURL }, + }, + ProviderClinePass: { + APIKeys: func(c *ProviderConfig) []string { return []string{c.ClinePassAPIKey} }, + Models: func(c *ProviderConfig) []string { return []string{c.ClinePassModel} }, + BaseURL: func(c *ProviderConfig) string { return c.ClinePassBaseURL }, + }, ProviderGemini: { APIKeys: func(c *ProviderConfig) []string { return []string{c.GeminiAPIKey} }, Models: func(c *ProviderConfig) []string { return []string{c.GeminiModel} }, @@ -331,7 +355,7 @@ func LoadProviderConfig(path string) *ProviderConfig { if path == "" { path = GetProviderConfigPath() } - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- path defaults to GetProviderConfigPath() or is supplied by the local caller, not untrusted input if err != nil { return nil } @@ -349,7 +373,7 @@ func LoadProviderConfigWithError(path string) (*ProviderConfig, error) { if path == "" { path = GetProviderConfigPath() } - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- path defaults to GetProviderConfigPath() or is supplied by the local caller, not untrusted input if err != nil { if os.IsNotExist(err) { return nil, nil @@ -462,6 +486,9 @@ func ClearProviderRuntimeEnv() { "GEMINI_API_KEY", "GEMINI_MODEL", "GEMINI_BASE_URL", "OLLAMA_BASE_URL", "OPENCODEGO_API_KEY", "OPENCODEGO_MODEL", "OPENCODEGO_BASE_URL", + "GROQ_API_KEY", "GROQ_MODEL", "GROQ_BASE_URL", + "POOLSIDE_API_KEY", "POOLSIDE_MODEL", "POOLSIDE_BASE_URL", + "CLINE_API_KEY", "CLINE_MODEL", "CLINE_API_BASE", "MOONSHOT_API_KEY", "MOONSHOT_MODEL", "MOONSHOT_BASE_URL", "XIAOMI_MIMO_PAYG_API_KEY", "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "XIAOMI_MIMO_TOKEN_PLAN_REGION", "XIAOMI_MODEL", "XIAOMI_BASE_URL", @@ -569,6 +596,30 @@ func ApplyProviderEnv(provider string, config *ProviderConfig, activeModel strin } collectEnvValue(env, "XAI_API_KEY", apiKey, overwrite) collectOpenAICompatibleProvider(env, "XAI", apiKey, m, base, overwrite) + case ProviderPoolside: + apiKey := AsNonEmptyString(config.PoolsideAPIKey) + base := firstNonEmpty(config.PoolsideBaseURL, DefaultPoolsideOpenAIBaseURL) + m := activeModel + if m == "" { + m = catalog.GetProviderDefaultModel("poolside", cat) + } + collectOpenAICompatibleProvider(env, "POOLSIDE", apiKey, m, base, overwrite) + case ProviderGroq: + apiKey := AsNonEmptyString(config.GroqAPIKey) + base := firstNonEmpty(config.GroqBaseURL, DefaultGroqOpenAIBaseURL) + m := activeModel + if m == "" { + m = catalog.GetProviderDefaultModel("groq", cat) + } + collectOpenAICompatibleProvider(env, "GROQ", apiKey, m, base, overwrite) + case ProviderClinePass: + apiKey := AsNonEmptyString(config.ClinePassAPIKey) + base := firstNonEmpty(config.ClinePassBaseURL, DefaultClinePassOpenAIBaseURL) + m := activeModel + if m == "" { + m = catalog.GetProviderDefaultModel("clinepass", cat) + } + collectOpenAICompatibleProvider(env, "CLINE", apiKey, m, base, overwrite) case ProviderCanopyWave: apiKey := AsNonEmptyString(config.CanopyWaveAPIKey) base := firstNonEmpty(config.CanopyWaveBaseURL, DefaultCanopyWaveOpenAIBaseURL) diff --git a/config/providers.go b/config/providers.go index 3d68bf5..e86d609 100644 --- a/config/providers.go +++ b/config/providers.go @@ -33,6 +33,9 @@ const ( DefaultKimiOpenAIBaseURL = "https://api.moonshot.ai/v1" DefaultXiaomiOpenAIBaseURL = "https://api.xiaomimimo.com/v1" DefaultMiniMaxOpenAIBaseURL = "https://api.minimax.io/v1" + 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 DefaultMiniMaxAnthropicBaseURL = "https://api.minimax.io/anthropic" ) diff --git a/config/routing_build.go b/config/routing_build.go index 28635b9..a30a955 100644 --- a/config/routing_build.go +++ b/config/routing_build.go @@ -120,6 +120,45 @@ func openRouterFallbackStage() []RoutingStage { }} } +func deploymentOwnerProviderID(deploymentID string) string { + switch deploymentID { + case "anthropic-direct", "anthropic-bedrock", "anthropic-vertex": + return "anthropic" + case "openai-direct", "openai-azure": + return "openai" + case "gemini-direct", "gemini-vertex": + return "google" + case "grok-direct": + return "xai" + case "openrouter": + return "openrouter" + case "canopywave": + return "canopywave" + case "poolside": + return "poolside" + case "groq-direct": + return "groq" + case "clinepass": + return "clinepass" + case "zai_payg-direct": + return "zai_payg" + case "zai_coding-direct": + return "zai_coding" + case "ollama-local": + return "ollama" + case "opencodego": + return "opencodego" + case "kimi-direct": + return "kimi" + case "xiaomi_mimo_payg-direct", "xiaomi_mimo-direct": + return "xiaomi_mimo_payg" + case "xiaomi_mimo_token_plan-direct": + return "xiaomi_mimo_token_plan" + default: + return "" + } +} + func singleDeploymentStages(deploymentID string, retries int) []RoutingStage { if retries <= 0 { retries = 1 diff --git a/config/user_profiles.go b/config/user_profiles.go index 4467f3d..8acde9d 100644 --- a/config/user_profiles.go +++ b/config/user_profiles.go @@ -52,14 +52,14 @@ func LoadProfiles() ([]ProviderProfile, error) { // SaveProfiles persists provider profiles. func SaveProfiles(profiles []ProviderProfile) error { dir := filepath.Dir(ProfilesPath()) - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o750); err != nil { return fmt.Errorf("create profiles directory: %w", err) } data, err := json.MarshalIndent(profiles, "", " ") if err != nil { return fmt.Errorf("marshal profiles: %w", err) } - return os.WriteFile(ProfilesPath(), data, 0o644) + return os.WriteFile(ProfilesPath(), data, 0o600) } // FindProfile returns a profile by name (case-insensitive). diff --git a/config/xiaomi_profile.go b/config/xiaomi_profile.go index 223ffac..531b4a8 100644 --- a/config/xiaomi_profile.go +++ b/config/xiaomi_profile.go @@ -1,17 +1,15 @@ package config import ( - "strings" - "github.com/GrayCodeAI/eyrie/catalog/xiaomi" ) const ( - EnvXiaomiPaygAPIKey = "XIAOMI_MIMO_PAYG_API_KEY" - EnvXiaomiTokenPlanAPIKey = "XIAOMI_MIMO_TOKEN_PLAN_API_KEY" + EnvXiaomiPaygAPIKey = "XIAOMI_MIMO_PAYG_API_KEY" // #nosec G101 -- environment variable name string, not a secret value + EnvXiaomiTokenPlanAPIKey = "XIAOMI_MIMO_TOKEN_PLAN_API_KEY" // #nosec G101 -- environment variable name string, not a secret value EnvXiaomiPaygBaseURL = "XIAOMI_MIMO_PAYG_BASE_URL" - EnvXiaomiTokenPlanBaseURL = "XIAOMI_MIMO_TOKEN_PLAN_BASE_URL" - EnvXiaomiTokenPlanRegion = "XIAOMI_MIMO_TOKEN_PLAN_REGION" + EnvXiaomiTokenPlanBaseURL = "XIAOMI_MIMO_TOKEN_PLAN_BASE_URL" // #nosec G101 -- environment variable name string, not a secret value + EnvXiaomiTokenPlanRegion = "XIAOMI_MIMO_TOKEN_PLAN_REGION" // #nosec G101 -- environment variable name string, not a secret value ) // XiaomiTokenPlanRegionFromConfig reads persisted Token Plan cluster from provider.json. @@ -60,24 +58,3 @@ func IsXiaomiMimoProvider(providerID string) bool { _, ok := xiaomi.BillingForProvider(providerID) return ok } - -// MigrateLegacyXiaomiProvider rewrites deprecated xiaomi_mimo ids and env to payg. -func MigrateLegacyXiaomiProvider(cfg *ProviderConfig) { - if cfg == nil { - return - } - if strings.TrimSpace(cfg.ActiveProvider) == "xiaomi_mimo" { - cfg.ActiveProvider = xiaomi.ProviderPayAsYouGo - } - if base := strings.TrimSpace(cfg.XiaomiBaseURL); base != "" && strings.TrimSpace(cfg.XiaomiMimoPaygBaseURL) == "" { - cfg.XiaomiMimoPaygBaseURL = base - } - if cfg.Deployments != nil { - if dep, ok := cfg.Deployments["xiaomi_mimo-direct"]; ok { - if _, exists := cfg.Deployments["xiaomi_mimo_payg-direct"]; !exists { - cfg.Deployments["xiaomi_mimo_payg-direct"] = dep - } - delete(cfg.Deployments, "xiaomi_mimo-direct") - } - } -} diff --git a/credentials/migrate.go b/credentials/migrate.go index 2a705d1..ce05f94 100644 --- a/credentials/migrate.go +++ b/credentials/migrate.go @@ -130,7 +130,7 @@ func legacyHawkDotEnvPath() string { } func readLegacyEnvFile(path string) (map[string]string, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- path is built from os.UserHomeDir() in legacyHawkDotEnvPath, not untrusted input if err != nil { return nil, err } diff --git a/credentials/oidc.go b/credentials/oidc.go index ad6d569..0b0db90 100644 --- a/credentials/oidc.go +++ b/credentials/oidc.go @@ -36,7 +36,7 @@ const ( const ( defaultSTSAWSURL = "https://sts.amazonaws.com/" defaultSTSGCPURL = "https://sts.googleapis.com/v1/token" - defaultIAMCredentialsHost = "https://iamcredentials.googleapis.com" + defaultIAMCredentialsHost = "https://iamcredentials.googleapis.com" // #nosec G101 -- public STS endpoint URL, not a secret value awsAssumeRoleWebIdentityVer = "2011-06-15" ) diff --git a/errors/errors.go b/errors/errors.go index a524783..4577901 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -10,11 +10,11 @@ const ( APIErrorMessagePrefix = "API Error" PromptTooLongErrorMessage = "Prompt is too long" CreditBalanceTooLowErrorMessage = "Credit balance is too low" - InvalidAPIKeyErrorMessage = "Not logged in · Please run /login" - InvalidAPIKeyErrorMessageExternal = "Invalid API key · Please check your credentials" + InvalidAPIKeyErrorMessage = "Not logged in · Please run /login" // #nosec G101 -- static error message text, not a secret value + InvalidAPIKeyErrorMessageExternal = "Invalid API key · Please check your credentials" // #nosec G101 -- static error message text, not a secret value OrgDisabledErrorMessageEnvKeyWithOAuth = "Organization disabled" OrgDisabledErrorMessageEnvKey = "Organization disabled" - TokenRevokedErrorMessage = "Token has been revoked" + TokenRevokedErrorMessage = "Token has been revoked" // #nosec G101 -- static error message text, not a secret value CCRAuthErrorMessage = "Authentication error" Repeated529ErrorMessage = "Repeated 529 Overloaded errors" CustomOffSwitchMessage = "Service temporarily disabled" diff --git a/internal/observability/audit.go b/internal/observability/audit.go index 99c3103..95b878f 100644 --- a/internal/observability/audit.go +++ b/internal/observability/audit.go @@ -62,7 +62,7 @@ type JSONLFileSink struct { // writes and returns a sink that writes to it. The caller owns the sink and // should call Close when done. func NewJSONLFileSink(path string) (*JSONLFileSink, error) { - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) // #nosec G304 -- path is an operator-supplied audit log destination, not derived from untrusted request input if err != nil { return nil, err } diff --git a/internal/observability/genai_semconv.go b/internal/observability/genai_semconv.go index c00f0b4..03a87b8 100644 --- a/internal/observability/genai_semconv.go +++ b/internal/observability/genai_semconv.go @@ -32,11 +32,11 @@ const ( // AttrGenAIUsageInputTokens is the number of prompt/input tokens consumed. // OTel: gen_ai.usage.input_tokens. - AttrGenAIUsageInputTokens = "gen_ai.usage.input_tokens" + AttrGenAIUsageInputTokens = "gen_ai.usage.input_tokens" // #nosec G101 -- OTel semconv attribute key string, not a secret value // AttrGenAIUsageOutputTokens is the number of completion/output tokens // generated. OTel: gen_ai.usage.output_tokens. - AttrGenAIUsageOutputTokens = "gen_ai.usage.output_tokens" + AttrGenAIUsageOutputTokens = "gen_ai.usage.output_tokens" // #nosec G101 -- OTel semconv attribute key string, not a secret value // AttrGenAIOperationName names the GenAI operation (e.g. "chat", "embeddings", // "tool_use"). OTel: gen_ai.operation.name. diff --git a/internal/observability/observability.go b/internal/observability/observability.go index f6123da..dc464dd 100644 --- a/internal/observability/observability.go +++ b/internal/observability/observability.go @@ -34,8 +34,8 @@ const ( const ( AttrLLMProvider = "llm.provider" AttrLLMModel = "llm.model" - AttrLLMInputTokens = "llm.input_tokens" - AttrLLMOutputTokens = "llm.output_tokens" + AttrLLMInputTokens = "llm.input_tokens" // #nosec G101 -- OTel attribute key string, not a secret value + AttrLLMOutputTokens = "llm.output_tokens" // #nosec G101 -- OTel attribute key string, not a secret value AttrLLMCostUSD = "llm.cost_usd" AttrLLMLatencyMs = "llm.latency_ms" AttrLLMStatus = "llm.status" diff --git a/internal/sdk/go/client.go b/internal/sdk/go/client.go index cf74244..2d67211 100644 --- a/internal/sdk/go/client.go +++ b/internal/sdk/go/client.go @@ -110,7 +110,7 @@ func (c *Client) StreamPrompt(ctx context.Context, req PromptRequest) (<-chan St } if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - resp.Body.Close() + _ = resp.Body.Close() return nil, &APIError{StatusCode: resp.StatusCode, Path: "/prompt", Method: "POST", Body: string(body)} } go func() { @@ -171,7 +171,7 @@ func (c *Client) StreamPromptFrom(ctx context.Context, nodeID string, req Prompt } if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - resp.Body.Close() + _ = resp.Body.Close() return nil, &APIError{StatusCode: resp.StatusCode, Path: path, Method: "POST", Body: string(body)} } go func() { diff --git a/internal/version/VERSION b/internal/version/VERSION deleted file mode 100644 index 6e8bf73..0000000 --- a/internal/version/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.1.0 diff --git a/internal/version/eyrie.go b/internal/version/eyrie.go deleted file mode 100644 index 7a6e276..0000000 --- a/internal/version/eyrie.go +++ /dev/null @@ -1,39 +0,0 @@ -// Package eyrie is the core LLM client library for hawk. -// -// It provides API provider configurations, model resolution, API limits, -// base types (messages, IDs, connectors), and error types. -// -// Sub-packages: -// - types: Message types, content blocks, usage, IDs, connectors -// - errors: Error message constants and utilities -// - constants: API limits (image, PDF, media) -// - catalog: Model catalog, tiers, names, deprecation, provider data -// - config: Provider configuration, profiles, env, OpenAI-compatible runtime -// - client: EyrieClient, factory, provider detection -// - utils: Error utilities (SSL detection, API error sanitization) -package eyrie - -import ( - _ "embed" - "strings" - - "github.com/GrayCodeAI/eyrie/client" -) - -// versionFile is the canonical version, embedded at compile time from the -// VERSION file at the repo root. The VERSION file is the single source of -// truth used by release tooling, CI, and the runtime Version variable. -// -//go:embed VERSION -var versionFile string - -// Version of the eyrie library. Sourced from the VERSION file at the repo -// root — do not edit this variable directly. Bump VERSION instead, or let -// release-please/goreleaser do it. -var Version = strings.TrimSpace(versionFile) - -func init() { - // Propagate the canonical version into the client sub-package without - // creating an import cycle (client cannot import eyrie). - client.SetVersion(Version) -} diff --git a/internal/version/eyrie_test.go b/internal/version/eyrie_test.go deleted file mode 100644 index ae77797..0000000 --- a/internal/version/eyrie_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package eyrie - -import ( - "regexp" - "strings" - "testing" - - "github.com/GrayCodeAI/eyrie/client" -) - -func TestVersionNotEmpty(t *testing.T) { - t.Parallel() - if Version == "" { - t.Fatal("Version should not be empty; check that the VERSION file is embedded") - } -} - -func TestVersionMatchesSemver(t *testing.T) { - t.Parallel() - // Expect a semver-like pattern: major.minor.patch with optional pre-release/build metadata - re := regexp.MustCompile(`^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$`) - if !re.MatchString(Version) { - t.Errorf("Version %q does not match semver pattern", Version) - } -} - -func TestVersionIsTrimmed(t *testing.T) { - t.Parallel() - if strings.TrimSpace(Version) != Version { - t.Errorf("Version contains leading/trailing whitespace: %q", Version) - } -} - -func TestVersionFromEmbedFile(t *testing.T) { - t.Parallel() - // The versionFile variable is the raw embedded content; Version should be the trimmed form - raw := strings.TrimSpace(versionFile) - if raw != Version { - t.Errorf("Version (%q) should equal trimmed versionFile (%q)", Version, raw) - } -} - -func TestVersionFileNotEmpty(t *testing.T) { - t.Parallel() - if strings.TrimSpace(versionFile) == "" { - t.Fatal("Embedded versionFile is empty; the VERSION file may be missing") - } -} - -func TestVersionPropagatedToClient(t *testing.T) { - // Not parallel: reads client.Version which is modified by other tests. - // The init() in this package calls client.SetVersion(Version). - // After package init, client.Version should match. - if client.Version != Version { - t.Errorf("client.Version = %q, want %q", client.Version, Version) - } -} - -func TestClientSetVersionDirectly(t *testing.T) { - // Not parallel: mutates client.Version global. - original := client.Version - defer client.SetVersion(original) - - client.SetVersion("1.2.3-test") - if client.Version != "1.2.3-test" { - t.Errorf("client.Version = %q after SetVersion, want %q", client.Version, "1.2.3-test") - } -} - -func TestClientSetVersionEmpty(t *testing.T) { - // Not parallel: mutates client.Version global. - original := client.Version - defer client.SetVersion(original) - - client.SetVersion("") - if client.Version != "" { - t.Errorf("client.Version = %q after SetVersion(empty), want empty", client.Version) - } -} - -func TestVersionStartsWithV0(t *testing.T) { - t.Parallel() - // The initial version is 0.1.0; verify it starts with 0. - if !strings.HasPrefix(Version, "0.") { - t.Errorf("expected Version to start with '0.', got %q", Version) - } -} diff --git a/router/deployment_router.go b/router/deployment_router.go index b3c3149..a220b89 100644 --- a/router/deployment_router.go +++ b/router/deployment_router.go @@ -51,14 +51,14 @@ func DefaultCircuitBreakerConfig() CircuitBreakerConfig { } type DeploymentRouterOptions struct { - Catalog *catalog.CompiledCatalogV1 + Catalog *catalog.CompiledCatalog Deployments map[string]DeploymentAdapter Routing RoutingPolicy CircuitBreaker *CircuitBreakerConfig // optional, defaults applied if nil } type DeploymentRouter struct { - catalog *catalog.CompiledCatalogV1 + catalog *catalog.CompiledCatalog deployments map[string]DeploymentAdapter routing RoutingPolicy cbConfig CircuitBreakerConfig @@ -463,10 +463,10 @@ func (r *DeploymentRouter) streamWithDeployment(ctx context.Context, out chan<- return true, fmt.Errorf("deployment %q stream ended before output", deploymentID) } -func (r *DeploymentRouter) resolveOffering(target deploymentTarget, deploymentID string) (catalog.ModelOfferingV1, DeploymentAdapter, error) { +func (r *DeploymentRouter) resolveOffering(target deploymentTarget, deploymentID string) (catalog.ModelOffering, DeploymentAdapter, error) { adapter, ok := r.deployments[deploymentID] if !ok { - return catalog.ModelOfferingV1{}, DeploymentAdapter{}, fmt.Errorf("deployment %q is not configured", deploymentID) + return catalog.ModelOffering{}, DeploymentAdapter{}, fmt.Errorf("deployment %q is not configured", deploymentID) } if offering, ok := r.catalog.OfferingForDeployment(target.canonicalModelID, deploymentID); ok { if nativeID := adapter.ModelMappings[target.canonicalModelID]; nativeID != "" { @@ -481,27 +481,27 @@ func (r *DeploymentRouter) resolveOffering(target deploymentTarget, deploymentID } nativeID := adapter.ModelMappings[target.canonicalModelID] if nativeID == "" { - return catalog.ModelOfferingV1{}, DeploymentAdapter{}, fmt.Errorf("deployment %q requires model mapping for %q", deploymentID, target.canonicalModelID) + return catalog.ModelOffering{}, DeploymentAdapter{}, fmt.Errorf("deployment %q requires model mapping for %q", deploymentID, target.canonicalModelID) } return materializeTemplate(tmpl, nativeID), adapter, nil } if target.nativeHint != "" { deployment := r.catalog.DeploymentsByID[deploymentID] if deployment.ID != "" && deployment.NativeModelIDSource == catalog.NativeModelIDDiscovered { - return catalog.ModelOfferingV1{ + return catalog.ModelOffering{ ID: deploymentID + ":" + target.nativeHint, CanonicalModelID: target.canonicalModelID, DeploymentID: deploymentID, NativeModelID: nativeModelHintForDeployment(target.nativeHint, deployment), - Pricing: catalog.PricingV1{Status: catalog.PricingUnknown}, + Pricing: catalog.Pricing{Status: catalog.PricingUnknown}, }, adapter, nil } } - return catalog.ModelOfferingV1{}, DeploymentAdapter{}, fmt.Errorf("deployment %q cannot serve %q", deploymentID, target.canonicalModelID) + return catalog.ModelOffering{}, DeploymentAdapter{}, fmt.Errorf("deployment %q cannot serve %q", deploymentID, target.canonicalModelID) } -func materializeTemplate(tmpl catalog.ModelOfferingTemplateV1, nativeID string) catalog.ModelOfferingV1 { - return catalog.ModelOfferingV1{ +func materializeTemplate(tmpl catalog.ModelOfferingTemplate, nativeID string) catalog.ModelOffering { + return catalog.ModelOffering{ ID: tmpl.DeploymentID + ":" + nativeID, CanonicalModelID: tmpl.CanonicalModelID, DeploymentID: tmpl.DeploymentID, @@ -512,7 +512,7 @@ func materializeTemplate(tmpl catalog.ModelOfferingTemplateV1, nativeID string) } } -func optsForOffering(opts client.ChatOptions, offering catalog.ModelOfferingV1) client.ChatOptions { +func optsForOffering(opts client.ChatOptions, offering catalog.ModelOffering) client.ChatOptions { copied := opts copied.Model = offering.NativeModelID copied.Provider = offering.DeploymentID @@ -522,7 +522,7 @@ func optsForOffering(opts client.ChatOptions, offering catalog.ModelOfferingV1) return copied } -func filterTools(tools []client.EyrieTool, offering catalog.ModelOfferingV1) []client.EyrieTool { +func filterTools(tools []client.EyrieTool, offering catalog.ModelOffering) []client.EyrieTool { if len(offering.Capabilities.ServerTools) == 0 { return tools } @@ -551,7 +551,7 @@ func requestedServerTools(tools []client.EyrieTool) []string { return out } -func offeringSupportsTools(offering catalog.ModelOfferingV1, tools []string) bool { +func offeringSupportsTools(offering catalog.ModelOffering, tools []string) bool { for _, tool := range tools { if offering.Capabilities.ServerTools[tool] != catalog.CapabilitySupported { return false @@ -571,7 +571,7 @@ func selectDeploymentChoice(choices []DeploymentChoice) DeploymentChoice { if total <= 0 { return choices[0] } - n := rand.IntN(total) + n := rand.IntN(total) // #nosec G404 -- non-cryptographic weighted load-balancing choice, not a security decision for _, choice := range choices { n -= choice.Weight if n < 0 { @@ -590,7 +590,7 @@ func ownerProviderID(canonicalModelID string) string { return catalog.CanonicalProviderID(owner) } -func nativeModelHintForDeployment(model string, deployment catalog.DeploymentV1) string { +func nativeModelHintForDeployment(model string, deployment catalog.Deployment) string { if deployment.ID == "openrouter" { if native, ok := strings.CutPrefix(model, "openrouter/"); ok { return native diff --git a/router/deployment_router_test.go b/router/deployment_router_test.go index 8b8b542..9d89c20 100644 --- a/router/deployment_router_test.go +++ b/router/deployment_router_test.go @@ -47,7 +47,7 @@ func (m *deploymentMockProvider) StreamChat(_ context.Context, _ []client.EyrieM func (m *deploymentMockProvider) Ping(_ context.Context) error { return m.err } func (m *deploymentMockProvider) Name() string { return m.name } -func testCompiledCatalog(t *testing.T) *catalog.CompiledCatalogV1 { +func testCompiledCatalog(t *testing.T) *catalog.CompiledCatalog { t.Helper() compiled, err := catalog.CompileTestCatalog() if err != nil { @@ -129,27 +129,27 @@ func TestShouldTryNextDeploymentCredits(t *testing.T) { func TestDeploymentRouterFallsBackOnInsufficientCredits(t *testing.T) { t.Parallel() - c := catalog.TestSeedCatalogV1() - c.Providers["moonshotai"] = catalog.ProviderV1{ID: "moonshotai", Name: "Moonshot AI"} - c.Models["moonshotai/kimi-k2.6"] = catalog.ModelV1{ + c := catalog.SeedCatalog() + c.Providers["moonshotai"] = catalog.Provider{ID: "moonshotai", Name: "Moonshot AI"} + c.Models["moonshotai/kimi-k2.6"] = catalog.Model{ ID: "moonshotai/kimi-k2.6", ProviderID: "moonshotai", Name: "Kimi K2.6", } c.Offerings = append( c.Offerings, - catalog.ModelOfferingV1{ + catalog.ModelOffering{ ID: "openrouter:moonshotai/kimi-k2.6", CanonicalModelID: "moonshotai/kimi-k2.6", DeploymentID: "openrouter", NativeModelID: "moonshotai/kimi-k2.6", - Pricing: catalog.PricingV1{Status: catalog.PricingUnknown}, + Pricing: catalog.Pricing{Status: catalog.PricingUnknown}, }, - catalog.ModelOfferingV1{ + catalog.ModelOffering{ ID: "canopywave:moonshotai/kimi-k2.6", CanonicalModelID: "moonshotai/kimi-k2.6", DeploymentID: "canopywave", NativeModelID: "moonshotai/kimi-k2.6", - Pricing: catalog.PricingV1{Status: catalog.PricingUnknown}, + Pricing: catalog.Pricing{Status: catalog.PricingUnknown}, }, ) - compiled, err := catalog.CompileCatalogV1(&c) + compiled, err := catalog.CompileCatalog(&c) if err != nil { t.Fatal(err) } @@ -310,27 +310,27 @@ func TestDeploymentRouterStreamFallbackBeforeOutput(t *testing.T) { func TestDeploymentRouterNativeMimoUsesConfiguredXiaomiDeployment(t *testing.T) { t.Parallel() mimo := &deploymentMockProvider{name: "xiaomi"} - compiled := &catalog.CompiledCatalogV1{ - Catalog: &catalog.CatalogV1{ + compiled := &catalog.CompiledCatalog{ + Catalog: &catalog.Catalog{ Aliases: map[string]string{"mimo-v2.5-pro": "opencodego/mimo-v2.5-pro"}, }, - ModelsByID: map[string]catalog.ModelV1{ + ModelsByID: map[string]catalog.Model{ "opencodego/mimo-v2.5-pro": {ID: "opencodego/mimo-v2.5-pro", ProviderID: "opencodego"}, "xiaomi_mimo_token_plan/mimo-v2.5-pro": { ID: "xiaomi_mimo_token_plan/mimo-v2.5-pro", ProviderID: "xiaomi_mimo_token_plan", }, }, - DeploymentsByID: map[string]catalog.DeploymentV1{ + DeploymentsByID: map[string]catalog.Deployment{ "xiaomi_mimo_token_plan-direct": {ID: "xiaomi_mimo_token_plan-direct", ProviderID: "xiaomi_mimo_token_plan"}, }, - OfferingsByDeployment: map[string][]catalog.ModelOfferingV1{ + OfferingsByDeployment: map[string][]catalog.ModelOffering{ "xiaomi_mimo_token_plan-direct": {{ CanonicalModelID: "xiaomi_mimo_token_plan/mimo-v2.5-pro", DeploymentID: "xiaomi_mimo_token_plan-direct", NativeModelID: "mimo-v2.5-pro", }}, }, - OfferingsByCanonicalModel: map[string][]catalog.ModelOfferingV1{ + OfferingsByCanonicalModel: map[string][]catalog.ModelOffering{ "xiaomi_mimo_token_plan/mimo-v2.5-pro": {{ CanonicalModelID: "xiaomi_mimo_token_plan/mimo-v2.5-pro", DeploymentID: "xiaomi_mimo_token_plan-direct", diff --git a/router/preview.go b/router/preview.go index fd174fd..2aab51c 100644 --- a/router/preview.go +++ b/router/preview.go @@ -16,7 +16,7 @@ type RoutingResolution struct { } // ResolveRouting previews routing stages without calling provider APIs. -func ResolveRouting(requested string, compiled *catalog.CompiledCatalogV1, policy RoutingPolicy) RoutingResolution { +func ResolveRouting(requested string, compiled *catalog.CompiledCatalog, policy RoutingPolicy) RoutingResolution { res := RoutingResolution{ RequestedModel: requested, Stages: nil, @@ -38,7 +38,7 @@ func ResolveRouting(requested string, compiled *catalog.CompiledCatalogV1, polic } // RoutingPreviewJSON returns indented JSON for CLI / config UI. -func RoutingPreviewJSON(requested string, compiled *catalog.CompiledCatalogV1, policy RoutingPolicy) (string, error) { +func RoutingPreviewJSON(requested string, compiled *catalog.CompiledCatalog, policy RoutingPolicy) (string, error) { res := ResolveRouting(requested, compiled, policy) data, err := json.MarshalIndent(res, "", " ") if err != nil { @@ -47,7 +47,7 @@ func RoutingPreviewJSON(requested string, compiled *catalog.CompiledCatalogV1, p return string(data), nil } -func resolveCanonicalModelID(requested string, compiled *catalog.CompiledCatalogV1) string { +func resolveCanonicalModelID(requested string, compiled *catalog.CompiledCatalog) string { if compiled == nil { if strings.Contains(requested, "/") { return requested @@ -63,7 +63,7 @@ func resolveCanonicalModelID(requested string, compiled *catalog.CompiledCatalog return "" } -func resolveRoutingStages(canonicalModelID string, compiled *catalog.CompiledCatalogV1, policy RoutingPolicy) ([]RoutingStage, string) { +func resolveRoutingStages(canonicalModelID string, compiled *catalog.CompiledCatalog, policy RoutingPolicy) ([]RoutingStage, string) { if stages, ok := policy.Models[canonicalModelID]; ok && len(stages) > 0 { return cloneRoutingStages(stages), "models" } @@ -89,7 +89,7 @@ func resolveRoutingStages(canonicalModelID string, compiled *catalog.CompiledCat return automaticPreviewStages(canonicalModelID, compiled), "automatic" } -func automaticPreviewStages(canonicalModelID string, compiled *catalog.CompiledCatalogV1) []RoutingStage { +func automaticPreviewStages(canonicalModelID string, compiled *catalog.CompiledCatalog) []RoutingStage { if compiled == nil { return nil } diff --git a/router/preview_test.go b/router/preview_test.go index 208766f..0f7e4ab 100644 --- a/router/preview_test.go +++ b/router/preview_test.go @@ -8,8 +8,8 @@ import ( func TestResolveRoutingModelOverride(t *testing.T) { t.Parallel() - c := catalog.DefaultCatalogV1() - compiled, err := catalog.CompileCatalogV1(&c) + c := catalog.SeedCatalog() + compiled, err := catalog.CompileCatalog(&c) if err != nil { t.Fatalf("compile: %v", err) } @@ -37,8 +37,8 @@ func TestResolveRoutingModelOverride(t *testing.T) { func TestResolveRoutingProviderFallback(t *testing.T) { t.Parallel() - c := catalog.DefaultCatalogV1() - compiled, err := catalog.CompileCatalogV1(&c) + c := catalog.SeedCatalog() + compiled, err := catalog.CompileCatalog(&c) if err != nil { t.Fatalf("compile: %v", err) } diff --git a/router/strategy.go b/router/strategy.go index 0be3274..fef5193 100644 --- a/router/strategy.go +++ b/router/strategy.go @@ -110,7 +110,7 @@ func (s *strategyState) recordUsage(name string, tokens int64) { func (s *strategyState) selectIndex(strategy Strategy, entries []RouteEntry, totalWeight int) int { switch strategy { case StrategySimpleShuffle: - return rand.IntN(len(entries)) + return rand.IntN(len(entries)) // #nosec G404 -- non-cryptographic shuffle-strategy choice, not a security decision case StrategyLeastBusy: best, bestVal := 0, int64(math.MaxInt64) @@ -173,7 +173,7 @@ func weightedIndex(entries []RouteEntry, totalWeight int) int { if totalWeight == 0 { return 0 } - n := rand.IntN(totalWeight) + n := rand.IntN(totalWeight) // #nosec G404 -- non-cryptographic weighted load-balancing choice, not a security decision cumulative := 0 for i, e := range entries { cumulative += e.Weight diff --git a/runtime/gateways.go b/runtime/gateways.go index 0db487b..f097a75 100644 --- a/runtime/gateways.go +++ b/runtime/gateways.go @@ -16,7 +16,7 @@ import ( ) const ( - GatewayXiaomiTokenPlan = "xiaomi_mimo_token_plan" + GatewayXiaomiTokenPlan = "xiaomi_mimo_token_plan" // #nosec G101 -- constant name, not a secret value gatewayZAIPayg = "zai_payg" gatewayZAICoding = "zai_coding" ) diff --git a/runtime/list_models_test.go b/runtime/list_models_test.go index f311b68..228dd96 100644 --- a/runtime/list_models_test.go +++ b/runtime/list_models_test.go @@ -41,33 +41,33 @@ func TestFormatSetupError_NilError(t *testing.T) { func TestListModels_CacheReadDoesNotRequireDiscover(t *testing.T) { cachePath := filepath.Join(t.TempDir(), "model_catalog.json") now := time.Now().UTC().Truncate(time.Second) - c := catalog.CatalogV1{ - SchemaVersion: catalog.CatalogV1SchemaVersion, + c := catalog.Catalog{ + SchemaVersion: catalog.CatalogSchemaVersion, GeneratedAt: now, StaleAfter: now.Add(time.Hour), - Providers: map[string]catalog.ProviderV1{ + Providers: map[string]catalog.Provider{ "openai": {ID: "openai", Name: "OpenAI"}, }, - APIProtocols: map[string]catalog.APIProtocolV1{ + Protocols: map[string]catalog.Protocol{ "openai-chat-completions": {ID: "openai-chat-completions", Name: "OpenAI Chat Completions"}, }, - Deployments: map[string]catalog.DeploymentV1{ + Deployments: map[string]catalog.Deployment{ "openai-direct": { ID: "openai-direct", Name: "OpenAI", ProviderID: "openai", APIProtocolID: "openai-chat-completions", AdapterConstructor: "openai", NativeModelIDSource: catalog.NativeModelIDCatalogKnown, }, }, - Models: map[string]catalog.ModelV1{ + Models: map[string]catalog.Model{ "openai/gpt-test": {ID: "openai/gpt-test", ProviderID: "openai", Name: "GPT Test"}, }, - Offerings: []catalog.ModelOfferingV1{{ + Offerings: []catalog.ModelOffering{{ ID: "openai-direct:gpt-test", CanonicalModelID: "openai/gpt-test", DeploymentID: "openai-direct", NativeModelID: "gpt-test", - Pricing: catalog.PricingV1{Status: catalog.PricingUnknown}, + Pricing: catalog.Pricing{Status: catalog.PricingUnknown}, }}, } - if err := catalog.WriteCatalogV1Cache(cachePath, &c); err != nil { + if err := catalog.WriteCatalogCache(cachePath, &c); err != nil { t.Fatal(err) } t.Setenv("EYRIE_MODEL_CATALOG_PATH", cachePath) @@ -87,36 +87,36 @@ func TestListModels_CacheReadDoesNotRequireDiscover(t *testing.T) { func TestListModels_CacheEntriesHaveCorrectFields(t *testing.T) { cachePath := filepath.Join(t.TempDir(), "model_catalog.json") now := time.Now().UTC().Truncate(time.Second) - c := catalog.CatalogV1{ - SchemaVersion: catalog.CatalogV1SchemaVersion, + c := catalog.Catalog{ + SchemaVersion: catalog.CatalogSchemaVersion, GeneratedAt: now, StaleAfter: now.Add(time.Hour), - Providers: map[string]catalog.ProviderV1{ + Providers: map[string]catalog.Provider{ "anthropic": {ID: "anthropic", Name: "Anthropic"}, }, - APIProtocols: map[string]catalog.APIProtocolV1{ + Protocols: map[string]catalog.Protocol{ "anthropic-messages": {ID: "anthropic-messages", Name: "Anthropic Messages"}, }, - Deployments: map[string]catalog.DeploymentV1{ + Deployments: map[string]catalog.Deployment{ "anthropic-direct": { ID: "anthropic-direct", Name: "Anthropic", ProviderID: "anthropic", APIProtocolID: "anthropic-messages", AdapterConstructor: "anthropic", NativeModelIDSource: catalog.NativeModelIDCatalogKnown, }, }, - Models: map[string]catalog.ModelV1{ + Models: map[string]catalog.Model{ "anthropic/claude-opus-4-6": { ID: "anthropic/claude-opus-4-6", ProviderID: "anthropic", Name: "Claude Opus 4.6", ContextWindow: 200000, MaxOutput: 32000, }, }, - Offerings: []catalog.ModelOfferingV1{{ + Offerings: []catalog.ModelOffering{{ ID: "anthropic-direct:claude-opus-4-6", CanonicalModelID: "anthropic/claude-opus-4-6", DeploymentID: "anthropic-direct", NativeModelID: "claude-opus-4-6", - Pricing: catalog.PricingV1{Status: catalog.PricingUnknown}, + Pricing: catalog.Pricing{Status: catalog.PricingUnknown}, }}, } - if err := catalog.WriteCatalogV1Cache(cachePath, &c); err != nil { + if err := catalog.WriteCatalogCache(cachePath, &c); err != nil { t.Fatal(err) } t.Setenv("EYRIE_MODEL_CATALOG_PATH", cachePath) @@ -152,41 +152,41 @@ func TestListModels_CacheEntriesHaveCorrectFields(t *testing.T) { func TestListModels_CacheMultipleModels(t *testing.T) { cachePath := filepath.Join(t.TempDir(), "model_catalog.json") now := time.Now().UTC().Truncate(time.Second) - c := catalog.CatalogV1{ - SchemaVersion: catalog.CatalogV1SchemaVersion, + c := catalog.Catalog{ + SchemaVersion: catalog.CatalogSchemaVersion, GeneratedAt: now, StaleAfter: now.Add(time.Hour), - Providers: map[string]catalog.ProviderV1{ + Providers: map[string]catalog.Provider{ "openai": {ID: "openai", Name: "OpenAI"}, }, - APIProtocols: map[string]catalog.APIProtocolV1{ + Protocols: map[string]catalog.Protocol{ "openai-chat-completions": {ID: "openai-chat-completions", Name: "OpenAI Chat Completions"}, }, - Deployments: map[string]catalog.DeploymentV1{ + Deployments: map[string]catalog.Deployment{ "openai-direct": { ID: "openai-direct", Name: "OpenAI", ProviderID: "openai", APIProtocolID: "openai-chat-completions", AdapterConstructor: "openai", NativeModelIDSource: catalog.NativeModelIDCatalogKnown, }, }, - Models: map[string]catalog.ModelV1{ + Models: map[string]catalog.Model{ "openai/gpt-4o": {ID: "openai/gpt-4o", ProviderID: "openai", Name: "GPT-4o"}, "openai/gpt-4o-mini": {ID: "openai/gpt-4o-mini", ProviderID: "openai", Name: "GPT-4o Mini"}, }, - Offerings: []catalog.ModelOfferingV1{ + Offerings: []catalog.ModelOffering{ { ID: "openai-direct:gpt-4o", CanonicalModelID: "openai/gpt-4o", DeploymentID: "openai-direct", NativeModelID: "gpt-4o", - Pricing: catalog.PricingV1{Status: catalog.PricingUnknown}, + Pricing: catalog.Pricing{Status: catalog.PricingUnknown}, }, { ID: "openai-direct:gpt-4o-mini", CanonicalModelID: "openai/gpt-4o-mini", DeploymentID: "openai-direct", NativeModelID: "gpt-4o-mini", - Pricing: catalog.PricingV1{Status: catalog.PricingUnknown}, + Pricing: catalog.Pricing{Status: catalog.PricingUnknown}, }, }, } - if err := catalog.WriteCatalogV1Cache(cachePath, &c); err != nil { + if err := catalog.WriteCatalogCache(cachePath, &c); err != nil { t.Fatal(err) } t.Setenv("EYRIE_MODEL_CATALOG_PATH", cachePath) @@ -214,33 +214,33 @@ func TestListModels_CacheMultipleModels(t *testing.T) { func TestListModels_CacheProviderNotInCatalog(t *testing.T) { cachePath := filepath.Join(t.TempDir(), "model_catalog.json") now := time.Now().UTC().Truncate(time.Second) - c := catalog.CatalogV1{ - SchemaVersion: catalog.CatalogV1SchemaVersion, + c := catalog.Catalog{ + SchemaVersion: catalog.CatalogSchemaVersion, GeneratedAt: now, StaleAfter: now.Add(time.Hour), - Providers: map[string]catalog.ProviderV1{ + Providers: map[string]catalog.Provider{ "openai": {ID: "openai", Name: "OpenAI"}, }, - APIProtocols: map[string]catalog.APIProtocolV1{ + Protocols: map[string]catalog.Protocol{ "openai-chat-completions": {ID: "openai-chat-completions", Name: "OpenAI Chat Completions"}, }, - Deployments: map[string]catalog.DeploymentV1{ + Deployments: map[string]catalog.Deployment{ "openai-direct": { ID: "openai-direct", Name: "OpenAI", ProviderID: "openai", APIProtocolID: "openai-chat-completions", AdapterConstructor: "openai", NativeModelIDSource: catalog.NativeModelIDCatalogKnown, }, }, - Models: map[string]catalog.ModelV1{ + Models: map[string]catalog.Model{ "openai/gpt-4o": {ID: "openai/gpt-4o", ProviderID: "openai", Name: "GPT-4o"}, }, - Offerings: []catalog.ModelOfferingV1{{ + Offerings: []catalog.ModelOffering{{ ID: "openai-direct:gpt-4o", CanonicalModelID: "openai/gpt-4o", DeploymentID: "openai-direct", NativeModelID: "gpt-4o", - Pricing: catalog.PricingV1{Status: catalog.PricingUnknown}, + Pricing: catalog.Pricing{Status: catalog.PricingUnknown}, }}, } - if err := catalog.WriteCatalogV1Cache(cachePath, &c); err != nil { + if err := catalog.WriteCatalogCache(cachePath, &c); err != nil { t.Fatal(err) } t.Setenv("EYRIE_MODEL_CATALOG_PATH", cachePath) @@ -257,11 +257,11 @@ func TestListModels_CacheProviderNotInCatalog(t *testing.T) { } } -func TestWriteCatalogV1Cache_AtomicReplace(t *testing.T) { +func TestWriteCatalogCache_AtomicReplace(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "model_catalog.json") - c := catalog.TestSeedCatalogV1() - if err := catalog.WriteCatalogV1Cache(path, &c); err != nil { + c := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(path, &c); err != nil { t.Fatal(err) } if _, err := os.Stat(path); err != nil { @@ -272,11 +272,11 @@ func TestWriteCatalogV1Cache_AtomicReplace(t *testing.T) { } } -func TestWriteCatalogV1Cache_CanBeReadBack(t *testing.T) { +func TestWriteCatalogCache_CanBeReadBack(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "model_catalog.json") - c := catalog.TestSeedCatalogV1() - if err := catalog.WriteCatalogV1Cache(path, &c); err != nil { + c := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(path, &c); err != nil { t.Fatal(err) } t.Setenv("EYRIE_MODEL_CATALOG_PATH", path) @@ -293,18 +293,18 @@ func TestWriteCatalogV1Cache_CanBeReadBack(t *testing.T) { } } -func TestWriteCatalogV1Cache_OverwritesExisting(t *testing.T) { +func TestWriteCatalogCache_OverwritesExisting(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "model_catalog.json") // Write initial catalog - c1 := catalog.TestSeedCatalogV1() - if err := catalog.WriteCatalogV1Cache(path, &c1); err != nil { + c1 := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(path, &c1); err != nil { t.Fatal(err) } // Overwrite with same content (should succeed) - if err := catalog.WriteCatalogV1Cache(path, &c1); err != nil { + if err := catalog.WriteCatalogCache(path, &c1); err != nil { t.Fatal(err) } diff --git a/runtime/preflight.go b/runtime/preflight.go index 5290202..30db742 100644 --- a/runtime/preflight.go +++ b/runtime/preflight.go @@ -48,7 +48,7 @@ func Preflight(ctx context.Context) PreflightReport { Detail: "model catalog cache missing — hawk will discover on /config or refresh automatically", }) } else { - compiled, err := catalog.LoadCatalogV1(ctx, catalog.LoadCatalogV1Options{ + compiled, err := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{ CachePath: cachePath, RequireCache: false, }) nModels := 0 diff --git a/runtime/preflight_test.go b/runtime/preflight_test.go index 2b0add3..077a26c 100644 --- a/runtime/preflight_test.go +++ b/runtime/preflight_test.go @@ -341,8 +341,8 @@ func TestPreflight_WithCatalogCache(t *testing.T) { // Write a valid catalog cache cachePath := filepath.Join(dir, "model_catalog.json") - c := catalog.TestSeedCatalogV1() - if err := catalog.WriteCatalogV1Cache(cachePath, &c); err != nil { + c := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(cachePath, &c); err != nil { t.Fatal(err) } t.Setenv("EYRIE_MODEL_CATALOG_PATH", cachePath) diff --git a/runtime/runtime.go b/runtime/runtime.go index 7f3d3ff..ad15d6b 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -38,7 +38,7 @@ import ( // Runtime is a loaded eyrie control plane: catalog cache + routing + env-backed credentials. type Runtime struct { - Catalog *catalog.CompiledCatalogV1 + Catalog *catalog.CompiledCatalog Provider *config.ProviderConfig ProviderPath string } @@ -88,9 +88,6 @@ func Load(ctx context.Context) (*Runtime, error) { return nil, err } cfg := config.LoadProviderConfig("") - if cfg != nil { - cfg = config.EnsureDeploymentConfigV2(cfg) - } return &Runtime{ Catalog: compiled, Provider: cfg, @@ -209,8 +206,8 @@ type DeploymentRow struct { func (r *Runtime) CredentialTargets() []CredentialTarget { compiled := r.Catalog if compiled == nil { - bootstrap := catalog.BootstrapCatalogV1() - c, err := catalog.CompileCatalogV1(&bootstrap) + bootstrap := catalog.BootstrapCatalog() + c, err := catalog.CompileCatalog(&bootstrap) if err != nil { return nil } @@ -248,7 +245,7 @@ type CredentialTarget struct { Set bool } -func configuredDeploymentIDsForProvider(compiled *catalog.CompiledCatalogV1, providerID string) []string { +func configuredDeploymentIDsForProvider(compiled *catalog.CompiledCatalog, providerID string) []string { if compiled == nil || compiled.Catalog == nil { return nil } diff --git a/runtime/runtime_test.go b/runtime/runtime_test.go index 0d2c46f..05f06f7 100644 --- a/runtime/runtime_test.go +++ b/runtime/runtime_test.go @@ -26,8 +26,8 @@ func TestModelIDs(t *testing.T) { { name: "empty catalog", runtime: &Runtime{ - Catalog: &catalog.CompiledCatalogV1{ - ModelsByID: map[string]catalog.ModelV1{}, + Catalog: &catalog.CompiledCatalog{ + ModelsByID: map[string]catalog.Model{}, }, }, wantNil: false, @@ -585,7 +585,7 @@ func TestConfiguredDeploymentIDsForProvider_NilCompiled(t *testing.T) { } func TestConfiguredDeploymentIDsForProvider_NilCatalog(t *testing.T) { - ids := configuredDeploymentIDsForProvider(&catalog.CompiledCatalogV1{}, "anthropic") + ids := configuredDeploymentIDsForProvider(&catalog.CompiledCatalog{}, "anthropic") if len(ids) != 0 { t.Fatalf("expected 0 IDs, got %d", len(ids)) } @@ -650,7 +650,7 @@ func TestListProviderSetupOptions(t *testing.T) { // --- helpers --- -func mustCompileTestCatalog(t *testing.T) *catalog.CompiledCatalogV1 { +func mustCompileTestCatalog(t *testing.T) *catalog.CompiledCatalog { t.Helper() compiled, err := catalog.CompileTestCatalog() if err != nil { diff --git a/runtime/selection.go b/runtime/selection.go index 240ca4c..be5bbf6 100644 --- a/runtime/selection.go +++ b/runtime/selection.go @@ -14,7 +14,7 @@ import ( type selectionRuntimeState struct { ctx context.Context - compiled *catalog.CompiledCatalogV1 + compiled *catalog.CompiledCatalog compiledLoaded bool discoveryEnv map[string]string discoveryEnvLoaded bool @@ -33,7 +33,7 @@ func newSelectionRuntimeState(ctx context.Context) *selectionRuntimeState { } } -func (s *selectionRuntimeState) compiledCatalog() *catalog.CompiledCatalogV1 { +func (s *selectionRuntimeState) compiledCatalog() *catalog.CompiledCatalog { if s == nil { return nil } @@ -360,7 +360,7 @@ func providerConfiguredWithState(state *selectionRuntimeState, provider string) if !ok { return false } - var compiled *catalog.CompiledCatalogV1 + var compiled *catalog.CompiledCatalog var env map[string]string if state != nil { compiled = state.compiledCatalog() diff --git a/setup/catalog.go b/setup/catalog.go index 14ae14f..4bb0be5 100644 --- a/setup/catalog.go +++ b/setup/catalog.go @@ -33,7 +33,7 @@ func DiscoverModelCatalogWithOptions(ctx context.Context, creds catalog.Credenti } } return discover.Run(ctx, discover.Options{ - LoadCatalogV1Options: catalog.LoadCatalogV1Options{ + LoadCatalogOptions: catalog.LoadCatalogOptions{ CachePath: cachePath, RefreshRemote: refreshRemote, }, @@ -47,8 +47,8 @@ func DiscoverProviderCatalog(ctx context.Context, providerID string, creds catal } // LoadCompiledCatalog returns the compiled catalog from cache/embedded data without network refresh. -func LoadCompiledCatalog(ctx context.Context) (*catalog.CompiledCatalogV1, error) { - return catalog.LoadCatalogV1(ctx, catalog.LoadCatalogV1Options{ +func LoadCompiledCatalog(ctx context.Context) (*catalog.CompiledCatalog, error) { + return catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{ CachePath: catalog.DefaultCachePath(), RequireCache: true, }) diff --git a/setup/deployment.go b/setup/deployment.go index c0a92f7..de39254 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -59,10 +59,9 @@ func UseDeploymentRouting(cfg *config.ProviderConfig) bool { // DeploymentProvider builds a catalog-aware router over configured deployments. func DeploymentProvider(ctx context.Context, cfg *config.ProviderConfig) (client.Provider, error) { - cfg = config.EnsureDeploymentConfigV2(cfg) home, _ := os.UserHomeDir() cachePath := filepath.Join(home, ".eyrie", "model_catalog.json") - compiled, err := catalog.LoadCatalogV1(ctx, catalog.LoadCatalogV1Options{ + compiled, err := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{ CachePath: cachePath, RefreshRemote: strings.EqualFold(os.Getenv("EYRIE_MODEL_CATALOG_REFRESH"), "true"), }) @@ -221,6 +220,24 @@ func ProviderForDeployment(id string, deployment config.DeploymentConfig) (clien openBase := FirstNonEmpty(deployment.BaseURL, "https://api.deepseek.com/v1") anthropicBase := "https://api.deepseek.com/anthropic" return client.NewDeepSeekClient(apiKey, openBase, anthropicBase, &client.DeepSeekCompat), true + case "poolside": + apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("POOLSIDE_API_KEY")) + if apiKey == "" { + return nil, false + } + return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultPoolsideOpenAIBaseURL), &client.PoolsideCompat), true + case "groq-direct": + apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("GROQ_API_KEY")) + if apiKey == "" { + return nil, false + } + return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultGroqOpenAIBaseURL), &client.GroqCompat), true + case "clinepass": + apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("CLINE_API_KEY")) + if apiKey == "" { + return nil, false + } + return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultClinePassOpenAIBaseURL), &client.ClinePassCompat), true case "zai_payg-direct": return newZAIDeploymentClient(deployment, "zai_payg", "ZAI_API_KEY", storeSecret) case "zai_coding-direct": @@ -354,8 +371,12 @@ func DefaultDeploymentForProvider(provider string) string { return "openrouter" case config.ProviderCanopyWave: return "canopywave" + case config.ProviderPoolside: + return "poolside" case config.ProviderDeepSeek: return "deepseek-direct" + case config.ProviderGroq: + return "groq-direct" case config.ProviderZAIPayg: return "zai_payg-direct" case config.ProviderZAICoding: @@ -397,8 +418,12 @@ 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.ProviderPoolside: + return config.DeploymentConfig{APIKey: cfg.PoolsideAPIKey, BaseURL: cfg.PoolsideBaseURL} case config.ProviderDeepSeek: return config.DeploymentConfig{APIKey: cfg.DeepSeekAPIKey, BaseURL: cfg.DeepSeekBaseURL} + case config.ProviderGroq: + return config.DeploymentConfig{APIKey: cfg.GroqAPIKey, BaseURL: cfg.GroqBaseURL} case config.ProviderZAIPayg: return config.DeploymentConfig{APIKey: cfg.ZAIAPIKey, BaseURL: cfg.ZAIBaseURL} case config.ProviderZAICoding: diff --git a/setup/setup_ui.go b/setup/setup_ui.go index 86d7a7c..19a8fad 100644 --- a/setup/setup_ui.go +++ b/setup/setup_ui.go @@ -29,7 +29,7 @@ type SetupUI struct { // BuildSetupUI lists models per provider from the compiled catalog. // If providerFilter is non-empty, only that provider is included. -func BuildSetupUI(compiled *catalog.CompiledCatalogV1, providerFilter string) *SetupUI { +func BuildSetupUI(compiled *catalog.CompiledCatalog, providerFilter string) *SetupUI { if compiled == nil { return &SetupUI{} } @@ -78,7 +78,7 @@ func displayNameForProvider(pid string) string { } } -func modelsForProvider(compiled *catalog.CompiledCatalogV1, providerID string) []ModelUI { +func modelsForProvider(compiled *catalog.CompiledCatalog, providerID string) []ModelUI { var ids []string for id, model := range compiled.ModelsByID { if catalog.CanonicalProviderID(model.ProviderID) == providerID { @@ -105,7 +105,7 @@ func modelsForProvider(compiled *catalog.CompiledCatalogV1, providerID string) [ return out } -func modelProvenanceSource(model catalog.ModelV1) string { +func modelProvenanceSource(model catalog.Model) string { if model.Provenance == nil || model.Provenance.Source == "" { return "" } @@ -113,7 +113,7 @@ func modelProvenanceSource(model catalog.ModelV1) string { } // ProviderIDForDeployment resolves catalog provider id for a deployment id. -func ProviderIDForDeployment(compiled *catalog.CompiledCatalogV1, deploymentID string) string { +func ProviderIDForDeployment(compiled *catalog.CompiledCatalog, deploymentID string) string { if compiled == nil || compiled.DeploymentsByID == nil { return "" } diff --git a/setup/setup_ui_test.go b/setup/setup_ui_test.go index c048a95..2fc966d 100644 --- a/setup/setup_ui_test.go +++ b/setup/setup_ui_test.go @@ -17,8 +17,8 @@ func TestBuildSetupUI_NilCatalog(t *testing.T) { } func TestBuildSetupUI_EmptyCatalog(t *testing.T) { - compiled := &catalog.CompiledCatalogV1{ - ModelsByID: map[string]catalog.ModelV1{}, + compiled := &catalog.CompiledCatalog{ + ModelsByID: map[string]catalog.Model{}, } ui := BuildSetupUI(compiled, "") if len(ui.Providers) != 0 { @@ -27,8 +27,8 @@ func TestBuildSetupUI_EmptyCatalog(t *testing.T) { } func TestBuildSetupUI_WithModels(t *testing.T) { - compiled := &catalog.CompiledCatalogV1{ - ModelsByID: map[string]catalog.ModelV1{ + compiled := &catalog.CompiledCatalog{ + ModelsByID: map[string]catalog.Model{ "anthropic/claude-sonnet-4": { ID: "anthropic/claude-sonnet-4", ProviderID: "anthropic", @@ -70,8 +70,8 @@ func TestBuildSetupUI_WithModels(t *testing.T) { } func TestBuildSetupUI_ProviderFilter(t *testing.T) { - compiled := &catalog.CompiledCatalogV1{ - ModelsByID: map[string]catalog.ModelV1{ + compiled := &catalog.CompiledCatalog{ + ModelsByID: map[string]catalog.Model{ "anthropic/claude-sonnet-4": { ID: "anthropic/claude-sonnet-4", ProviderID: "anthropic", @@ -94,8 +94,8 @@ func TestBuildSetupUI_ProviderFilter(t *testing.T) { } func TestBuildSetupUI_DisplayNameFallback(t *testing.T) { - compiled := &catalog.CompiledCatalogV1{ - ModelsByID: map[string]catalog.ModelV1{ + compiled := &catalog.CompiledCatalog{ + ModelsByID: map[string]catalog.Model{ "anthropic/my-model": { ID: "anthropic/my-model", ProviderID: "anthropic", @@ -117,8 +117,8 @@ func TestBuildSetupUI_DisplayNameFallback(t *testing.T) { } func TestBuildSetupUI_DisplayNameFallbackNoSlash(t *testing.T) { - compiled := &catalog.CompiledCatalogV1{ - ModelsByID: map[string]catalog.ModelV1{ + compiled := &catalog.CompiledCatalog{ + ModelsByID: map[string]catalog.Model{ "anthropic/claude": { ID: "anthropic/claude", ProviderID: "anthropic", @@ -136,19 +136,19 @@ func TestBuildSetupUI_DisplayNameFallbackNoSlash(t *testing.T) { } func TestBuildSetupUI_ModelProvenanceSource(t *testing.T) { - compiled := &catalog.CompiledCatalogV1{ - ModelsByID: map[string]catalog.ModelV1{ + compiled := &catalog.CompiledCatalog{ + ModelsByID: map[string]catalog.Model{ "anthropic/claude": { ID: "anthropic/claude", ProviderID: "anthropic", Name: "Claude", - Provenance: &catalog.CatalogProvenanceV1{Source: "remote"}, + Provenance: &catalog.Provenance{Source: "remote"}, }, "openai/gpt": { ID: "openai/gpt", ProviderID: "openai", Name: "GPT", - Provenance: &catalog.CatalogProvenanceV1{Source: "live"}, + Provenance: &catalog.Provenance{Source: "live"}, }, "gemini/pro": { ID: "gemini/pro", @@ -188,7 +188,7 @@ func TestProviderIDForDeployment_NilCatalog(t *testing.T) { } func TestProviderIDForDeployment_NilDeployments(t *testing.T) { - compiled := &catalog.CompiledCatalogV1{} + compiled := &catalog.CompiledCatalog{} got := ProviderIDForDeployment(compiled, "anthropic-direct") if got != "" { t.Fatalf("expected empty, got %q", got) @@ -196,8 +196,8 @@ func TestProviderIDForDeployment_NilDeployments(t *testing.T) { } func TestProviderIDForDeployment_Found(t *testing.T) { - compiled := &catalog.CompiledCatalogV1{ - DeploymentsByID: map[string]catalog.DeploymentV1{ + compiled := &catalog.CompiledCatalog{ + DeploymentsByID: map[string]catalog.Deployment{ "anthropic-direct": {ProviderID: "anthropic"}, }, } @@ -208,8 +208,8 @@ func TestProviderIDForDeployment_Found(t *testing.T) { } func TestProviderIDForDeployment_NotFound(t *testing.T) { - compiled := &catalog.CompiledCatalogV1{ - DeploymentsByID: map[string]catalog.DeploymentV1{ + compiled := &catalog.CompiledCatalog{ + DeploymentsByID: map[string]catalog.Deployment{ "anthropic-direct": {ProviderID: "anthropic"}, }, } @@ -220,8 +220,8 @@ func TestProviderIDForDeployment_NotFound(t *testing.T) { } func TestBuildSetupUI_SkipsModelsWithEmptyProviderID(t *testing.T) { - compiled := &catalog.CompiledCatalogV1{ - ModelsByID: map[string]catalog.ModelV1{ + compiled := &catalog.CompiledCatalog{ + ModelsByID: map[string]catalog.Model{ "no-provider/model": { ID: "no-provider/model", ProviderID: "", @@ -237,8 +237,8 @@ func TestBuildSetupUI_SkipsModelsWithEmptyProviderID(t *testing.T) { func TestBuildSetupUI_ProviderWithNoModelsExcluded(t *testing.T) { // All models belong to "openai"; filter for "anthropic" should yield nothing. - compiled := &catalog.CompiledCatalogV1{ - ModelsByID: map[string]catalog.ModelV1{ + compiled := &catalog.CompiledCatalog{ + ModelsByID: map[string]catalog.Model{ "openai/gpt-4o": { ID: "openai/gpt-4o", ProviderID: "openai", diff --git a/setup/status.go b/setup/status.go index 4c52535..9cc4ebd 100644 --- a/setup/status.go +++ b/setup/status.go @@ -33,7 +33,6 @@ type StatusReport struct { // DeploymentStatus builds a status report for CLI and agent diagnostics. func DeploymentStatus(ctx context.Context, activeModel string) (StatusReport, error) { cfg := config.LoadProviderConfig("") - cfg = config.EnsureDeploymentConfigV2(cfg) report := StatusReport{ ProviderConfig: config.GetProviderConfigPath(), ActiveModel: strings.TrimSpace(activeModel), @@ -54,7 +53,7 @@ func DeploymentStatus(ctx context.Context, activeModel string) (StatusReport, er report.CatalogModified = mod } - compiled, err := catalog.LoadCatalogV1(ctx, catalog.LoadCatalogV1Options{ + compiled, err := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{ CachePath: report.CatalogCache, }) if err != nil { @@ -119,8 +118,7 @@ func FormatStatus(report StatusReport) string { // RoutingPreview returns JSON describing effective routing for a model ID. func RoutingPreview(ctx context.Context, model string) (string, error) { cfg := config.LoadProviderConfig("") - cfg = config.EnsureDeploymentConfigV2(cfg) - compiled, err := catalog.LoadCatalogV1(ctx, catalog.LoadCatalogV1Options{ + compiled, err := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{ CachePath: catalog.DefaultCachePath(), }) if err != nil { diff --git a/storage/sqlite.go b/storage/sqlite.go index 0e13e68..dc96192 100644 --- a/storage/sqlite.go +++ b/storage/sqlite.go @@ -266,6 +266,9 @@ func (s *SQLiteStore) GetOrphanedToolUses(ctx context.Context, ancestorIDs []str args = append(args, id) } + // #nosec G202 -- inClause interpolates only literal "?" placeholders (one per + // ancestorIDs element); every actual value is bound via args through the + // parameterized QueryContext call below, so no untrusted data enters the SQL text. rows, err := s.db.QueryContext(ctx, ` SELECT nti.node_id, nti.tool_id FROM node_tool_ids nti diff --git a/version.go b/version.go new file mode 100644 index 0000000..19f3937 --- /dev/null +++ b/version.go @@ -0,0 +1,22 @@ +// Package eyrie provides LLM provider clients and configuration. +// +// The Version variable is sourced from the VERSION file at the repo root +// and propagated to sub-packages at init time. +package eyrie + +import ( + _ "embed" + "strings" + + "github.com/GrayCodeAI/eyrie/client" +) + +//go:embed VERSION +var versionFile string + +// Version of the eyrie library. Single source of truth: VERSION file. +var Version = strings.TrimSpace(versionFile) + +func init() { + client.SetVersion(Version) +}