From a2a4498c9e7ad151ce9dabd45a3b3ed06c44e5ef Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 14:37:37 +0530 Subject: [PATCH] WIP: chat, config, engine, and onboarding changes --- cmd/chat.go | 3 - cmd/chat_config_deployment.go | 77 +---- cmd/chat_config_hub.go | 16 +- cmd/chat_config_models.go | 46 +-- cmd/chat_platform_ctx.go | 86 ----- cmd/chat_print.go | 2 +- cmd/chat_status.go | 9 - cmd/chat_status_metadata_test.go | 72 ---- cmd/chat_update.go | 6 - cmd/chat_welcome.go | 3 +- cmd/completions.go | 8 +- cmd/model_table.go | 5 +- cmd/models.go | 12 +- internal/config/catalog_api.go | 6 +- internal/config/credentials_store.go | 39 ++- internal/config/eyrie_apply.go | 12 - internal/config/settings.go | 11 +- internal/config/xiaomi_setup.go | 11 +- internal/config/xiaomi_setup_test.go | 2 - internal/config/zai_setup.go | 12 +- internal/engine/chat_service.go | 78 ++--- internal/engine/compact_provider_native.go | 250 ++------------ .../engine/compact_provider_native_test.go | 24 +- internal/engine/compact_strategy_engine.go | 2 +- internal/engine/context_compaction_test.go | 15 - internal/engine/conversation_adapter.go | 240 ------------- internal/engine/conversation_adapter_test.go | 325 ------------------ internal/engine/session.go | 17 + internal/onboarding/onboarding.go | 116 ++----- internal/onboarding/onboarding_test.go | 44 ++- internal/types/client.go | 13 + 31 files changed, 241 insertions(+), 1321 deletions(-) delete mode 100644 cmd/chat_platform_ctx.go delete mode 100644 internal/engine/conversation_adapter.go delete mode 100644 internal/engine/conversation_adapter_test.go diff --git a/cmd/chat.go b/cmd/chat.go index 6397321a..c46c0e06 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -477,9 +477,6 @@ func (m chatModel) Init() tea.Cmd { cmds := []tea.Cmd{initTerminalMouseCmd(m.mouseEnabled()), promptKeepAliveCmd()} if gw, _ := m.sessionGatewayModel(); strings.TrimSpace(gw) != "" { cmds = append(cmds, fetchModelsAsync(gw)) - if isXiaomiMimoProvider(gw) { - cmds = append(cmds, fetchPlatformContextIndexCmd()) - } } if m.containerEnabled { m.containerStatus = "checking docker…" diff --git a/cmd/chat_config_deployment.go b/cmd/chat_config_deployment.go index 2265aed4..28680b9c 100644 --- a/cmd/chat_config_deployment.go +++ b/cmd/chat_config_deployment.go @@ -9,7 +9,6 @@ import ( tea "charm.land/bubbletea/v2" lipgloss "charm.land/lipgloss/v2" - "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/runtime" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" @@ -48,82 +47,28 @@ func firstRunModelProvider(m chatModel) string { return "" } -func credentialOptionFromHawk(in hawkconfig.CredentialInference) runtime.CredentialProviderOption { - return runtime.CredentialProviderOption{ - ProviderID: in.ProviderID, - DeploymentID: in.DeploymentID, - EnvVar: in.EnvVar, - DisplayName: in.DisplayName, - } -} - func saveProviderKeyAsync(inference hawkconfig.CredentialInference, secret string) tea.Cmd { - return saveCredentialAsync(inference, secret) + return saveCredentialAsync(inference.ProviderID, secret) } func saveOllamaAsync(baseURL string) tea.Cmd { - return func() tea.Msg { - inference, err := runtime.LocalCredentialInference(configProviderOllama) - if err != nil { - return configApplyCredentialsMsg{err: err} - } - inf := hawkconfig.CredentialInference{ - ProviderID: inference.ProviderID, - DeploymentID: inference.DeploymentID, - EnvVar: inference.EnvVar, - DisplayName: inference.DisplayName, - } - return saveCredentialAsync(inf, baseURL)() - } + return saveCredentialAsync(configProviderOllama, baseURL) } -func saveCredentialAsync(inference hawkconfig.CredentialInference, secret string) tea.Cmd { +func saveCredentialAsync(providerID, secret string) tea.Cmd { return func() tea.Msg { ctx := context.Background() - if inference.ProviderID == hawkconfig.ProviderXiaomiTokenPlan { - hawkconfig.ApplyXiaomiTokenPlanRegionEnv(ctx) - } - if inference.ProviderID == hawkconfig.ProviderZAICoding { - hawkconfig.ApplyZAIRegionEnv(ctx) - } - rtInf := config.InferenceFromOption(credentialOptionFromHawk(inference)) - if err := runtime.SaveCredential(ctx, rtInf, secret); err != nil { - return configApplyCredentialsMsg{ - err: err, - providerID: inference.ProviderID, - deploymentID: inference.DeploymentID, - } - } - hawkconfig.InvalidateConfigUICache() - hawkconfig.RefreshConfigCredSnapshot(ctx) - result, err := hawkconfig.ApplyEyrieCredentialsForProvider(ctx, inference.ProviderID) + result, err := hawkconfig.ConfigureProvider(ctx, providerID, secret) if err != nil { return configApplyCredentialsMsg{ - err: err, - providerID: inference.ProviderID, - deploymentID: inference.DeploymentID, + err: err, + providerID: providerID, } } - entries, listErr := runtime.ListModels(ctx, runtime.ListModelsOpts{ProviderID: inference.ProviderID, Source: runtime.ListSourceAuto}) - if listErr != nil { - return configApplyCredentialsMsg{ - err: listErr, - providerID: inference.ProviderID, - deploymentID: inference.DeploymentID, - } - } - opts := configModelOptionsFromEyrie(entries) - if len(opts) == 0 && result.Setup != nil { - fallback := hawkconfig.OptionsFromSetupUI(result.Setup, inference.ProviderID) - opts = toConfigModelOptionsFromHawk(fallback) - } - return configApplyCredentialsMsg{ - summary: hawkconfig.FormatApplyCredentialsSummary(result), - providerID: inference.ProviderID, - deploymentID: inference.DeploymentID, - modelOptions: opts, + summary: result.Summary, providerID: result.ProviderID, + deploymentID: result.DeploymentID, modelOptions: toConfigModelOptionsFromHawk(result.Models), } } } @@ -132,8 +77,10 @@ func toConfigModelOptionsFromHawk(in []hawkconfig.ModelOption) []configModelOpti out := make([]configModelOption, len(in)) for i, o := range in { out[i] = configModelOption{ - ID: o.ID, - DisplayName: o.DisplayName, + ID: o.ID, DisplayName: o.DisplayName, Owner: o.Owner, + ContextWindow: o.ContextWindow, + InputPricePer1M: o.InputPricePer1M, OutputPricePer1M: o.OutputPricePer1M, + PriceKnown: modelOptionPriceKnown(o.ID, o.DisplayName, o.InputPricePer1M, o.OutputPricePer1M, o.ContextWindow), } } return out diff --git a/cmd/chat_config_hub.go b/cmd/chat_config_hub.go index d27905c8..815c031e 100644 --- a/cmd/chat_config_hub.go +++ b/cmd/chat_config_hub.go @@ -5,7 +5,7 @@ import ( tea "charm.land/bubbletea/v2" - "github.com/GrayCodeAI/eyrie/catalog/registry" + "github.com/GrayCodeAI/eyrie/runtime" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) @@ -42,12 +42,7 @@ func (m chatModel) beginConfigModelsTab() (chatModel, tea.Cmd) { return m, refreshGatewayAsync(m.configModelProvider) } if m.configSaving { - var cmds []tea.Cmd - cmds = append(cmds, fetchModelsAsync(m.configModelProvider)) - if isXiaomiMimoProvider(m.configModelProvider) { - cmds = append(cmds, fetchPlatformContextIndexCmd()) - } - return m, tea.Batch(cmds...) + return m, fetchModelsAsync(m.configModelProvider) } m = m.focusConfigActiveModelSelection() return m, nil @@ -70,12 +65,7 @@ func catalogPricesAreStale(opts []configModelOption) bool { } func providerHasLiveFetcher(providerID string) bool { - for _, key := range registry.LiveFetcherKeys() { - if key == providerID { - return true - } - } - return false + return runtime.ProviderSupportsLiveModels(providerID) } func (m chatModel) returnToOllamaURLAfterError(err error) (chatModel, tea.Cmd) { diff --git a/cmd/chat_config_models.go b/cmd/chat_config_models.go index 6f06cb2c..41e0072f 100644 --- a/cmd/chat_config_models.go +++ b/cmd/chat_config_models.go @@ -7,7 +7,6 @@ import ( tea "charm.land/bubbletea/v2" - "github.com/GrayCodeAI/eyrie/catalog" "github.com/GrayCodeAI/eyrie/runtime" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" @@ -39,7 +38,6 @@ func InvalidateModelCache() { modelSyncMu.Lock() modelSyncAttempted = make(map[string]bool) modelSyncMu.Unlock() - invalidatePlatformContextCache() hawkconfig.InvalidateConfigUICache() } @@ -87,24 +85,8 @@ func configModelOptionsFromEyrie(entries []runtime.ModelEntry) []configModelOpti for i, e := range entries { opts[i] = configModelOption{ ID: e.ID, - DisplayName: catalog.DisplayModelLabel(e.ID, e.DisplayName), - Owner: catalog.DisplayModelOwner(e.Owner, e.ID), - ContextWindow: e.ContextWindow, - InputPricePer1M: e.InputPricePer1M, - OutputPricePer1M: e.OutputPricePer1M, - PriceKnown: modelOptionPriceKnown(e.ID, e.DisplayName, e.InputPricePer1M, e.OutputPricePer1M, e.ContextWindow), - } - } - return opts -} - -func configModelOptionsFromCatalog(entries []catalog.ModelCatalogEntry) []configModelOption { - opts := make([]configModelOption, len(entries)) - for i, e := range entries { - opts[i] = configModelOption{ - ID: e.ID, - DisplayName: catalog.DisplayModelLabel(e.ID, e.DisplayName), - Owner: catalog.DisplayModelOwner(e.Owner, e.ID, e.LiveMetadata), + DisplayName: e.DisplayName, + Owner: e.Owner, ContextWindow: e.ContextWindow, InputPricePer1M: e.InputPricePer1M, OutputPricePer1M: e.OutputPricePer1M, @@ -259,14 +241,6 @@ func applyLiveModelMetadata(sess *engine.Session, provider, modelID string) { return } } - if isXiaomiMimoProvider(provider) { - if w := platformContextForNativeModel(modelID); w > 0 { - applyModelOptionToSession(sess, configModelOption{ - ID: modelID, - ContextWindow: w, - }) - } - } } func loadConfigModelOptions(provider string) []configModelOption { @@ -280,15 +254,13 @@ func loadConfigModelOptions(provider string) []configModelOption { return cached } modelCacheMu.RUnlock() - if compiled := hawkconfig.CompiledCatalogV1(); compiled != nil { - entries := catalog.ModelEntriesForProvider(compiled, provider) - if len(entries) > 0 { - opts := configModelOptionsFromCatalog(entries) - modelCacheMu.Lock() - modelCache[provider] = opts - modelCacheMu.Unlock() - return opts - } + entries, err := runtime.ListModels(context.Background(), runtime.ListModelsOpts{ProviderID: provider, Source: runtime.ListSourceCache}) + if err == nil && len(entries) > 0 { + opts := configModelOptionsFromEyrie(entries) + modelCacheMu.Lock() + modelCache[provider] = opts + modelCacheMu.Unlock() + return opts } return nil } diff --git a/cmd/chat_platform_ctx.go b/cmd/chat_platform_ctx.go deleted file mode 100644 index 5b0e7cf8..00000000 --- a/cmd/chat_platform_ctx.go +++ /dev/null @@ -1,86 +0,0 @@ -package cmd - -import ( - "context" - "strings" - "sync" - "time" - - tea "charm.land/bubbletea/v2" - "github.com/GrayCodeAI/eyrie/catalog/xiaomi" -) - -var platformCtxCache struct { - mu sync.Mutex - idx map[string]int - at time.Time -} - -func isXiaomiMimoProvider(provider string) bool { - p := strings.ToLower(strings.TrimSpace(provider)) - p = strings.ReplaceAll(p, "-", "_") - switch p { - case "xiaomi_mimo_token_plan", "xiaomi_mimo_payg", "xiaomi_mimo": - return true - default: - return false - } -} - -// platformContextForNativeModel reads the public MiMo platform catalog (no API key) from cache. -func platformContextForNativeModel(modelID string) int { - modelID = xiaomi.NativeModelID(strings.TrimSpace(modelID)) - if modelID == "" { - return 0 - } - - platformCtxCache.mu.Lock() - defer platformCtxCache.mu.Unlock() - - if platformCtxCache.idx == nil { - return 0 - } - return platformCtxCache.idx[modelID] -} - -type platformContextIndexMsg struct { - idx map[string]int - err error -} - -func fetchPlatformContextIndexCmd() tea.Cmd { - return func() tea.Msg { - idx, err := xiaomi.FetchPlatformModelsIndex(context.Background(), "") - if err != nil { - return platformContextIndexMsg{err: err} - } - m := make(map[string]int, len(idx)) - for k, pm := range idx { - if pm.ContextLength > 0 { - m[xiaomi.NativeModelID(k)] = pm.ContextLength - } - } - return platformContextIndexMsg{idx: m} - } -} - -func updatePlatformContextCache(msg platformContextIndexMsg) { - platformCtxCache.mu.Lock() - defer platformCtxCache.mu.Unlock() - if msg.err != nil { - if platformCtxCache.idx == nil { - platformCtxCache.idx = make(map[string]int) - } - platformCtxCache.at = time.Now() - return - } - platformCtxCache.idx = msg.idx - platformCtxCache.at = time.Now() -} - -func invalidatePlatformContextCache() { - platformCtxCache.mu.Lock() - platformCtxCache.idx = nil - platformCtxCache.at = time.Time{} - platformCtxCache.mu.Unlock() -} diff --git a/cmd/chat_print.go b/cmd/chat_print.go index bcea18f8..340745f8 100644 --- a/cmd/chat_print.go +++ b/cmd/chat_print.go @@ -390,7 +390,7 @@ func replModelsSummary(settings hawkconfig.Settings, sessionProvider string) (st rows := make([]modelTableRow, len(models)) for i, m := range models { - rows[i] = modelTableRowFromCatalogEntry(m) + rows[i] = modelTableRowFromRuntimeEntry(m) } var b strings.Builder diff --git a/cmd/chat_status.go b/cmd/chat_status.go index 81c8dc10..00fc88b4 100644 --- a/cmd/chat_status.go +++ b/cmd/chat_status.go @@ -156,15 +156,6 @@ func (m chatModel) connectionStatusParts() (gateway, model, contextLabel string) contextLabel = formatModelTableContext(w) } } - if (contextLabel == "" || contextLabel == "—") && isXiaomiMimoProvider(gw) { - if w := platformContextForNativeModel(modelID); w > 0 { - contextLabel = formatModelTableContext(w) - if m.session != nil { - m.session.SetContextWindowCached(w) - m.session.EnsureAutoCompactor() - } - } - } // Omit ctx segment until a real window is known — never show the 128k default. } return gateway, model, contextLabel diff --git a/cmd/chat_status_metadata_test.go b/cmd/chat_status_metadata_test.go index b5fb4355..225e2f73 100644 --- a/cmd/chat_status_metadata_test.go +++ b/cmd/chat_status_metadata_test.go @@ -2,21 +2,11 @@ package cmd import ( "testing" - "time" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" ) -// seedPlatformContextCacheForTest primes the platform context cache so tests -// avoid network lookups. Test-only helper. -func seedPlatformContextCacheForTest(idx map[string]int) { - platformCtxCache.mu.Lock() - platformCtxCache.idx = idx - platformCtxCache.at = time.Now() - platformCtxCache.mu.Unlock() -} - func TestModelStatusMeta_UsesLiveModelCache(t *testing.T) { provider := "xiaomi_mimo_token_plan" modelID := "mimo-v2.5-pro" @@ -40,16 +30,6 @@ func TestModelStatusMeta_UsesLiveModelCache(t *testing.T) { } } -func TestPlatformContextForNativeModel_MimoV25Pro(t *testing.T) { - invalidatePlatformContextCache() - seedPlatformContextCacheForTest(map[string]int{"mimo-v2.5-pro": 1_048_576}) - t.Cleanup(invalidatePlatformContextCache) - w := platformContextForNativeModel("mimo-v2.5-pro") - if w < 1_000_000 { - t.Fatalf("platform context = %d, want >= 1M", w) - } -} - func TestConnectionStatusParts_OmitsDefault128kPlaceholder(t *testing.T) { m := chatModel{session: &engine.Session{}} m.session.SetModel("mimo-v2.5-pro") @@ -60,22 +40,6 @@ func TestConnectionStatusParts_OmitsDefault128kPlaceholder(t *testing.T) { } } -func TestConnectionStatusParts_MimoShowsPlatformContext(t *testing.T) { - invalidatePlatformContextCache() - seedPlatformContextCacheForTest(map[string]int{"mimo-v2.5-pro": 1_048_576}) - t.Cleanup(invalidatePlatformContextCache) - sess := &engine.Session{} - sess.SetProvider("xiaomi_mimo_token_plan") - sess.SetModel("mimo-v2.5-pro") - applyLiveModelMetadata(sess, "xiaomi_mimo_token_plan", "mimo-v2.5-pro") - - m := chatModel{session: sess} - _, _, ctxLabel := m.connectionStatusParts() - if ctxLabel != "1.0m" { - t.Fatalf("context label = %q, want 1.0m", ctxLabel) - } -} - func TestApplyLiveModelMetadata_SetsSessionWindow(t *testing.T) { provider := "xiaomi_mimo_token_plan" modelID := "mimo-v2.5-pro" @@ -96,42 +60,6 @@ func TestApplyLiveModelMetadata_SetsSessionWindow(t *testing.T) { } } -func TestIsXiaomiMimoProvider_AcceptsHyphenAndUnderscore(t *testing.T) { - cases := []string{ - "xiaomi_mimo_token_plan", - "xiaomi-mimo-token-plan", - "XIAOMI_MIMO_TOKEN_PLAN", - "xiaomi_mimo_payg", - "xiaomi-mimo-payg", - "xiaomi_mimo", - "xiaomi-mimo", - } - for _, c := range cases { - if !isXiaomiMimoProvider(c) { - t.Errorf("isXiaomiMimoProvider(%q) = false, want true", c) - } - } - if isXiaomiMimoProvider("anthropic") || isXiaomiMimoProvider("openai") { - t.Error("non-xiaomi providers should return false") - } -} - -func TestConnectionStatusParts_MimoShowsPlatformContext_HyphenProvider(t *testing.T) { - invalidatePlatformContextCache() - seedPlatformContextCacheForTest(map[string]int{"mimo-v2.5-pro": 1_048_576}) - t.Cleanup(invalidatePlatformContextCache) - sess := &engine.Session{} - sess.SetProvider("xiaomi-mimo-token-plan") // hyphenated as normalized at runtime - sess.SetModel("mimo-v2.5-pro") - applyLiveModelMetadata(sess, "xiaomi-mimo-token-plan", "mimo-v2.5-pro") - - m := chatModel{session: sess} - _, _, ctxLabel := m.connectionStatusParts() - if ctxLabel != "1.0m" { - t.Fatalf("context label = %q, want 1.0m for hyphen provider", ctxLabel) - } -} - func TestGatewayDisplayName_XiaomiTokenPlanHyphen(t *testing.T) { if got := hawkconfig.GatewayDisplayName("xiaomi-mimo-token-plan"); got != "Xiaomi MiMo — Token Plan" { t.Fatalf("GatewayDisplayName(hyphen) = %q, want nice name", got) diff --git a/cmd/chat_update.go b/cmd/chat_update.go index e468b606..13375d5c 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -640,12 +640,6 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.submitUserMessage() } - case platformContextIndexMsg: - updatePlatformContextCache(msg) - m.invalidateConnStatus() - m.viewDirty = true - return m, nil - case modelsFetchedMsg: m.configSaving = false if msg.err != nil { diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index 5cefb756..940298e3 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -15,7 +15,6 @@ import ( "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/tool" - "github.com/GrayCodeAI/hawk/internal/types" "github.com/GrayCodeAI/hawk/internal/ui/icons" ) @@ -364,7 +363,7 @@ func indentedAPIKeyLines() string { } func apiKeyStatusLines() []string { - providers := types.NewClient(nil).GetProviders() + providers := hawkconfig.AllSetupGateways() sort.Strings(providers) var lines []string for _, provider := range providers { diff --git a/cmd/completions.go b/cmd/completions.go index 1cbfa80f..9d58b4cd 100644 --- a/cmd/completions.go +++ b/cmd/completions.go @@ -9,7 +9,7 @@ import ( "sort" "strings" - "github.com/GrayCodeAI/eyrie/catalog/registry" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/provider/routing" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -63,11 +63,7 @@ func (g *CompletionGenerator) populateFlags() { } func (g *CompletionGenerator) populateProviders() { - providers := registry.All() - g.Providers = make([]string, 0, len(providers)) - for _, provider := range providers { - g.Providers = append(g.Providers, provider.ProviderID) - } + g.Providers = hawkconfig.AllSetupGateways() } func (g *CompletionGenerator) populateModels() { diff --git a/cmd/model_table.go b/cmd/model_table.go index 83b30e8d..2bd93b79 100644 --- a/cmd/model_table.go +++ b/cmd/model_table.go @@ -7,6 +7,7 @@ import ( lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/runtime" "github.com/GrayCodeAI/hawk/internal/ui/icons" "github.com/mattn/go-runewidth" ) @@ -303,12 +304,12 @@ func modelTableFooter(total, scroll, end, allTotal int, muted lipgloss.Style) st return muted.Render(fmt.Sprintf("%s%s · enter to select", prefix, label)) } -func modelTableRowFromCatalogEntry(m catalog.ModelCatalogEntry) modelTableRow { +func modelTableRowFromRuntimeEntry(m runtime.ModelEntry) modelTableRow { name := strings.TrimSpace(m.DisplayName) if name == "" { name = m.ID } - owner := catalog.ModelOwner(m) + owner := strings.TrimSpace(m.Owner) if owner == "" { owner = "—" } diff --git a/cmd/models.go b/cmd/models.go index b20e61cd..a5af0674 100644 --- a/cmd/models.go +++ b/cmd/models.go @@ -6,8 +6,7 @@ import ( "fmt" "time" - "github.com/GrayCodeAI/eyrie/catalog" - eyriecfg "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/runtime" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/spf13/cobra" ) @@ -92,13 +91,16 @@ var modelsListCmd = &cobra.Command{ providerName = args[0] } ctx := context.Background() - var models []catalog.ModelCatalogEntry + var models []runtime.ModelEntry var err error if modelsListLive { if providerName == "" { return fmt.Errorf("provider required with --live (e.g. hawk models list canopywave --live --json)") } - models, err = catalog.FetchLiveModelEntriesForProvider(eyriecfg.DiscoveryEnvMap(ctx), hawkconfig.ActiveProviderID(providerName)) + models, err = runtime.ListModels(ctx, runtime.ListModelsOpts{ + ProviderID: hawkconfig.ActiveProviderID(providerName), + Source: runtime.ListSourceLive, + }) } else { models, err = hawkconfig.FetchModelsForProvider(providerName) } @@ -143,7 +145,7 @@ var modelsListCmd = &cobra.Command{ cmd.Println() rows := make([]modelTableRow, len(models)) for i, m := range models { - rows[i] = modelTableRowFromCatalogEntry(m) + rows[i] = modelTableRowFromRuntimeEntry(m) } printModelTablePlain(rows) return nil diff --git a/internal/config/catalog_api.go b/internal/config/catalog_api.go index e35ebf0d..b00a000a 100644 --- a/internal/config/catalog_api.go +++ b/internal/config/catalog_api.go @@ -100,15 +100,11 @@ func GatewayStatuses(ctx context.Context, activeProvider, activeModel string) [] configured := credConfigured uiCacheMu.RUnlock() - compiled := compiledCatalogOrBootstrap() gateways := runtime.SetupGateways() statuses := make([]GatewayStatus, 0, len(gateways)) for _, providerID := range gateways { - count := 0 - if compiled != nil { - count = len(catalog.ModelEntriesForProvider(compiled, providerID)) - } + count := runtime.CachedModelCountForProvider(ctx, providerID) hasKey := false if configured != nil { diff --git a/internal/config/credentials_store.go b/internal/config/credentials_store.go index 81eaac50..52d925f9 100644 --- a/internal/config/credentials_store.go +++ b/internal/config/credentials_store.go @@ -38,8 +38,43 @@ func PrepareCredentialDiscovery(ctx context.Context) { // ModelOption is one hawk /config model row. type ModelOption struct { - ID string - DisplayName string + ID string + DisplayName string + Owner string + ContextWindow int + InputPricePer1M float64 + OutputPricePer1M float64 +} + +// ConfigureProviderResult is the Hawk-facing view of Eyrie's provider setup transaction. +type ConfigureProviderResult struct { + ProviderID string + DeploymentID string + Summary string + Models []ModelOption +} + +// ConfigureProvider delegates the complete save/probe/catalog/model flow to Eyrie. +func ConfigureProvider(ctx context.Context, providerID, secret string) (*ConfigureProviderResult, error) { + result, err := runtime.ConfigureProvider(ctx, runtime.ConfigureProviderOpts{ProviderID: providerID, Secret: secret}) + if err != nil { + return nil, err + } + models := make([]ModelOption, len(result.Models)) + for i, model := range result.Models { + models[i] = ModelOption{ + ID: model.ID, DisplayName: model.DisplayName, Owner: model.Owner, + ContextWindow: model.ContextWindow, + InputPricePer1M: model.InputPricePer1M, OutputPricePer1M: model.OutputPricePer1M, + } + } + InvalidateConfigUICache() + RefreshConfigCredSnapshot(ctx) + _ = SaveProjectOrGlobalDeploymentRouting(true) + return &ConfigureProviderResult{ + ProviderID: result.ProviderID, DeploymentID: result.DeploymentID, + Summary: result.Summary, Models: models, + }, nil } // CredentialInference is one eyrie provider match for a pasted API key. diff --git a/internal/config/eyrie_apply.go b/internal/config/eyrie_apply.go index 44debcdb..0952efb4 100644 --- a/internal/config/eyrie_apply.go +++ b/internal/config/eyrie_apply.go @@ -9,18 +9,6 @@ import ( "github.com/GrayCodeAI/eyrie/setup" ) -// ApplyEyrieCredentialsForProvider refreshes live models for one provider after /config saves a key. -func ApplyEyrieCredentialsForProvider(ctx context.Context, providerID string) (*setup.ApplyCredentialsResult, error) { - ctx, cancel := context.WithTimeout(ctx, 90*time.Second) - defer cancel() - result, err := runtime.ApplyCredentialsForProvider(ctx, providerID) - if err != nil { - return nil, err - } - _ = SaveProjectOrGlobalDeploymentRouting(true) - return result, nil -} - // ApplyEyrieCredentials discovers the catalog and writes provider.json (routing only on disk). func ApplyEyrieCredentials(ctx context.Context) (*setup.ApplyCredentialsResult, error) { ctx, cancel := context.WithTimeout(ctx, 90*time.Second) diff --git a/internal/config/settings.go b/internal/config/settings.go index 7dade60e..36c7dae2 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -24,8 +24,8 @@ import ( "github.com/GrayCodeAI/hawk/internal/types" ) -func fetchModelsViaRuntime(ctx context.Context, provider string) ([]catalog.ModelCatalogEntry, error) { - return runtime.ModelsForProvider(ctx, provider) +func fetchModelsViaRuntime(ctx context.Context, provider string) ([]runtime.ModelEntry, error) { + return runtime.ListModels(ctx, runtime.ListModelsOpts{ProviderID: provider, Source: runtime.ListSourceCache}) } // Settings holds hawk configuration. @@ -621,7 +621,7 @@ func providerCredentialEnvAliases(provider string) []string { // FetchModelsForProvider returns models from the eyrie catalog (dynamic; no hawk hardcoded lists). // RefreshModelCatalogV1 is the explicit network refresh boundary. -func FetchModelsForProvider(provider string) ([]catalog.ModelCatalogEntry, error) { +func FetchModelsForProvider(provider string) ([]runtime.ModelEntry, error) { provider = runtime.CatalogProviderID(provider) if provider == "" { return nil, fmt.Errorf("no provider specified") @@ -643,9 +643,8 @@ func FetchModelsForProvider(provider string) ([]catalog.ModelCatalogEntry, error continue } if id := strings.TrimSpace(cp.Model); id != "" { - return []catalog.ModelCatalogEntry{{ - ID: id, - DisplayName: id, + return []runtime.ModelEntry{{ + ID: id, DisplayName: id, ProviderID: provider, Source: "custom", }}, nil } } diff --git a/internal/config/xiaomi_setup.go b/internal/config/xiaomi_setup.go index 83d354e5..a5e96375 100644 --- a/internal/config/xiaomi_setup.go +++ b/internal/config/xiaomi_setup.go @@ -1,10 +1,6 @@ package config -import ( - "context" - - "github.com/GrayCodeAI/eyrie/runtime" -) +import "github.com/GrayCodeAI/eyrie/runtime" const ProviderXiaomiTokenPlan = "xiaomi_mimo_token_plan" // #nosec G101 -- provider ID string, not a credential @@ -22,8 +18,3 @@ func SetXiaomiTokenPlanRegion(region string) error { func XiaomiTokenPlanRegionLabel() string { return runtime.GatewayRegionLabel(ProviderXiaomiTokenPlan) } - -// ApplyXiaomiTokenPlanRegionEnv sets process env from provider.json before credential probe. -func ApplyXiaomiTokenPlanRegionEnv(ctx context.Context) { - runtime.ApplyGatewayEnv(ctx, ProviderXiaomiTokenPlan) -} diff --git a/internal/config/xiaomi_setup_test.go b/internal/config/xiaomi_setup_test.go index 768539f0..83d34c63 100644 --- a/internal/config/xiaomi_setup_test.go +++ b/internal/config/xiaomi_setup_test.go @@ -1,7 +1,6 @@ package config import ( - "context" "testing" eyriecfg "github.com/GrayCodeAI/eyrie/config" @@ -30,7 +29,6 @@ func TestSetXiaomiTokenPlanRegion_ClearsStaleBaseHost(t *testing.T) { // if loaded.XiaomiMimoTokenPlanBaseURL != want { // t.Fatalf("base = %q, want %s", loaded.XiaomiMimoTokenPlanBaseURL, want) // } - ApplyXiaomiTokenPlanRegionEnv(context.Background()) } func TestNeedsXiaomiTokenPlanRegion_InvalidAndMissing(t *testing.T) { diff --git a/internal/config/zai_setup.go b/internal/config/zai_setup.go index 4d19793f..aa0b3011 100644 --- a/internal/config/zai_setup.go +++ b/internal/config/zai_setup.go @@ -1,10 +1,6 @@ package config -import ( - "context" - - "github.com/GrayCodeAI/eyrie/runtime" -) +import "github.com/GrayCodeAI/eyrie/runtime" const ( ProviderZAIPayg = "zai_payg" @@ -25,9 +21,3 @@ func SetZAIRegion(providerID, region string) error { func ZAIRegionLabel(providerID string) string { return runtime.GatewayRegionLabel(providerID) } - -// ApplyZAIRegionEnv sets process envs from provider.json before probe/fetch/chat. -func ApplyZAIRegionEnv(ctx context.Context) { - runtime.ApplyGatewayEnv(ctx, ProviderZAIPayg) - runtime.ApplyGatewayEnv(ctx, ProviderZAICoding) -} diff --git a/internal/engine/chat_service.go b/internal/engine/chat_service.go index a04cfc60..353235ba 100644 --- a/internal/engine/chat_service.go +++ b/internal/engine/chat_service.go @@ -51,6 +51,11 @@ type ChatService struct { // glmThinkingEnabled toggles GLM/Z.ai extended reasoning on outgoing // requests. nil leaves the model default. glmThinkingEnabled *bool + // onContextOverflow, when set, is invoked after the provider rejects a + // request as too large. It must shrink the conversation (e.g. via the + // Session's smart compaction) and return the replacement history; + // ok=false means nothing was compacted and the retry is skipped. + onContextOverflow func(ctx context.Context) (msgs []types.EyrieMessage, ok bool) } // ChatServiceConfig bundles the optional fields the constructor doesn't @@ -68,6 +73,10 @@ type ChatServiceConfig struct { ContinuationConfig types.ContinuationConfig OutputSchema string GLMThinkingEnabled *bool + // OnContextOverflow compacts the conversation after a "context too + // long" provider error and returns the replacement message history. + // Optional; when nil, overflow errors are returned to the caller. + OnContextOverflow func(ctx context.Context) ([]types.EyrieMessage, bool) } // NewChatService constructs a ChatService with sensible defaults for any @@ -100,9 +109,17 @@ func NewChatService(client ChatClient, cfg ChatServiceConfig) *ChatService { contCfg: cfg.ContinuationConfig, outputSchema: cfg.OutputSchema, glmThinkingEnabled: cfg.GLMThinkingEnabled, + onContextOverflow: cfg.OnContextOverflow, } } +// SetOnContextOverflow installs the emergency-compaction hook. Used by +// NewSessionWithClient, which constructs the ChatService before the +// persistence service the hook reads from. +func (c *ChatService) SetOnContextOverflow(fn func(ctx context.Context) ([]types.EyrieMessage, bool)) { + c.onContextOverflow = fn +} + // Client returns the underlying eyrie client. Exposed for callers (e.g. // background goroutines) that need to issue one-off LLM calls without // the agent-loop retry wrapper. @@ -155,23 +172,14 @@ func (c *ChatService) Reattach(client ChatClient, provider string) { // max tokens, tools, structured output, etc.). func (c *ChatService) BuildOptions(systemPrompt, activeModel string, maxTokens int, tools []types.EyrieTool) types.ChatOptions { opts := types.ChatOptions{ - Provider: c.provider, - Model: activeModel, - MaxTokens: maxTokens, - System: systemPrompt, - EnableCaching: c.provider == "anthropic", - Tools: tools, - } - // GLM/Z.ai extended reasoning toggle: only meaningful for Z.AI - // providers, where eyrie emits thinking={type:enabled|disabled}. - if isZAIProvider(c.provider) && c.glmThinkingEnabled != nil { - opts.GLMThinkingEnabled = c.glmThinkingEnabled + Provider: c.provider, Model: activeModel, MaxTokens: maxTokens, + System: systemPrompt, Tools: tools, GLMThinkingEnabled: c.glmThinkingEnabled, } // Structured output: request a JSON-schema-constrained response when set. if c.outputSchema != "" { opts.ResponseFormat = &types.ResponseFormat{Type: "json_schema", Schema: c.outputSchema} } - return opts + return types.ApplyProviderChatDefaults(c.provider, opts) } // Stream issues a streaming LLM call with retry, rate-limit, and @@ -199,12 +207,18 @@ func (c *ChatService) Stream(ctx context.Context, messages []types.EyrieMessage, c.metrics.Counter("api.requests").Inc() var result *types.StreamResult + compacted := false err := retry.Do(ctx, c.retryCfg, func() error { var callErr error result, callErr = c.client.StreamChatContinue(ctx, messages, opts, c.contCfg) - if callErr != nil { - // On context overflow, do an emergency compact and retry once. - if isContextOverflow(callErr) { + if callErr != nil && types.IsContextOverflow(callErr) && !compacted && c.onContextOverflow != nil { + // Emergency compact: shrink the conversation once, then retry + // with the replacement history. Without a hook (or when the + // compact makes no progress) the overflow surfaces to the caller + // — retrying the identical oversized request cannot succeed. + if replacement, ok := c.onContextOverflow(ctx); ok { + compacted = true + messages = replacement result, callErr = c.client.StreamChatContinue(ctx, messages, opts, c.contCfg) } } @@ -223,40 +237,6 @@ func (c *ChatService) Chat(ctx context.Context, messages []types.EyrieMessage, o return c.client.Chat(ctx, messages, opts) } -// isContextOverflow reports whether err looks like a "context too long" -// error from the upstream provider. Used by Stream() to trigger an -// emergency context-compact + retry. -func isContextOverflow(err error) bool { - if err == nil { - return false - } - msg := err.Error() - return contains(msg, "too long") || contains(msg, "too many tokens") -} - -func contains(s, sub string) bool { - return len(sub) > 0 && len(s) >= len(sub) && (s == sub || (len(s) > 0 && indexOf(s, sub) >= 0)) -} - -// isZAIProvider reports whether the provider is a Z.AI gateway (payg or coding). -func isZAIProvider(provider string) bool { - switch provider { - case "zai_payg", "zai_coding": - return true - default: - return false - } -} - // Router returns the legacy single-provider circuit breaker. New // code should access this through s.ChatLLM().Router(). func (c *ChatService) Router() *modelPkg.Router { return c.router } - -func indexOf(s, sub string) int { - for i := 0; i+len(sub) <= len(s); i++ { - if s[i:i+len(sub)] == sub { - return i - } - } - return -1 -} diff --git a/internal/engine/compact_provider_native.go b/internal/engine/compact_provider_native.go index 3dd047ff..3132d6d8 100644 --- a/internal/engine/compact_provider_native.go +++ b/internal/engine/compact_provider_native.go @@ -1,266 +1,58 @@ package engine import ( - "bytes" "context" - "encoding/json" "fmt" - "io" - "net/http" - "strings" - "time" + "github.com/GrayCodeAI/eyrie/runtime" "github.com/GrayCodeAI/hawk/internal/types" ) -const ( - anthropicCompactBeta = "compact-2026-01-12" - anthropicCompactEdit = "compact_20260112" - anthropicMinCompactTrig = 50_000 -) - -// ProviderNativeCompactStrategy uses Anthropic server-side compaction when available. +// ProviderNativeCompactStrategy delegates provider-specific compaction to Eyrie. type ProviderNativeCompactStrategy struct{} func (s *ProviderNativeCompactStrategy) Name() string { return "provider_native" } func (s *ProviderNativeCompactStrategy) ShouldTrigger(msgs []types.EyrieMessage, tokenCount, threshold int) bool { - if tokenCount < threshold || len(msgs) < 8 { - return false - } - return true + return tokenCount >= threshold && len(msgs) >= 8 } func (s *ProviderNativeCompactStrategy) Compact(ctx context.Context, sess *Session) (*CompactResult, error) { if sess == nil { return nil, fmt.Errorf("no session") } - if !sess.supportsAnthropicNativeCompaction() { + if !sess.supportsProviderNativeCompaction(ctx) { return nil, fmt.Errorf("provider native compaction not available") } tokensBefore := EstimateTokens(sess.messages) - result, err := anthropicNativeCompact(ctx, sess, tokensBefore) + result, err := runtime.CompactNativeConversation(ctx, runtime.NativeCompactionOpts{ + Provider: sess.provider, + Model: sess.model, + Messages: types.ToClientMessages(sess.messages), + ContextWindow: sess.ContextWindowSize(), + ThresholdPct: sess.compactThresholdPct(), + MaxOutputTokens: 8192, + }) if err != nil { return nil, err } - sess.messages = result.Messages - return result, nil -} - -func (s *Session) supportsAnthropicNativeCompaction() bool { - if s == nil { - return false - } - key := s.anthropicAPIKey() - if key == "" { - return false - } - prov := strings.ToLower(strings.TrimSpace(s.provider)) - if prov != "anthropic" && !strings.Contains(prov, "anthropic") { - // Allow claude models routed via compatible gateways when direct key exists. - model := strings.ToLower(strings.TrimSpace(s.model)) - if !strings.HasPrefix(model, "claude-") { - return false - } - } - return anthropicCompactionModel(s.model) -} - -func (s *Session) anthropicAPIKey() string { - if s == nil { - return "" - } - s.mu.RLock() - defer s.mu.RUnlock() - if k := strings.TrimSpace(s.apiKeys["anthropic"]); k != "" { - return k - } - return "" -} - -func anthropicCompactionModel(model string) bool { - m := strings.ToLower(strings.TrimSpace(model)) - if m == "" { - return false - } - // Supported families per Anthropic compaction docs. - prefixes := []string{ - "claude-opus-4", "claude-sonnet-4", "claude-mythos", - } - for _, p := range prefixes { - if strings.HasPrefix(m, p) { - return true - } - } - return false -} - -func anthropicNativeCompact(ctx context.Context, sess *Session, tokensBefore int) (*CompactResult, error) { - key := sess.anthropicAPIKey() - trigger := sess.ContextWindowSize() * sess.compactThresholdPct() / 100 - if trigger < anthropicMinCompactTrig { - trigger = anthropicMinCompactTrig - } - - msgs, system := buildAnthropicPayloadMessages(sess.messages) - body := map[string]interface{}{ - "model": sess.model, - "max_tokens": 8192, - "messages": msgs, - "context_management": map[string]interface{}{ - "edits": []map[string]interface{}{ - { - "type": anthropicCompactEdit, - "trigger": map[string]interface{}{ - "type": "input_tokens", - "value": trigger, - }, - "pause_after_compaction": true, - }, - }, - }, - } - if system != "" { - body["system"] = system - } - raw, err := json.Marshal(body) - if err != nil { - return nil, err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(raw)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Api-Key", key) - req.Header.Set("Anthropic-Version", "2023-06-01") - req.Header.Set("Anthropic-Beta", anthropicCompactBeta) - - client := &http.Client{Timeout: 120 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - respBody, _ := io.ReadAll(resp.Body) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("anthropic compaction HTTP %d: %s", resp.StatusCode, truncateErrBody(respBody)) - } - - var parsed anthropicCompactResponse - if err := json.Unmarshal(respBody, &parsed); err != nil { - return nil, fmt.Errorf("parse compaction response: %w", err) - } - summary := parsed.compactionSummary() - if summary == "" { - return nil, fmt.Errorf("anthropic compaction produced no summary (stop=%s)", parsed.StopReason) - } keepEnd := 6 if keepEnd > len(sess.messages) { keepEnd = len(sess.messages) } tail := append([]types.EyrieMessage(nil), sess.messages[len(sess.messages)-keepEnd:]...) - compactMsg := types.EyrieMessage{ - Role: "user", - Content: FormatCompactSummary(summary), + newMessages := append([]types.EyrieMessage{{Role: "user", Content: FormatCompactSummary(result.Summary)}}, tail...) + compactResult := &CompactResult{ + Messages: newMessages, TokensBefore: tokensBefore, TokensAfter: EstimateTokens(newMessages), Strategy: s.Name(), } - newMsgs := append([]types.EyrieMessage{compactMsg}, tail...) - tokensAfter := EstimateTokens(newMsgs) - - return &CompactResult{ - Messages: newMsgs, - TokensBefore: tokensBefore, - TokensAfter: tokensAfter, - Strategy: "provider_native", - }, nil + sess.messages = compactResult.Messages + return compactResult, nil } -type anthropicCompactResponse struct { - StopReason string `json:"stop_reason"` - Content []struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - // compaction block may nest summary in text or dedicated fields - Summary string `json:"summary,omitempty"` - } `json:"content"` -} - -func (r *anthropicCompactResponse) compactionSummary() string { - for _, block := range r.Content { - switch block.Type { - case "compaction", "text": - if s := strings.TrimSpace(block.Summary); s != "" { - return s - } - if s := strings.TrimSpace(block.Text); s != "" { - return s - } - } - } - return "" -} - -func buildAnthropicPayloadMessages(messages []types.EyrieMessage) ([]map[string]interface{}, string) { - var system string - out := make([]map[string]interface{}, 0, len(messages)) - for _, m := range messages { - if m.Role == "system" { - system = m.Content - continue - } - if m.Role == "assistant" && len(m.ToolUse) > 0 { - content := make([]map[string]interface{}, 0) - if m.Content != "" { - content = append(content, map[string]interface{}{"type": "text", "text": m.Content}) - } - for _, tc := range m.ToolUse { - input := tc.Arguments - if input == nil { - input = map[string]interface{}{} - } - content = append(content, map[string]interface{}{ - "type": "tool_use", - "id": tc.ID, - "name": tc.Name, - "input": input, - }) - } - out = append(out, map[string]interface{}{"role": "assistant", "content": content}) - continue - } - if m.Role == "user" && len(m.ToolResults) > 0 { - content := make([]map[string]interface{}, 0) - if m.Content != "" { - content = append(content, map[string]interface{}{"type": "text", "text": m.Content}) - } - for _, tr := range m.ToolResults { - block := map[string]interface{}{ - "type": "tool_result", - "tool_use_id": tr.ToolUseID, - "content": tr.Content, - } - if tr.IsError { - block["is_error"] = true - } - content = append(content, block) - } - out = append(out, map[string]interface{}{"role": "user", "content": content}) - continue - } - out = append(out, map[string]interface{}{ - "role": m.Role, - "content": m.Content, - }) - } - return out, system -} - -func truncateErrBody(b []byte) string { - s := strings.TrimSpace(string(b)) - if len(s) > 200 { - return s[:200] + "..." +func (s *Session) supportsProviderNativeCompaction(ctx context.Context) bool { + if s == nil { + return false } - return s + return runtime.SupportsNativeCompaction(ctx, s.provider, s.model) } diff --git a/internal/engine/compact_provider_native_test.go b/internal/engine/compact_provider_native_test.go index 5f6985be..f75e5d7d 100644 --- a/internal/engine/compact_provider_native_test.go +++ b/internal/engine/compact_provider_native_test.go @@ -1,18 +1,18 @@ package engine -import "testing" +import ( + "testing" -func TestAnthropicCompactionModel(t *testing.T) { - cases := map[string]bool{ - "claude-sonnet-4-6": true, - "claude-opus-4-8": true, - "claude-mythos-preview": true, - "gpt-4o": false, - "": false, + "github.com/GrayCodeAI/hawk/internal/types" +) + +func TestProviderNativeCompactionTrigger(t *testing.T) { + strategy := &ProviderNativeCompactStrategy{} + messages := make([]types.EyrieMessage, 8) + if !strategy.ShouldTrigger(messages, 80_000, 80_000) { + t.Fatal("expected provider-native compaction at threshold") } - for model, want := range cases { - if got := anthropicCompactionModel(model); got != want { - t.Errorf("anthropicCompactionModel(%q) = %v, want %v", model, got, want) - } + if strategy.ShouldTrigger(messages[:7], 80_000, 80_000) { + t.Fatal("expected short conversations to skip provider-native compaction") } } diff --git a/internal/engine/compact_strategy_engine.go b/internal/engine/compact_strategy_engine.go index 4ab9a443..1b8ef6f6 100644 --- a/internal/engine/compact_strategy_engine.go +++ b/internal/engine/compact_strategy_engine.go @@ -32,7 +32,7 @@ func NewStrategyRegistry(config CompactConfig) *StrategyRegistry { func (r *StrategyRegistry) SelectStrategy(sess *Session, msgs []types.EyrieMessage, tokenCount int) CompactStrategy { threshold := r.config.ContextWindowSize - r.config.AutoCompactBuffer - r.config.MaxOutputTokens for _, s := range r.strategies { - if _, ok := s.(*ProviderNativeCompactStrategy); ok && (sess == nil || !sess.supportsAnthropicNativeCompaction()) { + if _, ok := s.(*ProviderNativeCompactStrategy); ok && (sess == nil || !sess.supportsProviderNativeCompaction(context.Background())) { continue } if s.ShouldTrigger(msgs, tokenCount, threshold) { diff --git a/internal/engine/context_compaction_test.go b/internal/engine/context_compaction_test.go index deb291ef..f286a316 100644 --- a/internal/engine/context_compaction_test.go +++ b/internal/engine/context_compaction_test.go @@ -15,18 +15,3 @@ func TestContextUsedTokens_PrefersAPI(t *testing.T) { t.Fatalf("expected API prompt tokens 42000, got %d", got) } } - -func TestSupportsAnthropicNativeCompaction_Model(t *testing.T) { - s := NewSession("", "claude-sonnet-4-6", "sys", nil) - s.mu.Lock() - s.apiKeys["anthropic"] = "sk-test" - s.provider = "anthropic" - s.mu.Unlock() - if !s.supportsAnthropicNativeCompaction() { - t.Fatal("expected anthropic native compaction support") - } - s.SetModel("gpt-4o") - if s.supportsAnthropicNativeCompaction() { - t.Fatal("expected no support for non-claude model") - } -} diff --git a/internal/engine/conversation_adapter.go b/internal/engine/conversation_adapter.go deleted file mode 100644 index 2046361a..00000000 --- a/internal/engine/conversation_adapter.go +++ /dev/null @@ -1,240 +0,0 @@ -package engine - -import ( - "fmt" - "strings" - "sync" - "time" -) - -// ConversationMessage represents a single message in a conversation. -type ConversationMessage struct { - Role string - Content string - Timestamp time.Time -} - -// ConversationConfig holds configuration for a ConversationManager. -type ConversationConfig struct { - MaxHistory int - SummarizeThreshold int - SystemPrompt string -} - -// ConversationState represents the full state of a conversation at a point in time. -type ConversationState struct { - Messages []ConversationMessage - TokenCount int - Summary string -} - -// ConversationManager manages a conversation lifecycle, bridging eyrie's -// conversation management into hawk's chat session flow. It is safe for -// concurrent use. -type ConversationManager struct { - mu sync.RWMutex - config ConversationConfig - messages []ConversationMessage - summary string - tokenCount int -} - -// NewConversationManager creates a new ConversationManager with the given config. -// If SystemPrompt is set, it is automatically added as the first message. -func NewConversationManager(config ConversationConfig) *ConversationManager { - cm := &ConversationManager{ - config: config, - } - - if config.SystemPrompt != "" { - msg := ConversationMessage{ - Role: "system", - Content: config.SystemPrompt, - Timestamp: time.Now(), - } - cm.messages = append(cm.messages, msg) - cm.tokenCount = estimateTokens(config.SystemPrompt) - } - - return cm -} - -// AddMessage appends a new message to the conversation history and updates the -// running token estimate. Returns the added message. -func (cm *ConversationManager) AddMessage(role, content string) ConversationMessage { - cm.mu.Lock() - defer cm.mu.Unlock() - - msg := ConversationMessage{ - Role: role, - Content: content, - Timestamp: time.Now(), - } - - cm.messages = append(cm.messages, msg) - cm.tokenCount += estimateTokens(content) - - return msg -} - -// GetHistory returns a copy of the current messages. -func (cm *ConversationManager) GetHistory() []ConversationMessage { - cm.mu.RLock() - defer cm.mu.RUnlock() - - out := make([]ConversationMessage, len(cm.messages)) - copy(out, cm.messages) - return out -} - -// GetState returns a snapshot of the full conversation state including messages, -// token count, and any active summary. -func (cm *ConversationManager) GetState() ConversationState { - cm.mu.RLock() - defer cm.mu.RUnlock() - - msgs := make([]ConversationMessage, len(cm.messages)) - copy(msgs, cm.messages) - - return ConversationState{ - Messages: msgs, - TokenCount: cm.tokenCount, - Summary: cm.summary, - } -} - -// TrimHistory removes the oldest non-system messages so that at most maxMessages -// total messages remain. The system prompt (if present) is always preserved. -func (cm *ConversationManager) TrimHistory(maxMessages int) { - cm.mu.Lock() - defer cm.mu.Unlock() - - if maxMessages <= 0 || len(cm.messages) <= maxMessages { - return - } - - hasSystem := len(cm.messages) > 0 && cm.messages[0].Role == "system" - - var systemPrefix []ConversationMessage - if hasSystem { - systemPrefix = append(systemPrefix, cm.messages[0]) - } - - keep := maxMessages - len(systemPrefix) - if keep <= 0 { - keep = 1 - } - - // Keep the most recent `keep` non-system messages. - trimmed := cm.messages[len(cm.messages)-keep:] - cm.messages = append(systemPrefix, trimmed...) - - // Recalculate token count. - cm.tokenCount = 0 - for _, m := range cm.messages { - cm.tokenCount += estimateTokens(m.Content) - } -} - -// ShouldSummarize reports whether the message count exceeds the configured -// SummarizeThreshold. A threshold of 0 disables summarization. -func (cm *ConversationManager) ShouldSummarize() bool { - cm.mu.RLock() - defer cm.mu.RUnlock() - - if cm.config.SummarizeThreshold <= 0 { - return false - } - return len(cm.messages) > cm.config.SummarizeThreshold -} - -// BuildSummary produces a condensed summary of all non-system, non-recent -// messages. The summary is stored internally and also returned. Older messages -// that have been summarized can be trimmed by the caller if desired. -func (cm *ConversationManager) BuildSummary() string { - cm.mu.Lock() - defer cm.mu.Unlock() - - hasSystem := len(cm.messages) > 0 && cm.messages[0].Role == "system" - - // Summarize all messages except system prompt and the most recent few. - cutoff := 10 - if hasSystem { - cutoff = 11 - } - if cutoff > len(cm.messages) { - cutoff = len(cm.messages) - } - - var oldest []ConversationMessage - if hasSystem && len(cm.messages) > cutoff { - oldest = cm.messages[1 : len(cm.messages)-cutoff+1] - } else if !hasSystem && len(cm.messages) > cutoff { - oldest = cm.messages[:len(cm.messages)-cutoff] - } - - if len(oldest) == 0 { - return cm.summary - } - - var sb strings.Builder - sb.WriteString("Summary of earlier conversation:\n") - for _, m := range oldest { - sb.WriteString(fmt.Sprintf("[%s] %s\n", m.Role, truncateContent(m.Content, 200))) - } - - cm.summary = sb.String() - return cm.summary -} - -// Reset clears all messages, summary, and token count. If the config has a -// system prompt, it is re-added. -func (cm *ConversationManager) Reset() { - cm.mu.Lock() - defer cm.mu.Unlock() - - cm.messages = nil - cm.summary = "" - cm.tokenCount = 0 - - if cm.config.SystemPrompt != "" { - msg := ConversationMessage{ - Role: "system", - Content: cm.config.SystemPrompt, - Timestamp: time.Now(), - } - cm.messages = append(cm.messages, msg) - cm.tokenCount = estimateTokens(cm.config.SystemPrompt) - } -} - -// ExportMessages returns the conversation as a slice of maps suitable for API -// calls (e.g., {"role": "user", "content": "..."}). -func (cm *ConversationManager) ExportMessages() []map[string]string { - cm.mu.RLock() - defer cm.mu.RUnlock() - - out := make([]map[string]string, 0, len(cm.messages)) - for _, m := range cm.messages { - out = append(out, map[string]string{ - "role": m.Role, - "content": m.Content, - }) - } - return out -} - -// TokenEstimate returns the current rough token count estimate (characters / 4). -func (cm *ConversationManager) TokenEstimate() int { - cm.mu.RLock() - defer cm.mu.RUnlock() - return cm.tokenCount -} - -// truncateContent truncates content to maxLen characters, appending "..." if truncated. -func truncateContent(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen] + "..." -} diff --git a/internal/engine/conversation_adapter_test.go b/internal/engine/conversation_adapter_test.go deleted file mode 100644 index 004faad0..00000000 --- a/internal/engine/conversation_adapter_test.go +++ /dev/null @@ -1,325 +0,0 @@ -package engine - -import ( - "sync" - "testing" -) - -func TestNewConversationManagerWithConfig(t *testing.T) { - cfg := ConversationConfig{ - MaxHistory: 100, - SummarizeThreshold: 50, - SystemPrompt: "You are a helpful assistant.", - } - cm := NewConversationManager(cfg) - - if cm == nil { - t.Fatal("expected non-nil ConversationManager") - } - if cm.config.MaxHistory != 100 { - t.Errorf("expected MaxHistory=100, got %d", cm.config.MaxHistory) - } - if cm.config.SummarizeThreshold != 50 { - t.Errorf("expected SummarizeThreshold=50, got %d", cm.config.SummarizeThreshold) - } - - // System prompt should be added as first message. - history := cm.GetHistory() - if len(history) != 1 { - t.Fatalf("expected 1 initial message (system prompt), got %d", len(history)) - } - if history[0].Role != "system" { - t.Errorf("expected first message role 'system', got '%s'", history[0].Role) - } - if history[0].Content != "You are a helpful assistant." { - t.Errorf("unexpected system prompt content: %s", history[0].Content) - } -} - -func TestAddMessageIncrementsHistory(t *testing.T) { - cm := NewConversationManager(ConversationConfig{}) - - before := len(cm.GetHistory()) - cm.AddMessage("user", "hello") - after := len(cm.GetHistory()) - - if after != before+1 { - t.Errorf("expected history length %d, got %d", before+1, after) - } - - cm.AddMessage("assistant", "hi there") - if len(cm.GetHistory()) != 2 { - t.Errorf("expected 2 messages, got %d", len(cm.GetHistory())) - } -} - -func TestGetHistoryReturnsMessagesInOrder(t *testing.T) { - cm := NewConversationManager(ConversationConfig{}) - - cm.AddMessage("user", "first") - cm.AddMessage("assistant", "second") - cm.AddMessage("user", "third") - - history := cm.GetHistory() - if len(history) != 3 { - t.Fatalf("expected 3 messages, got %d", len(history)) - } - - expected := []struct { - role string - content string - }{ - {"user", "first"}, - {"assistant", "second"}, - {"user", "third"}, - } - - for i, exp := range expected { - if history[i].Role != exp.role { - t.Errorf("message[%d].Role: expected '%s', got '%s'", i, exp.role, history[i].Role) - } - if history[i].Content != exp.content { - t.Errorf("message[%d].Content: expected '%s', got '%s'", i, exp.content, history[i].Content) - } - } -} - -func TestGetStateIncludesTokenCountAndSummary(t *testing.T) { - cm := NewConversationManager(ConversationConfig{ - SystemPrompt: "system instructions", - }) - - cm.AddMessage("user", "some message here") - cm.AddMessage("assistant", "a reply") - - state := cm.GetState() - - if state.TokenCount <= 0 { - t.Errorf("expected positive token count, got %d", state.TokenCount) - } - - expectedTokens := estimateTokens("system instructions") + - estimateTokens("some message here") + - estimateTokens("a reply") - if state.TokenCount != expectedTokens { - t.Errorf("expected token count %d, got %d", expectedTokens, state.TokenCount) - } - - // Summary should be empty when we haven't built one. - if state.Summary != "" { - t.Errorf("expected empty summary, got '%s'", state.Summary) - } - - // Build a summary by adding enough messages. - for i := 0; i < 15; i++ { - cm.AddMessage("user", "filler message content") - } - cm.BuildSummary() - - state = cm.GetState() - if state.Summary == "" { - t.Error("expected non-empty summary after BuildSummary") - } -} - -func TestTrimHistoryKeepsSystemPrompt(t *testing.T) { - cm := NewConversationManager(ConversationConfig{ - SystemPrompt: "You are a coding agent.", - }) - - // Add several messages. - for i := 0; i < 10; i++ { - cm.AddMessage("user", "msg") - cm.AddMessage("assistant", "reply") - } - - cm.TrimHistory(4) - - history := cm.GetHistory() - if len(history) != 4 { - t.Fatalf("expected 4 messages after trim, got %d", len(history)) - } - - // First message should still be the system prompt. - if history[0].Role != "system" { - t.Errorf("expected first message to be system prompt, got role '%s'", history[0].Role) - } - if history[0].Content != "You are a coding agent." { - t.Errorf("expected system prompt content preserved, got '%s'", history[0].Content) - } -} - -func TestShouldSummarizeReturnsTrueAboveThreshold(t *testing.T) { - cm := NewConversationManager(ConversationConfig{ - SummarizeThreshold: 5, - }) - - // Below threshold. - if cm.ShouldSummarize() { - t.Error("expected ShouldSummarize=false with 0 messages") - } - - // Add messages up to threshold. - for i := 0; i < 5; i++ { - cm.AddMessage("user", "msg") - } - // Exactly at threshold (5 messages, threshold is 5, but condition is >). - if cm.ShouldSummarize() { - t.Error("expected ShouldSummarize=false when message count equals threshold") - } - - cm.AddMessage("user", "one more") - // Now 6 messages, threshold is 5, so 6 > 5 = true. - if !cm.ShouldSummarize() { - t.Error("expected ShouldSummarize=true above threshold") - } -} - -func TestBuildSummaryProducesNonEmptySummary(t *testing.T) { - cm := NewConversationManager(ConversationConfig{ - SummarizeThreshold: 3, - }) - - // Add enough messages to have content to summarize. - for i := 0; i < 20; i++ { - cm.AddMessage("user", "some conversation content here") - } - - summary := cm.BuildSummary() - if summary == "" { - t.Fatal("expected non-empty summary") - } - if len(summary) < 10 { - t.Errorf("summary too short: '%s'", summary) - } - - // Verify internal state was updated. - state := cm.GetState() - if state.Summary != summary { - t.Error("expected GetState().Summary to match BuildSummary return value") - } -} - -func TestResetClearsEverything(t *testing.T) { - cfg := ConversationConfig{ - SystemPrompt: "system prompt text", - SummarizeThreshold: 2, - } - cm := NewConversationManager(cfg) - - cm.AddMessage("user", "hello") - cm.AddMessage("assistant", "hi") - cm.BuildSummary() - - if len(cm.GetHistory()) < 2 { - t.Fatal("expected messages before reset") - } - - cm.Reset() - - state := cm.GetState() - // System prompt should be re-added. - if len(state.Messages) != 1 { - t.Fatalf("expected 1 message after reset (system prompt), got %d", len(state.Messages)) - } - if state.Messages[0].Role != "system" { - t.Errorf("expected system prompt role after reset, got '%s'", state.Messages[0].Role) - } - if state.Summary != "" { - t.Errorf("expected empty summary after reset, got '%s'", state.Summary) - } - expectedTokens := estimateTokens("system prompt text") - if state.TokenCount != expectedTokens { - t.Errorf("expected token count %d after reset, got %d", expectedTokens, state.TokenCount) - } -} - -func TestExportMessagesFormat(t *testing.T) { - cm := NewConversationManager(ConversationConfig{ - SystemPrompt: "be helpful", - }) - cm.AddMessage("user", "what is 2+2?") - cm.AddMessage("assistant", "4") - - exported := cm.ExportMessages() - if len(exported) != 3 { - t.Fatalf("expected 3 exported messages, got %d", len(exported)) - } - - expected := []struct { - role string - content string - }{ - {"system", "be helpful"}, - {"user", "what is 2+2?"}, - {"assistant", "4"}, - } - - for i, exp := range expected { - if exported[i]["role"] != exp.role { - t.Errorf("exported[%d]['role']: expected '%s', got '%s'", i, exp.role, exported[i]["role"]) - } - if exported[i]["content"] != exp.content { - t.Errorf("exported[%d]['content']: expected '%s', got '%s'", i, exp.content, exported[i]["content"]) - } - // Verify only "role" and "content" keys exist. - if len(exported[i]) != 2 { - t.Errorf("exported[%d]: expected exactly 2 keys, got %d", i, len(exported[i])) - } - } -} - -func TestTokenEstimateRoughAccuracy(t *testing.T) { - cm := NewConversationManager(ConversationConfig{}) - - // estimateTokens uses len(text)/4 - content := "this is a test string that should produce some tokens" - cm.AddMessage("user", content) - - expected := estimateTokens(content) - got := cm.TokenEstimate() - - if got != expected { - t.Errorf("expected token estimate %d, got %d", expected, got) - } - - // Verify the formula: roughly 1 token per 4 characters. - if got != len(content)/4 { - t.Errorf("token estimate should be len/4 = %d, got %d", len(content)/4, got) - } -} - -func TestConcurrentAddMessageSafety(t *testing.T) { - cm := NewConversationManager(ConversationConfig{}) - const goroutines = 50 - const messagesPerGoroutine = 20 - - var wg sync.WaitGroup - wg.Add(goroutines) - - for i := 0; i < goroutines; i++ { - go func() { - defer wg.Done() - for j := 0; j < messagesPerGoroutine; j++ { - cm.AddMessage("user", "concurrent message") - } - }() - } - - wg.Wait() - - total := goroutines * messagesPerGoroutine - if len(cm.GetHistory()) != total { - t.Errorf("expected %d messages, got %d", total, len(cm.GetHistory())) - } - - // TokenEstimate should be consistent. - state := cm.GetState() - if state.TokenCount <= 0 { - t.Error("expected positive token count after concurrent writes") - } - if state.TokenCount != total*estimateTokens("concurrent message") { - t.Errorf("expected token count %d, got %d", - total*estimateTokens("concurrent message"), state.TokenCount) - } -} diff --git a/internal/engine/session.go b/internal/engine/session.go index d938ae6f..20831d59 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -318,6 +318,23 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, s.persist.SetSystem(systemPrompt) s.tools = NewToolService(registry) + // Emergency compact: when a provider rejects a request as too large, + // ChatService.Stream calls this to shrink the conversation in place and + // retries with the compacted history. Installed after s.persist exists + // because the hook reads and rewrites the persisted messages. + s.llm.SetOnContextOverflow(func(ctx context.Context) ([]types.EyrieMessage, bool) { + before := len(s.Persistence().RawMessages()) + s.smartCompact() + after := s.Persistence().RawMessages() + if len(after) >= before { + return nil, false + } + s.log.Info("emergency compact after context overflow", map[string]interface{}{ + "messages_before": before, "messages_after": len(after), + }) + return after, true + }) + // Alias legacy fields at the service instances so legacy readers see // the same state as new code that goes through the sub-service getters. // After this point, mutations to the sub-service internal state diff --git a/internal/onboarding/onboarding.go b/internal/onboarding/onboarding.go index 382623a7..f033b478 100644 --- a/internal/onboarding/onboarding.go +++ b/internal/onboarding/onboarding.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "os" + "strconv" "strings" "github.com/GrayCodeAI/eyrie/credentials" @@ -97,60 +98,32 @@ func RunSetup() error { fmt.Println(dim + " Keys are stored in " + credentials.PlatformSecretStoreName() + ", not .env or shell env." + reset) fmt.Println() - // Provider selection fmt.Println(" Choose your LLM provider:") fmt.Println() - providers := []struct { - name string - envKey string - desc string - }{ - {"anthropic", "ANTHROPIC_API_KEY", "Claude (recommended)"}, - {"openai", "OPENAI_API_KEY", "GPT-4o, o1, o3"}, - {"gemini", "GEMINI_API_KEY", "Gemini 2.5"}, - {"openrouter", "OPENROUTER_API_KEY", "200+ models"}, - {"groq", "GROQ_API_KEY", "Fast inference"}, - {"poolside", "POOLSIDE_API_KEY", "Poolside models"}, - {"ollama", "", "Local models (no API key needed)"}, + providers := setupProviderOptions() + if len(providers) == 0 { + return fmt.Errorf("eyrie returned no setup providers") } - - for i, p := range providers { - fmt.Printf(" %s%d%s) %s%-12s%s %s\n", teal, i+1, reset, bold, p.name, reset, dim+p.desc+reset) + for i, provider := range providers { + fmt.Printf(" %s%d%s) %s%s%s\n", teal, i+1, reset, bold, hawkconfig.GatewayDisplayName(provider), reset) } fmt.Println() - fmt.Print(" Enter number (1-7): ") + fmt.Printf(" Enter number (1-%d): ", len(providers)) input, _ := reader.ReadString('\n') - input = strings.TrimSpace(input) - - idx := 0 - switch input { - case "1": - idx = 0 - case "2": - idx = 1 - case "3": - idx = 2 - case "4": - idx = 3 - case "5": - idx = 4 - case "6": - idx = 5 - case "7": - idx = 6 - default: - idx = 0 // default to anthropic + selected, err := selectProvider(providers, input) + if err != nil { + return err } - selected := providers[idx] fmt.Println() - fmt.Printf(" Selected: %s%s%s\n", teal, selected.name, reset) + fmt.Printf(" Selected: %s%s%s\n", teal, hawkconfig.GatewayDisplayName(selected), reset) - // API key input - if selected.envKey != "" && !credentials.HasSecret(context.Background(), selected.envKey) { + ctx := context.Background() + envKey := hawkconfig.SetupGatewayCredentialEnv(selected) + if envKey != "" && !hawkconfig.HasStoredCredentialForProvider(ctx, selected) { fmt.Println() - fmt.Printf(" Enter your %s API key:\n", selected.name) + fmt.Printf(" Enter your %s API key:\n", hawkconfig.GatewayDisplayName(selected)) fmt.Printf(" %s(Get one at the provider's website)%s\n", dim, reset) fmt.Print(" > ") @@ -162,33 +135,23 @@ func RunSetup() error { return fmt.Errorf("no API key") } - // Validate key format before saving - if warning, valid := validateAPIKey(selected.name, apiKey); !valid { - fmt.Printf(" %s"+icons.Alert()+" %s%s\n", red, warning, reset) - fmt.Println(red + " API key not saved. Please check your key and try again." + reset) - return fmt.Errorf("invalid API key") - } else if warning != "" { - fmt.Printf(" %s"+icons.Alert()+" %s (saving anyway)%s\n", dim, warning, reset) - } - - ctx := context.Background() - if err := hawkconfig.PersistAPIKey(ctx, selected.envKey, apiKey); err != nil { - fmt.Printf(" %sWarning: couldn't save API key: %s%s\n", dim, err, reset) + inference, err := hawkconfig.CredentialInferenceForProvider(selected) + if err != nil { return err } - - if err := hawkconfig.SetActiveProvider(context.Background(), selected.name); err != nil { - fmt.Printf(" %sWarning: couldn't save provider: %s%s\n", dim, err, reset) + if err := hawkconfig.SaveCredential(ctx, inference, apiKey); err != nil { + fmt.Printf(" %s"+icons.Alert()+" %s%s\n", red, hawkconfig.FormatConfigProviderError(selected, err), reset) + return err } - fmt.Println() fmt.Printf(" %s"+icons.CheckBold()+" API key saved to %s%s\n", teal, credentials.PlatformSecretStoreName(), reset) - } else if selected.name == "ollama" { - _ = hawkconfig.SetActiveProvider(context.Background(), "ollama") - fmt.Printf(" %s"+icons.CheckBold()+" Ollama selected (make sure ollama is running)%s\n", teal, reset) + } else if envKey == "" { + fmt.Printf(" %s"+icons.CheckBold()+" %s selected; no API key required%s\n", teal, hawkconfig.GatewayDisplayName(selected), reset) } else { - _ = hawkconfig.SetActiveProvider(context.Background(), selected.name) - fmt.Printf(" %s"+icons.CheckBold()+" Using %s (credential already in %s)%s\n", teal, selected.name, credentials.PlatformSecretStoreName(), reset) + fmt.Printf(" %s"+icons.CheckBold()+" Using %s (credential already in %s)%s\n", teal, hawkconfig.GatewayDisplayName(selected), credentials.PlatformSecretStoreName(), reset) + } + if err := hawkconfig.SetActiveProvider(ctx, selected); err != nil { + return fmt.Errorf("save active provider: %w", err) } // Security notes @@ -210,22 +173,15 @@ func RunSetup() error { return nil } -// validateAPIKey checks the key format for known providers. -// Returns (warning, isValid). A warning with isValid=true means the key is -// acceptable but may have an unusual format. -func validateAPIKey(provider, key string) (string, bool) { - if len(key) <= 10 { - return "API key seems too short", false - } - switch strings.ToLower(provider) { - case "anthropic": - if !strings.HasPrefix(key, "sk-ant-") { - return "Anthropic keys typically start with 'sk-ant-'", false - } - case "openai": - if !strings.HasPrefix(key, "sk-") { - return "OpenAI keys typically start with 'sk-'", false - } +func setupProviderOptions() []string { + providers := hawkconfig.AllSetupGateways() + return append([]string(nil), providers...) +} + +func selectProvider(providers []string, input string) (string, error) { + selection, err := strconv.Atoi(strings.TrimSpace(input)) + if err != nil || selection < 1 || selection > len(providers) { + return "", fmt.Errorf("provider selection must be between 1 and %d", len(providers)) } - return "", true + return providers[selection-1], nil } diff --git a/internal/onboarding/onboarding_test.go b/internal/onboarding/onboarding_test.go index e760ca2a..f67987af 100644 --- a/internal/onboarding/onboarding_test.go +++ b/internal/onboarding/onboarding_test.go @@ -10,32 +10,46 @@ func TestNeedsSetup_AlwaysFalseForTUI(t *testing.T) { } } -func TestValidateAPIKey(t *testing.T) { +func TestSelectProvider(t *testing.T) { + providers := []string{"anthropic", "openai", "ollama"} tests := []struct { - name string - provider string - key string - valid bool + name string + input string + want string + wantErr bool }{ - {"valid anthropic", "anthropic", "sk-ant-api01-abcdefghijklmnopqrstuvwxyz", true}, - {"valid openai", "openai", "sk-abcdefghijklmnopqrstuvwxyz123456", true}, - {"too short", "anthropic", "sk-ant", false}, - {"wrong prefix anthropic", "anthropic", "wrong-prefix-long-enough-key", false}, - {"wrong prefix openai", "openai", "not-sk-prefix-long-enough-key", false}, - {"unknown provider accepts any", "gemini", "any-key-long-enough-to-be-valid", true}, - {"empty key", "anthropic", "", false}, + {name: "first", input: "1\n", want: "anthropic"}, + {name: "last", input: "3", want: "ollama"}, + {name: "not a number", input: "openai", wantErr: true}, + {name: "zero", input: "0", wantErr: true}, + {name: "too large", input: "4", wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, valid := validateAPIKey(tt.provider, tt.key) - if valid != tt.valid { - t.Errorf("validateAPIKey(%q, %q) valid = %v, want %v", tt.provider, tt.key, valid, tt.valid) + got, err := selectProvider(providers, tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("selectProvider() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Fatalf("selectProvider() = %q, want %q", got, tt.want) } }) } } +func TestSetupProviderOptionsComeFromEyrie(t *testing.T) { + providers := setupProviderOptions() + if len(providers) == 0 { + t.Fatal("expected Eyrie setup providers") + } + for _, provider := range providers { + if provider == "" { + t.Fatal("provider ID must not be empty") + } + } +} + func TestWelcome(t *testing.T) { Welcome("1.0.0") } diff --git a/internal/types/client.go b/internal/types/client.go index 86bb5c5d..e09cf234 100644 --- a/internal/types/client.go +++ b/internal/types/client.go @@ -197,6 +197,19 @@ func DefaultContinuationConfig() ContinuationConfig { } } +// ApplyProviderChatDefaults delegates provider-specific request policy to Eyrie. +func ApplyProviderChatDefaults(provider string, opts ChatOptions) ChatOptions { + normalized := client.ApplyProviderChatDefaults(provider, ToClientChatOptions(opts)) + opts.EnableCaching = normalized.EnableCaching + opts.GLMThinkingEnabled = normalized.GLMThinkingEnabled + return opts +} + +// IsContextOverflow delegates provider error classification to Eyrie. +func IsContextOverflow(err error) bool { + return client.IsContextOverflow(err) +} + func DetectProvider() string { return client.DetectProvider() }