diff --git a/external/eyrie b/external/eyrie index bb563fd8..036043c8 160000 --- a/external/eyrie +++ b/external/eyrie @@ -1 +1 @@ -Subproject commit bb563fd8af477e95ab701c3208bccc9b32a0212b +Subproject commit 036043c8e99cd5e398ad8d86275c87ec4085ff55 diff --git a/internal/provider/routing/catalog.go b/internal/provider/routing/catalog.go index 428e129e..3481c6b6 100644 --- a/internal/provider/routing/catalog.go +++ b/internal/provider/routing/catalog.go @@ -57,6 +57,17 @@ func fromEyrieV1(model catalog.ModelV1, offering catalog.ModelOfferingV1) ModelI } } +func fromEyrieEntry(entry catalog.ModelCatalogEntry, provider string) ModelInfo { + return ModelInfo{ + Name: entry.ID, + Provider: provider, + ContextSize: entry.ContextWindow, + InputPrice: entry.InputPricePer1M, + OutputPrice: entry.OutputPricePer1M, + Description: entry.DisplayName, + } +} + // Find looks up a model by name via eyrie's JSON catalog. func Find(name string) (ModelInfo, bool) { if compiled := eyrieCatalogV1(); compiled != nil { @@ -71,19 +82,13 @@ func Find(name string) (ModelInfo, bool) { // ByProvider returns all models for a given provider from eyrie's catalog. func ByProvider(provider string) []ModelInfo { - provider = canonicalProvider(provider) + provider = catalog.CanonicalProviderID(provider) compiled := eyrieCatalogV1() out := []ModelInfo{} if compiled != nil { - modelIDs := make([]string, 0, len(compiled.ModelsByID)) - for id, model := range compiled.ModelsByID { - if canonicalProvider(model.ProviderID) == provider { - modelIDs = append(modelIDs, id) - } - } - sort.Strings(modelIDs) - for _, id := range modelIDs { - out = append(out, fromEyrieV1(compiled.ModelsByID[id], firstOffering(compiled, id, ""))) + entries := catalog.ModelEntriesForProvider(compiled, provider) + for _, entry := range entries { + out = append(out, fromEyrieEntry(entry, provider)) } } return out @@ -104,26 +109,12 @@ func Recommended(provider string) (ModelInfo, bool) { // DefaultModel returns the first catalog model for a provider via eyrie JSON. func DefaultModel(provider string) string { - models := ByProvider(provider) - if len(models) > 0 { - return models[0].Name - } - return "" + return catalog.ProviderDefaultModelV1(eyrieCatalogV1(), provider, "") } // AllProviders returns all canonical model owner providers from eyrie's catalog. func AllProviders() []string { - seen := map[string]bool{} - var out []string - if compiled := eyrieCatalogV1(); compiled != nil { - for _, model := range compiled.ModelsByID { - provider := canonicalProvider(model.ProviderID) - if provider != "" && !seen[provider] { - seen[provider] = true - out = append(out, provider) - } - } - } + out := catalog.AllModelProvidersV1(eyrieCatalogV1()) sort.Strings(out) return out } @@ -147,13 +138,5 @@ func firstOffering(compiled *catalog.CompiledCatalogV1, canonicalModelID, deploy } func canonicalProvider(provider string) string { - switch provider { - case "gemini": - return "google" - case "grok": - return "xai" - // Z.AI uses zai_payg and zai_coding directly — no aliases. - default: - return provider - } + return catalog.CanonicalProviderID(provider) } diff --git a/internal/provider/routing/roles.go b/internal/provider/routing/roles.go index 7ccdb6df..efd1e246 100644 --- a/internal/provider/routing/roles.go +++ b/internal/provider/routing/roles.go @@ -1,6 +1,10 @@ package routing -import "strings" +import ( + "strings" + + eycatalog "github.com/GrayCodeAI/eyrie/catalog" +) // Role identifies the purpose of a model within a multi-model workflow. type Role string @@ -24,73 +28,35 @@ type ModelRoles struct { // DefaultRoles returns a ModelRoles where every role uses primaryModel except // Commit, which defaults to the cheapest available model from the catalog. func DefaultRoles(primaryModel string) ModelRoles { - commit := CheapestForProvider(providerOf(primaryModel), primaryModel) - return ModelRoles{ - Planner: primaryModel, - Coder: primaryModel, - Reviewer: primaryModel, - Commit: commit, - } + return fromEyrieRoles(eycatalog.DefaultModelRolesV1(eyrieCatalogV1(), primaryModel)) } // ModelForRole returns the model name assigned to role, falling back to the // Coder model (primary) if the role-specific field is empty. func (r ModelRoles) ModelForRole(role Role) string { - var m string - switch role { - case RolePlanner: - m = r.Planner - case RoleCoder: - m = r.Coder - case RoleReviewer: - m = r.Reviewer - case RoleCommit: - m = r.Commit - } - if strings.TrimSpace(m) == "" { - if strings.TrimSpace(r.Coder) != "" { - return r.Coder - } - return primaryModel() - } - return m + return eycatalog.ModelForRoleV1(eyrieCatalogV1(), toEyrieRoles(r), eycatalog.ModelRole(role)) } // CheapestForProvider queries eyrie's catalog at runtime and returns the // cheapest model for the given provider. No hardcoded model names. func CheapestForProvider(provider, fallback string) string { - models := ByProvider(provider) - if len(models) == 0 { - return fallback - } - cheapest := models[0] - for _, m := range models[1:] { - if m.InputPrice > 0 && m.InputPrice < cheapest.InputPrice { - cheapest = m - } - } - if cheapest.Name != "" { - return cheapest.Name - } - return fallback + return eycatalog.CheapestModelForProviderV1(eyrieCatalogV1(), provider, fallback) } -// providerOf extracts the provider from a model name by looking it up in the catalog. -func providerOf(modelName string) string { - info, ok := Find(modelName) - if ok { - return canonicalProvider(info.Provider) +func toEyrieRoles(r ModelRoles) eycatalog.ModelRoleAssignments { + return eycatalog.ModelRoleAssignments{ + Planner: strings.TrimSpace(r.Planner), + Coder: strings.TrimSpace(r.Coder), + Reviewer: strings.TrimSpace(r.Reviewer), + Commit: strings.TrimSpace(r.Commit), } - return "" } -// primaryModel returns a reasonable fallback by querying what's available. -func primaryModel() string { - providers := AllProviders() - for _, p := range providers { - if m := DefaultModel(p); m != "" { - return m - } +func fromEyrieRoles(r eycatalog.ModelRoleAssignments) ModelRoles { + return ModelRoles{ + Planner: strings.TrimSpace(r.Planner), + Coder: strings.TrimSpace(r.Coder), + Reviewer: strings.TrimSpace(r.Reviewer), + Commit: strings.TrimSpace(r.Commit), } - return "" } diff --git a/internal/provider/routing/tiers.go b/internal/provider/routing/tiers.go index 2993ee83..669cc80c 100644 --- a/internal/provider/routing/tiers.go +++ b/internal/provider/routing/tiers.go @@ -8,48 +8,19 @@ import ( ) // CostTier is a relative cost band for cascade routing (cheap / mid / expensive). -type CostTier int +type CostTier = eycatalog.ModelCostTier const ( - CostTierCheap CostTier = iota - CostTierMid - CostTierExpensive + CostTierCheap = eycatalog.CostTierCheap + CostTierMid = eycatalog.CostTierMid + CostTierExpensive = eycatalog.CostTierExpensive ) -// CostTierOf resolves a model's cost tier from eyrie catalog data (family and -// within-provider pricing). Unknown models default to mid-tier. +// CostTierOf resolves a model's cost tier from eyrie catalog policy. func CostTierOf(modelName string) CostTier { - if tier, ok := tierFromCatalogFamily(modelName); ok { - return mapEyrieTier(tier) - } - if tier, ok := tierFromCatalogPricing(modelName); ok { - return tier - } - return tierFromName(modelName) + return eycatalog.ModelCostTierOf(eyrieCatalogV1(), modelName) } -// tierFromName infers cost tier from well-known model name patterns. -// This is a fallback when the eyrie catalog is unavailable or incomplete. -func tierFromName(modelName string) CostTier { - lower := strings.ToLower(strings.TrimSpace(modelName)) - for _, pat := range cheapPatterns { - if strings.Contains(lower, pat) { - return CostTierCheap - } - } - for _, pat := range expensivePatterns { - if strings.Contains(lower, pat) { - return CostTierExpensive - } - } - return CostTierMid -} - -var ( - cheapPatterns = []string{"haiku", "mini", "flash", "lite", "nano", "micro", "small", "tiny"} - expensivePatterns = []string{"opus", "pro", "max", "ultra", "heavy", "large", "o1", "o3"} -) - // TierModels returns eyrie-preferred model IDs for haiku, sonnet, and opus tiers. func TierModels(provider string) (haiku, sonnet, opus string) { return PreferredModelForTier(provider, eycatalog.TierHaiku, ""), @@ -122,12 +93,9 @@ func catalogModelNames(compiled *eycatalog.CompiledCatalogV1) []string { // DefaultHealthTiers builds complexity-based routing tiers from the eyrie catalog. func DefaultHealthTiers(primaryProvider string) []ModelTier { primaryProvider = canonicalProvider(primaryProvider) - if primaryProvider == "" { - primaryProvider = "anthropic" - } - light := tierModelList(primaryProvider, eycatalog.TierHaiku, "openai", "gemini") - standard := tierModelList(primaryProvider, eycatalog.TierSonnet, "openai", "gemini") - heavy := tierModelList(primaryProvider, eycatalog.TierOpus, "openai", "gemini") + light := tierModelList(primaryProvider, eycatalog.TierHaiku) + standard := tierModelList(primaryProvider, eycatalog.TierSonnet) + heavy := tierModelList(primaryProvider, eycatalog.TierOpus) return []ModelTier{ {Name: "light", Models: light, MaxComplexity: 10.0}, {Name: "standard", Models: standard, MaxComplexity: 30.0}, @@ -135,119 +103,26 @@ func DefaultHealthTiers(primaryProvider string) []ModelTier { } } -func tierModelList(primaryProvider string, tier eycatalog.ModelTier, extraProviders ...string) []string { +func tierModelList(primaryProvider string, tier eycatalog.ModelTier) []string { seen := map[string]bool{} var out []string - add := func(m string) { - m = strings.TrimSpace(m) - if m != "" && !seen[m] { - seen[m] = true - out = append(out, m) + for _, model := range eycatalog.PreferredModelsForTierV1(eyrieCatalogV1(), primaryProvider, tier, 3) { + model = strings.TrimSpace(model) + if model == "" || seen[model] { + continue } - } - add(PreferredModelForTier(primaryProvider, tier, "")) - for _, p := range extraProviders { - add(PreferredModelForTier(p, tier, "")) + seen[model] = true + out = append(out, model) } return out } // PreferredModelForTier returns the eyrie-preferred model for a provider and tier. func PreferredModelForTier(provider string, tier eycatalog.ModelTier, fallback string) string { - provider = canonicalProvider(provider) - if provider == "" { - return fallback - } - if m := eycatalog.GetPreferredProviderModel(provider, tier, nil); m != "" { - return m - } - return fallback + return eycatalog.PreferredProviderModelV1(eyrieCatalogV1(), provider, tier, fallback) } // MostExpensiveForProvider picks the highest input-priced model for a provider. func MostExpensiveForProvider(provider, fallback string) string { - models := ByProvider(canonicalProvider(provider)) - if len(models) == 0 { - return fallback - } - best := models[0] - for _, m := range models[1:] { - if m.InputPrice > best.InputPrice { - best = m - } - } - if best.Name != "" { - return best.Name - } - return fallback -} - -func mapEyrieTier(tier eycatalog.ModelTier) CostTier { - switch tier { - case eycatalog.TierHaiku: - return CostTierCheap - case eycatalog.TierOpus: - return CostTierExpensive - default: - return CostTierMid - } -} - -func tierFromCatalogFamily(modelName string) (eycatalog.ModelTier, bool) { - compiled := eyrieCatalogV1() - if compiled == nil { - return "", false - } - canonical := modelName - if c, ok := compiled.CanonicalModelForAliasOrID(modelName); ok { - canonical = c - } - model := compiled.ModelsByID[canonical] - if model.ID == "" { - return "", false - } - switch strings.ToLower(strings.TrimSpace(model.Family)) { - case "haiku", "cheap", "lite", "flash", "mini": - return eycatalog.TierHaiku, true - case "opus", "pro", "max", "heavy", "ultra": - return eycatalog.TierOpus, true - case "sonnet", "standard", "balanced", "medium": - return eycatalog.TierSonnet, true - } - return "", false -} - -func tierFromCatalogPricing(modelName string) (CostTier, bool) { - info, ok := Find(modelName) - if !ok || info.InputPrice <= 0 { - return 0, false - } - models := ByProvider(canonicalProvider(info.Provider)) - if len(models) < 2 { - return 0, false - } - - prices := make([]float64, 0, len(models)) - seen := map[float64]bool{} - for _, m := range models { - if m.InputPrice <= 0 || seen[m.InputPrice] { - continue - } - seen[m.InputPrice] = true - prices = append(prices, m.InputPrice) - } - if len(prices) < 2 { - return 0, false - } - sort.Float64s(prices) - - price := info.InputPrice - switch { - case price <= prices[0]: - return CostTierCheap, true - case price >= prices[len(prices)-1]: - return CostTierExpensive, true - default: - return CostTierMid, true - } + return eycatalog.MostExpensiveModelForProviderV1(eyrieCatalogV1(), provider, fallback) } diff --git a/internal/testaudit/audit_test.go b/internal/testaudit/audit_test.go index d2481874..df229cd5 100644 --- a/internal/testaudit/audit_test.go +++ b/internal/testaudit/audit_test.go @@ -221,6 +221,39 @@ func TestNoDirectEyrieClientImportsOutsideAdapters(t *testing.T) { } } +// TestNoLazyProviderConstructionInHawk verifies Hawk does not construct lazy +// provider transports directly. Provider/model transport resolution belongs in Eyrie. +func TestNoLazyProviderConstructionInHawk(t *testing.T) { + root := repoRoot(t) + paths := []string{ + filepath.Join(root, "internal"), + filepath.Join(root, "cmd"), + } + + for _, dir := range paths { + files := parseGoFiles(t, dir) + for _, pf := range files { + rel := relPath(root, pf.Path) + if strings.HasSuffix(rel, "_test.go") { + continue + } + ast.Inspect(pf.File, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "NewLazyProvider" { + return true + } + pos := pf.FSet.Position(call.Pos()) + t.Fatalf("forbidden eyrie lazy provider construction at %s:%d; use runtime.ResolveChatTransport", rel, pos.Line) + return true + }) + } + } +} + // TestNoDirectSharedTypesImports verifies Hawk does not reintroduce the removed // legacy shared/types import path into production code. func TestNoDirectSharedTypesImports(t *testing.T) { diff --git a/internal/types/client.go b/internal/types/client.go index 751645de..6383aa44 100644 --- a/internal/types/client.go +++ b/internal/types/client.go @@ -227,14 +227,6 @@ func WrapClientProvider(p client.Provider) ChatProvider { return &providerAdapter{inner: p} } -// NewLazyChatProvider builds a lazy Eyrie provider behind Hawk's transport seam. -func NewLazyChatProvider(cfg *ClientConfig) ChatProvider { - if cfg == nil { - return nil - } - return WrapClientProvider(client.NewLazyProvider(ToClientConfig(cfg))) -} - func StreamChatWithContinuation(ctx context.Context, p ChatProvider, messages []EyrieMessage, opts ChatOptions, cfg ContinuationConfig) (*StreamResult, error) { if p == nil { return nil, nil