diff --git a/bin/buf b/bin/buf deleted file mode 100755 index 28ec383f..00000000 --- a/bin/buf +++ /dev/null @@ -1,140 +0,0 @@ -#!/bin/sh -# Hermetic launcher for the `buf` CLI (bufbuild/buf). -# -# Purpose: hermetic proto codegen — no host `buf` dependency. The binary is -# SHA-pinned per (platform, arch), fetched from the official GitHub release, -# cached locally, and reused. Mirrors grok's `bin/protoc` dotslash launcher, -# which fetches a SHA-pinned protoc per platform. Unlike dotslash (which ships -# an artifact manifest checked into the repo), this resolves a single pinned -# version via env override and verifies a SHA-256 per triple. -# -# Version is pinned via BUF_VERSION (default below). Override the cache root -# with HAWK_BIN_DIR (mirrors grok's GROK_SHELL_RG_DOWNLOAD_BASE overridability -# for offline / mirrored CI): -# -# HAWK_BIN_DIR — base cache dir (default ~/.cache/hawk/bin) -# BUF_VERSION — override the pinned version -# -# Exit codes are from the underlying `buf` invocation; fetch failures exit 127. -# -# Source pattern: grok `bin/protoc`. -set -e - -BUF_VERSION="${BUF_VERSION:-1.50.0}" - -# --------------------------------------------------------------------------- -# SHA-256 checksums of the buf release tarballs, keyed by -# _ (os lowercased, arch as Go sees it: amd64/arm64). -# Verified against https://github.com/bufbuild/buf/releases/tag/v1.50.0 -# sha256sum buf-Linux-x86_64.tar.gz buf-Linux-aarch64.tar.gz -# buf-Darwin-x86_64.tar.gz buf-Darwin-arm64.tar.gz -# --------------------------------------------------------------------------- -SHA256_darwin_arm64="c80f7f8a1d8ffd36c5db31a360c7e0b65c8cf671d60bd3c34e1558e54f84f4cc" -SHA256_darwin_amd64="fc64b4a16964d7ec49fb2d245159d57dbfb3dac947e2a86413f9685cf8de2ac5" -SHA256_linux_amd64="80c1211dfc4844499c6ddad341bb21206579883fd33cea0a2c40c82befd70602" -SHA256_linux_arm64="4c920c5f96eb99ad13eb6f25cf740fdb42963401faa267bee03fbd3e163730b2" - -# Detect platform + arch the same way install.sh does. -OS=$(uname -s | tr '[:upper:]' '[:lower:]') -ARCH=$(uname -m) -case "$ARCH" in - x86_64) arch="amd64" ;; - aarch64) arch="arm64" ;; - arm64) arch="arm64" ;; - *) - echo "bin/buf: unsupported arch $ARCH" >&2 - exit 127 - ;; -esac -case "$OS" in - darwin) triple="darwin_${arch}" ;; - linux) triple="linux_${arch}" ;; - *) - echo "bin/buf: unsupported OS $OS" >&2 - exit 127 - ;; -esac - -# The GitHub release asset name uses a capitalized OS and the arch spelling -# buf publishes (Darwin-arm64, Linux-aarch64), which differs from the -# cache-key triple above. Build it explicitly to avoid 404s. -# uname -m yields arm64 on macOS and aarch64 on Linux, so map each real -# (os, arch) pair to the asset name buf publishes. -case "$OS/$ARCH" in - darwin/x86_64) asset="Darwin-x86_64" ;; - darwin/arm64) asset="Darwin-arm64" ;; - linux/x86_64) asset="Linux-x86_64" ;; - linux/aarch64) asset="Linux-aarch64" ;; - *) asset="" ;; -esac -if [ -z "$asset" ]; then - echo "bin/buf: unsupported platform $OS/$ARCH" >&2 - exit 127 -fi - -# Resolve the pinned checksum for this triple. -sha_var="SHA256_${triple}" -sha="$(eval echo "\${$sha_var}")" -case "$sha" in - SHA256_*) echo "bin/buf: placeholder checksum for $triple — set real $sha_var before use" >&2; exit 127 ;; -esac - -# Cache the extracted binary so repeat invocations skip the network fetch. -XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" -CACHE_BASE="${HAWK_BIN_DIR:-$XDG_CACHE_HOME/hawk/bin}" -CACHE_BIN="$CACHE_BASE/buf-${BUF_VERSION}-${triple}/bin/buf" - -URL="https://github.com/bufbuild/buf/releases/download/v${BUF_VERSION}/buf-${asset}.tar.gz" - -fetch_bin() { - tmp=$(mktemp -d) - trap 'rm -rf "$tmp"' EXIT - archive="$tmp/buf.tar.gz" - if command -v curl >/dev/null 2>&1; then - curl -fsSL --proto '=https' --tlsv1.2 "$URL" -o "$archive" - elif command -v wget >/dev/null 2>&1; then - wget -q "$URL" -O "$archive" - else - echo "bin/buf: need curl or wget" >&2 - exit 127 - fi - - # Verify checksum before extraction. - if command -v sha256sum >/dev/null 2>&1; then - actual=$(sha256sum "$archive" | awk '{print $1}') - elif command -v shasum >/dev/null 2>&1; then - actual=$(shasum -a 256 "$archive" | awk '{print $1}') - else - echo "bin/buf: need sha256sum or shasum" >&2 - exit 127 - fi - if [ "$actual" != "$sha" ]; then - echo "bin/buf: checksum mismatch for $triple" >&2 - echo " expected: $sha" >&2 - echo " actual: $actual" >&2 - exit 127 - fi - - # Extract the `buf` binary from the release tarball. - mkdir -p "$tmp/extract" - tar xzf "$archive" -C "$tmp/extract" - extracted="$(find "$tmp/extract" -type f -name buf -path '*bin*' | head -n1)" - if [ -z "$extracted" ]; then - # Fallback: release tgz root may be the binary directly in bin/. - extracted="$tmp/extract/bin/buf" - if [ ! -f "$extracted" ]; then - echo "bin/buf: could not locate buf binary in archive" >&2 - exit 127 - fi - fi - - mkdir -p "$(dirname "$CACHE_BIN")" - chmod +x "$extracted" - mv -f "$extracted" "$CACHE_BIN" -} - -if [ ! -f "$CACHE_BIN" ]; then - fetch_bin -fi - -exec "$CACHE_BIN" "$@" diff --git a/cmd/chat_config_deployment.go b/cmd/chat_config_deployment.go index 47cc52b7..9d102d29 100644 --- a/cmd/chat_config_deployment.go +++ b/cmd/chat_config_deployment.go @@ -79,6 +79,13 @@ func saveCredentialAsync(inference hawkconfig.CredentialInference, secret string hawkconfig.InvalidateConfigUICache() hawkconfig.RefreshConfigCredSnapshot(ctx) result, err := hawkconfig.ApplyEyrieCredentialsForProvider(ctx, inference.ProviderID) + if err != nil && hawkconfig.IsCatalogCacheRequired(err) { + if refreshErr := hawkconfig.RefreshCatalogAfterCredentials(ctx, nil); refreshErr == nil { + result, err = hawkconfig.ApplyEyrieCredentialsForProvider(ctx, inference.ProviderID) + } else { + err = fmt.Errorf("%w; automatic catalog refresh failed: %v", err, refreshErr) + } + } if err != nil { return configApplyCredentialsMsg{ err: err, @@ -146,6 +153,7 @@ func (m chatModel) handleConfigApplyCredentialsMsg(msg configApplyCredentialsMsg m.configSaving = false ctx := context.Background() hawkconfig.RefreshConfigCredSnapshot(ctx) + m = m.refreshConfigGatewayRows() if msg.err != nil { m.invalidateConnStatus() if msg.providerID == configProviderOllama { @@ -155,8 +163,13 @@ func (m chatModel) handleConfigApplyCredentialsMsg(msg configApplyCredentialsMsg saved := hawkconfig.HasStoredCredentialForProvider(ctx, msg.providerID) || strings.Contains(strings.ToLower(msg.err.Error()), "key saved in keychain") if saved { - notice = "Key saved in " + credentialsStoreLabel() + " — provider rejected this key: " + notice - if !strings.Contains(strings.ToLower(notice), "refresh") { + if hawkconfig.IsCatalogCacheRequired(msg.err) { + notice = "Key saved in " + credentialsStoreLabel() + " — model catalog unavailable: " + notice + notice += " · run hawk models refresh" + } else { + notice = "Key saved in " + credentialsStoreLabel() + " — provider rejected this key: " + notice + } + if !hawkconfig.IsCatalogCacheRequired(msg.err) && !strings.Contains(strings.ToLower(notice), "refresh") { notice += " · press r on " + hawkconfig.GatewayDisplayName(msg.providerID) + " to retry" } } else { diff --git a/cmd/chat_config_gateways.go b/cmd/chat_config_gateways.go index 76c77320..7dd9feb9 100644 --- a/cmd/chat_config_gateways.go +++ b/cmd/chat_config_gateways.go @@ -15,10 +15,13 @@ type configGatewayRow struct { ID string DisplayName string HasKey bool + Configured bool ModelCount int Active bool RegionLabel string RegionRequired bool + CredentialEnv string + KeyConflict bool } type configGatewayRefreshMsg struct { @@ -28,6 +31,13 @@ type configGatewayRefreshMsg struct { } func (m chatModel) configGatewayRows() []configGatewayRow { + if !m.configGatewayRowsDirty && m.configGatewayRowsCache != nil { + return m.configGatewayRowsCache + } + return m.loadConfigGatewayRows() +} + +func (m chatModel) loadConfigGatewayRows() []configGatewayRow { ctx := context.Background() active := strings.TrimSpace(m.configModelProvider) activeModel := "" @@ -52,6 +62,10 @@ func (m chatModel) configGatewayRows() []configGatewayRow { modelCacheMu.RUnlock() } hasKey := status.HasStoredCredential + credentialEnv, keyConflict := "", false + if hasKey { + credentialEnv, keyConflict = hawkconfig.CredentialEnvironmentConflict(ctx, status.ID) + } display := status.DisplayName if status.ID == hawkconfig.ProviderXiaomiTokenPlan { if reg := status.RegionLabel; reg != "" { @@ -71,13 +85,63 @@ func (m chatModel) configGatewayRows() []configGatewayRow { ID: status.ID, DisplayName: display, HasKey: hasKey, + Configured: status.HasConfiguredDeployment || hasKey, ModelCount: count, Active: status.Active || hawkconfig.ActiveProviderID(status.ID) == hawkconfig.ActiveProviderID(active), RegionLabel: status.RegionLabel, RegionRequired: status.RegionRequired, + CredentialEnv: credentialEnv, + KeyConflict: keyConflict, }) } - return rows + return prioritizeConfigGatewayRows(rows) +} + +func prioritizeConfigGatewayRows(rows []configGatewayRow) []configGatewayRow { + ordered := make([]configGatewayRow, 0, len(rows)) + for _, row := range rows { + if row.Active { + ordered = append(ordered, row) + } + } + for _, row := range rows { + if !row.Active && row.Configured { + ordered = append(ordered, row) + } + } + for _, row := range rows { + if !row.Active && !row.Configured { + ordered = append(ordered, row) + } + } + return ordered +} + +func (m chatModel) refreshConfigGatewayRows() chatModel { + selectedID := "" + focusID := "" + if m.configSel >= 0 && m.configSel < len(m.configGatewayRowsCache) { + selectedID = m.configGatewayRowsCache[m.configSel].ID + } + if m.configGatewayFocus >= 0 && m.configGatewayFocus < len(m.configGatewayRowsCache) { + focusID = m.configGatewayRowsCache[m.configGatewayFocus].ID + } + m.configGatewayRowsCache = m.loadConfigGatewayRows() + m.configGatewayRowsDirty = false + for i, row := range m.configGatewayRowsCache { + if row.ID == selectedID { + m.configSel = i + } + if row.ID == focusID { + m.configGatewayFocus = i + } + } + return m +} + +func (m chatModel) invalidateConfigGatewayRows() chatModel { + m.configGatewayRowsDirty = true + return m } func (m chatModel) configGatewayRowIndex(provider string) int { @@ -152,13 +216,18 @@ func (m chatModel) configGatewaysView() string { rows := m.configGatewayRows() refreshSel := len(rows) + windowSize := m.configVisibleRows() + maxScroll := maxInt(0, len(rows)-windowSize) + if m.configScroll > maxScroll { + m.configScroll = maxScroll + } if m.configSel < len(rows) { if m.configSel < m.configScroll { m.configScroll = m.configSel } - if m.configSel >= m.configScroll+configWindowSize { - m.configScroll = m.configSel - configWindowSize + 1 + if m.configSel >= m.configScroll+windowSize { + m.configScroll = m.configSel - windowSize + 1 } } @@ -169,6 +238,9 @@ func (m chatModel) configGatewaysView() string { key := "—" if row.HasKey { key = "+" + icons.CheckBold() + " " + if row.KeyConflict { + key = "+" + icons.CheckBold() + " !" + } } models := "key required" if row.HasKey && row.ModelCount > 0 { @@ -190,7 +262,7 @@ func (m chatModel) configGatewaysView() string { if m.configScroll > 0 { b.WriteString(configTableScrollHint(m.configScroll, 0, mutedStyle) + "\n") } - end := m.configScroll + configWindowSize + end := m.configScroll + windowSize if end > len(rows) { end = len(rows) } @@ -215,6 +287,10 @@ func (m chatModel) configGatewaysView() string { b.WriteString("\n") targetIdx := m.configGatewayRefreshTargetIndex(rows) b.WriteString(renderConfigRefreshActionRow(rows[targetIdx].DisplayName, m.configSel == refreshSel) + "\n") + if targetIdx >= 0 && targetIdx < len(rows) && rows[targetIdx].KeyConflict { + warning := fmt.Sprintf("warning: %s differs from the stored keychain credential", rows[targetIdx].CredentialEnv) + b.WriteString("\n" + configWarningStyle().Render(strings.Repeat(" ", configTableIndent)+warning)) + } ctx := context.Background() indent := strings.Repeat(" ", configTableIndent) @@ -322,6 +398,7 @@ func refreshGatewayAsync(providerID string) tea.Cmd { func (m chatModel) handleConfigGatewayRefreshMsg(msg configGatewayRefreshMsg) chatModel { m.configSaving = false InvalidateModelCacheProvider(msg.providerID) + m = m.refreshConfigGatewayRows() if msg.err != nil { m.configNotice = sanitizeConfigNotice(hawkconfig.FormatConfigProviderError(msg.providerID, msg.err)) return m @@ -335,14 +412,15 @@ func (m chatModel) handleConfigGatewayRefreshMsg(msg configGatewayRefreshMsg) ch func (m chatModel) focusConfigActiveGateway() chatModel { rows := m.configGatewayRows() + windowSize := m.configVisibleRows() if i := m.activeGatewayRowIndex(rows); i >= 0 { m.configGatewayFocus = i m.configSel = i if m.configSel < m.configScroll { m.configScroll = m.configSel } - if m.configSel >= m.configScroll+configWindowSize { - m.configScroll = m.configSel - configWindowSize + 1 + if m.configSel >= m.configScroll+windowSize { + m.configScroll = m.configSel - windowSize + 1 } } return m @@ -352,15 +430,7 @@ func (m chatModel) trackConfigGatewayFocus() chatModel { if m.configTab != configTabGateways { return m } - active := strings.TrimSpace(m.configModelProvider) - activeModel := "" - if m.session != nil { - if active == "" { - active = strings.TrimSpace(m.session.Provider()) - } - activeModel = strings.TrimSpace(m.session.Model()) - } - rows := len(hawkconfig.GatewayStatuses(context.Background(), active, activeModel)) + rows := len(m.configGatewayRows()) if m.configSel >= 0 && m.configSel < rows { m.configGatewayFocus = m.configSel } diff --git a/cmd/chat_config_gateways_test.go b/cmd/chat_config_gateways_test.go index 78f9d69b..a8a03851 100644 --- a/cmd/chat_config_gateways_test.go +++ b/cmd/chat_config_gateways_test.go @@ -7,7 +7,7 @@ import ( "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" ) @@ -19,13 +19,46 @@ func chatModelForConfigPasteTest() chatModel { return chatModel{configInput: ti, input: textarea.New()} } +func TestConfigGatewayRows_UsesPanelCacheUntilInvalidated(t *testing.T) { + cached := []configGatewayRow{{ID: "cached", DisplayName: "Cached Gateway"}} + m := chatModel{configGatewayRowsCache: cached} + + if rows := m.configGatewayRows(); len(rows) != 1 || rows[0].ID != "cached" { + t.Fatalf("cached rows = %+v, want cached gateway", rows) + } + + m = m.invalidateConfigGatewayRows() + for _, row := range m.configGatewayRows() { + if row.ID == "cached" { + t.Fatal("invalidated gateway rows reused stale panel cache") + } + } +} + +func TestPrioritizeConfigGatewayRowsActiveThenConfigured(t *testing.T) { + rows := []configGatewayRow{ + {ID: "plain-1"}, + {ID: "configured-1", Configured: true}, + {ID: "active", Active: true, Configured: true}, + {ID: "plain-2"}, + {ID: "configured-2", Configured: true}, + } + got := prioritizeConfigGatewayRows(rows) + want := []string{"active", "configured-1", "configured-2", "plain-1", "plain-2"} + for i, id := range want { + if got[i].ID != id { + t.Fatalf("row %d = %q, want %q; rows=%+v", i, got[i].ID, id, got) + } + } +} + func TestConfigGatewaysView_RequiresKeyForModelCounts(t *testing.T) { isolateCredentialHome(t) hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) @@ -44,10 +77,10 @@ func TestConfigGatewaysView_RequiresKeyForModelCounts(t *testing.T) { func TestConfigGatewaysView_ShowsSaveOrProbeNotice(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) @@ -66,13 +99,13 @@ func TestConfigGatewaysView_ShowsSaveOrProbeNotice(t *testing.T) { func TestConfigGatewayRefreshTargetIndex_UsesSelectedRow(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() sess := &engine.Session{} @@ -93,13 +126,13 @@ func TestConfigGatewayRefreshTargetIndex_UsesSelectedRow(t *testing.T) { func TestConfigGatewayRefreshTargetIndex_UsesFocusOnRefreshRow(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() rows := []configGatewayRow{ @@ -115,13 +148,13 @@ func TestConfigGatewayRefreshTargetIndex_UsesFocusOnRefreshRow(t *testing.T) { func TestFocusConfigActiveGateway_SelectsActiveRow(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() sess := &engine.Session{} @@ -140,17 +173,17 @@ func TestFocusConfigActiveGateway_SelectsActiveRow(t *testing.T) { if next.configSel != active { t.Fatalf("configSel = %d, want active row %d", next.configSel, active) } - if next.configScroll > next.configSel || next.configSel >= next.configScroll+configWindowSize { + if next.configScroll > next.configSel || next.configSel >= next.configScroll+next.configVisibleRows() { t.Fatalf("active row not visible: sel=%d scroll=%d", next.configSel, next.configScroll) } } func TestHandleConfigGatewaysSelect_TokenPlanNoKeyShowsRegion(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) @@ -178,10 +211,10 @@ func TestHandleConfigGatewaysSelect_TokenPlanNoKeyShowsRegion(t *testing.T) { func TestHandleConfigGatewaysSelect_NoKeyStartsPaste(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) diff --git a/cmd/chat_config_keys_test.go b/cmd/chat_config_keys_test.go index 9e9e6442..a0db2378 100644 --- a/cmd/chat_config_keys_test.go +++ b/cmd/chat_config_keys_test.go @@ -5,19 +5,19 @@ import ( "testing" tea "charm.land/bubbletea/v2" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) func TestConfigGatewaysView_KeyHintsWithCredentials(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() m := chatModel{configTab: configTabGateways} @@ -29,13 +29,13 @@ func TestConfigGatewaysView_KeyHintsWithCredentials(t *testing.T) { func TestConfigGatewaysKeyView_OpenWithK(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() rows := chatModel{}.configGatewayRows() @@ -58,13 +58,13 @@ func TestConfigGatewaysKeyView_OpenWithK(t *testing.T) { func TestConfigGatewaysDelete_PendingRemove(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() rows := chatModel{}.configGatewayRows() @@ -87,13 +87,13 @@ func TestConfigGatewaysDelete_PendingRemove(t *testing.T) { func TestConfigGatewaysDelete_DoubleConfirm(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() rows := chatModel{}.configGatewayRows() @@ -124,10 +124,10 @@ func TestConfigGatewaysDelete_DoubleConfirm(t *testing.T) { func TestOpenConfigRemoveKeyPanel_OpensGatewaysTab(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) diff --git a/cmd/chat_config_models.go b/cmd/chat_config_models.go index cd33a003..44522f1f 100644 --- a/cmd/chat_config_models.go +++ b/cmd/chat_config_models.go @@ -23,6 +23,7 @@ type configModelOption struct { InputPricePer1M float64 OutputPricePer1M float64 PriceKnown bool + Capabilities []string } var ( @@ -97,6 +98,7 @@ func configModelOptionsFromEyrie(entries []hawkconfig.EngineModel) []configModel InputPricePer1M: e.InputPricePer1M, OutputPricePer1M: e.OutputPricePer1M, PriceKnown: e.PriceKnown, + Capabilities: append([]string(nil), e.Capabilities...), } } return opts @@ -122,6 +124,7 @@ func modelOptionMatchesQuery(opt configModelOption, query string) bool { strings.ToLower(strings.TrimSpace(opt.DisplayName)), strings.ToLower(strings.TrimSpace(opt.Owner)), strings.ToLower(shortModelID(opt.ID)), + strings.ToLower(strings.Join(opt.Capabilities, " ")), } for _, candidate := range candidates { if candidate != "" && strings.Contains(candidate, query) { diff --git a/cmd/chat_config_models_test.go b/cmd/chat_config_models_test.go index 4ef06740..c23732e1 100644 --- a/cmd/chat_config_models_test.go +++ b/cmd/chat_config_models_test.go @@ -47,7 +47,7 @@ func TestModelOptionIsActive(t *testing.T) { func TestConfigModelOptionsCarryResolvedEngineIdentity(t *testing.T) { opts := configModelOptionsFromEyrie([]hawkconfig.EngineModel{{ ID: "models/gemini-pro", CanonicalID: "google/gemini-pro", - ProviderID: "google", GatewayID: "gemini", + ProviderID: "google", GatewayID: "gemini", Capabilities: []string{"tools", "vision"}, }}) if len(opts) != 1 || opts[0].CanonicalID != "google/gemini-pro" || opts[0].ProviderID != "google" || opts[0].GatewayID != "gemini" { @@ -56,4 +56,7 @@ func TestConfigModelOptionsCarryResolvedEngineIdentity(t *testing.T) { if !modelOptionIsActiveResolved(opts[0], "google/gemini-pro", "google/gemini-pro") { t.Fatal("canonical identity did not match without catalog lookup") } + if len(opts[0].Capabilities) != 2 || opts[0].Capabilities[1] != "vision" { + t.Fatalf("capabilities were lost: %+v", opts[0].Capabilities) + } } diff --git a/cmd/chat_config_panel.go b/cmd/chat_config_panel.go index 7c656a2e..fbae1630 100644 --- a/cmd/chat_config_panel.go +++ b/cmd/chat_config_panel.go @@ -126,7 +126,67 @@ func (m chatModel) configOllamaURLView() string { return b.String() } -const configWindowSize = 10 +const ( + configDefaultVisibleRows = 10 + configMinVisibleRows = 4 +) + +// configVisibleRows uses the full-height config viewport after accounting for +// every non-list line rendered by the active tab. Scroll hints are included +// only when the current offset needs them, so a tall panel can use every row. +func (m chatModel) configVisibleRows() int { + if m.height <= 0 { + return configDefaultVisibleRows + } + reserved := m.configFixedChromeRows() + total := len(m.configGatewayRows()) + if m.configTab == configTabModels { + total = len(m.configFilteredModelOptions()) + } + rows := maxInt(configMinVisibleRows, m.height-reserved) + // Hint visibility depends on capacity, and capacity depends on hints. Two + // iterations are sufficient because there are at most two one-line hints. + for range 2 { + hints := 0 + if m.configScroll > 0 { + hints++ + } + if m.configScroll+rows < total { + hints++ + } + rows = maxInt(configMinVisibleRows, m.height-reserved-hints) + } + return rows +} + +func (m chatModel) configFixedChromeRows() int { + // title, status, blank, tabs, divider, blank, and final help line + rows := 7 + if notice := sanitizeConfigNotice(m.configNoticeForView()); notice != "" { + rows += strings.Count(notice, "\n") + 2 + } + if m.configTab == configTabModels { + gw := strings.TrimSpace(m.configModelProvider) + if gw == "" && m.session != nil { + gw = strings.TrimSpace(m.session.Provider()) + } + if gw != "" { + rows += 2 + } + if len(m.configModelOptions) > 0 || m.configModelSearchActive { + rows += 2 + } + rows += 5 // two-line header + blank + footer + capability legend + return rows + } + rows += 6 // two-line header + blank + refresh + blank + selection footer + gateways := m.configGatewayRows() + target := m.configGatewayRefreshTargetIndex(gateways) + if target >= 0 && target < len(gateways) && gateways[target].KeyConflict { + rows++ + } + return rows +} func (m chatModel) configModelsTabView() string { var body strings.Builder @@ -302,13 +362,14 @@ func (m chatModel) focusConfigActiveModelSelection() chatModel { } activeID := m.configActiveModelID() activeCanonicalID := hawkconfig.CanonicalModelID(context.Background(), activeID) + windowSize := m.configVisibleRows() for i, opt := range opts { if modelOptionIsActiveResolved(opt, activeID, activeCanonicalID) { m.configSel = i - if m.configSel < configWindowSize { + if m.configSel < windowSize { m.configScroll = 0 } else { - m.configScroll = m.configSel - configWindowSize + 1 + m.configScroll = m.configSel - windowSize + 1 } return m } @@ -322,8 +383,8 @@ func (m chatModel) focusConfigActiveModelSelection() chatModel { if m.configSel < m.configScroll { m.configScroll = m.configSel } - if m.configSel >= m.configScroll+configWindowSize { - m.configScroll = m.configSel - configWindowSize + 1 + if m.configSel >= m.configScroll+windowSize { + m.configScroll = m.configSel - windowSize + 1 } return m } @@ -342,15 +403,20 @@ func (m chatModel) configModelsBody() string { allTotal := len(m.configModelOptions) activeModelID := m.configActiveModelID() activeCanonicalID := hawkconfig.CanonicalModelID(context.Background(), activeModelID) + windowSize := m.configVisibleRows() + maxScroll := maxInt(0, total-windowSize) + if m.configScroll > maxScroll { + m.configScroll = maxScroll + } if m.configSel < m.configScroll { m.configScroll = m.configSel } - if m.configSel >= m.configScroll+configWindowSize { - m.configScroll = m.configSel - configWindowSize + 1 + if m.configSel >= m.configScroll+windowSize { + m.configScroll = m.configSel - windowSize + 1 } - end := m.configScroll + configWindowSize + end := m.configScroll + windowSize if end > total { end = total } @@ -404,6 +470,7 @@ func (m chatModel) configModelsBody() string { } b.WriteString("\n" + modelTableFooter(total, m.configScroll, end, allTotal, mutedStyle)) + b.WriteString("\n" + mutedStyle.Render(strings.Repeat(" ", modelTableIndent)+"Caps: T tools · V vision · R reasoning · J JSON")) return b.String() } @@ -448,7 +515,7 @@ func (m chatModel) startConfigEntry(kind, provider string) (chatModel, tea.Cmd) m.useConfigInput = true m.configInput.Reset() m.configInput.Prompt = " key " + icons.ChevronRight() + " " - m.configInput.Placeholder = "paste API key" + m.configInput.Placeholder = "" m.configInput.EchoMode = textinput.EchoPassword m.configInput.EchoCharacter = '*' m.configInput.SetStyles(textinput.Styles{ @@ -594,11 +661,12 @@ func (m chatModel) handleConfigEntryKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { const configWheelStep = 5 -func configPageStep() int { - if configWindowSize <= 2 { +func (m chatModel) configPageStep() int { + windowSize := m.configVisibleRows() + if windowSize <= 2 { return 1 } - return configWindowSize - 1 + return windowSize - 1 } func (m chatModel) configMoveSelection(delta int) chatModel { @@ -646,11 +714,80 @@ func (m chatModel) handleConfigMouse(msg tea.MouseMsg) (chatModel, bool) { return m.configMoveSelection(-step), true case tea.MouseWheelDown: return m.configMoveSelection(step), true + case tea.MouseLeft: + if _, ok := msg.(tea.MouseClickMsg); !ok || m.configEntry != configEntryNone { + return m, false + } + return m.configSelectClickedRow(msg.Mouse().Y) default: return m, false } } +// configSelectClickedRow maps a terminal click to an internally paginated +// gateway/model row. The outer config viewport stays pinned to the top, but +// account for its offset so this remains correct if that invariant changes. +func (m chatModel) configSelectClickedRow(terminalY int) (chatModel, bool) { + contentY := terminalY - m.chatPaneTopY() + m.viewport.YOffset() + firstRowY := m.configFirstVisibleRowY() + if contentY < firstRowY { + return m, false + } + + total := m.configTabItemCount() + if m.configTab == configTabGateways { + total-- // the refresh action is positioned separately below the table + } + end := minInt(total, m.configScroll+m.configVisibleRows()) + visible := maxInt(0, end-m.configScroll) + rowOffset := contentY - firstRowY + if rowOffset >= 0 && rowOffset < visible { + m.configSel = m.configScroll + rowOffset + if m.configTab == configTabGateways { + m = m.trackConfigGatewayFocus() + } + return m, true + } + + if m.configTab == configTabGateways { + refreshY := firstRowY + visible + if end < total { + refreshY++ // hidden-row hint below the visible table + } + refreshY++ // blank line before Refresh + if contentY == refreshY { + m.configSel = total + return m, true + } + } + return m, false +} + +func (m chatModel) configFirstVisibleRowY() int { + // Setup title, status, blank, tabs, divider, blank. + y := 6 + if notice := sanitizeConfigNotice(m.configNoticeForView()); notice != "" { + y += strings.Count(notice, "\n") + 2 + } + if m.configTab == configTabModels { + gateway := strings.TrimSpace(m.configModelProvider) + if gateway == "" && m.session != nil { + gateway = strings.TrimSpace(m.session.Provider()) + } + if gateway != "" { + y += 2 + } + if len(m.configModelOptions) > 0 || m.configModelSearchActive { + y += 2 + } + } + y += 2 // table header and rule + if m.configScroll > 0 { + y++ + } + return y +} + func (m chatModel) handleConfigMouseLeak(msg tea.KeyMsg) (chatModel, bool) { matches := mouseSGRReportRE.FindAllStringSubmatch(msg.Key().Text, -1) if len(matches) == 0 { @@ -771,9 +908,9 @@ func (m chatModel) handleConfigKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { m.configSel = (m.configSel + 1) % n return m.trackConfigGatewayFocus(), nil case tea.KeyPgUp: - return m.configMoveSelection(-configPageStep()), nil + return m.configMoveSelection(-m.configPageStep()), nil case tea.KeyPgDown: - return m.configMoveSelection(configPageStep()), nil + return m.configMoveSelection(m.configPageStep()), nil case tea.KeyHome: m.configSel = 0 return m.trackConfigGatewayFocus(), nil diff --git a/cmd/chat_config_remove.go b/cmd/chat_config_remove.go index 589b7e58..41b53ead 100644 --- a/cmd/chat_config_remove.go +++ b/cmd/chat_config_remove.go @@ -35,6 +35,7 @@ func (m chatModel) handleConfigRemoveCredentialMsg(msg configRemoveCredentialMsg delete(modelCache, msg.provider) ctx := context.Background() hawkconfig.RefreshConfigCredSnapshot(ctx) + m = m.refreshConfigGatewayRows() if hawkconfig.ShouldClearSelectionAfterCredentialRemove(ctx, msg.provider) { _ = hawkconfig.ClearActiveSelection(ctx) m.configModelProvider = "" diff --git a/cmd/chat_config_remove_test.go b/cmd/chat_config_remove_test.go index 8e11bfc2..1118a0cf 100644 --- a/cmd/chat_config_remove_test.go +++ b/cmd/chat_config_remove_test.go @@ -4,19 +4,19 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) func TestConfigGatewayRows_ShowsSavedKey(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() rows := chatModel{}.configGatewayRows() @@ -34,13 +34,13 @@ func TestConfigGatewayRows_ShowsSavedKey(t *testing.T) { func TestConfiguredCredentialProviders_UsedByGatewaysTab(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() got := hawkconfig.ConfiguredCredentialProviders() @@ -51,13 +51,13 @@ func TestConfiguredCredentialProviders_UsedByGatewaysTab(t *testing.T) { func TestRemoveCredentialAsync(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() cmd := removeCredentialAsync("openrouter") diff --git a/cmd/chat_config_save_flow_test.go b/cmd/chat_config_save_flow_test.go index cdcead2e..dda5144a 100644 --- a/cmd/chat_config_save_flow_test.go +++ b/cmd/chat_config_save_flow_test.go @@ -2,11 +2,13 @@ package cmd import ( "context" + "fmt" "net/http" "strings" "testing" tea "charm.land/bubbletea/v2" + "github.com/GrayCodeAI/eyrie/catalog" "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) @@ -164,3 +166,59 @@ func TestHandleConfigKey_EnterOnPasteSubmits(t *testing.T) { t.Fatalf("entry should close on submit, got %q", next.configEntry) } } + +func TestUpdate_PasteMsgRoutesEntireKeyToConfigInput(t *testing.T) { + const secret = "sk-or-pasted-key-12345678901234567890" + + m := chatModelForConfigPasteTest() + m.configOpen = true + next, _ := m.startConfigKeyForProvider("openrouter") + + updated, _ := next.Update(tea.PasteMsg{Content: secret}) + got, ok := updated.(chatModel) + if !ok { + t.Fatalf("updated model type = %T, want chatModel", updated) + } + if value := got.configInput.Value(); value != secret { + t.Fatalf("config input value = %q, want the complete pasted key", value) + } +} + +func TestStartConfigEntry_APIKeyPasteHasNoPlaceholder(t *testing.T) { + m := chatModelForConfigPasteTest() + next, _ := m.startConfigKeyForProvider("openrouter") + + if next.configInput.Placeholder != "" { + t.Fatalf("API key placeholder = %q, want empty", next.configInput.Placeholder) + } + if strings.Contains(next.configProviderKeyView(), "paste API key") { + t.Fatal("API key input view contains placeholder text") + } +} + +func TestHandleConfigApplyCredentialsMsg_CatalogFailureDoesNotBlameProvider(t *testing.T) { + hawkconfig.InvalidateConfigUICache() + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { + credentials.SetDefaultStore(nil) + hawkconfig.InvalidateConfigUICache() + }) + if err := store.Set(t.Context(), credentials.AccountForEnv("POOLSIDE_API_KEY"), "poolside-test-key"); err != nil { + t.Fatalf("store key: %v", err) + } + + m := chatModelForConfigPasteTest() + next, _ := m.handleConfigApplyCredentialsMsg(configApplyCredentialsMsg{ + providerID: "poolside", + err: fmt.Errorf("apply credentials: %w", catalog.ErrCatalogCacheRequired), + }) + + if strings.Contains(next.configNotice, "provider rejected") { + t.Fatalf("catalog failure blamed provider key: %q", next.configNotice) + } + if !strings.Contains(next.configNotice, "model catalog unavailable") || + !strings.Contains(next.configNotice, "hawk models refresh") { + t.Fatalf("catalog recovery guidance missing: %q", next.configNotice) + } +} diff --git a/cmd/chat_config_tabs.go b/cmd/chat_config_tabs.go index 69b7b75e..7ac1a93e 100644 --- a/cmd/chat_config_tabs.go +++ b/cmd/chat_config_tabs.go @@ -108,6 +108,7 @@ func (m chatModel) switchConfigTab(tab int) (chatModel, tea.Cmd) { return m.beginConfigModelsTab() } if tab == configTabGateways { + m = m.refreshConfigGatewayRows() m = m.focusConfigActiveGateway() } return m, nil @@ -122,6 +123,7 @@ func (m chatModel) openConfigAtTab(tab int) (chatModel, tea.Cmd) { m.configScroll = 0 m.viewDirty = true hawkconfig.RefreshConfigCredSnapshot(ctx) + m = m.refreshConfigGatewayRows() setup := hawkconfig.EvaluateSetupCached(ctx) if tab < 0 { diff --git a/cmd/chat_config_tabs_test.go b/cmd/chat_config_tabs_test.go index ac691f6d..1aab5b58 100644 --- a/cmd/chat_config_tabs_test.go +++ b/cmd/chat_config_tabs_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/ui/icons" ) @@ -29,10 +29,10 @@ func TestRenderConfigTabBar_DotIndicators(t *testing.T) { func TestOpenConfigPanel_FirstRunOpensGateways(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) @@ -48,10 +48,10 @@ func TestOpenConfigPanel_FirstRunOpensGateways(t *testing.T) { func TestOpenConfigAtTab_ModelsWithoutCredentialsFallsBackToGateways(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) diff --git a/cmd/chat_config_ui.go b/cmd/chat_config_ui.go index 675863a5..4bfc4eac 100644 --- a/cmd/chat_config_ui.go +++ b/cmd/chat_config_ui.go @@ -93,6 +93,10 @@ func configHeaderStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(textPrimary).Bold(true) } +func configWarningStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(warnAmber).Bold(true) +} + func configNoticeStyle(notice string) lipgloss.Style { n := strings.ToLower(notice) switch { diff --git a/cmd/chat_config_vertical_test.go b/cmd/chat_config_vertical_test.go new file mode 100644 index 00000000..1d42fc42 --- /dev/null +++ b/cmd/chat_config_vertical_test.go @@ -0,0 +1,158 @@ +package cmd + +import ( + "strings" + "testing" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" +) + +func TestConfigVisibleRowsExpandsWithTerminalHeight(t *testing.T) { + short := chatModel{height: 30, configTab: configTabGateways}.configVisibleRows() + tall := chatModel{height: 60, configTab: configTabGateways}.configVisibleRows() + if tall <= short { + t.Fatalf("expected tall terminal to show more rows: short=%d tall=%d", short, tall) + } + want := 60 - (chatModel{configTab: configTabGateways}).configFixedChromeRows() + if tall != want { + t.Fatalf("tall gateway rows = %d, want %d", tall, want) + } +} + +func TestConfigUsesFullTerminalHeight(t *testing.T) { + m := chatModel{height: 42, configOpen: true} + m.viewport = viewport.New(viewport.WithWidth(100), viewport.WithHeight(5)) + m = m.withSyncedLayout() + if got := m.viewport.Height(); got != 42 { + t.Fatalf("config viewport height = %d, want 42", got) + } +} + +func TestConfigMouseClickSelectsVisibleGatewayRow(t *testing.T) { + rows := []configGatewayRow{ + {ID: "one", DisplayName: "One"}, + {ID: "two", DisplayName: "Two"}, + {ID: "three", DisplayName: "Three"}, + } + m := chatModel{ + height: 30, + width: 100, + configOpen: true, + configTab: configTabGateways, + configGatewayRowsCache: rows, + } + m.viewport = viewport.New(viewport.WithWidth(100), viewport.WithHeight(30)) + click := tea.MouseClickMsg{Y: m.configFirstVisibleRowY() + 1, Button: tea.MouseLeft} + next, handled := m.handleConfigMouse(click) + if !handled || next.configSel != 1 || next.configGatewayFocus != 1 { + t.Fatalf("gateway click handled=%v selection=%d focus=%d", handled, next.configSel, next.configGatewayFocus) + } +} + +func TestConfigMouseClickSelectsVisibleModelRowAfterChrome(t *testing.T) { + m := chatModel{ + height: 30, + width: 100, + configOpen: true, + configTab: configTabModels, + configModelProvider: "poolside", + configModelOptions: []configModelOption{ + {ID: "poolside/one", DisplayName: "One"}, + {ID: "poolside/two", DisplayName: "Two"}, + }, + } + m.viewport = viewport.New(viewport.WithWidth(100), viewport.WithHeight(30)) + click := tea.MouseClickMsg{Y: m.configFirstVisibleRowY() + 1, Button: tea.MouseLeft} + next, handled := m.handleConfigMouse(click) + if !handled || next.configSel != 1 { + t.Fatalf("model click handled=%v selection=%d", handled, next.configSel) + } +} + +func TestConfigVisibleRowsAccountsForNoticeLines(t *testing.T) { + base := chatModel{height: 50, configTab: configTabGateways}.configVisibleRows() + withNotice := chatModel{ + height: 50, + configTab: configTabGateways, + configNotice: "first line\nsecond line", + }.configVisibleRows() + if base-withNotice != 3 { + t.Fatalf("two-line notice should consume three rows including spacing: base=%d notice=%d", base, withNotice) + } +} + +func TestConfigVisibleRowsReservesMoreModelChrome(t *testing.T) { + gateways := chatModel{height: 50, configTab: configTabGateways}.configVisibleRows() + models := chatModel{ + height: 50, + configTab: configTabModels, + configModelProvider: "poolside", + configModelOptions: []configModelOption{{ID: "poolside/model"}}, + }.configVisibleRows() + if models >= gateways { + t.Fatalf("models should reserve space for gateway/search rows: gateways=%d models=%d", gateways, models) + } +} + +func TestConfigVisibleRowsFallbackAndMinimum(t *testing.T) { + if got := (chatModel{}).configVisibleRows(); got != configDefaultVisibleRows { + t.Fatalf("unknown-height rows = %d, want %d", got, configDefaultVisibleRows) + } + if got := (chatModel{height: 8}).configVisibleRows(); got != configMinVisibleRows { + t.Fatalf("small-terminal rows = %d, want %d", got, configMinVisibleRows) + } +} + +func TestConfigPageStepTracksVisibleRows(t *testing.T) { + m := chatModel{height: 60, configTab: configTabModels} + if got, want := m.configPageStep(), m.configVisibleRows()-1; got != want { + t.Fatalf("page step = %d, want %d", got, want) + } +} + +func TestTallGatewayPanelClampsStaleScrollOffset(t *testing.T) { + rows := make([]configGatewayRow, 22) + for i := range rows { + rows[i] = configGatewayRow{ID: "gateway", DisplayName: "Gateway"} + } + rows[0].DisplayName = "First Gateway" + m := chatModel{ + height: 60, + width: 160, + configTab: configTabGateways, + configScroll: 10, + configSel: 15, + configGatewayRowsCache: rows, + } + view := m.configGatewaysView() + if !strings.Contains(view, "First Gateway") { + t.Fatalf("expected stale scroll to clamp and reveal first row, got:\n%s", view) + } +} + +func TestConfigViewportPinsOuterScrollToTop(t *testing.T) { + vp := viewport.New(viewport.WithWidth(100), viewport.WithHeight(20)) + vp.SetContent(strings.Repeat("old chat\n", 80)) + vp.SetYOffset(30) + m := chatModel{ + viewport: vp, + width: 100, + height: 30, + contentLines: 200, + configOpen: true, + configTab: configTabGateways, + viewDirty: true, + configGatewayRowsCache: []configGatewayRow{{ID: "poolside", DisplayName: "Poolside"}}, + } + m.updateViewportContent() + if !m.viewport.AtTop() { + t.Fatalf("config outer viewport retained chat offset %d", m.viewport.YOffset()) + } + if m.contentLines >= 200 { + t.Fatalf("config retained stale chat content line count %d", m.contentLines) + } + if !strings.Contains(m.viewport.View(), "Gateway") { + t.Fatalf("expected sticky config header in viewport, got:\n%s", m.viewport.View()) + } +} diff --git a/cmd/chat_config_xiaomi.go b/cmd/chat_config_xiaomi.go index baa680ba..687cbb17 100644 --- a/cmd/chat_config_xiaomi.go +++ b/cmd/chat_config_xiaomi.go @@ -99,6 +99,7 @@ func (m chatModel) handleConfigXiaomiRegionKey(msg tea.KeyMsg) (chatModel, tea.C return m, nil } InvalidateModelCacheProvider(hawkconfig.ProviderXiaomiTokenPlan) + m = m.refreshConfigGatewayRows() m.configEntry = configEntryNone ctx := context.Background() if post := strings.TrimSpace(m.configPostSaveKeysProvider); post == hawkconfig.ProviderXiaomiTokenPlan { diff --git a/cmd/chat_config_xiaomi_test.go b/cmd/chat_config_xiaomi_test.go index 0733044d..efd5e36b 100644 --- a/cmd/chat_config_xiaomi_test.go +++ b/cmd/chat_config_xiaomi_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) @@ -27,10 +27,10 @@ func TestStartConfigXiaomiTokenPlanRegion_WithSavedRegion(t *testing.T) { func TestHandleConfigGatewaysSelect_TokenPlanNoKeyShowsRegionPicker(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) diff --git a/cmd/chat_config_zai.go b/cmd/chat_config_zai.go index ecc31a47..c965ea2c 100644 --- a/cmd/chat_config_zai.go +++ b/cmd/chat_config_zai.go @@ -103,6 +103,7 @@ func (m chatModel) handleConfigZAIRegionKey(msg tea.KeyMsg) (chatModel, tea.Cmd) return m, nil } InvalidateModelCacheProvider(prov) + m = m.refreshConfigGatewayRows() m.configEntry = configEntryNone ctx := context.Background() if post := strings.TrimSpace(m.configPostSaveKeysProvider); post == prov { diff --git a/cmd/chat_focus_test.go b/cmd/chat_focus_test.go index 42935224..1145ce8a 100644 --- a/cmd/chat_focus_test.go +++ b/cmd/chat_focus_test.go @@ -129,3 +129,20 @@ func TestChatProgramOptions_IncludeFocusReporting(t *testing.T) { t.Fatalf("Bubble Tea v2 should configure terminal modes through View, got %d legacy options", len(chatProgramOptions(false))) } } + +func TestTerminalViewDeclaresBubbleTeaV2Modes(t *testing.T) { + enabled := true + view := (chatModel{mouseOverride: &enabled}).terminalView("chat") + if !view.AltScreen || !view.ReportFocus { + t.Fatalf("expected alt screen and focus reporting: %+v", view) + } + if view.MouseMode != tea.MouseModeCellMotion { + t.Fatalf("mouse mode = %v, want cell motion", view.MouseMode) + } + + disabled := false + view = (chatModel{mouseOverride: &disabled}).terminalView("chat") + if view.MouseMode != tea.MouseModeNone { + t.Fatalf("disabled mouse mode = %v, want none", view.MouseMode) + } +} diff --git a/cmd/chat_journey_e2e_test.go b/cmd/chat_journey_e2e_test.go index 68a923fe..4b27e9b0 100644 --- a/cmd/chat_journey_e2e_test.go +++ b/cmd/chat_journey_e2e_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) @@ -25,15 +25,15 @@ func requireChatModel(t *testing.T, model any) *chatModel { func TestChatJourney_ConfigPermissionsAndCoreCommands(t *testing.T) { hawkconfig.InvalidateConfigUICache() isolateCredentialHome(t) - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) ctx := context.Background() - if err := store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + if err := store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { t.Fatal(err) } hawkconfig.InvalidateConfigUICache() diff --git a/cmd/chat_layout.go b/cmd/chat_layout.go index 3abd2e69..3e6893cb 100644 --- a/cmd/chat_layout.go +++ b/cmd/chat_layout.go @@ -20,6 +20,9 @@ func (m chatModel) withSyncedLayout() chatModel { return m } bottomH := m.chatBottomBarLines() + if m.configOpen { + bottomH = 0 + } // Viewport takes all available space above the bottom bar. vpH := m.height - bottomH if vpH < minChatViewportLines { diff --git a/cmd/chat_model.go b/cmd/chat_model.go index 235aa319..5be32b8c 100644 --- a/cmd/chat_model.go +++ b/cmd/chat_model.go @@ -205,11 +205,13 @@ type chatModel struct { configModelSearchActive bool // typing into model search input configGuideAfterKey bool // open model picker when discover finishes configGatewayFocus int // last highlighted gateway row (for refresh action) - configKeysPendingRemove string // provider awaiting delete confirmation - configKeysRemoveStep int // 1 = first prompt, 2 = final confirm - configReplaceProvider string // replace-key flow target gateway - configPostSaveKeysProvider string // return to Gateways tab after replace save - configSaving bool // blocks hub/list input while async credential work runs + configGatewayRowsCache []configGatewayRow + configGatewayRowsDirty bool + configKeysPendingRemove string // provider awaiting delete confirmation + configKeysRemoveStep int // 1 = first prompt, 2 = final confirm + configReplaceProvider string // replace-key flow target gateway + configPostSaveKeysProvider string // return to Gateways tab after replace save + configSaving bool // blocks hub/list input while async credential work runs configPendingOllamaURL string configXiaomiRegionSel int // Token Plan region picker index configZAIRegionSel int // Z.AI (general or coding) region picker index diff --git a/cmd/chat_model_test.go b/cmd/chat_model_test.go index f04c8f72..fb6c4ad1 100644 --- a/cmd/chat_model_test.go +++ b/cmd/chat_model_test.go @@ -10,7 +10,7 @@ import ( "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/viewport" lipgloss "charm.land/lipgloss/v2" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" "github.com/GrayCodeAI/hawk/internal/bridge/sessioncapture" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" @@ -62,10 +62,10 @@ func isolateChatCommandSweepEnv(t *testing.T) { storage.SetTestDirs(t, root) isolateCredentialHome(t) hawkconfig.InvalidateConfigUICache() - credentials.SetDefaultStore(&credentials.MapStore{}) + gateway.SetDefaultStore(&gateway.MapStore{}) restoreThemeGlobals(t) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) } diff --git a/cmd/chat_multiturn_e2e_test.go b/cmd/chat_multiturn_e2e_test.go index 59310ae8..062b2fbb 100644 --- a/cmd/chat_multiturn_e2e_test.go +++ b/cmd/chat_multiturn_e2e_test.go @@ -6,7 +6,7 @@ import ( "testing" tea "charm.land/bubbletea/v2" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" ) @@ -16,15 +16,15 @@ func configureReadyChatState(t *testing.T) { isolateChatCommandSweepEnv(t) hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) ctx := context.Background() - if err := store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + if err := store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { t.Fatal(err) } if err := hawkconfig.SetActiveProvider(ctx, "openrouter"); err != nil { diff --git a/cmd/chat_status_test.go b/cmd/chat_status_test.go index 377ced70..7ac543a1 100644 --- a/cmd/chat_status_test.go +++ b/cmd/chat_status_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" "github.com/charmbracelet/x/ansi" @@ -94,15 +94,15 @@ func TestFormatContextUsedLabel(t *testing.T) { func TestChatConnectionStatus_WithModel(t *testing.T) { hawkconfig.InvalidateConfigUICache() isolateCredentialHome(t) - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) ctx := context.Background() - _ = store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() _ = hawkconfig.SetActiveProvider(ctx, "openrouter") _ = hawkconfig.SetActiveModel(ctx, "moonshotai/kimi-k2.6") @@ -128,15 +128,15 @@ func TestChatConnectionStatus_WithModel(t *testing.T) { func TestChatConnectionStatus_KeyNoModel(t *testing.T) { hawkconfig.InvalidateConfigUICache() isolateCredentialHome(t) - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) ctx := context.Background() - _ = store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() _ = hawkconfig.ClearActiveSelection(ctx) _ = hawkconfig.SetActiveProvider(ctx, "openrouter") @@ -152,15 +152,15 @@ func TestChatConnectionStatus_KeyNoModel(t *testing.T) { func TestChatConnectionStatus_NoGatewayNoModel(t *testing.T) { hawkconfig.InvalidateConfigUICache() isolateCredentialHome(t) - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) ctx := context.Background() - _ = store.Set(ctx, credentials.AccountForEnv("ANTHROPIC_API_KEY"), "sk-ant-test-key-long-enough") + _ = store.Set(ctx, gateway.AccountForEnv("ANTHROPIC_API_KEY"), "sk-ant-test-key-long-enough") hawkconfig.InvalidateConfigUICache() _ = hawkconfig.ClearActiveSelection(ctx) hawkconfig.RefreshConfigCredSnapshot(ctx) diff --git a/cmd/chat_terminal_mouse.go b/cmd/chat_terminal_mouse.go index fd68e3e6..7221aace 100644 --- a/cmd/chat_terminal_mouse.go +++ b/cmd/chat_terminal_mouse.go @@ -11,7 +11,10 @@ import ( // scroll events to arrive as literal "[<65;99;16M" KeyRunes in the input. const ( disableMouseCSI = "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l" - enableMouseCSI = "\x1b[?1006h\x1b[?1002h\x1b[?1003h" + // Explicitly disable any-motion mode left by an older Hawk process, then + // enable button/cell tracking. Wheel and click events still arrive, while + // ordinary pointer movement no longer floods the Bubble Tea update loop. + enableMouseCSI = "\x1b[?1003l\x1b[?1006h\x1b[?1002h" ) func writeTerminalMouse(mode string) { diff --git a/cmd/chat_update.go b/cmd/chat_update.go index e468b606..ad2a8393 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -182,6 +182,23 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case tea.PasteMsg: + if m.configOpen { + var cmd tea.Cmd + m.configInput, cmd = m.configInput.Update(msg) + m.viewDirty = true + m.updateViewportContent() + return m, cmd + } + if m.uiFocus == focusPrompt { + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.viewDirty = true + m.updateViewportContent() + return m, cmd + } + return m, nil + case tea.KeyMsg: s := msg.String() if (s == "up" || s == "down") && !m.processingGenuineArrow { @@ -1004,6 +1021,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height m.input.SetWidth(msg.Width - 4) + m.configInput.SetWidth(msg.Width - 4) m.invalidateInputLayoutCache() m.rebuildWelcomeCache(false) m.viewDirty = true diff --git a/cmd/chat_view.go b/cmd/chat_view.go index c71986c1..4391672a 100644 --- a/cmd/chat_view.go +++ b/cmd/chat_view.go @@ -258,7 +258,13 @@ func (m *chatModel) updateViewportContent() { if m.configOpen { var content strings.Builder content.WriteString(m.configPanelView()) - m.viewport.SetContent(content.String()) + contentString := content.String() + m.contentLines = renderedLineCount(contentString) + m.viewport.SetWidth(m.chatViewportWidth(viewWidth)) + m.viewport.SetContent(contentString) + // The config lists paginate internally. Never retain the chat + // transcript's outer Y offset, or the table header/help can disappear. + m.viewport.GotoTop() return } @@ -326,7 +332,7 @@ func renderedLineCount(s string) int { func (m chatModel) View() tea.View { if m.quitting { - return tea.NewView("") + return m.terminalView("") } m = m.withSyncedLayout() @@ -431,7 +437,7 @@ func (m chatModel) View() tea.View { paletteView := m.commandPalette.Render(viewWidth) frame.WriteByte('\n') frame.WriteString(paletteView) - return tea.NewView(frame.String()) + return m.terminalView(frame.String()) } // Autonomy tier picker overlay @@ -439,13 +445,13 @@ func (m chatModel) View() tea.View { pickerView := lipgloss.NewStyle().Width(viewWidth).Render(m.themePicker.View().Content) frame.WriteByte('\n') frame.WriteString(pickerView) - return tea.NewView(frame.String()) + return m.terminalView(frame.String()) } if m.autonomyPicker != nil && m.autonomyPicker.IsOpen() { pickerView := m.autonomyPicker.Render(viewWidth) frame.WriteByte('\n') frame.WriteString(pickerView) - return tea.NewView(frame.String()) + return m.terminalView(frame.String()) } // Spec workflow picker overlay @@ -453,7 +459,7 @@ func (m chatModel) View() tea.View { pickerView := m.specPicker.Render(viewWidth) frame.WriteByte('\n') frame.WriteString(pickerView) - return tea.NewView(frame.String()) + return m.terminalView(frame.String()) } // Agent Status HUD overlay @@ -461,12 +467,24 @@ func (m chatModel) View() tea.View { hudView := renderAgentStatusPanel(m.hudData, viewWidth) frame.WriteByte('\n') frame.WriteString(hudView) - return tea.NewView(frame.String()) + return m.terminalView(frame.String()) + } + + if bottomBar.Len() > 0 { + frame.WriteByte('\n') + frame.WriteString(bottomBar.String()) } + return m.terminalView(frame.String()) +} - frame.WriteByte('\n') - frame.WriteString(bottomBar.String()) - return tea.NewView(frame.String()) +func (m chatModel) terminalView(content string) tea.View { + view := tea.NewView(content) + view.AltScreen = true + view.ReportFocus = true + if m.mouseEnabled() { + view.MouseMode = tea.MouseModeCellMotion + } + return view } // renderPermissionBox renders a compact inline permission prompt. diff --git a/cmd/diagnostics_test.go b/cmd/diagnostics_test.go index 258ad4bc..9230b632 100644 --- a/cmd/diagnostics_test.go +++ b/cmd/diagnostics_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/resilience/health" ) @@ -14,7 +14,7 @@ import ( type diagnosticsContextKey struct{} type contextRecordingCredentialStore struct { - credentials.MapStore + gateway.MapStore contextValue any } @@ -57,9 +57,9 @@ func TestDoctorReportProviderModelOrder(t *testing.T) { func TestDoctorReportUsesResolvedProviderForChecks(t *testing.T) { isolateCredentialHome(t) hawkconfig.InvalidateConfigUICache() - credentials.SetDefaultStore(&credentials.MapStore{}) + gateway.SetDefaultStore(&gateway.MapStore{}) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) @@ -77,14 +77,14 @@ func TestProviderCredentialHealthCheckerResolvesAuto(t *testing.T) { t.Setenv("EYRIE_CONFIG_DIR", t.TempDir()) hawkconfig.InvalidateConfigUICache() store := &contextRecordingCredentialStore{} - credentials.SetDefaultStore(store) + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) ctx := context.WithValue(context.Background(), diagnosticsContextKey{}, "checker-context") - if err := store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + if err := store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { t.Fatal(err) } if err := hawkconfig.SetActiveProvider(ctx, "openrouter"); err != nil { @@ -119,9 +119,9 @@ func TestProviderCredentialHealthCheckerMissingIsUnhealthy(t *testing.T) { isolateCredentialHome(t) t.Setenv("EYRIE_CONFIG_DIR", t.TempDir()) hawkconfig.InvalidateConfigUICache() - credentials.SetDefaultStore(&credentials.MapStore{}) + gateway.SetDefaultStore(&gateway.MapStore{}) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) diff --git a/cmd/markdown.go b/cmd/markdown.go index daba3803..e5f43dec 100644 --- a/cmd/markdown.go +++ b/cmd/markdown.go @@ -1,12 +1,17 @@ package cmd import ( + "bytes" "fmt" "regexp" "strings" "unicode/utf8" lipgloss "charm.land/lipgloss/v2" + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" "github.com/mattn/go-runewidth" ) @@ -20,7 +25,11 @@ import ( // Markdown rendering styles using the project's purpose-named palette. var ( - mdHeaderStyle = lipgloss.NewStyle().Foreground(successTeal).Bold(true) + mdH1Style = lipgloss.NewStyle().Foreground(hawkColor).Bold(true).Underline(true) + mdH2Style = lipgloss.NewStyle().Foreground(successTeal).Bold(true) + mdH3Style = lipgloss.NewStyle().Foreground(infoSky).Bold(true) + mdH4Style = lipgloss.NewStyle().Foreground(costViolet).Bold(true) + mdHeaderStyle = lipgloss.NewStyle().Foreground(textPrimary).Bold(true) mdBoldStyle = lipgloss.NewStyle().Foreground(hawkColor).Bold(true) mdItalicStyle = lipgloss.NewStyle().Italic(true) mdInlineCodeStyle = lipgloss.NewStyle().Background(bgCode).Foreground(textPrimary) @@ -79,7 +88,7 @@ func renderMarkdown(content string, width int) string { // Headers if level, text := parseHeader(line); level > 0 { rendered := renderInlineFormatting(text, width) - result.WriteString(mdHeaderStyle.Render(rendered)) + result.WriteString(renderMarkdownHeading(level, rendered)) result.WriteByte('\n') i++ continue @@ -125,13 +134,15 @@ func renderMarkdown(content string, width int) string { // Unordered list if bullet, text := parseUnorderedList(line); bullet != "" { rendered := renderInlineFormatting(text, width) - prefix := " " + mdBulletStyle.Render(bullet) + " " - wrapped := mdWordWrap(rendered, width-5) + indent := markdownListIndent(line) + prefix := indent + mdBulletStyle.Render(bullet) + " " + prefixW := visibleWidth(prefix) + wrapped := mdWordWrap(rendered, width-prefixW) wrapLines := strings.Split(wrapped, "\n") result.WriteString(prefix + wrapLines[0]) result.WriteByte('\n') for _, wl := range wrapLines[1:] { - result.WriteString(" " + wl) + result.WriteString(strings.Repeat(" ", prefixW) + wl) result.WriteByte('\n') } i++ @@ -141,8 +152,8 @@ func renderMarkdown(content string, width int) string { // Ordered list if num, text := parseOrderedList(line); num != "" { rendered := renderInlineFormatting(text, width) - prefix := " " + num + " " - prefixW := 2 + runewidth.StringWidth(num) + 1 + prefix := markdownListIndent(line) + mdBulletStyle.Render(num) + " " + prefixW := visibleWidth(prefix) wrapped := mdWordWrap(rendered, width-prefixW) wrapLines := strings.Split(wrapped, "\n") result.WriteString(prefix + wrapLines[0]) @@ -171,6 +182,42 @@ func renderMarkdown(content string, width int) string { return strings.TrimRight(result.String(), "\n") } +func renderMarkdownHeading(level int, rendered string) string { + switch level { + case 1: + return mdH1Style.Render(rendered) + case 2: + return mdH2Style.Render(rendered) + case 3: + return mdH3Style.Render(rendered) + case 4: + return mdH4Style.Render(rendered) + default: + return mdHeaderStyle.Render(rendered) + } +} + +// markdownListIndent preserves nested Markdown hierarchy while coloring each +// indentation level. Two leading spaces (or one tab) represent one level. +func markdownListIndent(line string) string { + spaces := 0 + for _, r := range line { + switch r { + case ' ': + spaces++ + case '\t': + spaces += 2 + default: + levels := spaces / 2 + if levels < 1 { + return " " + } + return " " + mdBlockquoteBar.Render(strings.Repeat("| ", levels)) + } + } + return " " +} + // codeBlock holds a parsed fenced code block. type codeBlock struct { lang string @@ -210,29 +257,94 @@ func renderCodeBlock(lang, code string, width int) string { indent := " " innerWidth := width - 4 if innerWidth < 10 { - innerWidth = width + innerWidth = 10 } if lang != "" { - label := mdCodeLabelStyle.Render(" " + lang + " ") + label := mdCodeLabelStyle.Bold(true).Render(" " + strings.ToLower(lang) + " ") b.WriteString(indent + label) b.WriteByte('\n') } - for _, line := range strings.Split(code, "\n") { + // Literal tabs align to terminal-global tab stops, so their apparent width + // changes when the code panel itself is indented. Expand them before both + // highlighting and width measurement to keep nesting stable in every pane. + displayCode := expandCodeTabs(code, 4) + highlighted := highlightCodeWithChroma(displayCode, lang) + codeBG := markdownCodeBackgroundANSI() + for _, line := range strings.Split(highlighted, "\n") { // Pad line to inner width for consistent background - visW := runewidth.StringWidth(line) + visW := visibleWidth(line) pad := "" if visW < innerWidth { pad = strings.Repeat(" ", innerWidth-visW) } - styled := mdCodeBlockStyle.Render(" " + line + pad + " ") - b.WriteString(indent + styled) + b.WriteString(indent) + // Chroma resets attributes between tokens. Reapply the code background + // after every reset so indentation and padding remain one visual block. + line = strings.ReplaceAll(line, ansiReset, ansiReset+codeBG) + b.WriteString(codeBG + " " + line + pad + " " + ansiReset) b.WriteByte('\n') } return strings.TrimRight(b.String(), "\n") } +func expandCodeTabs(code string, tabWidth int) string { + if tabWidth <= 0 || !strings.ContainsRune(code, '\t') { + return code + } + var out strings.Builder + out.Grow(len(code)) + column := 0 + for _, r := range code { + switch r { + case '\n', '\r': + out.WriteRune(r) + column = 0 + case '\t': + spaces := tabWidth - column%tabWidth + out.WriteString(strings.Repeat(" ", spaces)) + column += spaces + default: + out.WriteRune(r) + column += runewidth.RuneWidth(r) + } + } + return out.String() +} + +func markdownCodeBackgroundANSI() string { + styled := mdCodeBlockStyle.Render(" ") + if i := strings.IndexByte(styled, ' '); i > 0 { + return styled[:i] + } + return "" +} + +// highlightCodeWithChroma uses a real lexer when the fenced language is known. +// Unknown and unlabelled fences remain untouched instead of guessing and +// potentially coloring ordinary terminal output incorrectly. +func highlightCodeWithChroma(code, language string) string { + language = strings.TrimSpace(strings.ToLower(language)) + if code == "" || language == "" { + return code + } + lexer := lexers.Get(language) + if lexer == nil { + return code + } + lexer = chroma.Coalesce(lexer) + iterator, err := lexer.Tokenise(nil, code) + if err != nil { + return code + } + var out bytes.Buffer + if err := formatters.TTY256.Format(&out, styles.Get("catppuccin-mocha"), iterator); err != nil { + return code + } + return strings.TrimSuffix(out.String(), "\n") +} + // isHorizontalRule detects ---, ***, ___ (3 or more of same char, optional spaces). func isHorizontalRule(trimmed string) bool { if len(trimmed) < 3 { diff --git a/cmd/markdown_renderer.go b/cmd/markdown_renderer.go index d2aa26ce..faca8d6b 100644 --- a/cmd/markdown_renderer.go +++ b/cmd/markdown_renderer.go @@ -432,6 +432,10 @@ func parseTableRow(line string) []string { // HighlightCode performs regex-based syntax highlighting for 20+ languages. // Supports Go, Python, JavaScript, TypeScript, Rust, YAML, JSON, XML, TOML, SQL, C/C++, Java, C#, and more. func HighlightCode(code string, language string) string { + if highlighted := highlightCodeWithChroma(code, language); highlighted != code { + return highlighted + } + lang := strings.ToLower(language) // Language-specific keyword maps for syntax highlighting diff --git a/cmd/markdown_test.go b/cmd/markdown_test.go index 6c91e6c1..0901bcd2 100644 --- a/cmd/markdown_test.go +++ b/cmd/markdown_test.go @@ -112,6 +112,48 @@ func TestRenderMarkdownCodeBlockPreservesNewlines(t *testing.T) { } } +func TestRenderMarkdownCodeBlockSyntaxHighlightsAndPreservesIndentation(t *testing.T) { + input := "```go\nfunc main() {\n\tif true {\n\t\treturn\n\t}\n}\n```" + out := renderMarkdown(input, 80) + if !strings.Contains(out, "\x1b[38;5;") { + t.Fatalf("expected Chroma terminal colors, got %q", out) + } + plain := stripAnsi(out) + if strings.ContainsRune(plain, '\t') { + t.Fatalf("displayed code should use deterministic spaces, got %q", plain) + } + if !strings.Contains(plain, " return") { + t.Fatalf("code indentation was not preserved, got %q", plain) + } +} + +func TestExpandCodeTabsUsesColumnAlignedStops(t *testing.T) { + input := "\tfirst\nxx\tsecond\n1234\tthird" + want := " first\nxx second\n1234 third" + if got := expandCodeTabs(input, 4); got != want { + t.Fatalf("expandCodeTabs() = %q, want %q", got, want) + } +} + +func TestRenderMarkdownNestedListsShowIndentGuides(t *testing.T) { + out := stripAnsi(renderMarkdown("- parent\n - child\n - grandchild", 80)) + if !strings.Contains(out, "| - child") || !strings.Contains(out, "| | - grandchild") { + t.Fatalf("expected visible nested indentation guides, got %q", out) + } +} + +func TestRenderMarkdownHeadingLevelsHaveDistinctStyles(t *testing.T) { + h1 := renderMarkdown("# Primary", 80) + h2 := renderMarkdown("## Secondary", 80) + h3 := renderMarkdown("### Tertiary", 80) + if h1 == h2 || h2 == h3 || h1 == h3 { + t.Fatalf("heading levels should use distinct visual styles") + } + if !strings.Contains(h1, ";4") { + t.Fatalf("top-level heading should be underlined, got %q", h1) + } +} + func TestRenderMarkdownUnorderedList(t *testing.T) { input := "- first item\n- second item\n* third item" out := renderMarkdown(input, 80) @@ -702,7 +744,7 @@ func TestHighlightCodePython(t *testing.T) { func TestHighlightCodeUnsupportedLanguage(t *testing.T) { code := "some code here" - out := HighlightCode(code, "brainfuck") + out := HighlightCode(code, "not-a-real-language") if out != code { t.Errorf("unsupported language should return code unchanged, got %q", out) } diff --git a/cmd/model_table.go b/cmd/model_table.go index e13d4d19..bd980e96 100644 --- a/cmd/model_table.go +++ b/cmd/model_table.go @@ -20,6 +20,7 @@ const ( type modelTableLayout struct { Model int Owner int + Caps int Price int Context int } @@ -27,6 +28,7 @@ type modelTableLayout struct { type modelTableRow struct { Model string Provider string + Caps string Price string Context string Free bool @@ -44,11 +46,13 @@ func computeModelTableLayout(viewWidth int, rows []modelTableRow) modelTableLayo modelW := runewidth.StringWidth("Model") ownerW := runewidth.StringWidth("Owner") + capsW := runewidth.StringWidth("Caps") priceW := runewidth.StringWidth("Price") ctxW := runewidth.StringWidth("Ctx") for _, row := range rows { modelW = maxInt(modelW, runewidth.StringWidth(row.Model)) ownerW = maxInt(ownerW, runewidth.StringWidth(row.Provider)) + capsW = maxInt(capsW, runewidth.StringWidth(row.Caps)) priceW = maxInt(priceW, runewidth.StringWidth(row.Price)) ctxText := row.Context if row.Active { @@ -58,12 +62,13 @@ func computeModelTableLayout(viewWidth int, rows []modelTableRow) modelTableLayo } ownerW += 2 + capsW += 2 priceW += 2 ctxW += 1 - gaps := modelTableColGap * 3 + gaps := modelTableColGap * 4 modelW += modelTableModelPad - maxModel := usable - ownerW - priceW - ctxW - gaps + maxModel := usable - ownerW - capsW - priceW - ctxW - gaps if maxModel < 20 { maxModel = 20 } @@ -71,7 +76,7 @@ func computeModelTableLayout(viewWidth int, rows []modelTableRow) modelTableLayo modelW = maxModel } - return modelTableLayout{Model: modelW, Owner: ownerW, Price: priceW, Context: ctxW} + return modelTableLayout{Model: modelW, Owner: ownerW, Caps: capsW, Price: priceW, Context: ctxW} } func modelTableRowFromOption(o configModelOption) modelTableRow { @@ -100,12 +105,46 @@ func modelTableRowFromOption(o configModelOption) modelTableRow { return modelTableRow{ Model: name, Provider: owner, + Caps: formatModelCapabilities(o.Capabilities), Price: price, Context: formatModelTableContext(o.ContextWindow), Free: free, } } +func formatModelCapabilities(capabilities []string) string { + var tools, vision, reasoning, structured bool + for _, capability := range capabilities { + switch strings.ToLower(strings.TrimSpace(capability)) { + case "tools", "tool_use", "function_calling", "function-calling": + tools = true + case "vision", "image", "image_input", "image-input": + vision = true + case "reasoning", "thinking", "adaptive_thinking", "explicit_thinking_budget", "effort": + reasoning = true + case "structured_json", "structured_output", "json_schema", "json": + structured = true + } + } + badges := make([]string, 0, 4) + if tools { + badges = append(badges, "T") + } + if vision { + badges = append(badges, "V") + } + if reasoning { + badges = append(badges, "R") + } + if structured { + badges = append(badges, "J") + } + if len(badges) == 0 { + return "—" + } + return strings.Join(badges, " ") +} + func formatModelTablePrice(input, output float64) string { if input <= 0 && output <= 0 { return "—" @@ -186,11 +225,11 @@ func parseContextWindowLabel(label string) int { func renderModelTableHeader(layout modelTableLayout, headerStyle, metaStyle lipgloss.Style) string { line := renderModelTableLine( - []string{"Model", "Owner", "Price", "Ctx"}, + []string{"Model", "Owner", "Caps", "Price", "Ctx"}, layout, - []lipgloss.Style{headerStyle, headerStyle, headerStyle, headerStyle}, + []lipgloss.Style{headerStyle, headerStyle, headerStyle, headerStyle, headerStyle}, ) - ruleLen := layout.Model + layout.Owner + layout.Price + layout.Context + modelTableColGap*3 + ruleLen := layout.Model + layout.Owner + layout.Caps + layout.Price + layout.Context + modelTableColGap*4 indent := strings.Repeat(" ", modelTableIndent) return indent + line + "\n" + indent + metaStyle.Render(strings.Repeat("─", ruleLen)) } @@ -223,17 +262,18 @@ func renderModelTableRow(row modelTableRow, cursor, active bool, layout modelTab []string{ truncateRunes(row.Model, layout.Model), truncateRunes(row.Provider, layout.Owner), + truncateRunes(row.Caps, layout.Caps), truncateRunes(row.Price, layout.Price), ctx, }, layout, - []lipgloss.Style{meta, meta, priceStyle, meta}, + []lipgloss.Style{meta, meta, meta, priceStyle, meta}, ) return prefix + line } func renderModelTableLine(values []string, layout modelTableLayout, styles []lipgloss.Style) string { - widths := []int{layout.Model, layout.Owner, layout.Price, layout.Context} + widths := []int{layout.Model, layout.Owner, layout.Caps, layout.Price, layout.Context} parts := make([]string, len(values)) for i, v := range values { parts[i] = styles[i].Render(padCellLeft(v, widths[i])) @@ -335,6 +375,7 @@ func modelTableRowFromCatalogEntry(m hawkconfig.EngineModel) modelTableRow { return modelTableRow{ Model: name, Provider: owner, + Caps: formatModelCapabilities(m.Capabilities), Price: price, Context: formatModelTableContext(m.ContextWindow), Free: free, diff --git a/cmd/model_table_test.go b/cmd/model_table_test.go index 59002272..2f7c0339 100644 --- a/cmd/model_table_test.go +++ b/cmd/model_table_test.go @@ -50,12 +50,22 @@ func TestComputeModelTableLayoutFitsModelNames(t *testing.T) { func TestComputeModelTableLayoutLeftPacked(t *testing.T) { rows := []modelTableRow{{Model: "qwen/qwen3.7-max", Provider: "qwen", Price: "$2.5/$7.5", Context: "1m"}} layout := computeModelTableLayout(120, rows) - total := layout.Model + layout.Owner + layout.Price + layout.Context + modelTableColGap*3 - if total > 92 { + total := layout.Model + layout.Owner + layout.Caps + layout.Price + layout.Context + modelTableColGap*4 + if total > 108 { t.Fatalf("expected compact left-aligned table, got %+v total=%d", layout, total) } } +func TestFormatModelCapabilities(t *testing.T) { + got := formatModelCapabilities([]string{"reasoning", "tools", "structured_json", "vision"}) + if got != "T V R J" { + t.Fatalf("capabilities = %q, want compact stable badges", got) + } + if got := formatModelCapabilities(nil); got != "—" { + t.Fatalf("unknown capabilities = %q, want em dash", got) + } +} + func TestModelTableRowFromOptionFree(t *testing.T) { row := modelTableRowFromOption(configModelOption{ ID: "baidu/cobuddy:free", DisplayName: "baidu/cobuddy:free", Owner: "baidu", PriceKnown: true, diff --git a/cmd/options_welcome_test.go b/cmd/options_welcome_test.go index 889cadc2..9e4a6f47 100644 --- a/cmd/options_welcome_test.go +++ b/cmd/options_welcome_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) @@ -24,10 +24,10 @@ func isolateCredentialHome(t *testing.T) { func TestEffectiveModelAndProvider_ClearsWithoutCredentials(t *testing.T) { hawkconfig.InvalidateConfigUICache() isolateCredentialHome(t) - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) @@ -48,15 +48,15 @@ func TestEffectiveModelAndProvider_ClearsWithoutCredentials(t *testing.T) { func TestEffectiveModelAndProvider_KeepsWithCredentials(t *testing.T) { hawkconfig.InvalidateConfigUICache() isolateCredentialHome(t) - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) ctx := context.Background() - _ = store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() if err := hawkconfig.SetActiveProvider(ctx, "openrouter"); err != nil { t.Fatal(err) diff --git a/cmd/path_test.go b/cmd/path_test.go index e5902668..e4efb761 100644 --- a/cmd/path_test.go +++ b/cmd/path_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) @@ -26,6 +26,6 @@ func TestPathCmdPrintsReport(t *testing.T) { func useInMemoryCredentials(t *testing.T) { t.Helper() - credentials.SetDefaultStore(&credentials.MapStore{}) - t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + gateway.SetDefaultStore(&gateway.MapStore{}) + t.Cleanup(func() { gateway.SetDefaultStore(nil) }) } diff --git a/cmd/session_sync_test.go b/cmd/session_sync_test.go index 262f2a09..7ff9d745 100644 --- a/cmd/session_sync_test.go +++ b/cmd/session_sync_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" ) @@ -13,15 +13,15 @@ import ( func TestSyncSessionFromPersistedSelection_FillsEmptySessionModel(t *testing.T) { hawkconfig.InvalidateConfigUICache() isolateCredentialHome(t) - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) ctx := context.Background() - _ = store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() _ = hawkconfig.SetActiveProvider(ctx, "openrouter") _ = hawkconfig.SetActiveModel(ctx, "gpt-4o") @@ -40,15 +40,15 @@ func TestSyncSessionFromPersistedSelection_FillsEmptySessionModel(t *testing.T) func TestEnsureSessionReadyForChat_UsesPersistedModel(t *testing.T) { hawkconfig.InvalidateConfigUICache() isolateCredentialHome(t) - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) ctx := context.Background() - _ = store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() _ = hawkconfig.SetActiveProvider(ctx, "openrouter") _ = hawkconfig.SetActiveModel(ctx, "gpt-4o") @@ -65,10 +65,10 @@ func TestEnsureSessionReadyForChat_UsesPersistedModel(t *testing.T) { func TestEnsureSessionReadyForChat_NoModel(t *testing.T) { hawkconfig.InvalidateConfigUICache() isolateCredentialHome(t) - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) @@ -85,15 +85,15 @@ func TestEnsureSessionReadyForChat_NoModel(t *testing.T) { func TestEnsureSessionReadyForChat_AppliesDeferredSystemContextOnce(t *testing.T) { hawkconfig.InvalidateConfigUICache() isolateCredentialHome(t) - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) ctx := context.Background() - _ = store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") hawkconfig.InvalidateConfigUICache() _ = hawkconfig.SetActiveProvider(ctx, "openrouter") _ = hawkconfig.SetActiveModel(ctx, "gpt-4o") diff --git a/cmd/theme.go b/cmd/theme.go index c3608bac..b2ac4043 100644 --- a/cmd/theme.go +++ b/cmd/theme.go @@ -294,7 +294,11 @@ func ApplyTheme(name string) { // unsynchronized from View. func refreshThemeStyles() { // markdown.go - mdHeaderStyle = lipgloss.NewStyle().Foreground(successTeal).Bold(true) + mdH1Style = lipgloss.NewStyle().Foreground(hawkColor).Bold(true).Underline(true) + mdH2Style = lipgloss.NewStyle().Foreground(successTeal).Bold(true) + mdH3Style = lipgloss.NewStyle().Foreground(infoSky).Bold(true) + mdH4Style = lipgloss.NewStyle().Foreground(costViolet).Bold(true) + mdHeaderStyle = lipgloss.NewStyle().Foreground(textPrimary).Bold(true) mdBoldStyle = lipgloss.NewStyle().Foreground(hawkColor).Bold(true) mdInlineCodeStyle = lipgloss.NewStyle().Background(bgCode).Foreground(textPrimary) mdCodeBlockStyle = lipgloss.NewStyle().Background(bgCode) diff --git a/cmd/version_display_test.go b/cmd/version_display_test.go index 09d4d348..afbf640c 100644 --- a/cmd/version_display_test.go +++ b/cmd/version_display_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) @@ -30,10 +30,10 @@ func TestDisplayVersion_ReleaseBuild(t *testing.T) { func TestChatConnectionStatus_NoCredentials(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) @@ -46,10 +46,10 @@ func TestChatConnectionStatus_NoCredentials(t *testing.T) { func TestChatBottomRightStatus_NoCredentials(t *testing.T) { hawkconfig.InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) diff --git a/external/eyrie b/external/eyrie index 1ba69293..d4bd7b71 160000 --- a/external/eyrie +++ b/external/eyrie @@ -1 +1 @@ -Subproject commit 1ba692935ce5f74a664d4dfc3775a552853b99fb +Subproject commit d4bd7b7141dd6ba796fc3759d4a98baf77e2ba5e diff --git a/external/hawk-core-contracts b/external/hawk-core-contracts index 1bb30382..227f77fa 160000 --- a/external/hawk-core-contracts +++ b/external/hawk-core-contracts @@ -1 +1 @@ -Subproject commit 1bb303825112b19615f8e9c18c4fc9a3adca1114 +Subproject commit 227f77fa4860fc5e7a232d12f0942eb175d9f840 diff --git a/go.mod b/go.mod index 9f75771f..a87ba80b 100644 --- a/go.mod +++ b/go.mod @@ -11,12 +11,13 @@ require ( charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.7 charm.land/lipgloss/v2 v2.0.3 - github.com/GrayCodeAI/eyrie v0.2.2-0.20260715190227-1ba692935ce5 + github.com/GrayCodeAI/eyrie v0.2.2-0.20260720045400-b2cab575ecc0 github.com/GrayCodeAI/hawk-core-contracts v0.1.6 github.com/GrayCodeAI/inspect v0.1.4 github.com/GrayCodeAI/sight v0.1.4 github.com/GrayCodeAI/tok v0.1.4 github.com/GrayCodeAI/yaad v0.0.0-20260715205802-15ffb18c0c4b + github.com/alecthomas/chroma/v2 v2.26.1 github.com/bwmarrin/discordgo v0.28.1 github.com/charmbracelet/x/ansi v0.11.7 github.com/fsnotify/fsnotify v1.10.1 @@ -49,7 +50,6 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/STARRY-S/zip v0.2.3 // indirect - github.com/alecthomas/chroma/v2 v2.26.1 // indirect github.com/andybalholm/brotli v1.2.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect diff --git a/go.sum b/go.sum index 2dabd49d..f4a4b210 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/BobuSumisu/aho-corasick v1.0.3 h1:uuf+JHwU9CHP2Vx+wAy6jcksJThhJS9ehR8 github.com/BobuSumisu/aho-corasick v1.0.3/go.mod h1:hm4jLcvZKI2vRF2WDU1N4p/jpWtpOzp3nLmi9AzX/XE= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/GrayCodeAI/eyrie v0.2.2-0.20260715190227-1ba692935ce5 h1:DeXnr2++IHHeZnbxCIey9R5Lyqv94iAp97niVTmYkvg= -github.com/GrayCodeAI/eyrie v0.2.2-0.20260715190227-1ba692935ce5/go.mod h1:8zw4jj5pvD9/tUqDhRaT4nnvl3Q8Zl2x4mJyEve7Obc= +github.com/GrayCodeAI/eyrie v0.2.2-0.20260720045400-b2cab575ecc0 h1:zKAtRFVKjSOOOyQ3dnKuxBivRq+wQ0JQ58QuwGoq3xI= +github.com/GrayCodeAI/eyrie v0.2.2-0.20260720045400-b2cab575ecc0/go.mod h1:8zw4jj5pvD9/tUqDhRaT4nnvl3Q8Zl2x4mJyEve7Obc= github.com/GrayCodeAI/hawk-core-contracts v0.1.6 h1:jLbFQIFSaV9inpVvIkgI6lDAhIcFOn+wfLCvrKxqUmY= github.com/GrayCodeAI/hawk-core-contracts v0.1.6/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/GrayCodeAI/inspect v0.1.4 h1:tQAcD9UuHkrqA20CZqdTcBgWKU/lhxxHBh8hlXY3FVw= diff --git a/internal/config/catalog_api.go b/internal/config/catalog_api.go index cac8f3fd..8783741b 100644 --- a/internal/config/catalog_api.go +++ b/internal/config/catalog_api.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" + gw "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) type GatewayStatus struct { @@ -19,6 +19,12 @@ type GatewayStatus struct { RegionRequired bool } +// IsCatalogCacheRequired reports whether an Eyrie operation failed because +// the local model catalog has not been created yet. +func IsCatalogCacheRequired(err error) bool { + return gw.IsCatalogCacheRequired(err) +} + func AllCatalogProviders() []string { engine, err := newEyrieEngine() if err != nil { @@ -27,7 +33,7 @@ func AllCatalogProviders() []string { providers, _ := engine.ModelProviders(context.Background()) seen := map[string]bool{} for _, provider := range providers { - if provider = eyrieengine.NormalizeProviderID(provider); provider != "" { + if provider = gw.NormalizeProviderID(provider); provider != "" { seen[provider] = true } } @@ -87,9 +93,9 @@ func ActiveGateway(ctx context.Context) string { return "" } selection := engine.ActiveSelection(ctx) - providerID := eyrieengine.NormalizeProviderID(selection.Provider) + providerID := gw.NormalizeProviderID(selection.Provider) for _, gateway := range engine.GatewayDefinitions() { - if eyrieengine.NormalizeProviderID(gateway.ID) == providerID { + if gw.NormalizeProviderID(gateway.ID) == providerID { return gateway.ID } } @@ -106,7 +112,7 @@ func GatewayStatuses(ctx context.Context, activeProvider, activeModel string) [] for _, gateway := range gateways { active := gateway.Active if activeProvider != "" { - active = eyrieengine.NormalizeProviderID(activeProvider) == eyrieengine.NormalizeProviderID(gateway.ID) + active = gw.NormalizeProviderID(activeProvider) == gw.NormalizeProviderID(gateway.ID) } else if activeModel != "" { active = engine.GatewayForModel(ctx, activeModel) == gateway.ID } @@ -135,10 +141,10 @@ func ShouldClearSelectionAfterCredentialRemove(ctx context.Context, removedProvi return true } selection := engine.ActiveSelection(ctx) - removedProvider = eyrieengine.NormalizeProviderID(removedProvider) - return !engine.EffectiveSelection(ctx, eyrieengine.SelectionOptions{}).HasConfiguredDeployment || - eyrieengine.NormalizeProviderID(selection.Provider) == removedProvider || - eyrieengine.NormalizeProviderID(engine.GatewayForModel(ctx, selection.Model)) == removedProvider + removedProvider = gw.NormalizeProviderID(removedProvider) + return !engine.EffectiveSelection(ctx, gw.SelectionOptions{}).HasConfiguredDeployment || + gw.NormalizeProviderID(selection.Provider) == removedProvider || + gw.NormalizeProviderID(engine.GatewayForModel(ctx, selection.Model)) == removedProvider } func ClearActiveSelection(ctx context.Context) error { @@ -158,13 +164,13 @@ func SyncSelectionWithCredentials(ctx context.Context) { ready := map[string]bool{} hasAny := false for _, gateway := range engine.Gateways(ctx) { - providerID := eyrieengine.NormalizeProviderID(gateway.ID) + providerID := gw.NormalizeProviderID(gateway.ID) ready[providerID] = gateway.DeploymentConfigured hasAny = hasAny || gateway.DeploymentConfigured } - activeGateway := eyrieengine.NormalizeProviderID(active.Provider) + activeGateway := gw.NormalizeProviderID(active.Provider) if activeGateway == "" && active.Model != "" { - activeGateway = eyrieengine.NormalizeProviderID(engine.GatewayForModel(ctx, active.Model)) + activeGateway = gw.NormalizeProviderID(engine.GatewayForModel(ctx, active.Model)) } if !hasAny || (activeGateway != "" && !ready[activeGateway]) { _ = engine.ClearSelection(ctx) @@ -211,7 +217,7 @@ func CheapestModelForProvider(provider, fallback string) string { if err != nil { return fallback } - return engine.PreferredModel(context.Background(), provider, eyrieengine.ModelClassEconomical, fallback) + return engine.PreferredModel(context.Background(), provider, gw.ModelClassEconomical, fallback) } func ProviderOfModel(modelName string) string { @@ -219,7 +225,7 @@ func ProviderOfModel(modelName string) string { if err != nil { return "" } - return eyrieengine.NormalizeProviderID(engine.ProviderForModel(context.Background(), modelName)) + return gw.NormalizeProviderID(engine.ProviderForModel(context.Background(), modelName)) } func ProviderOfModelWithSettings(settings Settings, modelName string) string { @@ -227,7 +233,7 @@ func ProviderOfModelWithSettings(settings Settings, modelName string) string { if err != nil { return "" } - return eyrieengine.NormalizeProviderID(engine.ProviderForModel(context.Background(), modelName)) + return gw.NormalizeProviderID(engine.ProviderForModel(context.Background(), modelName)) } func ExampleModelHints() (string, string) { @@ -282,16 +288,16 @@ func PrimaryAPIKeyEnvForDeployment(deploymentID string) string { return "" } -func engineGateway(providerID string) (eyrieengine.Gateway, bool) { +func engineGateway(providerID string) (gw.GatewayDefs, bool) { engine, err := newEyrieEngine() if err != nil { - return eyrieengine.Gateway{}, false + return gw.GatewayDefs{}, false } - providerID = eyrieengine.NormalizeProviderID(providerID) + providerID = gw.NormalizeProviderID(providerID) for _, gateway := range engine.GatewayDefinitions() { - if eyrieengine.NormalizeProviderID(gateway.ID) == providerID { + if gw.NormalizeProviderID(gateway.ID) == providerID { return gateway, true } } - return eyrieengine.Gateway{}, false + return gw.GatewayDefs{}, false } diff --git a/internal/config/catalog_gateways_test.go b/internal/config/catalog_gateways_test.go index 8f4bc98b..e589bf46 100644 --- a/internal/config/catalog_gateways_test.go +++ b/internal/config/catalog_gateways_test.go @@ -2,6 +2,7 @@ package config import ( "context" + "fmt" "testing" "github.com/GrayCodeAI/eyrie/catalog" @@ -9,6 +10,16 @@ import ( "github.com/GrayCodeAI/hawk/internal/catalogtest" ) +func TestIsCatalogCacheRequired(t *testing.T) { + err := fmt.Errorf("apply credentials: %w", catalog.ErrCatalogCacheRequired) + if !IsCatalogCacheRequired(err) { + t.Fatal("wrapped catalog-cache error was not recognized") + } + if IsCatalogCacheRequired(fmt.Errorf("authentication failed")) { + t.Fatal("unrelated error was classified as a catalog-cache error") + } +} + func TestAllSetupGateways_RegistryOnly(t *testing.T) { gws := AllSetupGateways() if len(gws) < 19 || len(gws) > 22 { diff --git a/internal/config/catalog_health_test.go b/internal/config/catalog_health_test.go index fdaa9be7..48b638d6 100644 --- a/internal/config/catalog_health_test.go +++ b/internal/config/catalog_health_test.go @@ -5,13 +5,13 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) func TestCatalogEmptyHint_NoCredentials(t *testing.T) { - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) - t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) + t.Cleanup(func() { gateway.SetDefaultStore(nil) }) hint := CatalogEmptyHint(context.Background()) if !strings.Contains(hint, "/config") { @@ -21,15 +21,15 @@ func TestCatalogEmptyHint_NoCredentials(t *testing.T) { func TestCatalogEmptyHint_WithCredentials(t *testing.T) { InvalidateConfigUICache() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) t.Cleanup(func() { - credentials.SetDefaultStore(nil) + gateway.SetDefaultStore(nil) InvalidateConfigUICache() }) ctx := context.Background() - _ = store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") InvalidateConfigUICache() hint := CatalogEmptyHint(ctx) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 232082af..6b56328d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" "github.com/GrayCodeAI/hawk/internal/testutil" ) @@ -154,9 +154,6 @@ func TestLoadSettingsAcceptsArchiveCamelCase(t *testing.T) { if len(settings.AutoAllow) != 1 || settings.AutoAllow[0] != "Read" { t.Fatalf("unexpected autoAllow: %v", settings.AutoAllow) } - if settings.CustomHeaders["x-test"] != "yes" { - t.Fatalf("unexpected customHeaders: %v", settings.CustomHeaders) - } if len(settings.MCPServers) != 1 || settings.MCPServers[0].Name != "demo" { t.Fatalf("unexpected mcpServers: %v", settings.MCPServers) } @@ -232,10 +229,10 @@ func TestSetGlobalSettingAndSettingValue(t *testing.T) { t.Fatalf("unexpected max budget value: %q ok=%v", got, ok) } // API key status from OS secret store - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) - t.Cleanup(func() { credentials.SetDefaultStore(nil) }) - _ = store.Set(context.Background(), credentials.AccountForEnv("OPENAI_API_KEY"), "sk-live-config-test-1234567890") + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) + t.Cleanup(func() { gateway.SetDefaultStore(nil) }) + _ = store.Set(context.Background(), gateway.AccountForEnv("OPENAI_API_KEY"), "sk-live-config-test-1234567890") if got, ok := SettingValue(settings, "apiKey.openai"); !ok || got != "set" { t.Fatalf("unexpected provider API key status: %q ok=%v", got, ok) } diff --git a/internal/config/credentials_store.go b/internal/config/credentials_store.go index 058ad3ba..f03d7c74 100644 --- a/internal/config/credentials_store.go +++ b/internal/config/credentials_store.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) // PersistAPIKey saves a provider API key via eyrie (OS secret store). @@ -113,6 +113,24 @@ func HasStoredCredentialForProvider(ctx context.Context, providerID string) bool return err == nil && status.Configured } +// CredentialEnvironmentConflict reports a safe environment-vs-keychain +// mismatch for a provider. It returns identifiers only, never secret values. +func CredentialEnvironmentConflict(ctx context.Context, providerID string) (envVar string, conflict bool) { + engine, err := newEyrieEngine() + if err != nil { + return "", false + } + status, err := engine.CredentialStatus(ctx, providerID) + if err != nil { + return "", false + } + envVar = status.EnvironmentVariable + if envVar == "" { + envVar = status.EnvVar + } + return envVar, status.EnvironmentConflict +} + // ConfiguredCredentialProviders returns setup gateways with a stored API key. func ConfiguredCredentialProviders() []string { return configuredCredentialProvidersCached(context.Background()) @@ -221,7 +239,7 @@ func FormatConfigProviderError(providerID string, err error) string { if err == nil { return "" } - return eyrieengine.FormatSetupError(providerID, err) + return gateway.FormatSetupError(providerID, err) } // InferCredentialsFromAPIKey is deprecated; select gateway first, then paste the key. @@ -239,12 +257,12 @@ func InferCredentialsFromAPIKey(ctx context.Context, secret string) []Credential return out } -func engineCredentialProvider(providers []eyrieengine.CredentialProvider, target string) (eyrieengine.CredentialProvider, bool) { +func engineCredentialProvider(providers []gateway.CredentialProvider, target string) (gateway.CredentialProvider, bool) { target = strings.TrimSpace(target) for _, provider := range providers { if strings.EqualFold(provider.ProviderID, target) || strings.EqualFold(provider.EnvVar, target) { return provider, true } } - return eyrieengine.CredentialProvider{}, false + return gateway.CredentialProvider{}, false } diff --git a/internal/config/credentials_store_test.go b/internal/config/credentials_store_test.go index 5f5bd38a..d13b5b70 100644 --- a/internal/config/credentials_store_test.go +++ b/internal/config/credentials_store_test.go @@ -5,16 +5,16 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) func TestRemoveStoredCredential_ByProvider(t *testing.T) { - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) - t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) + t.Cleanup(func() { gateway.SetDefaultStore(nil) }) ctx := context.Background() - _ = store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") removed, err := RemoveStoredCredential(ctx, "openrouter") if err != nil { @@ -23,18 +23,18 @@ func TestRemoveStoredCredential_ByProvider(t *testing.T) { if len(removed) != 1 || removed[0] != "OPENROUTER_API_KEY" { t.Fatalf("removed = %v", removed) } - if credentials.HasSecret(ctx, "OPENROUTER_API_KEY") { + if gateway.HasSecret(ctx, "OPENROUTER_API_KEY") { t.Fatal("key should be deleted") } } func TestRemoveStoredCredential_ByEnvVar(t *testing.T) { - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) - t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) + t.Cleanup(func() { gateway.SetDefaultStore(nil) }) ctx := context.Background() - _ = store.Set(ctx, credentials.AccountForEnv("ANTHROPIC_API_KEY"), "sk-ant-test-key-1234567890") + _ = store.Set(ctx, gateway.AccountForEnv("ANTHROPIC_API_KEY"), "sk-ant-test-key-1234567890") removed, err := RemoveStoredCredential(ctx, "ANTHROPIC_API_KEY") if err != nil { @@ -46,9 +46,9 @@ func TestRemoveStoredCredential_ByEnvVar(t *testing.T) { } func TestRemoveStoredCredential_NotFound(t *testing.T) { - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) - t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) + t.Cleanup(func() { gateway.SetDefaultStore(nil) }) _, err := RemoveStoredCredential(context.Background(), "openrouter") if err == nil || !strings.Contains(err.Error(), "no stored credential") { @@ -57,12 +57,12 @@ func TestRemoveStoredCredential_NotFound(t *testing.T) { } func TestFormatCredentialCLIStatus(t *testing.T) { - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) - t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) + t.Cleanup(func() { gateway.SetDefaultStore(nil) }) ctx := context.Background() - _ = store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + _ = store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") out := FormatCredentialCLIStatus(ctx) if !strings.Contains(out, "Configured:") { diff --git a/internal/config/developer_path.go b/internal/config/developer_path.go index 0a5f4a83..05d20f62 100644 --- a/internal/config/developer_path.go +++ b/internal/config/developer_path.go @@ -7,9 +7,9 @@ import ( "path/filepath" "strings" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" "github.com/GrayCodeAI/hawk/internal/home" "github.com/GrayCodeAI/hawk/internal/intelligence/memory" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" "github.com/GrayCodeAI/hawk/internal/sandbox" "github.com/GrayCodeAI/hawk/internal/tool" "github.com/GrayCodeAI/tok" @@ -190,7 +190,7 @@ func EvaluateDeveloperPath(ctx context.Context) DeveloperPathReport { } else { status := PathWarn for _, c := range pre.Checks { - if c.Status == eyrieengine.CheckFail { + if c.Status == gateway.CheckFail { status = PathFail break } diff --git a/internal/config/developer_path_test.go b/internal/config/developer_path_test.go index cfbbaae1..cdef2393 100644 --- a/internal/config/developer_path_test.go +++ b/internal/config/developer_path_test.go @@ -5,14 +5,14 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" "github.com/GrayCodeAI/hawk/internal/ui/icons" ) func TestEvaluateDeveloperPath_FreshInstall(t *testing.T) { isolateMilestoneTest(t) - credentials.SetDefaultStore(emptyCredentialStore{}) - t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + gateway.SetDefaultStore(emptyCredentialStore{}) + t.Cleanup(func() { gateway.SetDefaultStore(nil) }) r := EvaluateDeveloperPath(context.Background()) if r.Ready { @@ -33,8 +33,8 @@ func TestEvaluateDeveloperPath_FreshInstall(t *testing.T) { func TestFormatDeveloperPathReport_ContainsSections(t *testing.T) { isolateMilestoneTest(t) - credentials.SetDefaultStore(emptyCredentialStore{}) - t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + gateway.SetDefaultStore(emptyCredentialStore{}) + t.Cleanup(func() { gateway.SetDefaultStore(nil) }) out := FormatDeveloperPathReport(context.Background()) for _, want := range []string{ "Developer path", diff --git a/internal/config/eyrie_apply.go b/internal/config/eyrie_apply.go index cb2b1819..6ed88efc 100644 --- a/internal/config/eyrie_apply.go +++ b/internal/config/eyrie_apply.go @@ -5,14 +5,14 @@ import ( "fmt" "time" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) // ApplyCredentialsResult is Hawk's UI-safe view of an Eyrie catalog/routing // application. It intentionally excludes Eyrie setup/config implementation // types from the product boundary. type ApplyCredentialsResult struct { - Catalog eyrieengine.CatalogSnapshot + Catalog gateway.CatalogSnapshot } // ApplyEyrieCredentialsForProvider refreshes live models and writes sanitized @@ -71,7 +71,7 @@ func FormatApplyCredentialsSummary(result *ApplyCredentialsResult) string { return formatCatalogSnapshot(result.Catalog) } -func formatCatalogSnapshot(snapshot eyrieengine.CatalogSnapshot) string { +func formatCatalogSnapshot(snapshot gateway.CatalogSnapshot) string { if snapshot.CachePath == "" { return fmt.Sprintf("Catalog ready: %d models", len(snapshot.Models)) } diff --git a/internal/config/eyrie_engine.go b/internal/config/eyrie_engine.go index 02259682..4eac55cb 100644 --- a/internal/config/eyrie_engine.go +++ b/internal/config/eyrie_engine.go @@ -3,184 +3,178 @@ package config import ( "context" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) type ( - EngineModel = eyrieengine.Model - EnginePreflight = eyrieengine.PreflightReport - EnginePreflightOptions = eyrieengine.PreflightOptions + EngineModel = gateway.Model + EnginePreflight = gateway.PreflightReport + EnginePreflightOptions = gateway.PreflightOptions ) -func CredentialStoreName() string { return eyrieengine.SecretStoreName() } +func newEyrieEngine() (*gateway.Gateway, error) { + return gateway.New(context.Background(), globalCustomProviders()) +} + +func NewEyrieEngine() (*gateway.Gateway, error) { return newEyrieEngine() } + +// NewEyrieEngineForSettings composes a fresh gateway for one effective +// Hawk settings snapshot. It performs no package-global registration and does +// not mutate provider environment variables. +func NewEyrieEngineForSettings(settings Settings) (*gateway.Gateway, error) { + return gateway.New(context.Background(), gatewayCustomGateways(settings.CustomProviders)) +} + +func globalCustomProviders() []gateway.CustomProviderConfig { + return gatewayCustomGateways(LoadGlobalSettings().CustomProviders) +} + +func customGatewayProviders(providers []CustomProviderConfig) []gateway.CustomProviderConfig { + out := make([]gateway.CustomProviderConfig, 0, len(providers)) + for _, provider := range providers { + if provider.Name == "" && provider.BaseURL == "" { + continue + } + out = append(out, gateway.CustomProviderConfig{ + Name: provider.Name, BaseURL: provider.BaseURL, + APIKeyEnv: provider.APIKeyEnv, Model: provider.Model, + }) + } + return out +} + +// gatewayCustomGateways converts config providers to gateway specs, reusing +// the shared conversion loop. A new CustomProviderConfig field only needs +// wiring in customGatewayProviders. +func gatewayCustomGateways(providers []CustomProviderConfig) []gateway.CustomProviderConfig { + return customGatewayProviders(providers) +} + +func CredentialStoreName() string { return gateway.SecretStoreName() } -func CredentialStorageStatus(ctx context.Context) eyrieengine.CredentialStorageReport { - return eyrieengine.CredentialStorage(ctx) +func CredentialStorageStatus(ctx context.Context) gateway.CredentialStorageReport { + return gateway.CredentialStorage(ctx) } func MigrateLegacyCredentials(ctx context.Context) (int, error) { - return eyrieengine.MigrateLegacyCredentials(ctx) + return gateway.MigrateLegacyCredentials(ctx) } +// EnginePreflightReport runs preflight against the default gateway. func EnginePreflightReport(ctx context.Context) EnginePreflight { - return EnginePreflightReportWithOptions(ctx, EnginePreflightOptions{}) + return gateway.PreflightWithProviders(ctx, nil, EnginePreflightOptions{}) } +// EnginePreflightReportWithOptions runs preflight against the default +// gateway with explicit options. Kept for callers that need VerifyLive +// or other non-default preflight checks. func EnginePreflightReportWithOptions(ctx context.Context, opts EnginePreflightOptions) EnginePreflight { - engine, err := newEyrieEngine() - if err != nil { - return EnginePreflight{Checks: []eyrieengine.PreflightCheck{{ - Name: "engine", Status: eyrieengine.CheckFail, Detail: err.Error(), - }}} - } - return engine.PreflightWithOptions(ctx, opts) + return gateway.PreflightWithProviders(ctx, nil, opts) } // EnginePreflightReportWithSettings runs preflight against one invocation's -// effective settings. Custom gateways never escape into package or process -// state, so concurrent commands can safely use different settings files. +// effective settings (including its custom gateways). The settings' provider +// list is converted to the gateway spec at this boundary. func EnginePreflightReportWithSettings(ctx context.Context, settings Settings, opts EnginePreflightOptions) EnginePreflight { - engine, err := NewEyrieEngineForSettings(settings) - if err != nil { - return EnginePreflight{Checks: []eyrieengine.PreflightCheck{{ - Name: "engine", Status: eyrieengine.CheckFail, Detail: err.Error(), - }}} - } - return engine.PreflightWithOptions(ctx, opts) + return gateway.PreflightWithProviders(ctx, gatewayCustomGateways(settings.CustomProviders), opts) } func FormatEnginePreflight(report EnginePreflight) string { - return eyrieengine.FormatPreflight(report) + return gateway.FormatPreflight(report) } -func EngineGatewayRegion(providerID string) (string, bool) { - engine, err := newEyrieEngine() +func EngineGatewayRegion(ctx context.Context, providerID string) (string, bool) { + gw, err := newEyrieEngine() if err != nil { return "", false } - return engine.GatewayRegion(providerID) + return gw.GatewayRegion(providerID) } func SetEngineGatewayRegion(ctx context.Context, providerID, region string) error { - engine, err := newEyrieEngine() + gw, err := newEyrieEngine() if err != nil { return err } - return engine.SetGatewayRegion(ctx, providerID, region) + return gw.SetGatewayRegion(ctx, providerID, region) } func CanonicalModelID(ctx context.Context, modelID string) string { - engine, err := newEyrieEngine() + gw, err := newEyrieEngine() if err != nil { return modelID } - return engine.CanonicalModel(ctx, modelID) + return gw.CanonicalModel(ctx, modelID) } func HasCredentialEnv(ctx context.Context, envVar string) bool { - engine, err := newEyrieEngine() - return err == nil && engine.HasCredentialEnv(ctx, envVar) + gw, err := newEyrieEngine() + return err == nil && gw.HasCredentialEnv(ctx, envVar) } func CredentialGuidance(providerID, secret string) string { - return eyrieengine.CredentialGuidance(providerID, secret) + return gateway.CredentialGuidance(providerID, secret) } -func ProviderStateSecurityStatus() eyrieengine.ProviderStateSecurity { - engine, err := newEyrieEngine() +func ProviderStateSecurityStatus() gateway.ProviderStateSecurity { + gw, err := newEyrieEngine() if err != nil { - return eyrieengine.ProviderStateSecurity{Error: err.Error(), Detail: "Eyrie engine initialization failed"} + return gateway.ProviderStateSecurity{Error: err.Error(), Detail: "Eyrie engine initialization failed"} } - return engine.ProviderStateSecurityStatus() + return gw.ProviderStateSecurityStatus() } func MigrateEngineProviderSecrets() error { - engine, err := newEyrieEngine() + gw, err := newEyrieEngine() if err != nil { return err } - return engine.MigrateProviderSecrets() + return gw.MigrateProviderSecrets() } -func EngineDeploymentSummary(ctx context.Context, model string) (eyrieengine.DeploymentSummary, error) { - engine, err := newEyrieEngine() +func EngineDeploymentSummary(ctx context.Context, model string) (gateway.DeploymentSummary, error) { + gw, err := newEyrieEngine() if err != nil { - return eyrieengine.DeploymentSummary{}, err + return gateway.DeploymentSummary{}, err } - return engine.DeploymentSummary(ctx, model) + return gw.DeploymentSummary(ctx, model) } // newEyrieEngine is Hawk's default composition root for Eyrie's stable host // facade. Command paths that support --settings must use // NewEyrieEngineForSettings instead of relying on this global-settings default. -func newEyrieEngine() (*eyrieengine.Engine, error) { - return newEyrieEngineForCustomProviders(LoadGlobalSettings().CustomProviders) -} // ListEngineModels returns model-picker rows through Eyrie's stable facade. +// EngineModel is an alias of gateway.ModelInfo, so model lists returned by the +// gateway pass through without conversion. func ListEngineModels(ctx context.Context, providerID string, refresh bool) ([]EngineModel, error) { - engine, err := newEyrieEngine() - if err != nil { - return nil, err - } - return engine.ListModels(ctx, providerID, refresh) -} - -func ListLiveEngineModels(ctx context.Context, providerID string) ([]EngineModel, error) { - engine, err := newEyrieEngine() + gw, err := newEyrieEngine() if err != nil { return nil, err } - return engine.ListLiveModels(ctx, providerID) + return gw.ListModels(ctx, providerID, refresh) } func ListEngineModelsWithSettings(ctx context.Context, settings Settings, providerID string, refresh bool) ([]EngineModel, error) { - engine, err := NewEyrieEngineForSettings(settings) + gw, err := NewEyrieEngineForSettings(settings) if err != nil { return nil, err } - return engine.ListModels(ctx, providerID, refresh) + return gw.ListModels(ctx, providerID, refresh) } func ListLiveEngineModelsWithSettings(ctx context.Context, settings Settings, providerID string) ([]EngineModel, error) { - engine, err := NewEyrieEngineForSettings(settings) + gw, err := NewEyrieEngineForSettings(settings) if err != nil { return nil, err } - return engine.ListLiveModels(ctx, providerID) + return gw.ListLiveModels(ctx, providerID) } func ListPublicEngineModels(ctx context.Context, providerID string) ([]EngineModel, error) { - engine, err := newEyrieEngine() + gw, err := newEyrieEngine() if err != nil { return nil, err } - return engine.ListPublicModels(ctx, providerID) -} - -func NewEyrieEngine() (*eyrieengine.Engine, error) { return newEyrieEngine() } - -// NewEyrieEngineForSettings composes a fresh Eyrie engine for exactly one -// effective Hawk settings snapshot. It performs no package-global registration -// and does not mutate provider environment variables. -func NewEyrieEngineForSettings(settings Settings) (*eyrieengine.Engine, error) { - return newEyrieEngineForCustomProviders(settings.CustomProviders) -} - -func newEyrieEngineForCustomProviders(providers []CustomProviderConfig) (*eyrieengine.Engine, error) { - return eyrieengine.New(eyrieengine.Options{CustomGateways: customGatewaysFromSettings(providers)}) -} - -func customGatewaysFromSettings(providers []CustomProviderConfig) []eyrieengine.CustomGateway { - gateways := make([]eyrieengine.CustomGateway, 0, len(providers)) - for _, provider := range providers { - if provider.Name == "" && provider.BaseURL == "" { - continue - } - gateways = append(gateways, eyrieengine.CustomGateway{ - ID: provider.Name, BaseURL: provider.BaseURL, - CredentialEnv: provider.APIKeyEnv, DefaultModel: provider.Model, - }) - } - return gateways + return gw.ListPublicModels(ctx, providerID) } diff --git a/internal/config/eyrie_engine_test.go b/internal/config/eyrie_engine_test.go index 5d564234..4d7c79b7 100644 --- a/internal/config/eyrie_engine_test.go +++ b/internal/config/eyrie_engine_test.go @@ -4,14 +4,14 @@ import ( "context" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) func TestNewEyrieEngineForSettingsIsInvocationScoped(t *testing.T) { - previousStore := credentials.DefaultStore() - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) - t.Cleanup(func() { credentials.SetDefaultStore(previousStore) }) + previousStore := gateway.DefaultStore() + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) + t.Cleanup(func() { gateway.SetDefaultStore(previousStore) }) settings := Settings{CustomProviders: []CustomProviderConfig{{ Name: "private-gateway", BaseURL: "https://private.example.test/v1", @@ -36,7 +36,7 @@ func TestNewEyrieEngineForSettingsIsInvocationScoped(t *testing.T) { if err != nil || len(models) != 1 || models[0].ID != "private/model-v1" || models[0].GatewayID != "private_gateway" { t.Fatalf("custom model surface = %+v, err=%v", models, err) } - if setErr := store.Set(ctx, credentials.AccountForEnv("PRIVATE_GATEWAY_API_KEY"), "private-live-secret-1234567890"); setErr != nil { + if setErr := store.Set(ctx, gateway.AccountForEnv("PRIVATE_GATEWAY_API_KEY"), "private-live-secret-1234567890"); setErr != nil { t.Fatal(setErr) } status, err := engine.CredentialStatus(ctx, "private-gateway") diff --git a/internal/config/eyrie_selection.go b/internal/config/eyrie_selection.go index 4cc59a24..21f9bd11 100644 --- a/internal/config/eyrie_selection.go +++ b/internal/config/eyrie_selection.go @@ -4,12 +4,12 @@ import ( "context" "strings" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) type ( - Selection = eyrieengine.Selection - SelectionOptions = eyrieengine.SelectionOptions + Selection = gateway.Selection + SelectionOptions = gateway.SelectionOptions ) // EffectiveSelection resolves persisted selection and optional host overrides @@ -56,7 +56,7 @@ func ActiveProvider(ctx context.Context) string { // ActiveProviderID canonicalizes a host-facing provider/gateway id through Eyrie runtime. func ActiveProviderID(provider string) string { - return eyrieengine.NormalizeProviderID(provider) + return gateway.NormalizeProviderID(provider) } // SetActiveModel persists model selection to eyrie provider.json. diff --git a/internal/config/milestone_verify_test.go b/internal/config/milestone_verify_test.go index b9d7cebf..7fbb1767 100644 --- a/internal/config/milestone_verify_test.go +++ b/internal/config/milestone_verify_test.go @@ -35,7 +35,10 @@ func TestVerify_ProviderJSONOnDiskHasNoSecrets(t *testing.T) { } env := map[string]string{"ANTHROPIC_API_KEY": "sk-ant-verify-test-key-1234567890"} cfg := eyriecfg.SyncProviderConfigFromCatalog(compiled, env) - path := eyriecfg.GetProviderConfigPath() + path, err := eyriecfg.GetProviderConfigPath() + if err != nil { + t.Fatalf("GetProviderConfigPath: %v", err) + } if err := eyriecfg.SaveProviderConfig(cfg, path); err != nil { t.Fatal(err) } diff --git a/internal/config/minimal_test.go b/internal/config/minimal_test.go index 7fd44f99..75ad647d 100644 --- a/internal/config/minimal_test.go +++ b/internal/config/minimal_test.go @@ -2,20 +2,6 @@ package config import "testing" -func TestIsMinimalMode(t *testing.T) { - if IsMinimalMode(Settings{}) { - t.Error("nil MinimalMode should be false") - } - on := true - if !IsMinimalMode(Settings{MinimalMode: &on}) { - t.Error("MinimalMode=true should be true") - } - off := false - if IsMinimalMode(Settings{MinimalMode: &off}) { - t.Error("MinimalMode=false should be false") - } -} - func TestToolPresetByName(t *testing.T) { minimal, ok := ToolPresetByName("minimal") if !ok { @@ -43,28 +29,16 @@ func TestToolPresetByName(t *testing.T) { } } -func TestMergeSettings_MinimalMode(t *testing.T) { +func TestMergeSettings_DeploymentRouting(t *testing.T) { on := true - merged := MergeSettings(Settings{}, Settings{MinimalMode: &on}) - if !IsMinimalMode(merged) { - t.Error("override MinimalMode should propagate to base") + merged := MergeSettings(Settings{}, Settings{DeploymentRouting: &on}) + if merged.DeploymentRouting == nil || !*merged.DeploymentRouting { + t.Error("override DeploymentRouting should propagate to base") } // Nil override should not clobber base. - base := MergeSettings(Settings{MinimalMode: &on}, Settings{}) - if !IsMinimalMode(base) { - t.Error("nil override should preserve base MinimalMode") - } -} - -func TestSettingValue_MinimalMode(t *testing.T) { - on := true - v, ok := SettingValue(Settings{MinimalMode: &on}, "minimal_mode") - if !ok || v != "true" { - t.Errorf("expected 'true', got %q (ok=%v)", v, ok) - } - v, ok = SettingValue(Settings{}, "minimalmode") - if !ok || v != "false" { - t.Errorf("expected 'false', got %q (ok=%v)", v, ok) + base := MergeSettings(Settings{DeploymentRouting: &on}, Settings{}) + if base.DeploymentRouting == nil || !*base.DeploymentRouting { + t.Error("nil override should preserve base DeploymentRouting") } } diff --git a/internal/config/settings.go b/internal/config/settings.go index a74837e1..94673a2c 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -12,11 +12,10 @@ import ( "sync" "time" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" "github.com/GrayCodeAI/hawk/internal/provider/routing" "github.com/GrayCodeAI/hawk/internal/storage" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" - "github.com/GrayCodeAI/hawk/internal/types" ) @@ -36,7 +35,6 @@ type Settings struct { AllowedTools []string `json:"allowedTools,omitempty"` // archive-compatible allow rules DisallowedTools []string `json:"disallowedTools,omitempty"` // archive-compatible deny rules MaxBudgetUSD float64 `json:"max_budget_usd,omitempty"` // cost cap per session - CustomHeaders map[string]string `json:"custom_headers,omitempty"` MCPServers []MCPServerConfig `json:"mcp_servers,omitempty"` CustomProviders []CustomProviderConfig `json:"custom_providers,omitempty"` RepoMap *bool `json:"repo_map,omitempty"` @@ -49,7 +47,6 @@ type Settings struct { Frugal bool `json:"frugal,omitempty"` // aggressive cost optimization: cascade to cheap models, lower max_tokens, earlier compaction Attribution *Attribution `json:"attribution,omitempty"` DeploymentRouting *bool `json:"deployment_routing,omitempty"` // use catalog deployment router when true / unset + provider.json qualifies - MinimalMode *bool `json:"minimal_mode,omitempty"` // restrict to core tools only for a focused experience GLMThinkingEnabled *bool `json:"glm_thinking_enabled,omitempty"` // GLM/Z.ai extended reasoning toggle; nil = model default TuiMouse *bool `json:"tui_mouse,omitempty"` // TUI mouse capture; false preserves native click-drag copy ReplMode *bool `json:"repl_mode,omitempty"` // Start in REPL mode instead of TUI @@ -57,12 +54,8 @@ type Settings struct { ScrollMode string `json:"scroll_mode,omitempty"` // auto, wheel, trackpad InvertScroll bool `json:"invert_scroll,omitempty"` // natural scrolling invert CompactMode bool `json:"compact_mode,omitempty"` // reduce outer padding - AutoDarkTheme string `json:"auto_dark_theme,omitempty"` // override for auto dark theme - AutoLightTheme string `json:"auto_light_theme,omitempty"` // override for auto light theme PaginatorLines int `json:"paginator_lines,omitempty"` // scrollback buffer lines (0 = unlimited) PaginatorShowLineNums *bool `json:"paginator_show_line_nums,omitempty"` // show line numbers in scrollback - PaginatorMarginTop int `json:"paginator_margin_top,omitempty"` // top margin for pager - PaginatorMarginBottom int `json:"paginator_margin_bottom,omitempty"` // bottom margin for pager } // ToolPreset maps a named preset to a list of allowed tools. @@ -87,11 +80,6 @@ var builtinToolPresets = map[string]ToolPreset{ }, } -// IsMinimalMode reports whether minimal mode is enabled in the settings. -func IsMinimalMode(s Settings) bool { - return s.MinimalMode != nil && *s.MinimalMode -} - // ToolPresetByName returns a built-in tool preset by name. func ToolPresetByName(name string) (ToolPreset, bool) { p, ok := builtinToolPresets[name] @@ -116,7 +104,6 @@ func (s *Settings) UnmarshalJSON(data []byte) error { *alias AutoAllowCamel []string `json:"autoAllow,omitempty"` MaxBudgetUSDCamel float64 `json:"maxBudgetUSD,omitempty"` - CustomHeadersCamel map[string]string `json:"customHeaders,omitempty"` MCPServersCamel []MCPServerConfig `json:"mcpServers,omitempty"` AllowedToolsSnake []string `json:"allowed_tools,omitempty"` DisallowedToolsSnake []string `json:"disallowed_tools,omitempty"` @@ -132,9 +119,6 @@ func (s *Settings) UnmarshalJSON(data []byte) error { if s.MaxBudgetUSD == 0 { s.MaxBudgetUSD = aux.MaxBudgetUSDCamel } - if len(s.CustomHeaders) == 0 { - s.CustomHeaders = aux.CustomHeadersCamel - } if len(s.MCPServers) == 0 { s.MCPServers = aux.MCPServersCamel } @@ -297,9 +281,6 @@ func MergeSettings(base, override Settings) Settings { if override.DeploymentRouting != nil { base.DeploymentRouting = override.DeploymentRouting } - if override.MinimalMode != nil { - base.MinimalMode = override.MinimalMode - } if override.ModelRoles != nil { if base.ModelRoles == nil { base.ModelRoles = override.ModelRoles @@ -318,14 +299,6 @@ func MergeSettings(base, override Settings) Settings { } } } - if len(override.CustomHeaders) > 0 { - if base.CustomHeaders == nil { - base.CustomHeaders = make(map[string]string, len(override.CustomHeaders)) - } - for k, v := range override.CustomHeaders { - base.CustomHeaders[k] = v - } - } return base } @@ -371,19 +344,11 @@ func SettingValue(s Settings, key string) (string, bool) { return "", true } return strconv.FormatFloat(s.MaxBudgetUSD, 'f', -1, 64), true - case "customheaders": - data, _ := json.Marshal(s.CustomHeaders) - return string(data), true case "mcpservers": data, _ := json.Marshal(s.MCPServers) return string(data), true case "deploymentrouting": return DeploymentRoutingLabel(s), true - case "minimalmode": - if IsMinimalMode(s) { - return "true", true - } - return "false", true case "glmthinking", "glmthinkingenabled": if s.GLMThinkingEnabled == nil { return "default", true @@ -447,17 +412,6 @@ func SetGlobalSetting(key, value string) error { default: return fmt.Errorf("deployment_routing must be true or false") } - case "minimalmode": - switch strings.ToLower(strings.TrimSpace(value)) { - case "1", "true", "yes", "on": - enabled := true - s.MinimalMode = &enabled - case "0", "false", "no", "off": - enabled := false - s.MinimalMode = &enabled - default: - return fmt.Errorf("minimal_mode must be true or false") - } case "glmthinking", "glmthinkingenabled": switch strings.ToLower(strings.TrimSpace(value)) { case "1", "true", "yes", "on": @@ -516,10 +470,6 @@ func SetGlobalSetting(key, value string) error { default: return fmt.Errorf("compact_mode must be true or false") } - case "autodarktheme", "auto_dark_theme": - s.AutoDarkTheme = strings.TrimSpace(value) - case "autolighttheme", "auto_light_theme": - s.AutoLightTheme = strings.TrimSpace(value) case "paginatorlines", "paginator_lines": lines, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil || lines < 0 { @@ -653,7 +603,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) ([]EngineModel, error) { - provider = eyrieengine.NormalizeProviderID(provider) + provider = gateway.NormalizeProviderID(provider) if provider == "" { return nil, fmt.Errorf("no provider specified") } @@ -670,7 +620,7 @@ func FetchModelsForProvider(provider string) ([]EngineModel, error) { } // Custom OpenAI-compatible providers: single model from settings, not hawk catalog data. for _, cp := range LoadSettings().CustomProviders { - if eyrieengine.NormalizeProviderID(cp.Name) != provider { + if gateway.NormalizeProviderID(cp.Name) != provider { continue } if id := strings.TrimSpace(cp.Model); id != "" { @@ -686,7 +636,7 @@ func FetchModelsForProvider(provider string) ([]EngineModel, error) { // FetchModelsForProviderWithSettings resolves cached models using one // invocation's effective settings, including its custom gateways. func FetchModelsForProviderWithSettings(ctx context.Context, settings Settings, provider string) ([]EngineModel, error) { - provider = eyrieengine.NormalizeProviderID(provider) + provider = gateway.NormalizeProviderID(provider) if provider == "" { return nil, fmt.Errorf("no provider specified") } @@ -707,10 +657,10 @@ func FetchModelsForProviderWithSettings(ctx context.Context, settings Settings, return nil, fmt.Errorf("no models found for provider %s in eyrie catalog", provider) } -func refreshModelCatalog(ctx context.Context, _ bool) (eyrieengine.CatalogSnapshot, error) { +func refreshModelCatalog(ctx context.Context, _ bool) (gateway.CatalogSnapshot, error) { engine, err := newEyrieEngine() if err != nil { - return eyrieengine.CatalogSnapshot{}, err + return gateway.CatalogSnapshot{}, err } return engine.RefreshCatalog(ctx, "") } diff --git a/internal/config/settings_status_test.go b/internal/config/settings_status_test.go index 66672bdc..2fc290e6 100644 --- a/internal/config/settings_status_test.go +++ b/internal/config/settings_status_test.go @@ -4,18 +4,18 @@ import ( "context" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) func TestEnvKeyStatusUsesEyrieCredentialStatus(t *testing.T) { - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) - t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) + t.Cleanup(func() { gateway.SetDefaultStore(nil) }) if got := EnvKeyStatus("openai"); got != "empty" { t.Fatalf("EnvKeyStatus without credential = %q, want empty", got) } - if err := store.Set(context.Background(), credentials.AccountForEnv("OPENAI_API_KEY"), "sk-live-status-test-1234567890"); err != nil { + if err := store.Set(context.Background(), gateway.AccountForEnv("OPENAI_API_KEY"), "sk-live-status-test-1234567890"); err != nil { t.Fatal(err) } if got := EnvKeyStatus("openai"); got != "set" { diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index 85c84ad3..ad3f512f 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -88,19 +88,6 @@ func TestMergeSettings_MCPServersOverride(t *testing.T) { } } -func TestMergeSettings_CustomHeadersMerge(t *testing.T) { - t.Parallel() - base := Settings{CustomHeaders: map[string]string{"X-Base": "value1"}} - override := Settings{CustomHeaders: map[string]string{"X-Override": "value2"}} - merged := MergeSettings(base, override) - if merged.CustomHeaders["X-Base"] != "value1" { - t.Error("base header should be preserved") - } - if merged.CustomHeaders["X-Override"] != "value2" { - t.Error("override header should be added") - } -} - func TestMergeSettings_RepoMapOverride(t *testing.T) { t.Parallel() base := Settings{} @@ -257,9 +244,6 @@ func TestSettings_UnmarshalJSON_CamelCase(t *testing.T) { if s.MaxBudgetUSD != 25.0 { t.Errorf("expected budget 25.0, got %f", s.MaxBudgetUSD) } - if s.CustomHeaders["X-Test"] != "val" { - t.Error("expected custom header") - } } func TestSettings_UnmarshalJSON_SnakeCase(t *testing.T) { diff --git a/internal/config/validator_test.go b/internal/config/validator_test.go index 957d14b6..ab281a32 100644 --- a/internal/config/validator_test.go +++ b/internal/config/validator_test.go @@ -5,14 +5,14 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) func TestValidateSettingsValid(t *testing.T) { - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) - t.Cleanup(func() { credentials.SetDefaultStore(nil) }) - _ = store.Set(context.Background(), credentials.AccountForEnv("ANTHROPIC_API_KEY"), "sk-ant-test123456789") + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) + t.Cleanup(func() { gateway.SetDefaultStore(nil) }) + _ = store.Set(context.Background(), gateway.AccountForEnv("ANTHROPIC_API_KEY"), "sk-ant-test123456789") s := Settings{ Provider: "anthropic", @@ -26,9 +26,9 @@ func TestValidateSettingsValid(t *testing.T) { } func TestValidateSettingsProviderDelegatedToEyrie(t *testing.T) { - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) - t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) + t.Cleanup(func() { gateway.SetDefaultStore(nil) }) s := Settings{Provider: "anthropic"} result := ValidateSettings(s) diff --git a/internal/config/xiaomi_setup.go b/internal/config/xiaomi_setup.go index c21f437a..da586b3d 100644 --- a/internal/config/xiaomi_setup.go +++ b/internal/config/xiaomi_setup.go @@ -8,7 +8,7 @@ const ProviderXiaomiTokenPlan = "xiaomi_mimo_token_plan" // #nosec G101 -- provi // NeedsXiaomiTokenPlanRegion reports whether the Token Plan gateway still needs a cluster pick. func NeedsXiaomiTokenPlanRegion(providerID string) bool { - _, required := EngineGatewayRegion(providerID) + _, required := EngineGatewayRegion(context.Background(), providerID) return required } @@ -20,6 +20,6 @@ func SetXiaomiTokenPlanRegion(region string) error { // XiaomiTokenPlanRegionLabel returns the saved cluster id for UI (cn, sgp, ams) or "" if unset. func XiaomiTokenPlanRegionLabel() string { - label, _ := EngineGatewayRegion(ProviderXiaomiTokenPlan) + label, _ := EngineGatewayRegion(context.Background(), ProviderXiaomiTokenPlan) return label } diff --git a/internal/config/zai_setup.go b/internal/config/zai_setup.go index bd0b6231..2bdd5b7e 100644 --- a/internal/config/zai_setup.go +++ b/internal/config/zai_setup.go @@ -11,7 +11,7 @@ const ( // NeedsZAIRegion reports whether the Z.AI gateway still needs a region pick for the chosen plan. func NeedsZAIRegion(providerID string) bool { - _, required := EngineGatewayRegion(providerID) + _, required := EngineGatewayRegion(context.Background(), providerID) return required } @@ -23,6 +23,6 @@ func SetZAIRegion(providerID, region string) error { // ZAIRegionLabel returns the saved region label or "". func ZAIRegionLabel(providerID string) string { - label, _ := EngineGatewayRegion(providerID) + label, _ := EngineGatewayRegion(context.Background(), providerID) return label } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index f8514ff1..b71a3ccb 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -562,8 +562,15 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } sess.AddUser(req.Prompt) - isSSE := wantsSSE(r) - if isSSE { + + events, err := sess.Stream(r.Context()) + if err != nil { + slog.Error("stream failed", "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "stream failed"}) + return + } + + if wantsSSE(r) { // Publish the user turn before exposing the session ID in streaming // response headers. Even if the client disconnects mid-stream, the ID // it received remains retrievable and resumable. @@ -572,32 +579,56 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "session persistence failed"}) return } - } - - ctx := r.Context() - events, err := sess.Stream(ctx) - if err != nil { - slog.Error("stream failed", "err", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "stream failed"}) + streamSSE(s, w, r, events, sessionID, req, sess, saved, start) return } - // SSE streaming mode - if isSSE { - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("X-Hawk-Session-ID", sessionID) - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("X-Frame-Options", "DENY") - flusher, _ := w.(http.Flusher) - var totalIn, totalOut, turns int + writeJSONResponse(s, w, events, sessionID, req, sess, saved, start) +} + +// streamSSE writes a streaming response as SSE events, observing client +// disconnect via r.Context().Done() so the handler does not keep pushing +// events to a dead connection. +func streamSSE(s *Server, w http.ResponseWriter, r *http.Request, events <-chan engine.StreamEvent, sessionID string, req ChatRequest, sess *engine.Session, saved *hawksession.Session, start time.Time) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Hawk-Session-ID", sessionID) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + flusher, _ := w.(http.Flusher) + var totalIn, totalOut, turns int - for ev := range events { + for { + select { + case <-r.Context().Done(): + slog.Info("SSE client disconnected", "session_id", sessionID) + return + case ev, ok := <-events: + if !ok { + if saveErr := persistDaemonSession(sessionID, req, sess, saved, start); saveErr != nil { + slog.Error("persist streaming session failed", "err", saveErr, "session_id", sessionID) + _, _ = fmt.Fprint(w, "event: error\ndata: session persistence failed\n\n") + if flusher != nil { + flusher.Flush() + } + return + } + s.trackSession(sessionID, req, saved, start, turns) + doneData, _ := json.Marshal(map[string]interface{}{ + "session_id": sessionID, + "tokens_in": totalIn, + "tokens_out": totalOut, + "turns_taken": turns, + }) + _, _ = fmt.Fprintf(w, "event: done\ndata: %s\n\n", doneData) + if flusher != nil { + flusher.Flush() + } + return + } switch ev.Type { case "content": - // Per SSE spec, each line of a data field must be prefixed with "data:". - // This prevents injection of fake events via newlines in LLM output. for _, line := range strings.Split(ev.Content, "\n") { _, _ = fmt.Fprintf(w, "data: %s\n", line) } @@ -613,29 +644,11 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { flusher.Flush() } } - if saveErr := persistDaemonSession(sessionID, req, sess, saved, start); saveErr != nil { - slog.Error("persist streaming session failed", "err", saveErr, "session_id", sessionID) - _, _ = fmt.Fprint(w, "event: error\ndata: session persistence failed\n\n") - if flusher != nil { - flusher.Flush() - } - return - } - s.trackSession(sessionID, req, saved, start, turns) - doneData, _ := json.Marshal(map[string]interface{}{ - "session_id": sessionID, - "tokens_in": totalIn, - "tokens_out": totalOut, - "turns_taken": turns, - }) - _, _ = fmt.Fprintf(w, "event: done\ndata: %s\n\n", doneData) - if flusher != nil { - flusher.Flush() - } - return } +} - // Standard JSON response +// writeJSONResponse accumulates events and writes a single JSON response. +func writeJSONResponse(s *Server, w http.ResponseWriter, events <-chan engine.StreamEvent, sessionID string, req ChatRequest, sess *engine.Session, saved *hawksession.Session, start time.Time) { var response strings.Builder var totalIn, totalOut, turns int diff --git a/internal/engine/chat_provider.go b/internal/engine/chat_provider.go index 9614d810..3db6b10b 100644 --- a/internal/engine/chat_provider.go +++ b/internal/engine/chat_provider.go @@ -5,14 +5,14 @@ import ( "fmt" "strings" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" "github.com/GrayCodeAI/hawk/internal/types" ) // BuildChatProvider adapts Hawk's engine-backed session client to the smaller // provider contract used by host integrations such as Sight. Model resolution, // credentials, routing, and transport remain owned by Eyrie's engine facade. -func BuildChatProvider(ctx context.Context, selection eyrieengine.Selection, legacyProvider string) (types.ChatProvider, string, error) { +func BuildChatProvider(ctx context.Context, selection gateway.Selection, legacyProvider string) (types.ChatProvider, string, error) { client, provider, _, err := BuildChatClient(ctx, selection, legacyProvider) if err != nil { return nil, provider, err diff --git a/internal/engine/chat_provider_test.go b/internal/engine/chat_provider_test.go index 0904d36a..ec4c8606 100644 --- a/internal/engine/chat_provider_test.go +++ b/internal/engine/chat_provider_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/GrayCodeAI/hawk-core-contracts/llm" "github.com/GrayCodeAI/hawk/internal/types" ) @@ -21,7 +22,7 @@ func (c *recordingChatClient) StreamChatContinue(_ context.Context, _ []types.Ey c.streamOptions = opts events := make(chan types.EyrieStreamEvent) close(events) - return types.NewStreamResult(events, "", nil), nil + return llm.NewStreamResult(events, "", nil), nil } func TestEngineChatProviderAppliesResolvedSelection(t *testing.T) { diff --git a/internal/engine/chat_service.go b/internal/engine/chat_service.go index 75e342c9..5a7fefe2 100644 --- a/internal/engine/chat_service.go +++ b/internal/engine/chat_service.go @@ -103,6 +103,13 @@ func (c *ChatService) Model() string { return c.model } // (true) or a single-provider transport (false). func (c *ChatService) DeploymentRouting() bool { return c.deploymentRouting } +// SetGLMThinkingEnabled sets the GLM/Z.ai extended-reasoning toggle. The +// next StreamChat/Chat request applies it. Mirrors the legacy Session +// field setter. +func (c *ChatService) SetGLMThinkingEnabled(v *bool) { + c.glmThinkingEnabled = v +} + // SetModel updates the active model. The next StreamChat will use the new // model. func (c *ChatService) SetModel(model string) { diff --git a/internal/engine/chat_service_test.go b/internal/engine/chat_service_test.go index 472abaee..42cda1f8 100644 --- a/internal/engine/chat_service_test.go +++ b/internal/engine/chat_service_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/GrayCodeAI/hawk-core-contracts/llm" "github.com/GrayCodeAI/hawk/internal/resilience/retry" "github.com/GrayCodeAI/hawk/internal/types" ) @@ -185,7 +186,7 @@ func (c *flakyLegacyStartClient) StreamChatContinue(context.Context, []types.Eyr } events := make(chan types.EyrieStreamEvent) close(events) - return types.NewStreamResult(events, "", nil), nil + return llm.NewStreamResult(events, "", nil), nil } func TestChatService_LegacyClientRetainsStartRetry(t *testing.T) { diff --git a/internal/engine/client_interface.go b/internal/engine/client_interface.go index 2cfa3f36..eeaa6d2b 100644 --- a/internal/engine/client_interface.go +++ b/internal/engine/client_interface.go @@ -3,6 +3,7 @@ package engine import ( "context" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" "github.com/GrayCodeAI/hawk/internal/types" ) @@ -26,6 +27,21 @@ func clientManagesResilience(client ChatClient) bool { return ok && manager.ManagesResilience() } +// nativeCompactionCapable is an optional capability implemented by facade +// clients bound to an engine that supports provider-native compaction — so +// Session never needs to unwrap the raw engine. +type nativeCompactionCapable interface { + NativeCompaction(ctx context.Context, provider, model string) bool + CompactNative(ctx context.Context, req gateway.NativeCompactionRequest) (string, error) +} + +func clientNativeCompaction(client ChatClient, ctx context.Context, provider, model string) bool { + if c, ok := client.(nativeCompactionCapable); ok { + return c.NativeCompaction(ctx, provider, model) + } + return false +} + // SetTestClient replaces the session's LLM client. For testing only. // Also reattaches the ChatService so the agent loop's `s.ChatLLM().Stream` // call site sees the mock (Phase 7 migration). diff --git a/internal/engine/compact_provider_native.go b/internal/engine/compact_provider_native.go index 134499e1..682757d9 100644 --- a/internal/engine/compact_provider_native.go +++ b/internal/engine/compact_provider_native.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" "github.com/GrayCodeAI/hawk/internal/types" ) @@ -23,16 +23,19 @@ func (s *ProviderNativeCompactStrategy) Compact(ctx context.Context, sess *Sessi if sess == nil { return nil, fmt.Errorf("no session") } - client, ok := sess.engineFacadeClient() - if !ok || !client.engine.SupportsNativeCompaction(ctx, sess.provider, sess.model) { + if sess == nil || sess.ChatLLM() == nil { + return nil, fmt.Errorf("no session client") + } + compactor, ok := sess.ChatLLM().Client().(nativeCompactionCapable) + if !ok || !compactor.NativeCompaction(ctx, sess.provider, sess.model) { return nil, fmt.Errorf("provider native compaction not available") } tokensBefore := EstimateTokens(sess.messages) - summary, err := client.engine.CompactNative(ctx, eyrieengine.NativeCompactionRequest{ + summary, err := compactor.CompactNative(ctx, gateway.NativeCompactionRequest{ Provider: sess.provider, Model: sess.model, - Messages: toEngineMessages(sess.messages), + Messages: gateway.ToEngineMessages(sess.messages), ContextWindow: sess.ContextWindowSize(), ThresholdPct: sess.compactThresholdPct(), MaxOutputTokens: 8192, @@ -57,15 +60,9 @@ func (s *ProviderNativeCompactStrategy) Compact(ctx context.Context, sess *Sessi return compact, nil } -func (s *Session) engineFacadeClient() (*eyrieEngineClient, bool) { +func (s *Session) supportsNativeCompaction() bool { if s == nil || s.ChatLLM() == nil { - return nil, false + return false } - client, ok := s.ChatLLM().Client().(*eyrieEngineClient) - return client, ok && client != nil && client.engine != nil -} - -func (s *Session) supportsAnthropicNativeCompaction() bool { - client, ok := s.engineFacadeClient() - return ok && client.engine.SupportsNativeCompaction(context.Background(), s.provider, s.model) + return clientNativeCompaction(s.ChatLLM().Client(), context.Background(), s.provider, s.model) } diff --git a/internal/engine/compact_strategy_engine.go b/internal/engine/compact_strategy_engine.go index 4ab9a443..ceae14e7 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.supportsNativeCompaction()) { continue } if s.ShouldTrigger(msgs, tokenCount, threshold) { diff --git a/internal/engine/context_compaction_test.go b/internal/engine/context_compaction_test.go index 2b7532c3..bdb89ea5 100644 --- a/internal/engine/context_compaction_test.go +++ b/internal/engine/context_compaction_test.go @@ -6,6 +6,7 @@ import ( "github.com/GrayCodeAI/eyrie/credentials" eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) func TestContextUsedTokens_PrefersAPI(t *testing.T) { @@ -27,14 +28,14 @@ func TestNativeCompactionSupportUsesEyrieCredentialStore(t *testing.T) { if err != nil { t.Fatal(err) } - s := NewSessionWithClient(newEyrieEngineClient(runtime), "anthropic", "claude-sonnet-4-6", "sys", nil, true) - if s.supportsAnthropicNativeCompaction() { + s := NewSessionWithClient(gateway.NewFromEngine(runtime).ChatClient(), "anthropic", "claude-sonnet-4-6", "sys", nil, true) + if s.supportsNativeCompaction() { t.Fatal("expected no support before Eyrie has a credential") } if err := store.Set(ctx, credentials.AccountForEnv("ANTHROPIC_API_KEY"), "sk-test"); err != nil { t.Fatal(err) } - if !s.supportsAnthropicNativeCompaction() { + if !s.supportsNativeCompaction() { t.Fatal("expected support from Eyrie's injected credential store") } } diff --git a/internal/engine/eyrie_engine_client.go b/internal/engine/eyrie_engine_client.go deleted file mode 100644 index e316356f..00000000 --- a/internal/engine/eyrie_engine_client.go +++ /dev/null @@ -1,278 +0,0 @@ -package engine - -import ( - "context" - - eyrieengine "github.com/GrayCodeAI/eyrie/engine" - "github.com/GrayCodeAI/hawk/internal/types" -) - -// eyrieEngineClient is Hawk's anti-corruption adapter from the product-owned -// ChatClient port to Eyrie's stable engine facade. Hawk continues to own its -// agent loop and conversation state; Eyrie owns model resolution and transport. -type eyrieEngineClient struct { - engine *eyrieengine.Engine -} - -var _ resilienceManagingChatClient = (*eyrieEngineClient)(nil) - -func newEyrieEngineClient(runtime *eyrieengine.Engine) ChatClient { - return &eyrieEngineClient{engine: runtime} -} - -func (c *eyrieEngineClient) Chat(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) { - response, err := c.engine.Generate(ctx, toEngineRequest(messages, opts, types.ContinuationConfig{})) - if err != nil { - return nil, err - } - return fromEngineResponse(response), nil -} - -func (c *eyrieEngineClient) StreamChatContinue(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions, continuation types.ContinuationConfig) (*types.StreamResult, error) { - request := toEngineRequest(messages, opts, continuation) - request.Requirements.Streaming = true - stream, err := c.engine.Stream(ctx, request) - if err != nil { - return nil, err - } - events := make(chan types.EyrieStreamEvent, 64) - streamCtx, cancel := context.WithCancel(ctx) - go func() { - defer close(events) - defer func() { _ = stream.Close() }() - for stream.Next() { - event, emit := fromEngineEvent(stream.Event()) - if !emit { - continue - } - select { - case events <- event: - case <-streamCtx.Done(): - return - } - } - if err := stream.Err(); err != nil { - select { - case events <- types.EyrieStreamEvent{Type: "error", Error: err.Error()}: - case <-streamCtx.Done(): - } - } - }() - closeFn := func() { - cancel() - _ = stream.Close() - } - return types.NewStreamResult(events, "", closeFn), nil -} - -// ManagesResilience tells Hawk not to add provider retry, continuation, or -// protocol-recovery layers around Eyrie's routed transport. -func (c *eyrieEngineClient) ManagesResilience() bool { return true } - -func toEngineRequest(messages []types.EyrieMessage, opts types.ChatOptions, continuation types.ContinuationConfig) eyrieengine.GenerateRequest { - glmReasoningEnabled := opts.GLMThinkingEnabled != nil && *opts.GLMThinkingEnabled - request := eyrieengine.GenerateRequest{ - Messages: toEngineMessages(messages), - SystemPrompt: opts.System, - Tools: toEngineTools(opts.Tools), - Requirements: eyrieengine.Requirements{ - Streaming: opts.Stream, - Tools: len(opts.Tools) > 0, - Vision: messagesContainVision(messages), - StructuredJSON: opts.ResponseFormat != nil || opts.OutputSchema != "", - Reasoning: opts.ReasoningEffort != "" || opts.ThinkingBudgetTokens > 0 || opts.ThinkingMode != "" || glmReasoningEnabled, - }, - Preference: eyrieengine.Preference{ - PreferredProvider: opts.Provider, - PreferredModelID: opts.Model, - }, - Limits: eyrieengine.Limits{ - MaxOutputTokens: opts.MaxTokens, - MaxContinuations: continuation.MaxContinuations, - MaxTotalOutputTokens: continuation.MaxTotalTokens, - }, - Metadata: eyrieengine.Metadata{UserID: opts.MetadataUserID}, - Temperature: opts.Temperature, - OutputSchema: firstNonEmpty(opts.OutputSchema, responseSchema(opts.ResponseFormat)), - Options: eyrieengine.GenerationOptions{ - EnableCaching: opts.EnableCaching, ReasoningEffort: opts.ReasoningEffort, - ThinkingBudgetTokens: opts.ThinkingBudgetTokens, ThinkingMode: opts.ThinkingMode, - ThinkingDisplay: opts.ThinkingDisplay, GLMThinkingEnabled: opts.GLMThinkingEnabled, - VirtualKeyID: opts.VirtualKeyID, KimiContextCacheID: opts.KimiContextCacheID, - KimiCacheResetTTL: opts.KimiCacheResetTTL, TopP: opts.TopP, TopK: opts.TopK, - StopSequences: append([]string(nil), opts.StopSequences...), ToolChoice: toEngineToolChoice(opts.ToolChoice), - ServiceTier: opts.ServiceTier, OutputEffort: opts.OutputEffort, - PresencePenalty: opts.PresencePenalty, FrequencyPenalty: opts.FrequencyPenalty, - N: opts.N, LogProbs: opts.LogProbs, TopLogProbs: opts.TopLogProbs, Seed: opts.Seed, - Store: opts.Store, Metadata: cloneMetadata(opts.Metadata), Modalities: append([]string(nil), opts.Modalities...), - AudioConfig: opts.AudioConfig, Prediction: opts.Prediction, WebSearchOptions: opts.WebSearchOptions, - }, - } - return request -} - -func toEngineMessages(messages []types.EyrieMessage) []eyrieengine.Message { - out := make([]eyrieengine.Message, 0, len(messages)) - for _, message := range messages { - parts := make([]eyrieengine.ContentPart, 0, len(message.ContentParts)+len(message.Images)) - for _, part := range message.ContentParts { - converted := eyrieengine.ContentPart{Type: part.Type, Text: part.Text} - if part.ImageURL != nil { - converted.URL, converted.Detail = part.ImageURL.URL, part.ImageURL.Detail - } - if part.InputAudio != nil { - converted.AudioData, converted.AudioFormat = part.InputAudio.Data, part.InputAudio.Format - } - parts = append(parts, converted) - } - for _, image := range message.Images { - parts = append(parts, eyrieengine.ContentPart{Type: "image_url", URL: image}) - } - calls := make([]eyrieengine.ToolCall, 0, len(message.ToolUse)) - for _, call := range message.ToolUse { - calls = append(calls, eyrieengine.ToolCall{ID: call.ID, Name: call.Name, Arguments: call.Arguments}) - } - results := make([]eyrieengine.ToolResult, 0, len(message.ToolResults)) - for _, result := range message.ToolResults { - results = append(results, eyrieengine.ToolResult{ToolUseID: result.ToolUseID, Content: result.Content, IsError: result.IsError}) - } - out = append(out, eyrieengine.Message{ - Role: message.Role, Content: message.Content, Thinking: message.Thinking, - ContentParts: parts, ToolCalls: calls, ToolResults: results, - }) - } - return out -} - -func toEngineTools(tools []types.EyrieTool) []eyrieengine.Tool { - out := make([]eyrieengine.Tool, 0, len(tools)) - for _, tool := range tools { - out = append(out, eyrieengine.Tool{Name: tool.Name, Description: tool.Description, Parameters: tool.Parameters}) - } - return out -} - -func toEngineToolChoice(choice *types.ToolChoiceOption) *eyrieengine.ToolChoice { - if choice == nil { - return nil - } - return &eyrieengine.ToolChoice{Type: choice.Type, Name: choice.Name, DisableParallelToolUse: choice.DisableParallelToolUse} -} - -func fromEngineResponse(response *eyrieengine.GenerateResponse) *types.EyrieResponse { - if response == nil { - return &types.EyrieResponse{} - } - calls := make([]types.ToolCall, 0, len(response.ToolCalls)) - for _, call := range response.ToolCalls { - calls = append(calls, types.ToolCall{ID: call.ID, Name: call.Name, Arguments: call.Arguments}) - } - return &types.EyrieResponse{ - Content: response.Content, Thinking: response.Thinking, ToolCalls: calls, - FinishReason: response.FinishReason, RequestID: response.RequestID, Usage: fromEngineUsage(response.Usage), - Route: fromEngineRoute(response.Route), - } -} - -func fromEngineEvent(event eyrieengine.Event) (types.EyrieStreamEvent, bool) { - out := types.EyrieStreamEvent{ - Content: event.Content, Thinking: event.Thinking, RequestID: event.RequestID, - Usage: fromEngineUsage(event.Usage), StopReason: event.StopReason, TTFTms: event.TTFTMillis, - } - switch event.Type { - case eyrieengine.EventRouteSelected: - out.Type = "route_selected" - case eyrieengine.EventRouteChanged: - out.Type = "route_changed" - case eyrieengine.EventContentDelta: - out.Type = "content" - case eyrieengine.EventThinkingDelta: - out.Type = "thinking" - case eyrieengine.EventToolCallStart, eyrieengine.EventToolCallDone: - out.Type = "tool_call" - case eyrieengine.EventToolCallDelta: - out.Type = "tool_input_delta" - case eyrieengine.EventUsage: - out.Type = "usage" - case eyrieengine.EventTTFT: - out.Type = "ttft" - out.TTFT = event.TTFTMillis - case eyrieengine.EventDone: - out.Type = "done" - case eyrieengine.EventContinuation: - out.Type = "continuation" - case eyrieengine.EventWarning: - out.Type, out.Content = "warning", event.Warning - default: - out.Type = string(event.Type) - } - if event.ToolCall != nil { - out.ToolCall = &types.ToolCall{ID: event.ToolCall.ID, Name: event.ToolCall.Name, Arguments: event.ToolCall.Arguments} - } - if event.Route != nil { - out.Route = fromEngineRoute(*event.Route) - } - return out, true -} - -func fromEngineRoute(route eyrieengine.Route) *types.ResolvedRoute { - if route.Provider == "" && route.Model == "" && !route.DeploymentRouting { - return nil - } - return &types.ResolvedRoute{ - Provider: route.Provider, - Model: route.Model, - DeploymentRouting: route.DeploymentRouting, - } -} - -func fromEngineUsage(usage *eyrieengine.Usage) *types.EyrieUsage { - if usage == nil { - return nil - } - return &types.EyrieUsage{ - PromptTokens: usage.InputTokens, CompletionTokens: usage.OutputTokens, TotalTokens: usage.TotalTokens, - CacheCreationTokens: usage.CacheCreationTokens, CacheReadTokens: usage.CacheReadTokens, ThinkingTokens: usage.ThinkingTokens, - } -} - -func responseSchema(format *types.ResponseFormat) string { - if format == nil { - return "" - } - return format.Schema -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if value != "" { - return value - } - } - return "" -} - -func cloneMetadata(in map[string]string) map[string]string { - if in == nil { - return nil - } - out := make(map[string]string, len(in)) - for key, value := range in { - out[key] = value - } - return out -} - -func messagesContainVision(messages []types.EyrieMessage) bool { - for _, message := range messages { - if len(message.Images) > 0 { - return true - } - for _, part := range message.ContentParts { - if part.ImageURL != nil || part.Type == "image_url" { - return true - } - } - } - return false -} diff --git a/internal/engine/resilience_boundary_test.go b/internal/engine/resilience_boundary_test.go index 002e8168..2d52e68b 100644 --- a/internal/engine/resilience_boundary_test.go +++ b/internal/engine/resilience_boundary_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/GrayCodeAI/hawk-core-contracts/llm" "github.com/GrayCodeAI/hawk/internal/types" ) @@ -53,7 +54,7 @@ func (c *resilienceBoundaryClient) StreamChatContinue(_ context.Context, message ch <- event } close(ch) - return types.NewStreamResult(ch, "", nil), nil + return llm.NewStreamResult(ch, "", nil), nil } func (c *resilienceBoundaryClient) counts() (stream, chat int) { diff --git a/internal/engine/self_heal.go b/internal/engine/self_heal.go index d360ef1b..45afe66a 100644 --- a/internal/engine/self_heal.go +++ b/internal/engine/self_heal.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/GrayCodeAI/hawk/internal/tool" "github.com/GrayCodeAI/hawk/internal/ui/icons" ) @@ -448,6 +449,16 @@ func (sh *SelfHealer) RunScript(ctx context.Context, path string) (stdout, stder // runCommand executes an arbitrary shell command. func (sh *SelfHealer) runCommand(ctx context.Context, command string) (stdout, stderr string, exitCode int, err error) { + // Route model-generated shell through the same safety stack the Bash + // tool enforces. Without this, a jailbroken model could read + // ~/.hawk/provider.json, fork-bomb, or rm -rf from a "fix" attempt. + if reason := tool.CommandReferencesSensitivePath(command); reason != "" { + return "", "", -1, fmt.Errorf("self-heal: command references sensitive path (%s): %s", reason, command) + } + if tool.IsDestructiveCommand(command) { + return "", "", -1, fmt.Errorf("self-heal: command flagged as destructive: %s", command) + } + ctx, cancel := context.WithTimeout(ctx, sh.Timeout) defer cancel() diff --git a/internal/engine/self_heal_test.go b/internal/engine/self_heal_test.go index 0ab0a1ca..cbda79f3 100644 --- a/internal/engine/self_heal_test.go +++ b/internal/engine/self_heal_test.go @@ -617,3 +617,31 @@ func TestConcurrentHealAttempts(t *testing.T) { t.Errorf("expected 200 history entries, got %d", count) } } + +func TestRunCommand_RejectsSensitivePath(t *testing.T) { + chatFn := func(ctx context.Context, prompt string) (string, error) { return "", nil } + sh := NewSelfHealer(chatFn) + + // A command that reads a sensitive path should be rejected outright. + _, _, _, err := sh.runCommand(context.Background(), "cat ~/.hawk/provider.json") + if err == nil { + t.Fatal("expected error for command referencing sensitive path, got nil") + } + if !strings.Contains(err.Error(), "sensitive path") { + t.Fatalf("expected 'sensitive path' in error, got: %v", err) + } +} + +func TestRunCommand_RejectsDestructive(t *testing.T) { + chatFn := func(ctx context.Context, prompt string) (string, error) { return "", nil } + sh := NewSelfHealer(chatFn) + + // A command flagged as destructive should be rejected. + _, _, _, err := sh.runCommand(context.Background(), "rm -rf /") + if err == nil { + t.Fatal("expected error for destructive command, got nil") + } + if !strings.Contains(err.Error(), "destructive") { + t.Fatalf("expected 'destructive' in error, got: %v", err) + } +} diff --git a/internal/engine/session.go b/internal/engine/session.go index 1c3ca0f2..a1875701 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -9,7 +9,7 @@ import ( "sync" "time" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" "github.com/GrayCodeAI/hawk/internal/types" "github.com/GrayCodeAI/hawk/internal/engine/branching" @@ -77,19 +77,6 @@ type Session struct { log *logger.Logger metrics *metrics.Registry Cost Cost - // DeploymentRouting is true when the chat client is catalog-backed (e.g. DeploymentRouter). - // - // Deprecated: use s.ChatLLM().DeploymentRouting() (Phase 1 sub-service). - DeploymentRouting bool - - // ContainerExecutor runs Bash in an isolated container when set (no API keys in container env). - // - // Deprecated: use s.Tools().ContainerExecutor() (Phase 6 sub-service). - ContainerExecutor tool.ContainerExecutor - // ContainerRequired blocks tools until ContainerExecutor is running (container-first mode). - // - // Deprecated: use s.Tools().ContainerRequired() (Phase 6 sub-service). - ContainerRequired bool // llm is the LLM transport service (Phase 1 extraction). All new // code should go through s.llm.* rather than touching the legacy @@ -246,7 +233,7 @@ type Session struct { // NewSession creates a conversation session through Eyrie's engine facade. func NewSession(provider, model, systemPrompt string, registry *tool.Registry) *Session { - return NewHawkSession(context.Background(), eyrieengine.Selection{ + return NewHawkSession(context.Background(), gateway.Selection{ Provider: provider, Model: model, }, provider, model, systemPrompt, registry) @@ -260,29 +247,28 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, pe := NewPermissionEngine() log := logger.Default() s := &Session{ - client: chat, - registry: registry, - provider: provider, - model: model, - system: systemPrompt, - log: log, - metrics: metrics.NewRegistry(), - Perm: pe, - Permissions: pe.Memory, - AutoMode: pe.AutoMode, - Classifier: pe.Classifier, - BypassKill: pe.BypassKill, - Beliefs: NewBeliefState(), - Backtrack: NewBacktrackEngine(), - Limits: NewLimitTracker(DefaultLimits()), - Tracer: oteltrace.NewTracer(), - LintLoop: NewLintLoop(), - TestLoop: NewTestLoop(), - FileMentions: NewFileMentionDetector("."), - ResponseCache: NewResponseCache(1000, 24*time.Hour), - Pipeline: NewIntegrationPipeline(), - DeploymentRouting: deploymentRouting, - RateLimiter: ratelimit.PerSecond(10), + client: chat, + registry: registry, + provider: provider, + model: model, + system: systemPrompt, + log: log, + metrics: metrics.NewRegistry(), + Perm: pe, + Permissions: pe.Memory, + AutoMode: pe.AutoMode, + Classifier: pe.Classifier, + BypassKill: pe.BypassKill, + Beliefs: NewBeliefState(), + Backtrack: NewBacktrackEngine(), + Limits: NewLimitTracker(DefaultLimits()), + Tracer: oteltrace.NewTracer(), + LintLoop: NewLintLoop(), + TestLoop: NewTestLoop(), + FileMentions: NewFileMentionDetector("."), + ResponseCache: NewResponseCache(1000, 24*time.Hour), + Pipeline: NewIntegrationPipeline(), + RateLimiter: ratelimit.PerSecond(10), } s.Cost.Model = model s.AutoCompactThresholdPct = DefaultAutoCompactThresholdPct @@ -349,14 +335,22 @@ func (s *Session) ReattachTransport(chat ChatClient, provider string, deployment if chat == nil { return } + s.mu.Lock() s.client = chat if strings.TrimSpace(provider) != "" { s.provider = strings.TrimSpace(provider) } - s.DeploymentRouting = deploymentRouting - if s.llm != nil { - s.llm.Reattach(chat, s.provider) + prov := s.provider + llm := s.llm + s.mu.Unlock() + if llm != nil { + llm.Reattach(chat, prov) } + // deploymentRouting is now read through ChatService; the ChatService + // constructed at session creation already holds the value. If a + // future path needs to toggle it post-construction, extend + // ChatService with a setter. + _ = deploymentRouting } // SubSession clones transport and routing mode for explore/general sub-agents. @@ -364,12 +358,20 @@ func (s *Session) SubSession(model, systemPrompt string, registry *tool.Registry if registry == nil { registry = s.registry } - sub := NewSessionWithClient(s.client, s.provider, model, systemPrompt, registry, s.DeploymentRouting) + sub := NewSessionWithClient(s.client, s.provider, model, systemPrompt, registry, s.DeploymentRouting()) return sub } -func (s *Session) Model() string { return s.model } -func (s *Session) Provider() string { return s.provider } +func (s *Session) Model() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.model +} +func (s *Session) Provider() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.provider +} func (s *Session) Metrics() *metrics.Registry { return s.metrics } // ChatLLM returns the extracted ChatService (Phase 1 of the god-object @@ -398,6 +400,35 @@ func (s *Session) Persistence() *PersistenceService { return s.persist } // Tools returns the extracted ToolService (Phase 6). func (s *Session) Tools() *ToolService { return s.tools } +// DeploymentRouting reports whether the chat client is catalog-backed +// (e.g. DeploymentRouter). Read through to the ChatService so there is +// no separate stored field to drift out of sync on ReattachTransport. +func (s *Session) DeploymentRouting() bool { + if s.llm != nil { + return s.llm.DeploymentRouting() + } + return false +} + +// ContainerExecutor returns the executor that runs Bash in an isolated +// container, or nil. Read through to the ToolService. +func (s *Session) ContainerExecutor() tool.ContainerExecutor { + if s.tools != nil { + return s.tools.ContainerExecutor() + } + return nil +} + +// ContainerRequired reports whether tools are blocked until the +// container executor is running (container-first mode). Read through to +// the ToolService. +func (s *Session) ContainerRequired() bool { + if s.tools != nil { + return s.tools.ContainerRequired() + } + return false +} + // SubServices is the composed view of the 6 sub-services extracted // in Phases 1-6 of the god-object decomposition. New code should // prefer the SubServices() accessor over the legacy Session fields. @@ -440,8 +471,11 @@ func (s *Session) SubServices() SubServices { // SetModel updates the active model for subsequent requests. func (s *Session) SetModel(model string) { - s.model = strings.TrimSpace(model) - s.Cost.Model = s.model + m := strings.TrimSpace(model) + s.mu.Lock() + s.model = m + s.Cost.Model = m + s.mu.Unlock() s.syncCascadeDefaultModel() s.refreshContextWindowCache() } @@ -460,9 +494,12 @@ func (s *Session) syncCascadeDefaultModel() { // SetProvider updates the active provider for subsequent requests. func (s *Session) SetProvider(provider string) { p := strings.TrimSpace(provider) + s.mu.Lock() s.provider = p - if s.llm != nil { - s.llm.SetProvider(p) + llm := s.llm + s.mu.Unlock() + if llm != nil { + llm.SetProvider(p) } } @@ -707,11 +744,14 @@ func (s *Session) SetPinnedMessages(n int) { } } -// SetGLMThinkingEnabled sets the GLM/Z.AI extended-reasoning toggle. -// New code should call this instead of writing to the legacy -// s.GLMThinkingEnabled field directly. +// SetGLMThinkingEnabled sets the GLM/Z.AI extended-reasoning toggle on +// the ChatService (the source of truth). The legacy s.GLMThinkingEnabled +// field is kept for backward compat but is no longer the read path. func (s *Session) SetGLMThinkingEnabled(v *bool) { s.GLMThinkingEnabled = v + if s.llm != nil { + s.llm.SetGLMThinkingEnabled(v) + } } // SetSnapshots attaches the snapshot tracker. New code should call @@ -720,20 +760,19 @@ func (s *Session) SetSnapshots(snap *snapshot.Tracker) { s.Snapshots = snap } -// SetContainerRequired sets the container-first mode flag. -// New code should call this instead of writing to the legacy -// s.ContainerRequired field directly. +// SetContainerRequired sets the container-first mode flag on the +// ToolService (the source of truth). func (s *Session) SetContainerRequired(v bool) { - s.ContainerRequired = v + if s.tools != nil { + s.tools.WithContainerExecutor(s.tools.ContainerExecutor(), v) + } } -// SetContainerExecutor sets the container executor and updates -// the ToolService so the legacy s.ContainerExecutor field and -// s.Tools().ContainerExecutor() stay in sync. +// SetContainerExecutor sets the container executor on the ToolService +// (the source of truth), preserving the current required flag. func (s *Session) SetContainerExecutor(ce tool.ContainerExecutor) { - s.ContainerExecutor = ce if s.tools != nil { - s.tools.WithContainerExecutor(ce, s.ContainerRequired) + s.tools.WithContainerExecutor(ce, s.ContainerRequired()) } } diff --git a/internal/engine/session_factory.go b/internal/engine/session_factory.go index ed164c02..bc4d681b 100644 --- a/internal/engine/session_factory.go +++ b/internal/engine/session_factory.go @@ -6,22 +6,23 @@ import ( "fmt" "strings" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" - hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" "github.com/GrayCodeAI/hawk/internal/tool" ) // BuildChatClient returns an LLM client and whether deployment routing is active. -func BuildChatClient(ctx context.Context, selection eyrieengine.Selection, legacyProvider string) (ChatClient, string, bool, error) { - modelRuntime, err := hawkconfig.NewEyrieEngine() +// It is the single composition root: it builds one gateway.Gateway and adapts it +// to the hawk ChatClient port via the gateway's anti-corruption adapter. +func BuildChatClient(ctx context.Context, selection gateway.Selection, legacyProvider string) (ChatClient, string, bool, error) { + modelRuntime, err := gateway.New(ctx, nil) if err != nil { return nil, requestedProvider(selection, legacyProvider), false, fmt.Errorf("eyrie transport: %w", err) } return buildChatClientWithRuntime(ctx, modelRuntime, selection, legacyProvider) } -func buildChatClientWithRuntime(ctx context.Context, modelRuntime *eyrieengine.Engine, selection eyrieengine.Selection, legacyProvider string) (ChatClient, string, bool, error) { +func buildChatClientWithRuntime(ctx context.Context, modelRuntime *gateway.Gateway, selection gateway.Selection, legacyProvider string) (ChatClient, string, bool, error) { _ = ctx // request contexts are applied per generation by the facade adapter provider := strings.TrimSpace(selection.Provider) if provider == "" { @@ -38,20 +39,43 @@ func buildChatClientWithRuntime(ctx context.Context, modelRuntime *eyrieengine.E if label == "" { label = provider } - return newEyrieEngineClient(modelRuntime), label, true, nil + return modelRuntime.ChatClient(), label, true, nil } // BuildChatClientForSettings composes the model runtime from one effective // settings snapshot. It is the command-facing path for --settings isolation. -func BuildChatClientForSettings(ctx context.Context, settings hawkconfig.Settings, selection eyrieengine.Selection, legacyProvider string) (ChatClient, string, bool, error) { - modelRuntime, err := hawkconfig.NewEyrieEngineForSettings(settings) +func BuildChatClientForSettings(ctx context.Context, settings hawkconfig.Settings, selection gateway.Selection, legacyProvider string) (ChatClient, string, bool, error) { + modelRuntime, err := gateway.New(ctx, gatewayCustomGateways(settings.CustomProviders)) if err != nil { return nil, requestedProvider(selection, legacyProvider), false, fmt.Errorf("eyrie transport: %w", err) } return buildChatClientWithRuntime(ctx, modelRuntime, selection, legacyProvider) } -func requestedProvider(selection eyrieengine.Selection, legacyProvider string) string { +// convertCustomProviders maps config.CustomProviderConfig → gateway.CustomProviderConfig +// at the composition root (the gateway package cannot import config). +// Delegates the final step to gateway.BuildCustomGateways so a new +// CustomProviderConfig field only needs wiring once. +func convertCustomProviders(in []hawkconfig.CustomProviderConfig) []gateway.CustomProviderConfig { + out := make([]gateway.CustomProviderConfig, 0, len(in)) + for _, p := range in { + if p.Name == "" && p.BaseURL == "" { + continue + } + out = append(out, gateway.CustomProviderConfig{ + Name: p.Name, BaseURL: p.BaseURL, APIKeyEnv: p.APIKeyEnv, Model: p.Model, + }) + } + return out +} + +// gatewayCustomGateways converts config providers to gateway specs, reusing +// the shared conversion loop. +func gatewayCustomGateways(in []hawkconfig.CustomProviderConfig) []gateway.CustomProviderConfig { + return convertCustomProviders(in) +} + +func requestedProvider(selection gateway.Selection, legacyProvider string) string { if provider := strings.TrimSpace(selection.Provider); provider != "" { return provider } @@ -59,7 +83,7 @@ func requestedProvider(selection eyrieengine.Selection, legacyProvider string) s } // NewHawkSession constructs a Session using an engine-resolved selection. -func NewHawkSession(ctx context.Context, selection eyrieengine.Selection, provider, model, systemPrompt string, registry *tool.Registry) *Session { +func NewHawkSession(ctx context.Context, selection gateway.Selection, provider, model, systemPrompt string, registry *tool.Registry) *Session { chat, label, deploy, err := BuildChatClient(ctx, selection, provider) if err != nil { chat = NewUnavailableChatClient(err) @@ -73,7 +97,7 @@ func NewHawkSession(ctx context.Context, selection eyrieengine.Selection, provid // NewHawkSessionForSettings constructs a session with an invocation-scoped // Eyrie engine, including custom gateways from an explicit settings file. -func NewHawkSessionForSettings(ctx context.Context, settings hawkconfig.Settings, selection eyrieengine.Selection, provider, model, systemPrompt string, registry *tool.Registry) *Session { +func NewHawkSessionForSettings(ctx context.Context, settings hawkconfig.Settings, selection gateway.Selection, provider, model, systemPrompt string, registry *tool.Registry) *Session { chat, label, deploy, err := BuildChatClientForSettings(ctx, settings, selection, provider) if err != nil { chat = NewUnavailableChatClient(err) @@ -86,7 +110,7 @@ func NewHawkSessionForSettings(ctx context.Context, settings hawkconfig.Settings } // RebuildSessionTransport rebuilds the LLM client from the engine-resolved selection. -func RebuildSessionTransport(ctx context.Context, s *Session, selection eyrieengine.Selection, legacyProvider string) error { +func RebuildSessionTransport(ctx context.Context, s *Session, selection gateway.Selection, legacyProvider string) error { if s == nil { return errors.New("session is nil") } @@ -98,7 +122,7 @@ func RebuildSessionTransport(ctx context.Context, s *Session, selection eyrieeng return nil } -func RebuildSessionTransportForSettings(ctx context.Context, settings hawkconfig.Settings, s *Session, selection eyrieengine.Selection, legacyProvider string) error { +func RebuildSessionTransportForSettings(ctx context.Context, settings hawkconfig.Settings, s *Session, selection gateway.Selection, legacyProvider string) error { if s == nil { return errors.New("session is nil") } diff --git a/internal/engine/session_factory_test.go b/internal/engine/session_factory_test.go index fd35f796..98814777 100644 --- a/internal/engine/session_factory_test.go +++ b/internal/engine/session_factory_test.go @@ -4,13 +4,13 @@ import ( "context" "testing" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) func TestNewHawkSession_UsesResolvedSelectionModel(t *testing.T) { - selection := eyrieengine.Selection{ + selection := gateway.Selection{ Provider: "openrouter", Model: "openrouter/auto", DeploymentRouting: false, @@ -30,7 +30,7 @@ func TestBuildChatClientForSettingsComposesCustomGateway(t *testing.T) { Name: "private-gateway", BaseURL: "https://private.example.test/v1", APIKeyEnv: "PRIVATE_GATEWAY_API_KEY", Model: "private/model-v1", }}} - selection := eyrieengine.Selection{Provider: "private-gateway", Model: "private/model-v1"} + selection := gateway.Selection{Provider: "private-gateway", Model: "private/model-v1"} client, label, deployment, err := BuildChatClientForSettings(context.Background(), settings, selection, "") if err != nil { t.Fatal(err) @@ -41,7 +41,7 @@ func TestBuildChatClientForSettingsComposesCustomGateway(t *testing.T) { } func TestNewHawkSession_FallsBackToCallerModelWhenSelectionEmpty(t *testing.T) { - selection := eyrieengine.Selection{ + selection := gateway.Selection{ Provider: "openrouter", DeploymentRouting: false, } diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 0804cb0d..bccb51c5 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -7,7 +7,7 @@ import ( "strings" "time" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" "github.com/GrayCodeAI/hawk/internal/types" "github.com/GrayCodeAI/hawk/internal/engine/branching" @@ -573,7 +573,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { // text): Moonshot/kimi <|tool_calls_section_begin|> or Hermes/Nous // (Qwen and most OpenAI-compatible local models). if len(toolCalls) == 0 && (strings.Contains(textContent.String(), "<|tool_calls_section_begin|>") || strings.Contains(textContent.String(), "")) { - cleanText, engineCalls := eyrieengine.ParseInlineToolCalls(textContent.String()) + cleanText, engineCalls := gateway.ParseInlineToolCalls(textContent.String()) inlineCalls := make([]types.ToolCall, 0, len(engineCalls)) for _, call := range engineCalls { inlineCalls = append(inlineCalls, types.ToolCall{ID: call.ID, Name: call.Name, Arguments: call.Arguments}) diff --git a/internal/mcp/http.go b/internal/mcp/http.go index 966e2e45..fd8a120a 100644 --- a/internal/mcp/http.go +++ b/internal/mcp/http.go @@ -160,22 +160,10 @@ func (s *HTTPServer) CallTool(ctx context.Context, name string, args map[string] if err != nil { return "", err } - var resp struct { - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"content"` - } - if err := json.Unmarshal(result, &resp); err != nil { - return string(result), nil - } - var texts []string - for _, c := range resp.Content { - if c.Text != "" { - texts = append(texts, c.Text) - } - } - return strings.Join(texts, "\n"), nil + // Reuse the shared decoder so the HTTP/SSE transport honors the spec's + // isError flag identically to the stdio transport: a remote tool failure + // must surface as a Go error, not a successful result. + return parseToolCallResult(result) } // Close is a no-op for HTTP/SSE servers (no persistent connection). diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index b9e25c1e..c2379dd1 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -37,6 +37,8 @@ type Server struct { pending map[int]chan json.RawMessage // response channels keyed by request ID pendErrors map[int]string // error details keyed by request ID pendMu sync.Mutex + closeOnce sync.Once + closeErr error } // Tool is a tool exposed by an MCP server. @@ -286,17 +288,26 @@ func parseToolCallResult(result json.RawMessage) (string, error) { // Close shuts down the MCP server, killing the child process if it doesn't exit // within 5 seconds of stdin being closed. +// +// It is idempotent and safe to call concurrently: Connect's initialize-failure +// path and a manager's later cleanup may both reach Close, and closing stdin or +// calling cmd.Wait twice races. The sync.Once ensures the shutdown runs exactly +// once and every caller observes the same result. Killing the child causes +// stdout to EOF, which unblocks and terminates the readLoop goroutine. func (s *Server) Close() error { - _ = s.stdin.Close() - done := make(chan error, 1) - go func() { done <- s.cmd.Wait() }() - select { - case err := <-done: - return err - case <-time.After(5 * time.Second): - _ = s.cmd.Process.Kill() - return <-done - } + s.closeOnce.Do(func() { + _ = s.stdin.Close() + done := make(chan error, 1) + go func() { done <- s.cmd.Wait() }() + select { + case err := <-done: + s.closeErr = err + case <-time.After(5 * time.Second): + _ = s.cmd.Process.Kill() + s.closeErr = <-done + } + }) + return s.closeErr } func (s *Server) call(method string, params interface{}) (json.RawMessage, error) { diff --git a/internal/mcp/mcp_close_test.go b/internal/mcp/mcp_close_test.go new file mode 100644 index 00000000..eaec8346 --- /dev/null +++ b/internal/mcp/mcp_close_test.go @@ -0,0 +1,71 @@ +package mcp + +import ( + "os/exec" + "sync" + "testing" +) + +// newTestServerCat builds a Server backed by a real `cat` subprocess. cat reads +// stdin and exits cleanly (status 0) as soon as stdin is closed, which is +// exactly the shutdown Close() performs. This exercises the real +// stdin.Close + cmd.Wait path without a full MCP handshake. +func newTestServerCat(t *testing.T) *Server { + t.Helper() + if _, err := exec.LookPath("cat"); err != nil { + t.Skip("cat not available on PATH") + } + cmd := exec.Command("cat") + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatalf("stdin pipe: %v", err) + } + if err := cmd.Start(); err != nil { + t.Fatalf("start cat: %v", err) + } + return &Server{Name: "test", cmd: cmd, stdin: stdin} +} + +// TestServerCloseIdempotent verifies Close() can be called repeatedly: the +// sync.Once guard runs the shutdown exactly once and every subsequent call +// observes the same cached result, without double-closing stdin or calling +// cmd.Wait twice (either of which would race or error). +func TestServerCloseIdempotent(t *testing.T) { + s := newTestServerCat(t) + + err1 := s.Close() + if err1 != nil { + t.Fatalf("first Close() = %v, want nil (cat exits 0 on stdin EOF)", err1) + } + for i := 0; i < 3; i++ { + if err := s.Close(); err != err1 { + t.Fatalf("Close() call %d = %v, want same as first (%v)", i+2, err, err1) + } + } +} + +// TestServerCloseConcurrent verifies Close() is safe under concurrent callers: +// Connect's initialize-failure path and a manager's later cleanup can both +// reach Close at once. Run with -race to catch a regression of the sync.Once +// guard. +func TestServerCloseConcurrent(t *testing.T) { + s := newTestServerCat(t) + + const n = 16 + var wg sync.WaitGroup + errs := make([]error, n) + wg.Add(n) + for i := 0; i < n; i++ { + go func(idx int) { + defer wg.Done() + errs[idx] = s.Close() + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Errorf("concurrent Close() [%d] = %v, want nil", i, err) + } + } +} diff --git a/internal/mcp/ws.go b/internal/mcp/ws.go index 485a6326..6d6b5aac 100644 --- a/internal/mcp/ws.go +++ b/internal/mcp/ws.go @@ -479,22 +479,10 @@ func (s *WSServer) CallTool(ctx context.Context, name string, args map[string]in if err != nil { return "", err } - var resp struct { - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"content"` - } - if err := json.Unmarshal(result, &resp); err != nil { - return string(result), nil - } - var texts []string - for _, c := range resp.Content { - if c.Text != "" { - texts = append(texts, c.Text) - } - } - return strings.Join(texts, "\n"), nil + // Reuse the shared decoder so the WebSocket transport honors the spec's + // isError flag identically to the stdio transport: a remote tool failure + // must surface as a Go error, not a successful result. + return parseToolCallResult(result) } // Close sends a WebSocket close frame and tears down the connection. diff --git a/internal/multiagent/worker.go b/internal/multiagent/worker.go index d954dda7..66d4e5ec 100644 --- a/internal/multiagent/worker.go +++ b/internal/multiagent/worker.go @@ -21,11 +21,11 @@ func EngineWorker(provider, model, systemPrompt string) WorkerFunc { } // Create worktree for isolation - wtPath, err := createWorktree(cfg.RepoDir, cfg.BaseBranch, feature.Branch) + wtPath, err := createWorktree(ctx, cfg.RepoDir, cfg.BaseBranch, feature.Branch) if err != nil { return nil, fmt.Errorf("worktree: %w", err) } - defer removeWorktree(cfg.RepoDir, wtPath) + defer removeWorktree(ctx, cfg.RepoDir, wtPath) // Build the worker prompt workerPrompt := fmt.Sprintf( @@ -76,9 +76,9 @@ func EngineWorker(provider, model, systemPrompt string) WorkerFunc { } // Check for commit - commitID := getLastCommit(wtPath) - filesChanged := getChangedFiles(wtPath, cfg.BaseBranch) - testsPassed := runTests(wtPath) + commitID := getLastCommit(ctx, wtPath) + filesChanged := getChangedFiles(ctx, wtPath, cfg.BaseBranch) + testsPassed := runTests(ctx, wtPath) return &Handoff{ CommitID: commitID, @@ -132,11 +132,11 @@ func ReadOnlyValidationWorker(provider, model, systemPrompt string) WorkerFunc { model = cfg.WorkerModel } - wtPath, err := createWorktree(cfg.RepoDir, cfg.BaseBranch, feature.Branch) + wtPath, err := createWorktree(ctx, cfg.RepoDir, cfg.BaseBranch, feature.Branch) if err != nil { return nil, fmt.Errorf("worktree: %w", err) } - defer removeWorktree(cfg.RepoDir, wtPath) + defer removeWorktree(ctx, cfg.RepoDir, wtPath) validationPrompt := fmt.Sprintf( "You are validating the implementation of feature: %s\n\nDescription: %s\n\n"+ @@ -190,15 +190,15 @@ func ReadOnlyValidationWorker(provider, model, systemPrompt string) WorkerFunc { } } -func createWorktree(repoDir, baseBranch, branch string) (string, error) { - dir, err := exec.CommandContext(context.Background(), "mktemp", "-d").Output() +func createWorktree(ctx context.Context, repoDir, baseBranch, branch string) (string, error) { + dir, err := exec.CommandContext(ctx, "mktemp", "-d").Output() if err != nil { return "", err } wtPath := strings.TrimSpace(string(dir)) // #nosec G204 -- binary is the fixed string "git"; branch/wtPath/baseBranch come from internal mission state, not raw external input - cmd := exec.CommandContext(context.Background(), "git", "worktree", "add", "-b", branch, wtPath, baseBranch) + cmd := exec.CommandContext(ctx, "git", "worktree", "add", "-b", branch, wtPath, baseBranch) cmd.Dir = repoDir if out, err := cmd.CombinedOutput(); err != nil { return "", fmt.Errorf("%s: %w", strings.TrimSpace(string(out)), err) @@ -206,16 +206,16 @@ func createWorktree(repoDir, baseBranch, branch string) (string, error) { return wtPath, nil } -func removeWorktree(repoDir, wtPath string) { - cmd := exec.CommandContext(context.Background(), "git", "worktree", "remove", "--force", wtPath) +func removeWorktree(ctx context.Context, repoDir, wtPath string) { + cmd := exec.CommandContext(ctx, "git", "worktree", "remove", "--force", wtPath) cmd.Dir = repoDir if err := cmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to remove worktree %s: %v\n", wtPath, err) } } -func getLastCommit(dir string) string { - cmd := exec.CommandContext(context.Background(), "git", "rev-parse", "HEAD") +func getLastCommit(ctx context.Context, dir string) string { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD") cmd.Dir = dir out, err := cmd.Output() if err != nil { @@ -224,9 +224,9 @@ func getLastCommit(dir string) string { return strings.TrimSpace(string(out)) } -func getChangedFiles(dir, baseBranch string) []string { +func getChangedFiles(ctx context.Context, dir, baseBranch string) []string { // #nosec G204 -- binary is the fixed string "git"; baseBranch comes from internal Config, not raw external input - cmd := exec.CommandContext(context.Background(), "git", "diff", "--name-only", baseBranch+"..HEAD") + cmd := exec.CommandContext(ctx, "git", "diff", "--name-only", baseBranch+"..HEAD") cmd.Dir = dir out, err := cmd.Output() if err != nil { @@ -239,8 +239,8 @@ func getChangedFiles(dir, baseBranch string) []string { return lines } -func runTests(dir string) bool { - cmd := exec.CommandContext(context.Background(), "go", "test", "./...", "-timeout", "60s") +func runTests(ctx context.Context, dir string) bool { + cmd := exec.CommandContext(ctx, "go", "test", "./...", "-timeout", "60s") cmd.Dir = dir return cmd.Run() == nil } diff --git a/internal/provider/gateway/engine_client.go b/internal/provider/gateway/engine_client.go new file mode 100644 index 00000000..917d3b24 --- /dev/null +++ b/internal/provider/gateway/engine_client.go @@ -0,0 +1,510 @@ +// Package gateway is Hawk's single boundary to Eyrie's provider runtime. It is +// the only package that imports Eyrie; everything else speaks the hawk-owned +// Provider interface and the internal/types DTOs. +// +// hawk = product face (UX/agent/sessions) · eyrie = provider engine +// One-way dependency only: eyrie never imports hawk. See README ecosystems. +package gateway + +import ( + "context" + "fmt" + "log/slog" + + eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk-core-contracts/llm" + "github.com/GrayCodeAI/hawk/internal/types" +) + +// Provider is Hawk's hawk-owned view of the Eyrie engine: a composition of the +// role interfaces below. It wraps the concrete *eyrieengine.Engine (whose fields +// are unexported and therefore not directly mockable) so Hawk tests can inject a +// stub. Splitting into roles lets callers and stubs depend only on the facet they +// use (e.g. the ChatClient path needs only Generator); Provider stays the full +// surface so nothing that depends on it breaks. +type Provider interface { + Generator + NativeCompactor + ModelCatalog + CredentialManager + SelectionManager + GatewayInspector + CatalogMaintenance +} + +// Generator is the chat transport facet: the only part the ChatClient path uses. +type Generator interface { + Generate(ctx context.Context, req eyrieengine.GenerateRequest) (*eyrieengine.GenerateResponse, error) + Stream(ctx context.Context, req eyrieengine.GenerateRequest) (*eyrieengine.Stream, error) +} + +// NativeCompactor is the provider-native-compaction facet. +type NativeCompactor interface { + SupportsNativeCompaction(ctx context.Context, provider, model string) bool + CompactNative(ctx context.Context, req eyrieengine.NativeCompactionRequest) (string, error) +} + +// ModelCatalog is the model-discovery facet (used by routing + config). +type ModelCatalog interface { + ListModels(ctx context.Context, providerID string, refresh bool) ([]eyrieengine.Model, error) + ListLiveModels(ctx context.Context, providerID string) ([]eyrieengine.Model, error) + ListPublicModels(ctx context.Context, providerID string) ([]eyrieengine.Model, error) + ModelInfo(ctx context.Context, modelID string) (eyrieengine.Model, bool, error) + ModelProviders(ctx context.Context) ([]string, error) + DefaultModel(ctx context.Context, provider, fallback string) string + PreferredModel(ctx context.Context, provider string, class eyrieengine.ModelClass, fallback string) string + PreferredModels(ctx context.Context, primaryProvider string, class eyrieengine.ModelClass, limit int) []string + ModelClassOf(ctx context.Context, modelID string) eyrieengine.ModelClass + ProviderForModel(ctx context.Context, modelID string) string + PrimaryModel(ctx context.Context) string + ModelNames(ctx context.Context) []string + Catalog(ctx context.Context) (eyrieengine.CatalogSnapshot, error) +} + +// CredentialManager is the key/credential facet (config only). +type CredentialManager interface { + SaveCredential(ctx context.Context, providerID, secret string) (eyrieengine.CredentialStatus, error) + RemoveCredential(ctx context.Context, providerID string) error + CredentialStatus(ctx context.Context, providerID string) (eyrieengine.CredentialStatus, error) + SaveCredentialEnv(ctx context.Context, envVar, secret string) error + HasCredentialEnv(ctx context.Context, envVar string) bool + CredentialEnvKeys(providerID string) []string + ResolveCredential(ctx context.Context, secret string) eyrieengine.CredentialResolution + CredentialProviders(context.Context) []eyrieengine.CredentialProvider + ApplyCredentials(ctx context.Context, providerID string) (eyrieengine.CatalogSnapshot, error) +} + +// SelectionManager is the get/set selection facet (config only). +type SelectionManager interface { + ActiveSelection(ctx context.Context) eyrieengine.Route + EffectiveSelection(ctx context.Context, opts eyrieengine.SelectionOptions) eyrieengine.Selection + SetActiveProvider(ctx context.Context, provider string) error + SetActiveModel(ctx context.Context, modelID string) error + SetSelection(ctx context.Context, provider, modelID string) error + ClearSelection(ctx context.Context) error +} + +// GatewayInspector is the gateway/deployment/catalog-state facet (config only). +type GatewayInspector interface { + GatewayDefinitions() []eyrieengine.Gateway + Gateways(ctx context.Context) []eyrieengine.Gateway + GatewayRegion(providerID string) (label string, required bool) + SetGatewayRegion(ctx context.Context, providerID, value string) error + GatewayForModel(ctx context.Context, modelID string) string + CanonicalModel(ctx context.Context, modelID string) string + DeploymentRoutingEnabled(override *bool) bool + DeploymentStatus(ctx context.Context, activeModel string) (string, error) + DeploymentSummary(ctx context.Context, activeModel string) (eyrieengine.DeploymentSummary, error) + RoutingPreview(ctx context.Context, modelID string) (string, error) +} + +// CatalogMaintenance is the refresh/preflight/security facet (config only). +type CatalogMaintenance interface { + RefreshCatalog(ctx context.Context, providerID string) (eyrieengine.CatalogSnapshot, error) + CatalogHealth(ctx context.Context) eyrieengine.CatalogHealth + StatePaths() eyrieengine.StatePaths + DefaultProviderFilter(ctx context.Context) string + PreflightWithOptions(ctx context.Context, opts eyrieengine.PreflightOptions) eyrieengine.PreflightReport + ProviderStateSecurityStatus() eyrieengine.ProviderStateSecurity + MigrateProviderSecrets() error +} + +// engineProvider is the production Provider: a thin wrapper over Eyrie's +// concrete engine facade. +type engineProvider struct { + eng *eyrieengine.Engine +} + +func newEngineProvider(eng *eyrieengine.Engine) *engineProvider { + return &engineProvider{eng: eng} +} + +func (p *engineProvider) Generate(ctx context.Context, req eyrieengine.GenerateRequest) (*eyrieengine.GenerateResponse, error) { + return p.eng.Generate(ctx, req) +} +func (p *engineProvider) Stream(ctx context.Context, req eyrieengine.GenerateRequest) (*eyrieengine.Stream, error) { + return p.eng.Stream(ctx, req) +} +func (p *engineProvider) ListModels(ctx context.Context, providerID string, refresh bool) ([]eyrieengine.Model, error) { + return p.eng.ListModels(ctx, providerID, refresh) +} +func (p *engineProvider) ListLiveModels(ctx context.Context, providerID string) ([]eyrieengine.Model, error) { + return p.eng.ListLiveModels(ctx, providerID) +} +func (p *engineProvider) ListPublicModels(ctx context.Context, providerID string) ([]eyrieengine.Model, error) { + return p.eng.ListPublicModels(ctx, providerID) +} +func (p *engineProvider) ModelInfo(ctx context.Context, modelID string) (eyrieengine.Model, bool, error) { + return p.eng.ModelInfo(ctx, modelID) +} +func (p *engineProvider) ModelProviders(ctx context.Context) ([]string, error) { + return p.eng.ModelProviders(ctx) +} +func (p *engineProvider) DefaultModel(ctx context.Context, provider, fallback string) string { + return p.eng.DefaultModel(ctx, provider, fallback) +} +func (p *engineProvider) PreferredModel(ctx context.Context, provider string, class eyrieengine.ModelClass, fallback string) string { + return p.eng.PreferredModel(ctx, provider, class, fallback) +} +func (p *engineProvider) PreferredModels(ctx context.Context, primaryProvider string, class eyrieengine.ModelClass, limit int) []string { + return p.eng.PreferredModels(ctx, primaryProvider, class, limit) +} +func (p *engineProvider) ModelClassOf(ctx context.Context, modelID string) eyrieengine.ModelClass { + return p.eng.ModelClassOf(ctx, modelID) +} +func (p *engineProvider) ProviderForModel(ctx context.Context, modelID string) string { + return p.eng.ProviderForModel(ctx, modelID) +} +func (p *engineProvider) PrimaryModel(ctx context.Context) string { + return p.eng.PrimaryModel(ctx) +} +func (p *engineProvider) ModelNames(ctx context.Context) []string { + return p.eng.ModelNames(ctx) +} +func (p *engineProvider) StatePaths() eyrieengine.StatePaths { + return p.eng.StatePaths() +} +func (p *engineProvider) DefaultProviderFilter(ctx context.Context) string { + return p.eng.DefaultProviderFilter(ctx) +} +func (p *engineProvider) Catalog(ctx context.Context) (eyrieengine.CatalogSnapshot, error) { + return p.eng.Catalog(ctx) +} +func (p *engineProvider) RefreshCatalog(ctx context.Context, providerID string) (eyrieengine.CatalogSnapshot, error) { + return p.eng.RefreshCatalog(ctx, providerID) +} +func (p *engineProvider) ApplyCredentials(ctx context.Context, providerID string) (eyrieengine.CatalogSnapshot, error) { + return p.eng.ApplyCredentials(ctx, providerID) +} +func (p *engineProvider) SaveCredential(ctx context.Context, providerID, secret string) (eyrieengine.CredentialStatus, error) { + return p.eng.SaveCredential(ctx, providerID, secret) +} +func (p *engineProvider) RemoveCredential(ctx context.Context, providerID string) error { + return p.eng.RemoveCredential(ctx, providerID) +} +func (p *engineProvider) CredentialStatus(ctx context.Context, providerID string) (eyrieengine.CredentialStatus, error) { + return p.eng.CredentialStatus(ctx, providerID) +} +func (p *engineProvider) SaveCredentialEnv(ctx context.Context, envVar, secret string) error { + return p.eng.SaveCredentialEnv(ctx, envVar, secret) +} +func (p *engineProvider) HasCredentialEnv(ctx context.Context, envVar string) bool { + return p.eng.HasCredentialEnv(ctx, envVar) +} +func (p *engineProvider) CredentialEnvKeys(providerID string) []string { + return p.eng.CredentialEnvKeys(providerID) +} +func (p *engineProvider) ResolveCredential(ctx context.Context, secret string) eyrieengine.CredentialResolution { + return p.eng.ResolveCredential(ctx, secret) +} +func (p *engineProvider) CredentialProviders(ctx context.Context) []eyrieengine.CredentialProvider { + return p.eng.CredentialProviders(ctx) +} +func (p *engineProvider) GatewayDefinitions() []eyrieengine.Gateway { + return p.eng.GatewayDefinitions() +} +func (p *engineProvider) Gateways(ctx context.Context) []eyrieengine.Gateway { + return p.eng.Gateways(ctx) +} +func (p *engineProvider) GatewayRegion(providerID string) (string, bool) { + return p.eng.GatewayRegion(providerID) +} +func (p *engineProvider) SetGatewayRegion(ctx context.Context, providerID, value string) error { + return p.eng.SetGatewayRegion(ctx, providerID, value) +} +func (p *engineProvider) GatewayForModel(ctx context.Context, modelID string) string { + return p.eng.GatewayForModel(ctx, modelID) +} +func (p *engineProvider) CanonicalModel(ctx context.Context, modelID string) string { + return p.eng.CanonicalModel(ctx, modelID) +} +func (p *engineProvider) DeploymentRoutingEnabled(override *bool) bool { + return p.eng.DeploymentRoutingEnabled(override) +} +func (p *engineProvider) DeploymentStatus(ctx context.Context, activeModel string) (string, error) { + return p.eng.DeploymentStatus(ctx, activeModel) +} +func (p *engineProvider) DeploymentSummary(ctx context.Context, activeModel string) (eyrieengine.DeploymentSummary, error) { + return p.eng.DeploymentSummary(ctx, activeModel) +} +func (p *engineProvider) RoutingPreview(ctx context.Context, modelID string) (string, error) { + return p.eng.RoutingPreview(ctx, modelID) +} +func (p *engineProvider) CatalogHealth(ctx context.Context) eyrieengine.CatalogHealth { + return p.eng.CatalogHealth(ctx) +} +func (p *engineProvider) PreflightWithOptions(ctx context.Context, opts eyrieengine.PreflightOptions) eyrieengine.PreflightReport { + return p.eng.PreflightWithOptions(ctx, opts) +} +func (p *engineProvider) ActiveSelection(ctx context.Context) eyrieengine.Route { + return p.eng.ActiveSelection(ctx) +} +func (p *engineProvider) EffectiveSelection(ctx context.Context, opts eyrieengine.SelectionOptions) eyrieengine.Selection { + return p.eng.EffectiveSelection(ctx, opts) +} +func (p *engineProvider) SetActiveProvider(ctx context.Context, provider string) error { + return p.eng.SetActiveProvider(ctx, provider) +} +func (p *engineProvider) SetActiveModel(ctx context.Context, modelID string) error { + return p.eng.SetActiveModel(ctx, modelID) +} +func (p *engineProvider) SetSelection(ctx context.Context, provider, modelID string) error { + return p.eng.SetSelection(ctx, provider, modelID) +} +func (p *engineProvider) ClearSelection(ctx context.Context) error { + return p.eng.ClearSelection(ctx) +} +func (p *engineProvider) ProviderStateSecurityStatus() eyrieengine.ProviderStateSecurity { + return p.eng.ProviderStateSecurityStatus() +} +func (p *engineProvider) MigrateProviderSecrets() error { + return p.eng.MigrateProviderSecrets() +} +func (p *engineProvider) SupportsNativeCompaction(ctx context.Context, provider, model string) bool { + return p.eng.SupportsNativeCompaction(ctx, provider, model) +} +func (p *engineProvider) CompactNative(ctx context.Context, req eyrieengine.NativeCompactionRequest) (string, error) { + return p.eng.CompactNative(ctx, req) +} + +// translateProvider bridges the hawk-owned ChatClient port to the Generator and +// NativeCompactor roles. It needs no other Provider facet. The type conversions +// here (internal/types <-> eyrieengine.*) are the single, centralized +// translation point — Hawk's conversation DTOs never leak past it. +type translateProvider struct { + generator Generator + compactor NativeCompactor +} + +func newChatClientProvider(provider Provider) *translateProvider { + return &translateProvider{generator: provider, compactor: provider} +} + +func (c *translateProvider) Chat(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) { + response, err := c.generator.Generate(ctx, toEngineRequest(messages, opts, types.ContinuationConfig{})) + if err != nil { + return nil, err + } + return fromEngineResponse(response), nil +} + +func (c *translateProvider) StreamChatContinue(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions, continuation types.ContinuationConfig) (*types.StreamResult, error) { + request := toEngineRequest(messages, opts, continuation) + request.Requirements.Streaming = true + stream, err := c.generator.Stream(ctx, request) + if err != nil { + return nil, err + } + events := make(chan types.EyrieStreamEvent, 64) + streamCtx, cancel := context.WithCancel(ctx) + go func() { + defer close(events) + defer func() { _ = stream.Close() }() + for stream.Next() { + event, emit := fromEngineEvent(stream.Event()) + if !emit { + continue + } + select { + case events <- event: + case <-streamCtx.Done(): + return + } + } + if err := stream.Err(); err != nil { + select { + case events <- types.EyrieStreamEvent{Type: "error", Error: err.Error()}: + case <-streamCtx.Done(): + } + } + }() + closeFn := func() { + cancel() + _ = stream.Close() + } + return llm.NewStreamResult(events, "", closeFn), nil +} + +// ManagesResilience tells Hawk not to add provider retry, continuation, or +// protocol-recovery layers around Eyrie's routed transport. +func (c *translateProvider) ManagesResilience() bool { return true } + +// NativeCompaction reports whether the bound compactor supports provider-native +// compaction for a provider/model pair. +func (c *translateProvider) NativeCompaction(ctx context.Context, provider, model string) bool { + if c.compactor == nil { + return false + } + return c.compactor.SupportsNativeCompaction(ctx, provider, model) +} + +// CompactNative performs provider-native compaction through the bound compactor. +func (c *translateProvider) CompactNative(ctx context.Context, req eyrieengine.NativeCompactionRequest) (string, error) { + if c.compactor == nil { + return "", fmt.Errorf("gateway: no provider") + } + return c.compactor.CompactNative(ctx, req) +} + +func toEngineRequest(messages []types.EyrieMessage, opts types.ChatOptions, continuation types.ContinuationConfig) eyrieengine.GenerateRequest { + glmReasoningEnabled := opts.GLMThinkingEnabled != nil && *opts.GLMThinkingEnabled + request := eyrieengine.GenerateRequest{ + Messages: toEngineMessages(messages), + SystemPrompt: opts.System, + Tools: toEngineTools(opts.Tools), + Requirements: eyrieengine.Requirements{ + Streaming: opts.Stream, + Tools: len(opts.Tools) > 0, + Vision: messagesContainVision(messages), + StructuredJSON: opts.ResponseFormat != nil || opts.OutputSchema != "", + Reasoning: opts.ReasoningEffort != "" || opts.ThinkingBudgetTokens > 0 || opts.ThinkingMode != "" || glmReasoningEnabled, + }, + Preference: eyrieengine.Preference{ + PreferredProvider: opts.Provider, + PreferredModelID: opts.Model, + }, + Limits: eyrieengine.Limits{ + MaxOutputTokens: opts.MaxTokens, + MaxContinuations: continuation.MaxContinuations, + MaxTotalOutputTokens: continuation.MaxTotalTokens, + }, + Metadata: eyrieengine.Metadata{UserID: opts.MetadataUserID}, + Temperature: opts.Temperature, + OutputSchema: firstNonEmpty(opts.OutputSchema, responseSchema(opts.ResponseFormat)), + Options: eyrieengine.GenerationOptions{ + EnableCaching: opts.EnableCaching, ReasoningEffort: opts.ReasoningEffort, + ThinkingBudgetTokens: opts.ThinkingBudgetTokens, ThinkingMode: opts.ThinkingMode, + ThinkingDisplay: opts.ThinkingDisplay, GLMThinkingEnabled: opts.GLMThinkingEnabled, + VirtualKeyID: opts.VirtualKeyID, KimiContextCacheID: opts.KimiContextCacheID, + KimiCacheResetTTL: opts.KimiCacheResetTTL, TopP: opts.TopP, TopK: opts.TopK, + StopSequences: append([]string(nil), opts.StopSequences...), ToolChoice: toEngineToolChoice(opts.ToolChoice), + ServiceTier: opts.ServiceTier, OutputEffort: opts.OutputEffort, + PresencePenalty: opts.PresencePenalty, FrequencyPenalty: opts.FrequencyPenalty, + N: opts.N, LogProbs: opts.LogProbs, TopLogProbs: opts.TopLogProbs, Seed: opts.Seed, + Store: opts.Store, Metadata: cloneMetadata(opts.Metadata), Modalities: append([]string(nil), opts.Modalities...), + AudioConfig: opts.AudioConfig, Prediction: opts.Prediction, WebSearchOptions: opts.WebSearchOptions, + }, + } + return request +} + +// ToEngineMessages returns the messages unchanged: hawk, the engine, and the +// client all speak the canonical contract message type, so no per-field +// conversion is needed. It is exposed for the session layer (e.g. native +// compaction), which translates without reaching into the raw engine. +func ToEngineMessages(messages []types.EyrieMessage) []eyrieengine.Message { + return messages +} + +func toEngineMessages(messages []types.EyrieMessage) []eyrieengine.Message { + return messages +} + +func toEngineTools(tools []types.EyrieTool) []eyrieengine.Tool { + return tools +} + +func toEngineToolChoice(choice *types.ToolChoiceOption) *eyrieengine.ToolChoice { + return choice +} + +func fromEngineResponse(response *eyrieengine.GenerateResponse) *types.EyrieResponse { + return response +} + +func fromEngineEvent(event eyrieengine.Event) (types.EyrieStreamEvent, bool) { + out := types.EyrieStreamEvent{ + Content: event.Content, Thinking: event.Thinking, RequestID: event.RequestID, + Usage: event.Usage, StopReason: event.StopReason, TTFTms: event.TTFTms, + } + switch event.Type { + case eyrieengine.EventRouteSelected: + out.Type = "route_selected" + case eyrieengine.EventRouteChanged: + out.Type = "route_changed" + case eyrieengine.EventContentDelta: + out.Type = "content" + case eyrieengine.EventThinkingDelta: + out.Type = "thinking" + case eyrieengine.EventToolCallStart, eyrieengine.EventToolCallDone: + out.Type = "tool_call" + case eyrieengine.EventToolCallDelta: + out.Type = "tool_input_delta" + case eyrieengine.EventUsage: + out.Type = "usage" + case eyrieengine.EventTTFT: + out.Type = "ttft" + out.TTFT = event.TTFTms + case eyrieengine.EventDone: + out.Type = "done" + case eyrieengine.EventContinuation: + out.Type = "continuation" + case eyrieengine.EventWarning: + out.Type, out.Content = "warning", event.Warning + default: + // An unrecognized engine event type means eyrie emits something this + // adapter has not been taught to translate. Forward it verbatim so no + // data is silently dropped, but log it so the mapping gap is visible. + slog.Warn("gateway: forwarding unrecognized engine event type", "type", event.Type) + out.Type = event.Type + } + if event.ToolCall != nil { + out.ToolCall = event.ToolCall + } + if event.Route != nil { + out.Route = event.Route + } + return out, true +} + +func fromEngineRoute(route eyrieengine.Route) *types.ResolvedRoute { + if route.Provider == "" && route.Model == "" && !route.DeploymentRouting { + return nil + } + return &route +} + +func fromEngineUsage(usage *eyrieengine.Usage) *types.EyrieUsage { + return usage +} + +func responseSchema(format *types.ResponseFormat) string { + if format == nil { + return "" + } + return format.Schema +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func cloneMetadata(in map[string]string) map[string]string { + if in == nil { + return nil + } + out := make(map[string]string, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func messagesContainVision(messages []types.EyrieMessage) bool { + for _, message := range messages { + if len(message.Images) > 0 { + return true + } + for _, part := range message.ContentParts { + if part.ImageURL != nil || part.Type == "image_url" { + return true + } + } + } + return false +} diff --git a/internal/engine/eyrie_engine_client_test.go b/internal/provider/gateway/engine_client_test.go similarity index 90% rename from internal/engine/eyrie_engine_client_test.go rename to internal/provider/gateway/engine_client_test.go index c4a7fad7..6e51d8c6 100644 --- a/internal/engine/eyrie_engine_client_test.go +++ b/internal/provider/gateway/engine_client_test.go @@ -1,4 +1,4 @@ -package engine +package gateway import ( "testing" @@ -11,7 +11,11 @@ func TestEngineAdapterPreservesHawkRequestOptions(t *testing.T) { topP := 0.75 thinking := true request := toEngineRequest( - []types.EyrieMessage{{Role: "user", Content: "inspect", Images: []string{"data:image/png;base64,abc"}}}, + []types.EyrieMessage{{ + Role: "user", + Content: "inspect", + ContentParts: []types.ContentPart{{Type: "image_url", ImageURL: &types.ImageURLPart{URL: "data:image/png;base64,abc"}}}, + }}, types.ChatOptions{ Provider: "openrouter", Model: "openrouter/auto", MaxTokens: 2048, Tools: []types.EyrieTool{{Name: "read_file", Description: "read", Parameters: map[string]interface{}{"type": "object"}}}, @@ -37,7 +41,7 @@ func TestEngineAdapterPreservesHawkRequestOptions(t *testing.T) { if request.Metadata.UserID != "hawk-user-1" { t.Fatalf("metadata user ID lost: %+v", request.Metadata) } - if len(request.Messages) != 1 || len(request.Messages[0].ContentParts) != 1 || request.Messages[0].ContentParts[0].URL == "" { + if len(request.Messages) != 1 || len(request.Messages[0].ContentParts) != 1 || request.Messages[0].ContentParts[0].ImageURL == nil || request.Messages[0].ContentParts[0].ImageURL.URL == "" { t.Fatalf("multimodal message lost: %+v", request.Messages) } } @@ -78,7 +82,7 @@ func TestEngineAdapterNormalizesEventsForHawkLoop(t *testing.T) { {in: eyrieengine.Event{Type: eyrieengine.EventThinkingDelta}, wantType: "thinking", emit: true}, {in: eyrieengine.Event{Type: eyrieengine.EventToolCallDone, ToolCall: &eyrieengine.ToolCall{Name: "read"}}, wantType: "tool_call", emit: true}, {in: eyrieengine.Event{Type: eyrieengine.EventUsage, Usage: &eyrieengine.Usage{TotalTokens: 8}}, wantType: "usage", emit: true}, - {in: eyrieengine.Event{Type: eyrieengine.EventDone, StopReason: "end_turn", Usage: &eyrieengine.Usage{InputTokens: 5, OutputTokens: 3, TotalTokens: 8}}, wantType: "done", emit: true}, + {in: eyrieengine.Event{Type: eyrieengine.EventDone, StopReason: "end_turn", Usage: &eyrieengine.Usage{PromptTokens: 5, CompletionTokens: 3, TotalTokens: 8}}, wantType: "done", emit: true}, } for _, tt := range tests { got, emit := fromEngineEvent(tt.in) @@ -97,7 +101,7 @@ func TestEngineAdapterNormalizesEventsForHawkLoop(t *testing.T) { func TestEngineAdapterPreservesResolvedRouteInBlockingResponse(t *testing.T) { got := fromEngineResponse(&eyrieengine.GenerateResponse{ Content: "ok", - Route: eyrieengine.Route{ + Route: &eyrieengine.Route{ Provider: "openai", Model: "openai/gpt-5", DeploymentRouting: true, }, }) diff --git a/internal/provider/gateway/gateway.go b/internal/provider/gateway/gateway.go new file mode 100644 index 00000000..a7f7a51e --- /dev/null +++ b/internal/provider/gateway/gateway.go @@ -0,0 +1,413 @@ +// Package gateway is Hawk's single boundary to Eyrie's provider runtime. It is +// the only package that imports Eyrie; everything else speaks the hawk-owned +// Provider interface and the internal/types DTOs. +// +// hawk = product face (UX/agent/sessions) · eyrie = provider engine +// One-way dependency only: eyrie never imports hawk. See README ecosystems. +package gateway + +import ( + "context" + "sync" + + "github.com/GrayCodeAI/eyrie/credentials" + eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk-core-contracts/llm" +) + +// Gateway is Hawk's single boundary to the Eyrie provider runtime. It embeds +// Provider so every engine method is forwarded, and it is the only type that +// constructs one (via New). All other Hawk packages hold a *Gateway or speak +// the Provider interface — never an *eyrieengine.Engine. +// +// Construction is centralized here: New is the only call to eyrieengine.New and +// the place Hawk declares its identity to the credential store. +// Gateway is Hawk's single boundary to the Eyrie provider runtime. It embeds the +// Provider roles so every engine method is forwarded, and it is the only type +// that constructs one (via New). All other Hawk packages hold a *Gateway or speak +// the Provider interface — never an *eyrieengine.Engine. *Gateway satisfies the +// composite Provider interface. +type Gateway struct { + Generator + NativeCompactor + ModelCatalog + CredentialManager + SelectionManager + GatewayInspector + CatalogMaintenance +} + +// declareHawkIdentity sets Eyrie's OS keychain service name to "hawk" so existing +// credentials (filed under "hawk") stay readable under Eyrie's now host-neutral +// default. It is idempotent and runs exactly once. Called from New so the +// identity is always declared before any credential read, no matter which New +// path runs first. +var declareHawkIdentity = sync.OnceFunc(func() { + credentials.SetServiceName("hawk") +}) + +// New composes the Eyrie engine for one effective settings snapshot and wraps it +// as a Provider. It is the single composition root — every eyrieengine.New call +// in Hawk flows through here. +func New(ctx context.Context, providers []CustomProviderConfig) (*Gateway, error) { + // Declare hawk's identity to the credential store FIRST, before + // constructing the engine, so no credential read ever happens under + // Eyrie's host-neutral default service name. The OnceFunc makes this + // safe to call from every construction path. + declareHawkIdentity() + + gateways := customGatewaysFromSettings(providers) + eng, err := eyrieengine.New(eyrieengine.Options{CustomGateways: gateways}) + if err != nil { + return nil, err + } + p := newEngineProvider(eng) + return &Gateway{ + Generator: p, + NativeCompactor: p, + ModelCatalog: p, + CredentialManager: p, + SelectionManager: p, + GatewayInspector: p, + CatalogMaintenance: p, + }, nil +} + +// BuildCustomGateways maps Hawk's OpenAI-compatible provider config onto +// Eyrie's CustomGateway spec. Shared by gateway.New, config.eyrie_engine, and +// engine.session_factory so a new CustomProviderConfig field only needs wiring +// in one place. +func BuildCustomGateways(providers []CustomProviderConfig) []eyrieengine.CustomGateway { + gateways := make([]eyrieengine.CustomGateway, 0, len(providers)) + for _, provider := range providers { + if provider.Name == "" && provider.BaseURL == "" { + continue + } + gateways = append(gateways, eyrieengine.CustomGateway{ + ID: provider.Name, BaseURL: provider.BaseURL, + CredentialEnv: provider.APIKeyEnv, DefaultModel: provider.Model, + }) + } + return gateways +} + +// customGatewaysFromSettings is the internal alias kept for backward compat. +func customGatewaysFromSettings(providers []CustomProviderConfig) []eyrieengine.CustomGateway { + return BuildCustomGateways(providers) +} + +// CustomProviderConfig is Hawk's spec for a user-defined OpenAI-compatible +// provider. Kept here (rather than reusing config.CustomProviderConfig) so the +// gateway package does not import config and create an import cycle. +type CustomProviderConfig struct { + Name string + BaseURL string + APIKeyEnv string + Model string +} + +// ModelInfo is Hawk's product-facing view of Eyrie model metadata. +type ModelInfo struct { + Name string `json:"name"` + Provider string `json:"provider"` + ContextSize int `json:"context_size"` + InputPrice float64 `json:"input_price_per_million"` + OutputPrice float64 `json:"output_price_per_million"` + Description string `json:"description,omitempty"` + Recommended bool `json:"recommended,omitempty"` +} + +func fromEngineModel(model eyrieengine.Model) ModelInfo { + return ModelInfo{ + Name: model.ID, Provider: model.ProviderID, + ContextSize: model.ContextWindow, + InputPrice: model.InputPricePer1M, OutputPrice: model.OutputPricePer1M, + Description: model.Description, + } +} + +// ChatClient returns a hawk ChatClient bound to this gateway's Provider. +func (g *Gateway) ChatClient() *translateProvider { + return newChatClientProvider(g) +} + +// MustSelectProvider returns the Provider, or nil if the gateway is unset. +func (g *Gateway) MustSelectProvider() Provider { + if g == nil { + return nil + } + return g +} + +// NewFromEngine wraps an existing *eyrieengine.Engine as a Gateway. Tests that +// inject an Eyrie SecretStore (e.g. compaction-support detection) use it so the +// rest of Hawk still speaks the Gateway boundary. +func NewFromEngine(eng *eyrieengine.Engine) *Gateway { + if eng == nil { + return nil + } + p := newEngineProvider(eng) + return &Gateway{ + Generator: p, + NativeCompactor: p, + ModelCatalog: p, + CredentialManager: p, + SelectionManager: p, + GatewayInspector: p, + CatalogMaintenance: p, + } +} + +// --- Stateless package-level lookups ------------------------------------- +// These delegate Eyrie reads to one shared default gateway so hawk-owned +// policy packages (routing, config) never import Eyrie themselves. eyrie's +// Engine reloads its catalog and provider config from disk on every method +// call, so a single long-lived gateway returns identical freshness to +// constructing one per call — this just avoids redundant construction. It +// mirrors the sync.Once singleton the old routing code used. +// +// The ctx parameter is retained (though unused here) to keep this helper's +// signature stable across its 11 call sites; ctx still flows through to every +// data call via the helpers below. + +var ( + defaultGatewayOnce sync.Once + defaultGatewayVal *Gateway +) + +func defaultGateway(ctx context.Context) *Gateway { + defaultGatewayOnce.Do(func() { + defaultGatewayVal, _ = New(context.Background(), nil) + }) + return defaultGatewayVal +} + +// ModelInfoLookup returns a model by id or alias, or false if unknown. +func ModelInfoLookup(ctx context.Context, name string) (ModelInfo, bool) { + g := defaultGateway(ctx) + if g == nil { + return ModelInfo{}, false + } + model, ok, err := g.ModelInfo(ctx, name) + if err != nil || !ok { + return ModelInfo{}, false + } + return fromEngineModel(model), true +} + +// ModelsByProvider returns every model served by a provider/gateway. +func ModelsByProvider(ctx context.Context, provider string) ([]ModelInfo, error) { + g := defaultGateway(ctx) + if g == nil { + return nil, nil + } + models, err := g.ListModels(ctx, provider, false) + if err != nil { + return nil, err + } + out := make([]ModelInfo, 0, len(models)) + for _, model := range models { + out = append(out, fromEngineModel(model)) + } + return out, nil +} + +// RecommendedModel returns the catalog default for a provider, flagged as +// recommended. +func RecommendedModel(ctx context.Context, provider string) (ModelInfo, bool) { + g := defaultGateway(ctx) + if g == nil { + return ModelInfo{}, false + } + name := g.DefaultModel(ctx, provider, "") + if name == "" { + return ModelInfo{}, false + } + info, ok := ModelInfoLookup(ctx, name) + if ok { + info.Recommended = true + } + return info, ok +} + +// DefaultModel returns the catalog default model name for a provider. +func DefaultModel(ctx context.Context, provider string) string { + g := defaultGateway(ctx) + if g == nil { + return "" + } + return g.DefaultModel(ctx, provider, "") +} + +// AllProviders returns the distinct set of providers/gateways in the catalog. +func AllProviders(ctx context.Context) ([]string, error) { + g := defaultGateway(ctx) + if g == nil { + return nil, nil + } + return g.ModelProviders(ctx) +} + +// ProviderForModel resolves which provider owns a model name. +func ProviderForModel(ctx context.Context, modelName string) string { + g := defaultGateway(ctx) + if g == nil { + return "" + } + return g.ProviderForModel(ctx, modelName) +} + +// PreferredModel returns Eyrie's tier-preferred model for a provider. +func PreferredModel(ctx context.Context, provider string, class ModelClass, fallback string) string { + g := defaultGateway(ctx) + if g == nil { + return fallback + } + return g.PreferredModel(ctx, provider, class, fallback) +} + +// PreferredModels returns up to limit tier-preferred models for a provider. +func PreferredModels(ctx context.Context, primaryProvider string, class ModelClass, limit int) []string { + g := defaultGateway(ctx) + if g == nil { + return nil + } + return g.PreferredModels(ctx, primaryProvider, class, limit) +} + +// ModelClassOf returns the cost tier of a model. +func ModelClassOf(ctx context.Context, modelID string) ModelClass { + g := defaultGateway(ctx) + if g == nil { + return ModelClassBalanced + } + return g.ModelClassOf(ctx, modelID) +} + +// PrimaryModel returns the catalog-wide primary model. +func PrimaryModel(ctx context.Context) string { + g := defaultGateway(ctx) + if g == nil { + return "" + } + return g.PrimaryModel(ctx) +} + +// ModelNames returns all model names known to the catalog. +func ModelNames(ctx context.Context) []string { + g := defaultGateway(ctx) + if g == nil { + return nil + } + return g.ModelNames(ctx) +} + +// --- hawk-owned mirror of Eyrie's ModelClass tier enum ------------------- +// Kept here (rather than importing neutral constants) so the boundary stays +// one-way; values match eyrieengine.ModelClass. +type ModelClass = eyrieengine.ModelClass + +const ( + ModelClassEconomical = llm.ModelClassEconomical + ModelClassBalanced = llm.ModelClassBalanced + ModelClassPremium = llm.ModelClassPremium + CheckFail = eyrieengine.CheckFail +) + +// NormalizeProviderID canonicalizes a host-facing provider/gateway id. +func NormalizeProviderID(id string) string { + return eyrieengine.NormalizeProviderID(id) +} + +// --- Eyrie report/type re-exports config internals consume ---------------- +// These alias Eyrie types that a few config-only report paths return. They +// live in gateway (the single Eyrie importer) rather than config. + +type ( + PreflightReport = eyrieengine.PreflightReport + PreflightOptions = eyrieengine.PreflightOptions + ProviderStateSecurity = eyrieengine.ProviderStateSecurity + DeploymentSummary = eyrieengine.DeploymentSummary + CredentialStorageReport = eyrieengine.CredentialStorageReport + CredentialStatus = eyrieengine.CredentialStatus + CredentialResolution = eyrieengine.CredentialResolution + CredentialProvider = eyrieengine.CredentialProvider + GatewayDefs = eyrieengine.Gateway + CatalogSnapshot = eyrieengine.CatalogSnapshot + Model = eyrieengine.Model + StatePaths = eyrieengine.StatePaths + SelectionOptions = eyrieengine.SelectionOptions + Selection = eyrieengine.Selection + NativeCompactionRequest = eyrieengine.NativeCompactionRequest +) + +// Package-level Eyrie helpers that config delegates to (gateway stays the only importer). + +func PreflightReportWithOptions(ctx context.Context, opts PreflightOptions) PreflightReport { + return PreflightWithProviders(ctx, nil, opts) +} + +// PreflightWithProviders runs preflight against a gateway built from an +// explicit provider list (a settings snapshot's custom gateways), so concurrent +// commands can isolate custom-provider state. +func PreflightWithProviders(ctx context.Context, providers []CustomProviderConfig, opts PreflightOptions) PreflightReport { + g, err := New(ctx, providers) + if err != nil { + return PreflightReport{} + } + return g.PreflightWithOptions(ctx, opts) +} + +func FormatPreflight(report PreflightReport) string { + return eyrieengine.FormatPreflight(report) +} + +func IsCatalogCacheRequired(err error) bool { + return eyrieengine.IsCatalogCacheRequired(err) +} + +func SecretStoreName() string { return eyrieengine.SecretStoreName() } + +func CredentialStorage(ctx context.Context) CredentialStorageReport { + return eyrieengine.CredentialStorage(ctx) +} + +func MigrateLegacyCredentials(ctx context.Context) (int, error) { + return eyrieengine.MigrateLegacyCredentials(ctx) +} + +func CredentialGuidance(providerID, secret string) string { + return eyrieengine.CredentialGuidance(providerID, secret) +} + +func FormatSetupError(providerID string, err error) string { + return eyrieengine.FormatSetupError(providerID, err) +} + +// ParseInlineToolCalls extracts inline tool-call markup from model output. +func ParseInlineToolCalls(content string) (string, []eyrieengine.ToolCall) { + return eyrieengine.ParseInlineToolCalls(content) +} + +// --- Test fixtures ----------------------------------------------------- +// Re-exported so hawk tests inject credential fixtures through the single +// gateway boundary instead of importing eyrie/credentials directly. These are +// thin aliases only; gateway still owns the eyrie relationship. +// +// Keep this block last and the symbols minimal — it exists purely to keep test +// code behind the boundary. + +// SetDefaultStore replaces the process-wide credential store (for tests). +var SetDefaultStore = credentials.SetDefaultStore + +// DefaultStore returns the process-wide credential store (for tests). +var DefaultStore = credentials.DefaultStore + +// MapStore is the in-memory credential store for tests (alias). +type MapStore = credentials.MapStore + +// AccountForEnv returns the keychain account name for an env var. +func AccountForEnv(envVar string) string { return credentials.AccountForEnv(envVar) } + +// HasSecret reports whether a secret exists for an env var (for tests). +func HasSecret(ctx context.Context, envKey string) bool { return credentials.HasSecret(ctx, envKey) } diff --git a/internal/provider/gateway/provider_stub_test.go b/internal/provider/gateway/provider_stub_test.go new file mode 100644 index 00000000..0df0df82 --- /dev/null +++ b/internal/provider/gateway/provider_stub_test.go @@ -0,0 +1,140 @@ +package gateway + +import ( + "context" + "testing" + + eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/types" +) + +// stubProvider proves the Provider interface is swappable: a test can inject a +// deterministic fake and drive the ChatClient adapter without constructing any +// Eyrie engine. This is the swappable-Engine guarantee Phase 3 delivers. +type stubProvider struct { + resp *eyrieengine.GenerateResponse + err error +} + +func (s *stubProvider) Resolve(context.Context, eyrieengine.SelectionRequest) (eyrieengine.Route, error) { + return eyrieengine.Route{}, nil +} +func (s *stubProvider) Generate(context.Context, eyrieengine.GenerateRequest) (*eyrieengine.GenerateResponse, error) { + return s.resp, s.err +} +func (s *stubProvider) Stream(context.Context, eyrieengine.GenerateRequest) (*eyrieengine.Stream, error) { + return nil, nil +} +func (s *stubProvider) ListModels(context.Context, string, bool) ([]eyrieengine.Model, error) { + return nil, nil +} +func (s *stubProvider) ListLiveModels(context.Context, string) ([]eyrieengine.Model, error) { + return nil, nil +} +func (s *stubProvider) ListPublicModels(context.Context, string) ([]eyrieengine.Model, error) { + return nil, nil +} +func (s *stubProvider) ModelInfo(context.Context, string) (eyrieengine.Model, bool, error) { + return eyrieengine.Model{}, false, nil +} +func (s *stubProvider) ModelProviders(context.Context) ([]string, error) { return nil, nil } +func (s *stubProvider) DefaultModel(context.Context, string, string) string { return "" } +func (s *stubProvider) PreferredModel(context.Context, string, eyrieengine.ModelClass, string) string { + return "" +} +func (s *stubProvider) PreferredModels(context.Context, string, eyrieengine.ModelClass, int) []string { + return nil +} +func (s *stubProvider) ModelClassOf(context.Context, string) eyrieengine.ModelClass { + return ModelClassEconomical +} +func (s *stubProvider) ProviderForModel(context.Context, string) string { return "" } +func (s *stubProvider) PrimaryModel(context.Context) string { return "" } +func (s *stubProvider) ModelNames(context.Context) []string { return nil } +func (s *stubProvider) StatePaths() eyrieengine.StatePaths { return eyrieengine.StatePaths{} } +func (s *stubProvider) DefaultProviderFilter(context.Context) string { return "" } +func (s *stubProvider) Catalog(context.Context) (eyrieengine.CatalogSnapshot, error) { + return eyrieengine.CatalogSnapshot{}, nil +} +func (s *stubProvider) RefreshCatalog(context.Context, string) (eyrieengine.CatalogSnapshot, error) { + return eyrieengine.CatalogSnapshot{}, nil +} +func (s *stubProvider) ApplyCredentials(context.Context, string) (eyrieengine.CatalogSnapshot, error) { + return eyrieengine.CatalogSnapshot{}, nil +} +func (s *stubProvider) SaveCredential(context.Context, string, string) (eyrieengine.CredentialStatus, error) { + return eyrieengine.CredentialStatus{}, nil +} +func (s *stubProvider) RemoveCredential(context.Context, string) error { return nil } +func (s *stubProvider) CredentialStatus(context.Context, string) (eyrieengine.CredentialStatus, error) { + return eyrieengine.CredentialStatus{}, nil +} +func (s *stubProvider) SaveCredentialEnv(context.Context, string, string) error { return nil } +func (s *stubProvider) HasCredentialEnv(context.Context, string) bool { return false } +func (s *stubProvider) CredentialEnvKeys(string) []string { return nil } +func (s *stubProvider) ResolveCredential(context.Context, string) eyrieengine.CredentialResolution { + return eyrieengine.CredentialResolution{} +} +func (s *stubProvider) CredentialProviders(context.Context) []eyrieengine.CredentialProvider { + return nil +} +func (s *stubProvider) GatewayDefinitions() []eyrieengine.Gateway { return nil } +func (s *stubProvider) Gateways(context.Context) []eyrieengine.Gateway { return nil } +func (s *stubProvider) GatewayRegion(string) (string, bool) { return "", false } +func (s *stubProvider) SetGatewayRegion(context.Context, string, string) error { return nil } +func (s *stubProvider) GatewayForModel(context.Context, string) string { return "" } +func (s *stubProvider) CanonicalModel(context.Context, string) string { return "" } +func (s *stubProvider) ApplyGatewayEnvironment(context.Context, string) {} +func (s *stubProvider) DeploymentRoutingEnabled(*bool) bool { return false } +func (s *stubProvider) DeploymentStatus(context.Context, string) (string, error) { + return "", nil +} +func (s *stubProvider) DeploymentSummary(context.Context, string) (eyrieengine.DeploymentSummary, error) { + return eyrieengine.DeploymentSummary{}, nil +} +func (s *stubProvider) RoutingPreview(context.Context, string) (string, error) { return "", nil } +func (s *stubProvider) CatalogHealth(context.Context) eyrieengine.CatalogHealth { + return eyrieengine.CatalogHealth{} +} +func (s *stubProvider) Preflight(context.Context) eyrieengine.PreflightReport { + return eyrieengine.PreflightReport{} +} +func (s *stubProvider) PreflightWithOptions(context.Context, eyrieengine.PreflightOptions) eyrieengine.PreflightReport { + return eyrieengine.PreflightReport{} +} +func (s *stubProvider) ActiveSelection(context.Context) eyrieengine.Route { return eyrieengine.Route{} } +func (s *stubProvider) EffectiveSelection(context.Context, eyrieengine.SelectionOptions) eyrieengine.Selection { + return eyrieengine.Selection{} +} +func (s *stubProvider) SetActiveProvider(context.Context, string) error { return nil } +func (s *stubProvider) SetActiveModel(context.Context, string) error { return nil } +func (s *stubProvider) SetSelection(context.Context, string, string) error { return nil } +func (s *stubProvider) ClearSelection(context.Context) error { return nil } +func (s *stubProvider) ProviderStateSecurityStatus() eyrieengine.ProviderStateSecurity { + return eyrieengine.ProviderStateSecurity{} +} +func (s *stubProvider) MigrateProviderSecrets() error { return nil } +func (s *stubProvider) MigrateProviderSecretsContext(context.Context) error { return nil } +func (s *stubProvider) SupportsNativeCompaction(context.Context, string, string) bool { return false } +func (s *stubProvider) CompactNative(context.Context, eyrieengine.NativeCompactionRequest) (string, error) { + return "", nil +} + +func TestStubProviderDrivesChatClient(t *testing.T) { + stub := &stubProvider{resp: &eyrieengine.GenerateResponse{Content: "from stub", FinishReason: "end_turn"}} + gw := &Gateway{Generator: stub} + client := gw.ChatClient() + + got, err := client.Chat(context.Background(), []types.EyrieMessage{{Role: "user", Content: "hi"}}, types.ChatOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Content != "from stub" { + t.Fatalf("stub content not propagated: %+v", got) + } + if !client.ManagesResilience() { + t.Fatal("expected resilience managed flag") + } +} + +var _ Provider = (*stubProvider)(nil) diff --git a/internal/provider/routing/catalog.go b/internal/provider/routing/catalog.go index cdb33958..0612245c 100644 --- a/internal/provider/routing/catalog.go +++ b/internal/provider/routing/catalog.go @@ -1,104 +1,46 @@ // Package routing provides Hawk-owned task routing and health policy. Model // discovery, pricing, provider ownership, and catalog policy are delegated to -// Eyrie's host-neutral engine facade. +// Eyrie's engine facade through the gateway package (the single Eyrie boundary). package routing import ( "context" "sort" - "sync" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) -// ModelInfo is Hawk's product-facing view of Eyrie model metadata. -type ModelInfo struct { - Name string `json:"name"` - Provider string `json:"provider"` - ContextSize int `json:"context_size"` - InputPrice float64 `json:"input_price_per_million"` - OutputPrice float64 `json:"output_price_per_million"` - Description string `json:"description,omitempty"` - Recommended bool `json:"recommended,omitempty"` -} - -var ( - modelEngineOnce sync.Once - modelEngine *eyrieengine.Engine -) - -func eyrieModelEngine() *eyrieengine.Engine { - modelEngineOnce.Do(func() { - modelEngine, _ = eyrieengine.New(eyrieengine.Options{}) - }) - return modelEngine -} - -func fromEngineModel(model eyrieengine.Model) ModelInfo { - return ModelInfo{ - Name: model.ID, Provider: model.ProviderID, - ContextSize: model.ContextWindow, - InputPrice: model.InputPricePer1M, OutputPrice: model.OutputPricePer1M, - Description: model.Description, - } -} +// ModelInfo is Hawk's product-facing view of Eyrie model metadata (the gateway +// layer owns the Eyrie conversation that produces it). +type ModelInfo = gateway.ModelInfo // Find looks up a model by id or alias through Eyrie. func Find(name string) (ModelInfo, bool) { - engine := eyrieModelEngine() - if engine == nil { - return ModelInfo{}, false - } - model, ok, err := engine.ModelInfo(context.Background(), name) - if err != nil || !ok { - return ModelInfo{}, false - } - return fromEngineModel(model), true + return gateway.ModelInfoLookup(context.Background(), name) } // ByProvider returns all models served by a provider/gateway. func ByProvider(provider string) []ModelInfo { - engine := eyrieModelEngine() - if engine == nil { - return nil - } - models, err := engine.ListModels(context.Background(), provider, false) + infos, err := gateway.ModelsByProvider(context.Background(), provider) if err != nil { return nil } - out := make([]ModelInfo, 0, len(models)) - for _, model := range models { - out = append(out, fromEngineModel(model)) - } - return out + return infos } // Recommended returns the default catalog model for a provider. func Recommended(provider string) (ModelInfo, bool) { - name := DefaultModel(provider) - if name == "" { - return ModelInfo{}, false - } - info, ok := Find(name) - if ok { - info.Recommended = true - } - return info, ok + return gateway.RecommendedModel(context.Background(), provider) } +// DefaultModel returns the catalog default model name for a provider. func DefaultModel(provider string) string { - if engine := eyrieModelEngine(); engine != nil { - return engine.DefaultModel(context.Background(), provider, "") - } - return "" + return gateway.DefaultModel(context.Background(), provider) } +// AllProviders returns the distinct set of providers/gateways in the catalog. func AllProviders() []string { - engine := eyrieModelEngine() - if engine == nil { - return nil - } - providers, err := engine.ModelProviders(context.Background()) + providers, err := gateway.AllProviders(context.Background()) if err != nil { return nil } @@ -106,6 +48,11 @@ func AllProviders() []string { return providers } +// ProviderOfModel resolves which provider owns a model name. +func ProviderOfModel(modelName string) string { + return gateway.ProviderForModel(context.Background(), modelName) +} + func canonicalProvider(provider string) string { - return eyrieengine.NormalizeProviderID(provider) + return gateway.NormalizeProviderID(provider) } diff --git a/internal/provider/routing/roles.go b/internal/provider/routing/roles.go index 7ea8b461..c05fcb26 100644 --- a/internal/provider/routing/roles.go +++ b/internal/provider/routing/roles.go @@ -4,7 +4,7 @@ import ( "context" "strings" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) // Role identifies the purpose of a model within a Hawk multi-model workflow. @@ -28,11 +28,10 @@ type ModelRoles struct { // economical same-provider model used for commit/summarization work. func DefaultRoles(primaryModel string) ModelRoles { primaryModel = strings.TrimSpace(primaryModel) + ctx := context.Background() commit := primaryModel - if engine := eyrieModelEngine(); engine != nil { - if provider := engine.ProviderForModel(context.Background(), primaryModel); provider != "" { - commit = engine.PreferredModel(context.Background(), provider, eyrieengine.ModelClassEconomical, primaryModel) - } + if provider := gateway.ProviderForModel(ctx, primaryModel); provider != "" { + commit = gateway.PreferredModel(ctx, provider, gateway.ModelClassEconomical, primaryModel) } return ModelRoles{Planner: primaryModel, Coder: primaryModel, Reviewer: primaryModel, Commit: commit} } @@ -55,15 +54,9 @@ func (r ModelRoles) ModelForRole(role Role) string { if coder := strings.TrimSpace(r.Coder); coder != "" { return coder } - if engine := eyrieModelEngine(); engine != nil { - return engine.PrimaryModel(context.Background()) - } - return "" + return gateway.PrimaryModel(context.Background()) } func CheapestForProvider(provider, fallback string) string { - if engine := eyrieModelEngine(); engine != nil { - return engine.PreferredModel(context.Background(), provider, eyrieengine.ModelClassEconomical, fallback) - } - return fallback + return gateway.PreferredModel(context.Background(), provider, gateway.ModelClassEconomical, fallback) } diff --git a/internal/provider/routing/tiers.go b/internal/provider/routing/tiers.go index 1b9236b3..97f67ef2 100644 --- a/internal/provider/routing/tiers.go +++ b/internal/provider/routing/tiers.go @@ -3,7 +3,7 @@ package routing import ( "context" - eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) type CostTier int @@ -14,22 +14,20 @@ const ( CostTierExpensive ) -type PreferenceTier = eyrieengine.ModelClass +type PreferenceTier = gateway.ModelClass const ( - TierHaiku PreferenceTier = eyrieengine.ModelClassEconomical - TierSonnet PreferenceTier = eyrieengine.ModelClassBalanced - TierOpus PreferenceTier = eyrieengine.ModelClassPremium + TierHaiku PreferenceTier = gateway.ModelClassEconomical + TierSonnet PreferenceTier = gateway.ModelClassBalanced + TierOpus PreferenceTier = gateway.ModelClassPremium ) func CostTierOf(modelName string) CostTier { - if engine := eyrieModelEngine(); engine != nil { - switch engine.ModelClassOf(context.Background(), modelName) { - case eyrieengine.ModelClassEconomical: - return CostTierCheap - case eyrieengine.ModelClassPremium: - return CostTierExpensive - } + switch gateway.ModelClassOf(context.Background(), modelName) { + case gateway.ModelClassEconomical: + return CostTierCheap + case gateway.ModelClassPremium: + return CostTierExpensive } return CostTierMid } @@ -57,10 +55,7 @@ func SuggestTierForTask(taskType string) PreferenceTier { } func AllCatalogModelNames() []string { - if engine := eyrieModelEngine(); engine != nil { - return engine.ModelNames(context.Background()) - } - return nil + return gateway.ModelNames(context.Background()) } func DefaultHealthTiers(primaryProvider string) []ModelTier { @@ -73,19 +68,11 @@ func DefaultHealthTiers(primaryProvider string) []ModelTier { } func tierModelList(primaryProvider string, tier PreferenceTier) []string { - engine := eyrieModelEngine() - if engine == nil { - return nil - } - models := engine.PreferredModels(context.Background(), primaryProvider, tier, 3) - return models + return gateway.PreferredModels(context.Background(), primaryProvider, tier, 3) } func PreferredModelForTier(provider string, tier PreferenceTier, fallback string) string { - if engine := eyrieModelEngine(); engine != nil { - return engine.PreferredModel(context.Background(), provider, tier, fallback) - } - return fallback + return gateway.PreferredModel(context.Background(), provider, tier, fallback) } func MostExpensiveForProvider(provider, fallback string) string { diff --git a/internal/resilience/health/diagnostics_test.go b/internal/resilience/health/diagnostics_test.go index ac603f18..637bff68 100644 --- a/internal/resilience/health/diagnostics_test.go +++ b/internal/resilience/health/diagnostics_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) func TestNewDiagnostics(t *testing.T) { @@ -342,9 +342,9 @@ func TestCheckTempDirWritable(t *testing.T) { } func TestCheckAPIKeySet(t *testing.T) { - store := &credentials.MapStore{} - credentials.SetDefaultStore(store) - t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + store := &gateway.MapStore{} + gateway.SetDefaultStore(store) + t.Cleanup(func() { gateway.SetDefaultStore(nil) }) result := checkAPIKeySet() if result.Status != "fail" { @@ -352,7 +352,7 @@ func TestCheckAPIKeySet(t *testing.T) { } ctx := context.Background() - _ = store.Set(ctx, credentials.AccountForEnv("ANTHROPIC_API_KEY"), "sk-ant-test-key-1234567890") + _ = store.Set(ctx, gateway.AccountForEnv("ANTHROPIC_API_KEY"), "sk-ant-test-key-1234567890") result = checkAPIKeySet() if result.Status != "pass" { t.Errorf("Expected pass when key is in store, got %q: %s", result.Status, result.Message) diff --git a/internal/testaudit/audit_test.go b/internal/testaudit/audit_test.go index 9b847f64..d64b5ead 100644 --- a/internal/testaudit/audit_test.go +++ b/internal/testaudit/audit_test.go @@ -207,6 +207,14 @@ func TestNoDirectLowerEyrieImports(t *testing.T) { strings.HasPrefix(path, "github.com/GrayCodeAI/eyrie/engine/") { continue } + // The gateway package is Hawk's single Eyrie boundary; it may + // import eyrie/credentials to declare Hawk's OS keychain service + // name (the host-neutral default would otherwise orphan existing + // secrets). All other production code must use eyrie/engine only. + if strings.HasPrefix(rel, "internal/provider/gateway/") && + path == "github.com/GrayCodeAI/eyrie/credentials" { + continue + } pos := pf.FSet.Position(imp.Pos()) t.Fatalf("forbidden lower-level Eyrie import %q at %s:%d; use github.com/GrayCodeAI/eyrie/engine", path, rel, pos.Line) } diff --git a/internal/tool/bash.go b/internal/tool/bash.go index e8bc87ce..494cd61b 100644 --- a/internal/tool/bash.go +++ b/internal/tool/bash.go @@ -7,8 +7,10 @@ import ( "fmt" "os" "os/exec" + "os/user" "regexp" "strings" + "syscall" "time" homepkg "github.com/GrayCodeAI/hawk/internal/home" @@ -30,6 +32,11 @@ var dangerousSubstrings = []string{ "> /dev/sd", "> /dev/nv", } +// tildeUserRe matches a ~username reference (e.g. ~root/, ~alice ) so it can be +// expanded to that user's home directory. The current-user forms (~ and ~/) +// are handled separately above. +var tildeUserRe = regexp.MustCompile(`~([a-zA-Z_][a-zA-Z0-9_-]*)(/|\s|$)`) + // normalizeCommand normalizes a command to prevent trivial bypass of // dangerous-command detection. It expands tilde and collapses repeated root globs. func normalizeCommand(cmd string) string { @@ -41,6 +48,20 @@ func normalizeCommand(cmd string) string { cmd = home + cmd[1:] } } + // Expand ~username/ references to the real home directory so a command + // targeting another user's home (e.g. "rm -rf ~root") is normalized to its + // concrete path before dangerous-command detection runs, instead of being + // left as an unexpanded tilde the checks would miss. + cmd = tildeUserRe.ReplaceAllStringFunc(cmd, func(m string) string { + sub := tildeUserRe.FindStringSubmatch(m) + if sub == nil { + return m + } + if u, err := user.Lookup(sub[1]); err == nil && u.HomeDir != "" { + return u.HomeDir + sub[2] + } + return m + }) // Collapse repeated /* sequences: rm -rf /* -> rm -rf / for strings.Contains(cmd, "/*") { cmd = strings.ReplaceAll(cmd, "/*", "/") @@ -631,6 +652,9 @@ func (BashTool) Execute(ctx context.Context, input json.RawMessage) (string, err } cmd := exec.CommandContext(ctx, execName, execArgs...) // #nosec G204 -- command parsed from tool-configured command string (lint/test command) + // Put the child in its own process group so we can kill the whole tree + // (including grandchildren spawned by the shell) via kill(-pgid). + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} if tc := GetToolContext(ctx); tc != nil && tc.WorkingDir != "" { cmd.Dir = tc.WorkingDir } diff --git a/internal/tool/mcp_tool.go b/internal/tool/mcp_tool.go index 85e50894..76944185 100644 --- a/internal/tool/mcp_tool.go +++ b/internal/tool/mcp_tool.go @@ -56,14 +56,44 @@ func (m *MCPTool) Aliases() []string { return m.aliases } func (m *MCPTool) Description() string { return m.description } func (m *MCPTool) Parameters() map[string]interface{} { return m.schema } +// RiskLevel reports MCP tools as high risk. A remote MCP server is untrusted +// third-party code whose declared schema does not reveal whether the tool +// writes files, executes commands, or performs network calls, so it must not +// default to the "medium" bucket that skips the strongest permission prompts. +func (m *MCPTool) RiskLevel() string { return "high" } + func (m *MCPTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { var args map[string]interface{} if err := json.Unmarshal(input, &args); err != nil { return "", err } + if err := m.validateRequired(args); err != nil { + return "", err + } return m.server.CallTool(ctx, m.remoteName, args) } +// validateRequired enforces the tool's declared JSON-Schema "required" list +// before forwarding LLM-supplied arguments to the remote server, so a +// malformed or partial tool call is rejected locally instead of producing an +// opaque remote failure. +func (m *MCPTool) validateRequired(args map[string]interface{}) error { + required, ok := m.schema["required"].([]interface{}) + if !ok { + return nil + } + for _, r := range required { + name, ok := r.(string) + if !ok { + continue + } + if _, present := args[name]; !present { + return fmt.Errorf("mcp tool %s: missing required argument %q", m.toolName, name) + } + } + return nil +} + func normalizeNameForMCP(name string) string { out := make([]rune, 0, len(name)) for _, r := range name { diff --git a/internal/tool/task_tools.go b/internal/tool/task_tools.go index 040242a4..43b57563 100644 --- a/internal/tool/task_tools.go +++ b/internal/tool/task_tools.go @@ -9,6 +9,7 @@ import ( "os/exec" "strings" "sync" + "syscall" "time" "github.com/GrayCodeAI/hawk/internal/taskruntime" @@ -68,7 +69,18 @@ func startBackgroundBash(ctx context.Context, command string) (string, error) { id := fmt.Sprintf("task_%d", backgroundTasks.next) backgroundTasks.Unlock() - cmd := exec.CommandContext(ctx, "bash", "-c", command) + // Background tasks must outlive the request, so use an independent + // context. The request ctx would kill the task when the HTTP/Tool-call + // request times out. + bgCtx, bgCancel := context.WithCancel(context.Background()) + defer bgCancel() + + cmd := exec.CommandContext(bgCtx, "bash", "-c", command) + // Put the child in its own process group so we can kill the whole tree + // (including grandchildren spawned by the shell) via kill(-pgid). Without + // this, e.g. `bash -c 'sleep 60 &'` leaves an orphan when the parent is + // killed. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} stdout, err := cmd.StdoutPipe() if err != nil { return "", err @@ -84,7 +96,7 @@ func startBackgroundBash(ctx context.Context, command string) (string, error) { backgroundTasks.Unlock() // Bridge into unified taskruntime (PACK-06). - shellCtx, shellCancel := context.WithCancel(context.Background()) + shellCtx, shellCancel := context.WithCancel(bgCtx) taskruntime.Default.RegisterExternal(id, taskruntime.KindShell, command, shellCancel) if err := cmd.Start(); err != nil { @@ -98,28 +110,31 @@ func startBackgroundBash(ctx context.Context, command string) (string, error) { captureWg.Add(2) go func() { task.capture(stdout); captureWg.Done() }() go func() { task.capture(stderr); captureWg.Done() }() + + // Single outer goroutine: flatten the nested-goroutine pattern. Honor + // registry kill via shellCancel → kill process group, then wait. go func() { - // Honor registry kill via shellCancel → kill process + // Watch shellCancel (registry kill) in parallel with cmd.Wait. + waitCh := make(chan struct{}) go func() { - <-shellCtx.Done() - _ = task.stop() + _ = cmd.Wait() + close(waitCh) }() - err := cmd.Wait() + select { + case <-shellCtx.Done(): + _ = task.stop() + case <-waitCh: + } captureWg.Wait() task.mu.Lock() - if err != nil { - task.exitText = err.Error() - } else { - task.exitText = "exit status 0" - } status := taskruntime.StatusCompleted errMsg := "" if task.stopped { status = taskruntime.StatusKilled errMsg = "killed" - } else if err != nil { + } else if waitErr := cmd.ProcessState; waitErr != nil && !waitErr.Success() { status = taskruntime.StatusFailed - errMsg = err.Error() + errMsg = waitErr.String() } out := task.output.String() task.mu.Unlock() @@ -199,7 +214,9 @@ func (t *backgroundTask) stop() error { if t.cmd.Process == nil { return nil } - return t.cmd.Process.Kill() + // Kill the whole process group (grandchildren spawned by the shell too), + // since the child was started with Setpgid: true. + return syscall.Kill(-t.cmd.Process.Pid, syscall.SIGKILL) } type TaskOutputTool struct{} diff --git a/internal/types/client.go b/internal/types/client.go index fa0c98a5..a23aaf7f 100644 --- a/internal/types/client.go +++ b/internal/types/client.go @@ -1,28 +1,21 @@ package types -import "context" - -// ContentPart represents a provider-neutral multimodal message part. Hawk owns -// this conversation shape; the Eyrie engine adapter translates it at the -// transport boundary. -type ContentPart struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - ImageURL *ImageURLPart `json:"image_url,omitempty"` - InputAudio *InputAudioPart `json:"input_audio,omitempty"` -} +import ( + "context" + + "github.com/GrayCodeAI/hawk-core-contracts/llm" +) + +// ContentPart is a provider-neutral multimodal message part. Hawk owns this +// conversation shape; the Eyrie engine adapter translates it at the transport +// boundary. It aliases the canonical contract type. +type ContentPart = llm.ContentPart // ImageURLPart describes an image URL or data URI. -type ImageURLPart struct { - URL string `json:"url"` - Detail string `json:"detail,omitempty"` -} +type ImageURLPart = llm.ImageURLPart // InputAudioPart describes base64-encoded audio content. -type InputAudioPart struct { - Data string `json:"data"` - Format string `json:"format"` -} +type InputAudioPart = llm.InputAudioPart // ChatProvider is Hawk's transport-provider interface. type ChatProvider interface { @@ -39,71 +32,19 @@ type ChatClient interface { } // ResponseFormat specifies the desired output format for a Hawk runtime request. -type ResponseFormat struct { - Type string `json:"type"` - Schema string `json:"schema,omitempty"` -} +type ResponseFormat = llm.ResponseFormat // ToolChoiceOption controls how the model uses tools. -type ToolChoiceOption struct { - Type string `json:"type"` - Name string `json:"name,omitempty"` - DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"` -} +type ToolChoiceOption = llm.ToolChoiceOption // EyrieTool is Hawk's runtime tool definition DTO. -type EyrieTool struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]interface{} `json:"parameters"` -} +type EyrieTool = llm.EyrieTool // ChatOptions holds Hawk-owned request options for an engine chat call. -type ChatOptions struct { - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Stream bool `json:"stream,omitempty"` - Tools []EyrieTool `json:"tools,omitempty"` - System string `json:"system,omitempty"` - EnableCaching bool `json:"enable_caching,omitempty"` - ResponseFormat *ResponseFormat `json:"response_format,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` - ThinkingBudgetTokens int `json:"thinking_budget_tokens,omitempty"` - ThinkingMode string `json:"thinking_mode,omitempty"` - ThinkingDisplay string `json:"thinking_display,omitempty"` - GLMThinkingEnabled *bool `json:"glm_thinking_enabled,omitempty"` - VirtualKeyID string `json:"virtual_key_id,omitempty"` - KimiContextCacheID string `json:"kimi_context_cache_id,omitempty"` - KimiCacheResetTTL bool `json:"kimi_cache_reset_ttl,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - TopK *int `json:"top_k,omitempty"` - StopSequences []string `json:"stop_sequences,omitempty"` - ToolChoice *ToolChoiceOption `json:"tool_choice,omitempty"` - MetadataUserID string `json:"metadata_user_id,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` - OutputEffort string `json:"output_effort,omitempty"` - OutputSchema string `json:"output_schema,omitempty"` - PresencePenalty *float64 `json:"presence_penalty,omitempty"` - FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` - N *int `json:"n,omitempty"` - LogProbs *bool `json:"logprobs,omitempty"` - TopLogProbs *int `json:"top_logprobs,omitempty"` - Seed *int `json:"seed,omitempty"` - Store *bool `json:"store,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Modalities []string `json:"modalities,omitempty"` - AudioConfig string `json:"audio_config,omitempty"` - Prediction string `json:"prediction,omitempty"` - WebSearchOptions string `json:"web_search_options,omitempty"` -} +type ChatOptions = llm.ChatOptions // ContinuationConfig controls output continuation behavior for Hawk runtime calls. -type ContinuationConfig struct { - MaxContinuations int - MaxTotalTokens int -} +type ContinuationConfig = llm.ContinuationConfig // DefaultContinuationConfig is Hawk's agent-loop continuation policy. Eyrie // receives these limits through its engine request rather than owning the @@ -113,93 +54,30 @@ func DefaultContinuationConfig() ContinuationConfig { } // ToolCall is Hawk's runtime tool invocation DTO. -type ToolCall struct { - ID string `json:"id,omitempty"` - Name string `json:"name"` - Arguments map[string]interface{} `json:"arguments"` -} +type ToolCall = llm.ToolCall // ToolResult is Hawk's runtime tool result DTO. -type ToolResult struct { - ToolUseID string `json:"tool_use_id"` - Content string `json:"content"` - IsError bool `json:"is_error,omitempty"` -} +type ToolResult = llm.ToolResult // EyrieUsage tracks token usage for Hawk runtime responses and streams. -type EyrieUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - CacheCreationTokens int `json:"cache_creation_tokens,omitempty"` - CacheReadTokens int `json:"cache_read_tokens,omitempty"` - ThinkingTokens int `json:"thinking_tokens,omitempty"` -} +type EyrieUsage = llm.EyrieUsage // ResolvedRoute is Hawk's view of the concrete provider/model route selected // by the provider engine. Keeping this projection Hawk-owned prevents Eyrie's // transport DTOs from leaking into product state and observability payloads. -type ResolvedRoute struct { - Provider string `json:"provider"` - Model string `json:"model"` - DeploymentRouting bool `json:"deployment_routing,omitempty"` -} +type ResolvedRoute = llm.ResolvedRoute // EyrieResponse is Hawk's runtime chat response DTO. -type EyrieResponse struct { - Content string `json:"content"` - Thinking string `json:"thinking,omitempty"` - Usage *EyrieUsage `json:"usage,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - FinishReason string `json:"finish_reason"` - RequestID string `json:"request_id,omitempty"` - OrganizationID string `json:"organization_id,omitempty"` - Route *ResolvedRoute `json:"route,omitempty"` -} +type EyrieResponse = llm.EyrieResponse // EyrieStreamEvent is Hawk's runtime stream event DTO. -type EyrieStreamEvent struct { - Type string `json:"type"` - Content string `json:"content,omitempty"` - ToolCall *ToolCall `json:"tool_call,omitempty"` - Thinking string `json:"thinking,omitempty"` - Error string `json:"error,omitempty"` - RequestID string `json:"request_id,omitempty"` - Usage *EyrieUsage `json:"usage,omitempty"` - StopReason string `json:"stop_reason,omitempty"` - TTFTms int `json:"ttft_ms,omitempty"` - TTFT int `json:"ttft,omitempty"` - Route *ResolvedRoute `json:"route,omitempty"` -} +type EyrieStreamEvent = llm.EyrieStreamEvent -// StreamResult wraps a Hawk-owned streaming response with cleanup. -type StreamResult struct { - Events <-chan EyrieStreamEvent - RequestID string - cancel context.CancelFunc -} - -// NewStreamResult constructs a Hawk-owned stream result around an engine -// event channel. The cancel function is optional and must be idempotent. -func NewStreamResult(events <-chan EyrieStreamEvent, requestID string, cancel context.CancelFunc) *StreamResult { - return &StreamResult{Events: events, RequestID: requestID, cancel: cancel} -} - -// Close stops the stream and releases resources. -func (sr *StreamResult) Close() { - if sr != nil && sr.cancel != nil { - sr.cancel() - } -} +// StreamResult wraps a Hawk-owned streaming response with cleanup. It aliases +// the canonical contract type; its Close() method and canonical constructor +// (NewStreamResult) live in github.com/GrayCodeAI/hawk-core-contracts/llm. +type StreamResult = llm.StreamResult // EyrieMessage is Hawk's runtime conversation DTO. // It intentionally mirrors the engine boundary shape while remaining Hawk-owned. -type EyrieMessage struct { - Role string `json:"role"` - Content string `json:"content,omitempty"` - Thinking string `json:"thinking,omitempty"` - ContentParts []ContentPart `json:"content_parts,omitempty"` - Images []string `json:"images,omitempty"` - ToolUse []ToolCall `json:"tool_use,omitempty"` - ToolResults []ToolResult `json:"tool_results,omitempty"` -} +type EyrieMessage = llm.EyrieMessage diff --git a/internal/types/client_test.go b/internal/types/client_test.go index b0e43d8b..2e5916b2 100644 --- a/internal/types/client_test.go +++ b/internal/types/client_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "testing" + + "github.com/GrayCodeAI/hawk-core-contracts/llm" ) func TestContentPartJSONContract(t *testing.T) { @@ -44,7 +46,7 @@ func TestDefaultContinuationConfig(t *testing.T) { func TestStreamResultCloseIsOptionalAndIdempotentCompatible(t *testing.T) { closed := 0 - result := NewStreamResult(nil, "request-1", func() { closed++ }) + result := llm.NewStreamResult(nil, "request-1", func() { closed++ }) if result.RequestID != "request-1" { t.Fatalf("RequestID = %q", result.RequestID) } @@ -53,5 +55,5 @@ func TestStreamResultCloseIsOptionalAndIdempotentCompatible(t *testing.T) { t.Fatalf("close calls = %d, want 1", closed) } (*StreamResult)(nil).Close() - NewStreamResult(nil, "", context.CancelFunc(nil)).Close() + llm.NewStreamResult(nil, "", context.CancelFunc(nil)).Close() } diff --git a/internal/ui/icons/icons.go b/internal/ui/icons/icons.go index 7273add0..b1faeeb8 100644 --- a/internal/ui/icons/icons.go +++ b/internal/ui/icons/icons.go @@ -132,12 +132,12 @@ func lookup(name string) (string, string, bool) { return "", "", false } -// Glyph returns the active-mode glyph for the given name. Panics on -// unknown names — that is a programmer error, not a runtime condition. +// Glyph returns the active-mode glyph for the given name. Returns "" for +// unknown names (a programmer error, but not worth crashing the TUI). func Glyph(name string) string { nerd, ascii, ok := lookup(name) if !ok { - panic("icons: unknown glyph " + name) + return "" } if Mode() == ModeNerd { return nerd @@ -153,7 +153,7 @@ func Glyph(name string) string { func ASCII(name string) string { _, ascii, ok := lookup(name) if !ok { - panic("icons: unknown glyph " + name) + return "" } return ascii } @@ -163,7 +163,7 @@ func ASCII(name string) string { func Nerd(name string) string { nerd, _, ok := lookup(name) if !ok { - panic("icons: unknown glyph " + name) + return "" } return nerd } diff --git a/internal/ui/icons/icons_test.go b/internal/ui/icons/icons_test.go index d28bef99..c05f5841 100644 --- a/internal/ui/icons/icons_test.go +++ b/internal/ui/icons/icons_test.go @@ -1,7 +1,6 @@ package icons import ( - "strings" "testing" "unicode/utf8" ) @@ -85,15 +84,16 @@ func TestNerdGlyphs_AllInPrivateUseArea(t *testing.T) { } } -func TestGlyph_UnknownPanics(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("Glyph(\"nope\") did not panic") - } else if !strings.Contains(r.(string), "unknown glyph") { - t.Errorf("panic message = %v, want it to mention 'unknown glyph'", r) - } - }() - Glyph("nope") +func TestGlyph_UnknownReturnsEmpty(t *testing.T) { + if got := Glyph("nope"); got != "" { + t.Errorf("Glyph(\"nope\") = %q, want empty string", got) + } + if got := ASCII("nope"); got != "" { + t.Errorf("ASCII(\"nope\") = %q, want empty string", got) + } + if got := Nerd("nope"); got != "" { + t.Errorf("Nerd(\"nope\") = %q, want empty string", got) + } } func TestTypedAccessors_AllReturnNonEmpty(t *testing.T) {