diff --git a/.github/actions/checkout-eyrie/action.yml b/.github/actions/checkout-eyrie/action.yml new file mode 100644 index 0000000..98485f3 --- /dev/null +++ b/.github/actions/checkout-eyrie/action.yml @@ -0,0 +1,23 @@ +name: Checkout eyrie +description: Clone eyrie as a sibling repo for hawk go.work (../eyrie) + +inputs: + ref: + description: Git ref to checkout (branch or tag) + required: false + default: main + +runs: + using: composite + steps: + - name: Clone eyrie + shell: bash + run: | + set -euo pipefail + dest="${GITHUB_WORKSPACE}/../eyrie" + if [ -d "$dest/.git" ]; then + echo "eyrie already present at $dest" + exit 0 + fi + git clone --depth=1 --branch "${{ inputs.ref }}" \ + "https://github.com/GrayCodeAI/eyrie.git" "$dest" diff --git a/.github/actions/setup-deps/action.yml b/.github/actions/setup-deps/action.yml index a685f35..2ef28da 100644 --- a/.github/actions/setup-deps/action.yml +++ b/.github/actions/setup-deps/action.yml @@ -39,4 +39,4 @@ runs: - name: Create workspace shell: bash run: | - printf 'go 1.26.1\n\nuse .\n\nreplace (\n\tgithub.com/GrayCodeAI/eyrie => ../eyrie\n\tgithub.com/GrayCodeAI/tok => ../tok\n\tgithub.com/GrayCodeAI/yaad => ../yaad\n\tgithub.com/GrayCodeAI/inspect => ../inspect\n\tgithub.com/GrayCodeAI/sight => ../sight\n)\n' > go.work + printf 'go 1.26.3\n\nuse (\n\t.\n\t../eyrie\n\t../tok\n\t../yaad\n\t../inspect\n\t../sight\n)\n' > go.work diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d92a254..d1aae02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - submodules: recursive - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ env.GO_VERSION }} @@ -56,22 +54,23 @@ jobs: fi # ------------------------------------------------------------------------- - # 2. Module hygiene — tidy, verify (Herm-style: submodule + go.work, no go.mod replace). + # 2. Module hygiene — tidy, verify (hawk + sibling eyrie via go.work + go.mod replace). # ------------------------------------------------------------------------- module: name: module hygiene runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/checkout-eyrie with: - submodules: recursive + ref: ${{ github.head_ref || github.ref_name }} - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ env.GO_VERSION }} cache: true - name: go work sync + module consistency run: | - # Herm uses submodule + go.work only (no go.mod replace). go mod tidy can mis-resolve + # Eyrie is a sibling checkout (go.work + replace ../eyrie). go mod tidy can mis-resolve # workspace modules here; go work sync is the supported workspace hygiene step. go work sync go build -mod=readonly -o /dev/null . @@ -82,10 +81,10 @@ jobs: fi - name: go mod verify run: go mod verify - - name: no replace directives in go.mod + - name: eyrie replace points at sibling run: | - if grep -qE '^\s*replace\s' go.mod; then - echo "::error::go.mod must not use replace (Eyrie comes from submodule + go.work; see Herm / LangDAG)." + if ! grep -qE 'replace github\.com/GrayCodeAI/eyrie => \.\./eyrie' go.mod; then + echo "::error::go.mod must replace eyrie with ../eyrie (sibling checkout)." grep -nE '^\s*replace\s' go.mod || true exit 1 fi @@ -98,8 +97,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/checkout-eyrie with: - submodules: recursive + ref: ${{ github.head_ref || github.ref_name }} - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ env.GO_VERSION }} @@ -116,8 +116,9 @@ jobs: needs: [format, vet] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/checkout-eyrie with: - submodules: recursive + ref: ${{ github.head_ref || github.ref_name }} - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ env.GO_VERSION }} @@ -136,8 +137,9 @@ jobs: needs: [format, vet] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/checkout-eyrie with: - submodules: recursive + ref: ${{ github.head_ref || github.ref_name }} - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ env.GO_VERSION }} @@ -171,8 +173,9 @@ jobs: needs: [format, vet] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/checkout-eyrie with: - submodules: recursive + ref: ${{ github.head_ref || github.ref_name }} - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ env.GO_VERSION }} @@ -194,8 +197,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - submodules: recursive - uses: trufflesecurity/trufflehog@0fa069c12f0c7baf431041cd1e564a9c5058846c # main 2026-05-18 with: extra_args: --only-verified @@ -206,11 +207,10 @@ jobs: dependency-review: name: dependency review runs-on: ubuntu-latest + continue-on-error: true # requires GitHub Dependency graph (repo settings) if: github.event_name == 'pull_request' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - submodules: recursive - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 # ------------------------------------------------------------------------- @@ -221,12 +221,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - submodules: recursive - name: Run markdownlint-cli2 run: | npm install -g markdownlint-cli2 - printf '%s\n' '{"ignores":["external/**"],"config":{"default":true,"line-length":false,"no-inline-html":false,"first-line-h1":false,"no-duplicate-heading":false,"no-emphasis-as-heading":false,"blanks-around-headings":false,"blanks-around-lists":false,"blanks-around-fences":false,"fenced-code-language":false,"table-column-style":false,"no-space-in-emphasis":false,"ol-prefix":false,"link-fragments":false,"blanks-around-tables":false,"table-column-count":false,"single-trailing-newline":false}}' > .markdownlint-cli2.jsonc + printf '%s\n' '{"config":{"default":true,"line-length":false,"no-inline-html":false,"first-line-h1":false,"no-duplicate-heading":false,"no-emphasis-as-heading":false,"blanks-around-headings":false,"blanks-around-lists":false,"blanks-around-fences":false,"fenced-code-language":false,"table-column-style":false,"no-space-in-emphasis":false,"ol-prefix":false,"link-fragments":false,"blanks-around-tables":false,"table-column-count":false,"single-trailing-newline":false}}' > .markdownlint-cli2.jsonc markdownlint-cli2 '**/*.md' # ------------------------------------------------------------------------- @@ -246,8 +244,9 @@ jobs: goarch: arm64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/checkout-eyrie with: - submodules: recursive + ref: ${{ github.head_ref || github.ref_name }} - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ env.GO_VERSION }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9eeb5cf..eb33f5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,8 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # goreleaser needs full history for changelog - submodules: recursive + + - uses: ./.github/actions/checkout-eyrie - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 5419ec0..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "external/eyrie"] - path = external/eyrie - url = https://github.com/GrayCodeAI/eyrie.git diff --git a/AGENTS.md b/AGENTS.md index 3141a3a..54c5f4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,20 +70,21 @@ go test -race ./... # Run all tests | Module | In `go.mod` | In-repo checkout | Used from | |--------|-------------|------------------|-----------| -| eyrie | ✓ | **`external/eyrie`** submodule + **`go.work`** | Provider client, setup, streaming | +| eyrie | ✓ | sibling **`../eyrie`** + **`go.work`** + **`replace` in `go.mod`** | Provider client, setup, streaming | | sight | ✓ | proxy (optional local `replace`) | `hawk sight`, `internal/bridge/sight` | | inspect | ✓ | proxy | Inspect bridges | | tok | ✓ | proxy | Tokenizer pipeline | | yaad | ✓ | proxy | Memory bridge | | trace | — | separate **`trace` CLI** | Session capture only; not a Go import | -**Eyrie submodule** (Herm / LangDAG-style): +**Eyrie sibling checkout** (hawk + eyrie): ```bash -git submodule update --init --recursive +# hawk-eco layout: clone eyrie next to hawk, then: +cd hawk && go work sync ``` -Committed **`go.work`** lists `.` and **`./external/eyrie`** only. **`go.mod` must not contain `replace` directives** for Eyrie (CI enforces this). +Committed **`go.work`** lists `.` and **`../eyrie`**. **`go.mod`** includes **`replace github.com/GrayCodeAI/eyrie => ../eyrie`** (CI enforces this path). **`shared/types`** forwards **`internal/types`** for **sight**, **inspect**, **tok**, and friends so they never import hawk `internal/` directly. @@ -91,7 +92,7 @@ For sibling clones on one machine, use a **personal** parent **`go.work`** or te ### CI -- Checkout uses **`submodules: recursive`** so `external/eyrie` is populated +- CI clones **eyrie** to **`../eyrie`** via **`.github/actions/checkout-eyrie`** - Module hygiene: **`go work sync`** and **`go build -mod=readonly`** (not `go mod tidy`, which mis-resolves workspace Eyrie) - golangci-lint with errcheck, staticcheck, gosec, unused, misspell - Multi-platform builds (linux/darwin/windows × amd64/arm64) @@ -105,3 +106,23 @@ For sibling clones on one machine, use a **personal** parent **`go.work`** or te - Landlock: filesystem access restrictions - seccomp-bpf: blocks 21 dangerous syscalls - Fallback: no-op on non-Linux (`internal/sandbox/landlock_other.go`) + +## Milestone: API key → model → sandbox + +Active branch: **`feature/secure-credentials-sandbox`** (hawk + eyrie sibling). + +| Concern | Where | +|---------|--------| +| First-run `/config`, setup guards | `internal/config/setup_status.go`, `cmd/chat.go` | +| Keychain + `PersistAPIKey` / `RemoveStoredCredential` | `internal/config/credentials_store.go`, eyrie `credentials/` | +| Remove stored key (TUI) | `/config key remove` → `cmd/chat_config_remove.go` | +| Remove stored key (CLI) | `hawk credentials remove` → `cmd/credentials.go` | +| Catalog discover + routing only on disk | `internal/config/eyrie_apply.go`, eyrie `setup/apply_credentials.go` | +| Catalog empty / refresh hints | `internal/config/catalog_health.go`, `catalog_startup.go` | +| No API keys in `provider.json` | eyrie `SanitizeDeploymentConfigForDisk`, hawk `MigrateProviderSecrets` | +| Verification tests | `internal/config/milestone_verify_test.go`, `./scripts/verify-milestone.sh` | +| Plan + phase status | `plans/MILESTONE-api-key-model-sandbox.md` | + +**Not in this milestone:** conversation DAG as source of truth, langdag Go import. + +**`/sandbox` vs Docker:** `/sandbox` toggles **approval mode** in the TUI. **Docker container mode** is the default for bash (`shouldUseContainer`); use `--no-container` for host execution. diff --git a/README.md b/README.md index 8ed11e4..3e2822e 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,11 @@ hawk works with any LLM provider. Set your API key via environment variable or ` | Ollama | `OLLAMA_BASE_URL` (no key) | Provider routing, model resolution, and retries are handled by [eyrie](https://github.com/GrayCodeAI/eyrie). +For deployment-aware routing, set `"deployment_routing": true` in `.hawk/settings.json` +or export `HAWK_DEPLOYMENT_ROUTING=true`. Hawk will route canonical model IDs through +Eyrie's deployment catalog, so new models can be exposed by refreshing the catalog +instead of changing Hawk. In chat, run `/refresh-model-catalog` to fetch the latest +deployment-aware catalog into `~/.eyrie/model_catalog.json`. ## Architecture @@ -201,12 +206,12 @@ hawk/ hawk integrates these GrayCodeAI repos in three ways: - **`go.mod` modules:** **eyrie**, **sight**, **inspect**, **tok**, **yaad** — pinned versions from the module proxy (same semver story across CI). -- **Submodule + `go.work`:** **eyrie** only — checked out under **`external/eyrie`** (`git submodule update --init --recursive`) so CI/builds always see the same Eyrie source layout as Herm-style repos. +- **Sibling + `go.work` + `replace`:** **eyrie** — clone [eyrie](https://github.com/GrayCodeAI/eyrie) next to hawk (`../eyrie`). `go.mod` uses `replace github.com/GrayCodeAI/eyrie => ../eyrie`. CI clones the same layout via **`.github/actions/checkout-eyrie`**. - **Optional CLI (no Go import):** **trace** — installed separately; `hawk` shells into `trace` for session capture when present. Cross-repo types (severity, etc.) are exported from **`github.com/GrayCodeAI/hawk/shared/types`** so **sight** / **inspect** / **tok** do not import **`internal/`**. -You may keep a **personal** parent **`go.work`** that lists sibling clones on disk (`../sight`, …); nothing besides **`external/eyrie`** is committed as a submodule in hawk. +You may keep a **personal** parent **`go.work`** that lists sibling clones on disk (`../sight`, …) for multi-repo development. | Component | Repository | Purpose | |---|---|---| diff --git a/cmd/catalog_startup.go b/cmd/catalog_startup.go new file mode 100644 index 0000000..7fcb293 --- /dev/null +++ b/cmd/catalog_startup.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "context" + "os" + + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" +) + +var ( + refreshCatalogFlag bool + skipCatalogRefreshFlag bool +) + +func ensureCatalogBeforeAgent(ctx context.Context, strict bool) error { + _ = hawkconfig.MigrateProviderConfig() + opts := hawkconfig.CatalogStartupOptions{ + ForceRefresh: refreshCatalogFlag, + SkipAutoRefresh: skipCatalogRefreshFlag, + VerboseOutput: refreshCatalogFlag, + } + if strict { + return hawkconfig.PrepareCatalogForSession(ctx, os.Stderr, opts) + } + hawkconfig.StartupCatalogPrefetch(ctx) + return nil +} + +func startBackgroundCatalogRefresh(ctx context.Context) { + if skipCatalogRefreshFlag { + return + } + hawkconfig.ScheduleBackgroundCatalogRefresh(ctx) +} diff --git a/cmd/chat.go b/cmd/chat.go index 93bf9f6..fce626e 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -25,6 +25,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/bridge/sessioncapture" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/GrayCodeAI/hawk/internal/eyrieclient" "github.com/GrayCodeAI/hawk/internal/feature/shellmode" "github.com/GrayCodeAI/hawk/internal/feature/taste" "github.com/GrayCodeAI/hawk/internal/intelligence/memory" @@ -242,6 +243,12 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting m.containerEnabled = shouldUseContainer() if m.containerEnabled { m.containerStatus = "checking docker…" + } else if noContainer { + m.messages = append(m.messages, displayMsg{ + role: "system", + content: "--no-container runs tools on the host without sandbox isolation. " + + "Use default container mode for safer agent execution.", + }) } // Initialize lacy-inspired features @@ -300,10 +307,10 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting // Prefetch models for current provider in background so /config and /model are instant go func() { provider := effectiveProvider - models, _ := hawkconfig.FetchModelsForProvider(provider) - ids := extractModelIDs(models) - if len(ids) > 0 { - modelCache[provider] = ids + entries, _ := eyrieclient.ListModelsForProvider(context.Background(), provider) + opts := configModelOptionsFromEyrie(entries) + if len(opts) > 0 { + modelCache[provider] = opts } }() @@ -346,6 +353,9 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting func (m chatModel) Init() tea.Cmd { cmds := []tea.Cmd{m.input.Focus(), m.spinner.Tick, blinkTickCmd(), glimmerTickCmd()} + if hawkconfig.EvaluateSetup(context.Background()).NeedsSetup { + cmds = append(cmds, func() tea.Msg { return firstRunOpenConfigMsg{} }) + } if m.containerEnabled { m.containerStatus = "checking docker…" cwd, _ := os.Getwd() @@ -469,7 +479,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg.Type { case tea.KeyCtrlN: - models := configModelChoices(m.session.Provider(), m.configModels) + models := configModelChoices(m.configModelOptions, false) if len(models) > 1 { current := m.session.Model() idx := 0 @@ -610,6 +620,16 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleShellEscape(text) } // ClassAgent or ClassNeutral → route to AI + if setup := hawkconfig.EvaluateSetup(context.Background()); setup.NeedsSetup { + hint := setup.Hint + if hint == "" { + hint = "Complete setup in /config (API key and model) before chatting." + } + m.messages = append(m.messages, displayMsg{role: "system", content: hint}) + m.viewDirty = true + m.updateViewportContent() + return m, nil + } // @ mention: resolve file references and include as context. text = m.handleMentions(text) // Build delta-based terminal context for the query @@ -646,13 +666,21 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case modelsFetchedMsg: - if len(msg) > 0 { - m.configModels = []string(msg) - // Auto-set first model so provider switch is immediately usable - if m.configOpen && len(m.configModels) > 0 { - m.session.SetModel(m.configModels[0]) - _ = hawkconfig.SetGlobalSetting("model", m.configModels[0]) + if msg.err != nil { + if m.configOpen { + m.configNotice = eyrieclient.FormatSetupError(msg.provider, msg.err) + m.viewDirty = true + m.updateViewportContent() + } + return m, nil + } + if len(msg.options) > 0 { + m.configModelOptions = msg.options + if msg.provider != "" { + modelCache[msg.provider] = msg.options } + } else if m.configOpen && msg.err == nil { + m.configNotice = hawkconfig.CatalogEmptyHint(context.Background()) } if m.configOpen { m.viewDirty = true @@ -660,6 +688,30 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case configApplyCredentialsMsg: + next, cmd := m.handleConfigApplyCredentialsMsg(msg) + if m.configOpen { + next.viewDirty = true + next.updateViewportContent() + } + return next, cmd + + case configKeyResolvedMsg: + next, cmd := m.handleConfigKeyResolvedMsg(msg) + if m.configOpen { + next.viewDirty = true + next.updateViewportContent() + } + return next, cmd + + case configRemoveCredentialMsg: + next, cmd := m.handleConfigRemoveCredentialMsg(msg) + if m.configOpen { + next.viewDirty = true + next.updateViewportContent() + } + return next, cmd + case loopTickMsg: if !m.waiting { result, cmd := m.handleCommand(msg.command) @@ -779,12 +831,18 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewDirty = true } + case firstRunOpenConfigMsg: + return m.openFirstRunConfig() + case containerStatusMsg: m.containerStatus = msg.status m.containerReady = msg.ready m.containerErr = msg.err if msg.sandbox != nil { m.containerSandbox = msg.sandbox + if m.session != nil { + m.session.ContainerExecutor = msg.sandbox + } } if msg.err != nil { m.input.Blur() @@ -844,6 +902,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func runChat() error { + startBackgroundCatalogRefresh(context.Background()) + ref := &progRef{} systemPrompt, err := buildSystemPrompt() if err != nil { diff --git a/cmd/chat_commands.go b/cmd/chat_commands.go index 5134613..021bd3d 100644 --- a/cmd/chat_commands.go +++ b/cmd/chat_commands.go @@ -21,7 +21,6 @@ import ( "github.com/GrayCodeAI/hawk/internal/intelligence/memory" analytics "github.com/GrayCodeAI/hawk/internal/observability" "github.com/GrayCodeAI/hawk/internal/plugin" - hawkmodel "github.com/GrayCodeAI/hawk/internal/provider/routing" "github.com/GrayCodeAI/hawk/internal/recipe" "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/system/staleness" @@ -111,7 +110,7 @@ var slashDescriptions = map[string]string{ "/review": "Code review for bugs and issues", "/rewind": "Undo last exchange", "/run": "Run command, add output to context", - "/sandbox": "Toggle sandbox mode", + "/sandbox": "Toggle approval mode (not Docker; use default container or --no-container)", "/search": "Search across sessions", "/snapshot": "Manage file snapshots: list, restore , diff ", "/stale": "Show stale rules that may need updating or removal", @@ -470,7 +469,7 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { /resume — Resume session /review — Ask hawk to review changes /rewind — Undo last exchange -/sandbox — Toggle sandbox mode +/sandbox — Toggle approval mode (Docker isolation: default container; --no-container for host) /security-review — Ask hawk to review security risks /share — Share session /learn — LLM-powered skill advisor (deep, update) @@ -571,10 +570,10 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { m.viewDirty = true provider := m.session.Provider() if cached, ok := modelCache[provider]; ok && len(cached) > 0 { - m.configModels = cached + m.configModelOptions = cached return m, nil } - m.configModels = nil + m.configModelOptions = nil return m, fetchModelsAsync(provider) } arg := strings.TrimSpace(strings.TrimPrefix(text, "/model")) @@ -584,12 +583,12 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { return m, nil } // Validate model against known models for current provider - known := configModelChoices(m.session.Provider(), m.configModels) + known := configModelChoices(m.configModelOptions, false) if len(known) > 0 { found := false - for _, k := range known { - if strings.EqualFold(k, arg) { - arg = k + for i, k := range known { + if strings.EqualFold(k, arg) || strings.EqualFold(m.configModelOptions[i].ID, arg) { + arg = m.configModelOptions[i].ID found = true break } @@ -611,12 +610,15 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { return m, nil } } + if hawkconfig.DeploymentRoutingEnabled(m.settings) { + arg = hawkconfig.ResolveCanonicalModel(arg) + } if err := hawkconfig.SetGlobalSetting("model", arg); err != nil { m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) return m, nil } m.session.SetModel(arg) - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Model switched to: %s\nSaved to global config.", m.session.Model())}) + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Model switched to: %s\nSaved in eyrie (provider.json).", m.session.Model())}) return m, nil case "/branches": if m.session.ConvoDAG == nil { @@ -1082,20 +1084,20 @@ Generate the recap:`, summary.String()) m.session.SetProvider(engineProvider) // Use cached model or set first from cache if cached, ok := modelCache[engineProvider]; ok && len(cached) > 0 { - m.session.SetModel(cached[0]) - _ = hawkconfig.SetGlobalSetting("model", cached[0]) + m.session.SetModel(cached[0].ID) + _ = hawkconfig.SetGlobalSetting("model", cached[0].ID) } - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Provider set to: %s\nModel: %s\nSaved to global config.", value, m.session.Model())}) + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Provider set to: %s\nModel: %s\nSaved in eyrie (provider.json).", value, m.session.Model())}) return m, nil } if len(parts) >= 3 && parts[1] == "model" { value := strings.TrimSpace(strings.Join(parts[2:], " ")) - known := configModelChoices(m.session.Provider(), m.configModels) + known := configModelChoices(m.configModelOptions, false) if len(known) > 0 { found := false - for _, k := range known { - if strings.EqualFold(k, value) { - value = k + for i, k := range known { + if strings.EqualFold(k, value) || strings.EqualFold(m.configModelOptions[i].ID, value) { + value = m.configModelOptions[i].ID found = true break } @@ -1111,13 +1113,20 @@ Generate the recap:`, summary.String()) return m, nil } m.session.SetModel(value) - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Model switched to: %s\nSaved to global config.", value)}) + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Model switched to: %s\nSaved in eyrie (provider.json).", value)}) return m, nil } if len(parts) >= 2 && parts[1] == "keys" { m.messages = append(m.messages, displayMsg{role: "system", content: apiKeyConfigSummary()}) return m, nil } + if len(parts) >= 3 && parts[1] == "key" && parts[2] == "remove" { + if len(parts) > 3 { + m.messages = append(m.messages, displayMsg{role: "error", content: "Usage: /config key remove"}) + return m, nil + } + return m.openConfigRemoveKeyPanel() + } if len(parts) >= 3 && parts[1] == "get" { settings, err := loadEffectiveSettings() if err != nil { @@ -1159,12 +1168,8 @@ Generate the recap:`, summary.String()) return m, nil } m.settings = settings - m.configOpen = true - m.configMenu = "provider" - m.configSel = 0 - m.configNotice = "" - m.viewDirty = true - return m, nil + next, cmd := m.openConfigPanel() + return next, cmd case "/mcp": m.messages = append(m.messages, displayMsg{role: "system", content: m.mcpSummary()}) return m, nil @@ -1629,14 +1634,12 @@ Generate the recap:`, summary.String()) } return m, nil case "/fast": - if m.session.Model() == m.settings.Model { + savedModel := hawkconfig.ActiveModel(context.Background()) + if m.session.Model() == savedModel { norm := hawkconfig.NormalizeProviderForEngine(m.session.Provider()) - fastModel := hawkmodel.CheapestForProvider(norm, m.session.Model()) - if strings.TrimSpace(fastModel) == "" { - fastModel = hawkmodel.DefaultModel(norm) - } + fastModel := hawkconfig.CheapestModelForProvider(norm, m.session.Model()) if strings.TrimSpace(fastModel) == "" { - fastModel = client.ResolveDefaultModel(m.session.Provider()) + fastModel = hawkconfig.DefaultModelForProvider(norm) } if strings.TrimSpace(fastModel) == "" { m.messages = append(m.messages, displayMsg{role: "error", content: "Fast mode: no catalog model resolved for this provider"}) @@ -1645,8 +1648,8 @@ Generate the recap:`, summary.String()) m.session.SetModel(fastModel) m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Fast mode on → %s", fastModel)}) } else { - m.session.SetModel(m.settings.Model) - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Fast mode off → %s", m.settings.Model)}) + m.session.SetModel(savedModel) + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Fast mode off → %s", savedModel)}) } return m, nil case "/effort": @@ -1865,10 +1868,10 @@ Generate the recap:`, summary.String()) case "/sandbox": if string(m.session.Mode) == "acceptEdits" { _ = m.session.SetPermissionMode("default") - m.messages = append(m.messages, displayMsg{role: "system", content: "Sandbox ON — all actions require approval."}) + m.messages = append(m.messages, displayMsg{role: "system", content: "Approval mode ON — all actions require confirmation. (Docker tool isolation is separate: default container mode, or --no-container on host.)"}) } else { _ = m.session.SetPermissionMode("acceptEdits") - m.messages = append(m.messages, displayMsg{role: "system", content: "Sandbox OFF — file edits auto-approved, other actions require approval."}) + m.messages = append(m.messages, displayMsg{role: "system", content: "Approval mode relaxed — file edits auto-approved; other actions still prompt. (Docker tool isolation unchanged.)"}) } return m, nil case "/output-style": @@ -1894,7 +1897,12 @@ Generate the recap:`, summary.String()) case "/ultrareview": return m.startPromptCommand("/ultrareview", "Perform a deep, adversarial code review of this change set. Prioritize correctness, security, regressions, and missing tests.") case "/provider-status": - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Provider: %s\nModel: %s", m.session.Provider(), m.session.Model())}) + report, err := hawkconfig.DeploymentStatusReport(context.Background(), m.session.Model()) + if err != nil { + m.messages = append(m.messages, displayMsg{role: "error", content: fmt.Sprintf("Provider status failed: %v", err)}) + return m, nil + } + m.messages = append(m.messages, displayMsg{role: "system", content: report}) return m, nil case "/session": info := fmt.Sprintf("Session: %s\nModel: %s/%s\nPermission mode: %s\nMessages: %d\nTools: %d\n%s", @@ -1915,7 +1923,12 @@ Generate the recap:`, summary.String()) m.messages = append(m.messages, displayMsg{role: "system", content: "Plugins reloaded."}) return m, nil case "/refresh-model-catalog": - m.messages = append(m.messages, displayMsg{role: "system", content: "Model catalog is built-in in this build; refresh not required."}) + summary, err := hawkconfig.RefreshModelCatalogV1(context.Background()) + if err != nil { + m.messages = append(m.messages, displayMsg{role: "error", content: fmt.Sprintf("Model catalog refresh failed: %v", err)}) + return m, nil + } + m.messages = append(m.messages, displayMsg{role: "system", content: summary}) return m, nil case "/insights": days := 30 diff --git a/cmd/chat_config_deployment.go b/cmd/chat_config_deployment.go new file mode 100644 index 0000000..fe5c6cd --- /dev/null +++ b/cmd/chat_config_deployment.go @@ -0,0 +1,386 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/GrayCodeAI/hawk/internal/eyrieclient" +) + +type configApplyCredentialsMsg struct { + summary string + err error + providerID string + deploymentID string + modelOptions []configModelOption +} + +type configKeyResolvedMsg struct { + secret string + result hawkconfig.CredentialResolveResult +} + +// openConfigPanel: hub → paste key / Ollama / pick model. +func (m chatModel) openConfigPanel() (chatModel, tea.Cmd) { + ctx := context.Background() + st := hawkconfig.EvaluateSetup(ctx) + m = m.openConfigHub(!st.HasCredentials) + return m, nil +} + +func (m chatModel) openFirstRunConfig() (chatModel, tea.Cmd) { + return m.openConfigPanel() +} + +func firstRunModelProvider(m chatModel) string { + ctx := context.Background() + if p := hawkconfig.DefaultModelProviderFilter(ctx); p != "" { + return p + } + return strings.TrimSpace(m.session.Provider()) +} + +func resolveKeyAsync(secret string) tea.Cmd { + return func() tea.Msg { + res := eyrieclient.ResolveCredentialForHost(context.Background(), secret) + return configKeyResolvedMsg{ + secret: secret, + result: credentialResolveFromRuntime(res), + } + } +} + +func credentialResolveFromRuntime(res eyrieclient.CredentialResolveResult) hawkconfig.CredentialResolveResult { + out := hawkconfig.CredentialResolveResult{ + FormatOK: res.FormatOK, + FormatError: res.FormatError, + Providers: make([]hawkconfig.CredentialProviderOption, len(res.Providers)), + } + for i, p := range res.Providers { + out.Providers[i] = hawkconfig.CredentialProviderOption{ + ProviderID: p.ProviderID, + DeploymentID: p.DeploymentID, + EnvVar: p.EnvVar, + DisplayName: p.DisplayName, + Inferred: p.Inferred, + RequiresKey: p.RequiresKey, + Rank: p.Rank, + } + } + return out +} + +func credentialOptionFromHawk(in hawkconfig.CredentialInference) eyrieclient.CredentialProviderOption { + return eyrieclient.CredentialProviderOption{ + ProviderID: in.ProviderID, + DeploymentID: in.DeploymentID, + EnvVar: in.EnvVar, + DisplayName: in.DisplayName, + } +} + +func saveProviderKeyAsync(inference hawkconfig.CredentialInference, secret string) tea.Cmd { + return saveCredentialAsync(inference, secret) +} + +func saveOllamaAsync(baseURL string) tea.Cmd { + return func() tea.Msg { + inference, err := eyrieclient.LocalCredentialInference("ollama") + if err != nil { + return configApplyCredentialsMsg{err: err} + } + inf := hawkconfig.CredentialInference{ + ProviderID: inference.ProviderID, + DeploymentID: inference.DeploymentID, + EnvVar: inference.EnvVar, + DisplayName: inference.DisplayName, + } + return saveCredentialAsync(inf, baseURL)() + } +} + +func saveCredentialAsync(inference hawkconfig.CredentialInference, secret string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + rtInf := eyrieclient.InferenceFromOption(credentialOptionFromHawk(inference)) + if err := eyrieclient.SaveCredentialForHost(ctx, rtInf, secret); err != nil { + return configApplyCredentialsMsg{ + err: err, + providerID: inference.ProviderID, + deploymentID: inference.DeploymentID, + } + } + result, err := eyrieclient.ApplyEyrieCredentials(ctx) + if err != nil { + return configApplyCredentialsMsg{ + err: err, + providerID: inference.ProviderID, + deploymentID: inference.DeploymentID, + } + } + + entries, listErr := eyrieclient.ListModelsForProviderAfterApply(ctx, inference.ProviderID) + if listErr != nil { + return configApplyCredentialsMsg{ + err: listErr, + providerID: inference.ProviderID, + deploymentID: inference.DeploymentID, + } + } + opts := configModelOptionsFromEyrie(entries) + if len(opts) == 0 { + fallback := eyrieclient.OptionsFromSetupUI(result, inference.ProviderID) + opts = toConfigModelOptionsFromEyrie(fallback) + } + + return configApplyCredentialsMsg{ + summary: eyrieclient.FormatApplySummary(result), + providerID: inference.ProviderID, + deploymentID: inference.DeploymentID, + modelOptions: opts, + } + } +} + +func toConfigModelOptionsFromEyrie(in []eyrieclient.ModelOption) []configModelOption { + out := make([]configModelOption, len(in)) + for i, o := range in { + out[i] = configModelOption{ID: o.ID, DisplayName: o.DisplayName} + } + return out +} + +func (m chatModel) configHubView() string { + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")).Bold(true) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")).Bold(true) + mutedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8D939E")) + style := lipgloss.NewStyle().Foreground(lipgloss.Color("#E6E6E6")) + + opts := m.configHubLabels() + var b strings.Builder + b.WriteString(titleStyle.Render("⚙ Connect a provider") + "\n\n") + if notice := strings.TrimSpace(m.configNotice); notice != "" { + b.WriteString(mutedStyle.Render(notice) + "\n\n") + } + for i, opt := range opts { + prefix := " " + lineStyle := style + if i == m.configSel { + prefix = "❯ " + lineStyle = selectedStyle + } + b.WriteString(lineStyle.Render(prefix+opt) + "\n") + } + help := "↑/↓ · enter · esc close" + if m.configSaving { + help = "please wait…" + } + b.WriteString("\n" + mutedStyle.Render(help)) + return b.String() +} + +func (m chatModel) handleConfigHubSelect() (chatModel, tea.Cmd) { + if m.configSaving { + return m, nil + } + opts := m.configHubOptions() + if m.configSel < 0 || m.configSel >= len(opts) { + return m, nil + } + switch opts[m.configSel].action { + case "model": + return m.beginConfigModelPicker() + case "apikey": + m.configNotice = "Paste your provider API key" + return m.startConfigEntry("apikey-paste", "") + case "ollama": + return m.startConfigOllamaURL() + default: + return m, nil + } +} + +func (m chatModel) startConfigOllamaURL() (chatModel, tea.Cmd) { + return m.startConfigOllamaURLWithValue("http://localhost:11434/v1") +} + +func (m chatModel) startConfigOllamaURLWithValue(url string) (chatModel, tea.Cmd) { + m.configEntry = "ollama-url" + m.configProvider = "ollama" + m.configMenu = "" + if strings.TrimSpace(m.configNotice) == "" || strings.TrimSpace(m.configNotice) == "Working…" { + m.configNotice = "Confirm Ollama URL (run: ollama serve)" + } + return m.startConfigURLInput(url) +} + +func (m chatModel) startConfigURLInput(defaultURL string) (chatModel, tea.Cmd) { + m.useConfigInput = true + m.configInput.Reset() + m.configInput.SetValue(defaultURL) + m.configInput.Prompt = " url ❯ " + m.configInput.Placeholder = defaultURL + m.configInput.EchoMode = textinput.EchoNormal + m.configInput.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")).Bold(true) + m.configInput.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F2F2F2")) + m.configInput.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")) + m.configInput.Focus() + return m, textinput.Blink +} + +func (m chatModel) configProvidersView() string { + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")).Bold(true) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")).Bold(true) + mutedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8D939E")) + style := lipgloss.NewStyle().Foreground(lipgloss.Color("#E6E6E6")) + + opts := m.configProviderLabels() + total := len(opts) + + if m.configSel < m.configScroll { + m.configScroll = m.configSel + } + if m.configSel >= m.configScroll+configWindowSize { + m.configScroll = m.configSel - configWindowSize + 1 + } + + var b strings.Builder + b.WriteString(titleStyle.Render("🔑 Select provider (eyrie)") + "\n\n") + if notice := strings.TrimSpace(m.configNotice); notice != "" { + b.WriteString(mutedStyle.Render(notice) + "\n\n") + } + if m.configScroll > 0 { + b.WriteString(mutedStyle.Render(fmt.Sprintf(" ··· %d more above ···", m.configScroll)) + "\n") + } + end := m.configScroll + configWindowSize + if end > total { + end = total + } + for i := m.configScroll; i < end; i++ { + prefix := " " + lineStyle := style + if i == m.configSel { + prefix = "❯ " + lineStyle = selectedStyle + } + b.WriteString(lineStyle.Render(prefix+opts[i]) + "\n") + } + if end < total { + b.WriteString(mutedStyle.Render(fmt.Sprintf(" ··· %d more below ···", total-end)) + "\n") + } + b.WriteString("\n" + mutedStyle.Render(fmt.Sprintf("%d providers · ★ = eyrie guess · ↑/↓ · enter · esc", total))) + return b.String() +} + +func (m chatModel) configProviderLabels() []string { + out := make([]string, len(m.configProviderOptions)) + for i, p := range m.configProviderOptions { + label := strings.TrimSpace(p.DisplayName) + if label == "" { + label = p.ProviderID + } + mark := " " + if p.Inferred { + mark = "★ " + } + out[i] = fmt.Sprintf("%s%-22s %s", mark, label, p.ProviderID) + } + return out +} + +func (m chatModel) handleConfigKeyResolvedMsg(msg configKeyResolvedMsg) (chatModel, tea.Cmd) { + secret := strings.TrimSpace(msg.secret) + if !msg.result.FormatOK { + m.configNotice = msg.result.FormatError + return m.startConfigEntry("apikey-paste", "") + } + if secret == "" { + m.configNotice = "Paste a valid API key" + return m.startConfigEntry("apikey-paste", "") + } + m.configPendingKey = secret + m.configProviderOptions = msg.result.Providers + m.configEntry = "" + m.configMenu = "providers" + m.configSel = 0 + m.configScroll = 0 + m.configNotice = "Step 2: select provider (★ = suggested from key shape)" + m.restoreChatInput() + return m, nil +} + +func (m chatModel) handleConfigProviderSelect() (chatModel, tea.Cmd) { + idx := m.configSel + if idx < 0 || idx >= len(m.configProviderOptions) { + return m, nil + } + opt := m.configProviderOptions[idx] + secret := strings.TrimSpace(m.configPendingKey) + if secret == "" { + m.configNotice = "Session expired — paste your API key again" + return m.startConfigEntry("apikey-paste", "") + } + inference := hawkconfig.InferenceFromOption(opt) + m.configNotice = fmt.Sprintf("Validating key for %s via eyrie…", opt.DisplayName) + m.configSaving = true + return m, saveProviderKeyAsync(inference, secret) +} + +func (m chatModel) handleConfigApplyCredentialsMsg(msg configApplyCredentialsMsg) (chatModel, tea.Cmd) { + m.configSaving = false + if msg.err != nil { + if msg.providerID == "ollama" { + return m.returnToOllamaURLAfterError(msg.err) + } + m.configNotice = formatConfigApplyError(msg.providerID, msg.err) + if strings.TrimSpace(m.configPendingKey) != "" && len(m.configProviderOptions) > 0 { + m.configMenu = "providers" + m.configSel = 0 + } else { + m.configMenu = "hub" + } + return m, nil + } + m.configPendingKey = "" + m.configProviderOptions = nil + m.configPendingOllamaURL = "" + m.configNotice = msg.summary + InvalidateModelCache() + m.configModelProvider = msg.providerID + if len(msg.modelOptions) > 0 { + modelCache[msg.providerID] = msg.modelOptions + } + next, cmd := m.rebuildSessionTransport() + if msg.providerID == "ollama" { + _ = hawkconfig.SetGlobalSetting("provider", "ollama") + next.session.SetProvider(hawkconfig.NormalizeProviderForEngine("ollama")) + } + next.configGuideAfterKey = false + if len(msg.modelOptions) == 0 { + if msg.providerID == "ollama" { + return next.returnToOllamaURLAfterError(fmt.Errorf("no models installed — run: ollama pull llama3.2")) + } + next.configMenu = "hub" + next.configNotice = "No models in catalog for " + msg.providerID + " — try another provider" + return next, cmd + } + next.configMenu = "model" + next.configSel = 0 + next.configScroll = 0 + next.configModelOptions = msg.modelOptions + next.configNotice = "Pick a model (" + msg.providerID + ")" + return next, cmd +} + +func (m chatModel) rebuildSessionTransport() (chatModel, tea.Cmd) { + if err := eyrieclient.RebuildSessionTransport(context.Background(), m.session, m.settings, m.session.Provider()); err != nil { + m.configNotice = err.Error() + } + return m, nil +} diff --git a/cmd/chat_config_hub.go b/cmd/chat_config_hub.go new file mode 100644 index 0000000..a0ad1cd --- /dev/null +++ b/cmd/chat_config_hub.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/GrayCodeAI/hawk/internal/eyrieclient" +) + +type configHubOption struct { + action string + label string +} + +func (m chatModel) configHubOptions() []configHubOption { + var out []configHubOption + if hawkconfig.EvaluateSetup(context.Background()).HasCredentials { + out = append(out, configHubOption{action: "model", label: "Pick model"}) + } + out = append( + out, + configHubOption{action: "apikey", label: "Paste API key"}, + configHubOption{action: "ollama", label: "Ollama (local — no key)"}, + ) + return out +} + +func (m chatModel) configHubLabels() []string { + opts := m.configHubOptions() + out := make([]string, len(opts)) + for i, o := range opts { + out[i] = o.label + } + return out +} + +func (m chatModel) configHubNotice() string { + if m.configSaving { + return "Working…" + } + st := hawkconfig.EvaluateSetup(context.Background()) + if !st.HasCredentials { + return "Step 1: choose how to connect" + } + prov := strings.TrimSpace(m.session.Provider()) + model := strings.TrimSpace(m.session.Model()) + if prov == "" { + prov = "unknown provider" + } + if model != "" { + return fmt.Sprintf("Current: %s · %s", prov, model) + } + return fmt.Sprintf("Current: %s · pick a model to start", prov) +} + +func (m chatModel) openConfigHub(firstRun bool) chatModel { + m.configOpen = true + m.configMenu = "hub" + m.configSel = 0 + m.configScroll = 0 + m.configEntry = "" + m.configSaving = false + m.configGuideAfterKey = firstRun + m.configNotice = m.configHubNotice() + m.viewDirty = true + return m +} + +func (m chatModel) beginConfigModelPicker() (chatModel, tea.Cmd) { + m.configMenu = "model" + m.configSel = 0 + m.configScroll = 0 + m.configModelProvider = firstRunModelProvider(m) + m.configModelOptions = loadConfigModelOptions(m.configModelProvider) + if len(m.configModelOptions) == 0 { + m.configNotice = "Loading models…" + return m, fetchModelsAsync(m.configModelProvider) + } + m.configNotice = "Pick a model" + return m, nil +} + +func (m chatModel) returnToOllamaURLAfterError(err error) (chatModel, tea.Cmd) { + m.configSaving = false + url := strings.TrimSpace(m.configPendingOllamaURL) + if url == "" { + url = "http://localhost:11434/v1" + } + if err != nil { + m.configNotice = hawkconfig.FormatConfigProviderError("ollama", err) + } + return m.startConfigOllamaURLWithValue(url) +} + +func formatConfigApplyError(providerID string, err error) string { + return eyrieclient.FormatSetupError(providerID, err) +} diff --git a/cmd/chat_config_models.go b/cmd/chat_config_models.go new file mode 100644 index 0000000..2e27a47 --- /dev/null +++ b/cmd/chat_config_models.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "context" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/GrayCodeAI/hawk/internal/eyrieclient" +) + +// configModelOption is one row in the /config model picker (display from eyrie, id for settings). +type configModelOption struct { + ID string + DisplayName string +} + +var modelCache = make(map[string][]configModelOption) + +// InvalidateModelCache clears in-memory model picker rows (call after credential apply or catalog refresh). +func InvalidateModelCache() { + modelCache = make(map[string][]configModelOption) +} + +func fetchModelsAsync(provider string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + provider = strings.TrimSpace(provider) + if provider == "" { + provider = hawkconfig.DefaultModelProviderFilter(ctx) + } + entries, err := eyrieclient.ListModelsForProvider(ctx, provider) + if err != nil { + if _, derr := eyrieclient.Discover(ctx); derr == nil { + InvalidateModelCache() + entries, err = eyrieclient.ListModelsForProvider(ctx, provider) + } + } + if err != nil { + return modelsFetchedMsg{provider: provider, err: err} + } + opts := configModelOptionsFromEyrie(entries) + if len(opts) > 0 { + modelCache[provider] = opts + } + return modelsFetchedMsg{options: opts, provider: provider} + } +} + +func configModelOptionsFromEyrie(entries []eyrieclient.ModelEntry) []configModelOption { + out := eyrieclient.ModelOptionsFromEntries(entries) + opts := make([]configModelOption, len(out)) + for i, o := range out { + opts[i] = configModelOption{ID: o.ID, DisplayName: o.DisplayName} + } + return opts +} + +func loadConfigModelOptions(provider string) []configModelOption { + provider = strings.TrimSpace(provider) + if provider == "" { + return nil + } + if cached, ok := modelCache[provider]; ok && len(cached) > 0 { + return cached + } + entries, err := eyrieclient.ListModelsForProvider(context.Background(), provider) + if err != nil || len(entries) == 0 { + return nil + } + opts := configModelOptionsFromEyrie(entries) + if len(opts) > 0 { + modelCache[provider] = opts + } + return opts +} diff --git a/cmd/chat_config_panel.go b/cmd/chat_config_panel.go index e0b82eb..5660f62 100644 --- a/cmd/chat_config_panel.go +++ b/cmd/chat_config_panel.go @@ -1,12 +1,10 @@ package cmd import ( + "context" "fmt" - "os" - "sort" "strings" - "github.com/GrayCodeAI/eyrie/catalog" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -14,103 +12,65 @@ import ( hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) -// In-memory model cache per provider (avoids re-fetching on every interaction) -var modelCache = make(map[string][]string) - -func fetchModelsAsync(provider string) tea.Cmd { - return func() tea.Msg { - models, _ := hawkconfig.FetchModelsForProvider(provider) - ids := extractModelIDs(models) - if len(ids) > 0 { - modelCache[provider] = ids - } - return modelsFetchedMsg(ids) +func configModelChoices(opts []configModelOption, showProvider bool) []string { + if len(opts) == 0 { + return nil } -} - -func configProviderChoices() []string { - providers := []string{ - "anthropic", "openai", "gemini", "openrouter", - "canopywave", "grok", "opencodego", "ollama", - } - var out []string - for _, p := range providers { - status := hawkconfig.EnvKeyStatus(p) - var statusText string - if p == "ollama" { - statusText = "local" - } else if status == "set" { - statusText = "✓" - } else { - statusText = "key needed" + out := make([]string, len(opts)) + for i, opt := range opts { + label := strings.TrimSpace(opt.DisplayName) + if label == "" { + label = shortModelID(opt.ID) } - // Fixed-width alignment: name in 12 chars, status right-aligned - label := fmt.Sprintf("%-12s %s", p, statusText) - out = append(out, label) - } - return out -} - -func configModelChoices(provider string, cached []string) []string { - provider = strings.ToLower(strings.TrimSpace(provider)) - if len(cached) > 0 { - out := make([]string, len(cached)) - copy(out, cached) - return out - } - // Fallback: load from embedded catalog synchronously - var out []string - if provider != "" { - cat := catalog.LoadModelCatalogSync("") - for _, entry := range catalog.ModelsForProvider(&cat, provider) { - if strings.TrimSpace(entry.ID) != "" { - out = append(out, entry.ID) + if showProvider { + if prov := hawkconfig.ProviderOfModel(opt.ID); prov != "" { + label = fmt.Sprintf("%-28s %s", label, prov) } } + out[i] = label } - sort.Strings(out) return out } -func extractModelIDs(models []catalog.ModelCatalogEntry) []string { - var out []string - seen := make(map[string]bool) - for _, m := range models { - id := strings.TrimSpace(m.ID) - if id != "" && !seen[id] { - seen[id] = true - out = append(out, id) - } +func shortModelID(id string) string { + id = strings.TrimSpace(id) + if i := strings.LastIndex(id, "/"); i >= 0 && i < len(id)-1 { + return id[i+1:] } - return out + return id } -// ─── Simple Config Wizard ─── -// /config opens provider list → select → [key prompt] → model list → select → done +// /config → paste key → all providers (eyrie) → model from catalog func (m chatModel) configOptions() []string { switch m.configMenu { - case "provider": - return configProviderChoices() - case "provider-action": - return []string{"Use this key", "Remove key"} + case "hub": + return m.configHubLabels() + case "providers": + return m.configProviderLabels() + case "remove-key": + return m.configRemoveKeyLabels() case "model": - settings := hawkconfig.LoadSettings() - return configModelChoices(settings.Provider, m.configModels) + return configModelChoices(m.configModelOptions, m.configModelProvider == "") default: return nil } } func (m chatModel) configPanelView() string { - if m.configEntry == "provider-apikey" { + if m.configEntry == "apikey-paste" { return m.configProviderKeyView() } + if m.configEntry == "ollama-url" { + return m.configOllamaURLView() + } switch m.configMenu { - case "provider": - return m.configProviderView() - case "provider-action": - return m.configProviderActionView() + case "hub": + return m.configHubView() + case "providers": + return m.configProvidersView() + case "remove-key": + return m.configRemoveKeyView() case "model": return m.configModelView() default: @@ -119,83 +79,37 @@ func (m chatModel) configPanelView() string { } func (m chatModel) configProviderKeyView() string { - provider := strings.TrimSpace(m.configProvider) - envKey := hawkconfig.ProviderAPIKeyEnv(provider) - titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")).Bold(true) mutedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8D939E")) - valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E6E6E6")) var b strings.Builder - b.WriteString(titleStyle.Render("🔑 ") + valueStyle.Render(provider) + "\n") - b.WriteString(mutedStyle.Render(envKey) + "\n\n") + b.WriteString(titleStyle.Render("🔑 Paste API key") + "\n") + b.WriteString(mutedStyle.Render("eyrie validates key · you pick provider · dynamic models") + "\n\n") if m.useConfigInput { b.WriteString(m.configInput.View() + "\n") } else { b.WriteString(m.input.View() + "\n") } - b.WriteString("\n" + mutedStyle.Render("enter save · esc skip") + "\n") + b.WriteString("\n" + mutedStyle.Render("enter continue · esc cancel") + "\n") return b.String() } -func (m chatModel) configProviderView() string { +func (m chatModel) configOllamaURLView() string { titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")).Bold(true) - selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")).Bold(true) mutedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8D939E")) - style := lipgloss.NewStyle().Foreground(lipgloss.Color("#E6E6E6")) - okStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#4ECDC4")) - warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#e05555")) var b strings.Builder - b.WriteString(titleStyle.Render("⚙ Select Provider") + "\n\n") - - opts := m.configOptions() - for i, opt := range opts { - prefix := " " - lineStyle := style - if i == m.configSel { - prefix = "❯ " - lineStyle = selectedStyle - } - // Colorize status indicators - if strings.Contains(opt, "✓") { - opt = strings.Replace(opt, "✓", okStyle.Render("✓"), 1) - } else if strings.Contains(opt, "key needed") { - opt = strings.Replace(opt, "key needed", warnStyle.Render("key needed"), 1) - } else if strings.Contains(opt, "local") { - opt = strings.Replace(opt, "local", mutedStyle.Render("local"), 1) - } - b.WriteString(lineStyle.Render(prefix+opt) + "\n") + b.WriteString(titleStyle.Render("🦙 Ollama local") + "\n") + b.WriteString(mutedStyle.Render("no API key · eyrie discovers installed models") + "\n\n") + if notice := strings.TrimSpace(m.configNotice); notice != "" { + b.WriteString(mutedStyle.Render(notice) + "\n\n") } - b.WriteString("\n" + mutedStyle.Render("↑/↓ · enter · esc")) - return b.String() -} - -func (m chatModel) configProviderActionView() string { - provider := strings.TrimSpace(m.configProvider) - envKey := hawkconfig.ProviderAPIKeyEnv(provider) - - titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")).Bold(true) - selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")).Bold(true) - mutedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8D939E")) - style := lipgloss.NewStyle().Foreground(lipgloss.Color("#E6E6E6")) - okStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#4ECDC4")) - - var b strings.Builder - b.WriteString(titleStyle.Render("⚙ ") + okStyle.Render("✓") + " " + style.Render(provider) + "\n") - b.WriteString(mutedStyle.Render(envKey) + "\n\n") - - opts := m.configOptions() - for i, opt := range opts { - prefix := " " - lineStyle := style - if i == m.configSel { - prefix = "❯ " - lineStyle = selectedStyle - } - b.WriteString(lineStyle.Render(prefix+opt) + "\n") + if m.useConfigInput { + b.WriteString(m.configInput.View() + "\n") + } else { + b.WriteString(m.input.View() + "\n") } - b.WriteString("\n" + mutedStyle.Render("↑/↓ · enter · esc")) + b.WriteString("\n" + mutedStyle.Render("enter connect · esc back") + "\n") return b.String() } @@ -210,7 +124,6 @@ func (m chatModel) configModelView() string { opts := m.configOptions() total := len(opts) - // Ensure scroll keeps cursor visible if m.configSel < m.configScroll { m.configScroll = m.configSel } @@ -219,14 +132,31 @@ func (m chatModel) configModelView() string { } var b strings.Builder - b.WriteString(titleStyle.Render("⚙ Select Model") + "\n\n") + title := "⚙ Select Model" + if p := strings.TrimSpace(m.configModelProvider); p != "" { + title = "⚙ Pick model (" + p + ")" + } + b.WriteString(titleStyle.Render(title) + "\n\n") + if notice := strings.TrimSpace(m.configNotice); notice != "" { + b.WriteString(mutedStyle.Render(notice) + "\n\n") + } + + if total == 0 { + b.WriteString(mutedStyle.Render(" No models available.") + "\n") + if hint := hawkconfig.CatalogEmptyHint(context.Background()); hint != "" { + b.WriteString(mutedStyle.Render(" "+hint) + "\n") + } + if m.configModelProvider == "ollama" { + b.WriteString(mutedStyle.Render(" Run: ollama pull llama3.2") + "\n") + } + b.WriteString("\n" + mutedStyle.Render("esc → change provider")) + return b.String() + } - // Scroll up indicator if m.configScroll > 0 { b.WriteString(mutedStyle.Render(fmt.Sprintf(" ··· %d more above ···", m.configScroll)) + "\n") } - // Visible window end := m.configScroll + configWindowSize if end > total { end = total @@ -241,7 +171,6 @@ func (m chatModel) configModelView() string { b.WriteString(lineStyle.Render(prefix+opts[i]) + "\n") } - // Scroll down indicator if end < total { b.WriteString(mutedStyle.Render(fmt.Sprintf(" ··· %d more below ···", total-end)) + "\n") } @@ -258,7 +187,11 @@ func (m chatModel) closeConfigPanel() chatModel { m.configNotice = "" m.configEntry = "" m.configProvider = "" - m.configModels = nil + m.configPendingKey = "" + m.configProviderOptions = nil + m.configPendingOllamaURL = "" + m.configSaving = false + m.configModelOptions = nil m.viewDirty = true m.restoreChatInput() return m @@ -275,149 +208,92 @@ func (m *chatModel) restoreChatInput() { func (m chatModel) startConfigEntry(kind, provider string) (chatModel, tea.Cmd) { m.configEntry = kind m.configProvider = provider - switch kind { - case "provider-apikey": - // Use textinput for password masking - m.useConfigInput = true - m.configInput.Reset() - m.configInput.Prompt = " key ❯ " - m.configInput.Placeholder = "paste " + provider + " API key" - m.configInput.EchoMode = textinput.EchoPassword - m.configInput.EchoCharacter = '*' - m.configInput.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")).Bold(true) - m.configInput.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F2F2F2")) - m.configInput.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")) - m.configInput.Focus() - return m, textinput.Blink - default: - // Use textarea for normal text entry - m.useConfigInput = false - m.input.Reset() - switch kind { - case "model": - m.input.Prompt = " model ❯ " - m.input.Placeholder = "model name" - case "provider": - m.input.Prompt = " provider ❯ " - m.input.Placeholder = "provider name" - } - m.input.Focus() - return m, m.input.Focus() + if kind == "ollama-url" { + return m.startConfigOllamaURL() + } + if kind != "apikey-paste" { + return m, nil } + m.useConfigInput = true + m.configInput.Reset() + m.configInput.Prompt = " key ❯ " + m.configInput.Placeholder = "paste API key" + m.configInput.EchoMode = textinput.EchoPassword + m.configInput.EchoCharacter = '*' + m.configInput.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")).Bold(true) + m.configInput.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F2F2F2")) + m.configInput.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")) + m.configInput.Focus() + return m, textinput.Blink } func (m chatModel) finishConfigEntry() (chatModel, tea.Cmd) { - var value string - if m.useConfigInput { - value = strings.TrimSpace(m.configInput.Value()) - } else { - value = strings.TrimSpace(m.input.Value()) - } - + value := strings.TrimSpace(m.configInput.Value()) switch m.configEntry { - case "provider-apikey": - provider := strings.TrimSpace(m.configProvider) - if value != "" { - envKey := hawkconfig.ProviderAPIKeyEnv(provider) - if envKey != "" { - _ = os.Setenv(envKey, value) - _ = hawkconfig.SaveEnvFile(envKey, value) - } - m.session.SetAPIKey(provider, value) + case "ollama-url": + if value == "" { + value = "http://localhost:11434/v1" } + m.configPendingOllamaURL = value + m.configSaving = true + m.configNotice = "Checking Ollama and discovering models…" m.configEntry = "" - m.configMenu = "model" - m.configSel = 0 - m.configModels = nil m.restoreChatInput() - // Invalidate cache for this provider since key just changed - delete(modelCache, provider) - return m, fetchModelsAsync(provider) - - case "model": - if value == "" { - m.configEntry = "" - m.configProvider = "" - m.restoreChatInput() - return m, nil - } - if err := hawkconfig.SetGlobalSetting("model", value); err != nil { - m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) - } else { - m.session.SetModel(value) - } - return m.closeConfigPanel(), nil - - case "provider": + return m, saveOllamaAsync(value) + case "apikey-paste": if value == "" { m.configEntry = "" - m.configProvider = "" m.restoreChatInput() return m, nil } - engineProvider := hawkconfig.NormalizeProviderForEngine(value) - if err := hawkconfig.SetGlobalSetting("provider", value); err != nil { - m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) - return m.closeConfigPanel(), nil - } - m.session.SetProvider(engineProvider) - - // Same flow as normal provider selection: key prompt or model list - if engineProvider != "ollama" && hawkconfig.EnvKeyStatus(engineProvider) != "set" { - m.configProvider = engineProvider - return m.startConfigEntry("provider-apikey", engineProvider) - } - models, _ := hawkconfig.FetchModelsForProvider(engineProvider) - m.configModels = extractModelIDs(models) + m.configNotice = "Resolving providers via eyrie…" + m.configEntry = "" + m.restoreChatInput() + return m, resolveKeyAsync(value) + default: m.configEntry = "" - m.configProvider = "" - m.configMenu = "model" - m.configSel = 0 m.restoreChatInput() return m, nil } - - // Fallback - m.configEntry = "" - m.configProvider = "" - m.restoreChatInput() - return m, nil } func (m chatModel) handleConfigEntryKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { switch msg.Type { case tea.KeyEsc: - if m.configEntry == "provider-apikey" { - // Skip key entry, go to model selection + switch m.configEntry { + case "ollama-url": m.configEntry = "" m.configProvider = "" - m.configMenu = "model" - m.configSel = 0 + m.configMenu = "hub" + m.configSel = 1 + m.configNotice = "Step 1: choose how to connect" m.restoreChatInput() return m, nil + default: + m.configEntry = "" + m.configProvider = "" + m.restoreChatInput() + return m.closeConfigPanel(), nil } - m.configEntry = "" - m.configProvider = "" - m.restoreChatInput() - return m, nil case tea.KeyEnter: return m.finishConfigEntry() default: - if m.useConfigInput { - var cmd tea.Cmd - m.configInput, cmd = m.configInput.Update(msg) - return m, cmd - } var cmd tea.Cmd - m.input, cmd = m.input.Update(msg) + m.configInput, cmd = m.configInput.Update(msg) return m, cmd } } func (m chatModel) handleConfigKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { if m.configEntry != "" { + if m.configSaving { + return m, nil + } return m.handleConfigEntryKey(msg) } + if m.configSaving { + return m, nil + } opts := m.configOptions() if len(opts) == 0 { m.configSel = 0 @@ -429,21 +305,28 @@ func (m chatModel) handleConfigKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { switch msg.Type { case tea.KeyEsc: - if m.configMenu == "provider" || m.configMenu == "" { - return m.closeConfigPanel(), nil - } - if m.configMenu == "provider-action" { - m.configProvider = "" - m.configMenu = "provider" + switch m.configMenu { + case "providers": + m.configPendingKey = "" + m.configProviderOptions = nil + return m.startConfigEntry("apikey-paste", "") + case "model": + m.configMenu = "hub" + m.configSel = 0 + m.configNotice = m.configHubNotice() + m.restoreChatInput() + return m, nil + case "remove-key": + m.configMenu = "hub" m.configSel = 0 + m.configNotice = m.configHubNotice() + m.restoreChatInput() return m, nil + case "hub": + return m.closeConfigPanel(), nil + default: + return m.closeConfigPanel(), nil } - // From model list → back to provider list - m.configMenu = "provider" - m.configSel = 0 - m.configNotice = "" - m.configModels = nil - return m, nil case tea.KeyUp: if m.configSel == 0 { m.configSel = len(opts) - 1 @@ -455,70 +338,52 @@ func (m chatModel) handleConfigKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { m.configSel = (m.configSel + 1) % len(opts) return m, nil case tea.KeyEnter: - return m.selectConfigOption(opts[m.configSel]) + switch m.configMenu { + case "hub": + return m.handleConfigHubSelect() + case "providers": + return m.handleConfigProviderSelect() + case "remove-key": + return m.handleConfigRemoveKeySelect() + case "model": + if m.configSel >= 0 && m.configSel < len(opts) { + return m.selectConfigOption(opts[m.configSel]) + } + } + return m, nil } return m, nil } func (m chatModel) selectConfigOption(option string) (chatModel, tea.Cmd) { - switch m.configMenu { - case "provider": - // Extract provider name (first word) and normalize for engine - provider := strings.Fields(option)[0] - engineProvider := hawkconfig.NormalizeProviderForEngine(provider) - if err := hawkconfig.SetGlobalSetting("provider", provider); err != nil { - m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) - return m.closeConfigPanel(), nil - } - m.session.SetProvider(engineProvider) - - if hawkconfig.EnvKeyStatus(engineProvider) != "set" && engineProvider != "ollama" { - // Key missing → prompt for it - m.configProvider = engineProvider - return m.startConfigEntry("provider-apikey", engineProvider) - } - - // Key is set → show action menu - m.configProvider = engineProvider - m.configMenu = "provider-action" - m.configSel = 0 - return m, nil - - case "provider-action": - provider := strings.TrimSpace(m.configProvider) - switch option { - case "Use this key": - m.configMenu = "model" - m.configSel = 0 - if cached, ok := modelCache[provider]; ok && len(cached) > 0 { - m.configModels = cached - return m, nil - } - m.configModels = nil - return m, fetchModelsAsync(provider) - case "Remove key": - envKey := hawkconfig.ProviderAPIKeyEnv(provider) - if envKey != "" { - _ = os.Unsetenv(envKey) - _ = hawkconfig.RemoveEnvFile(envKey) - } - delete(modelCache, provider) - m.configProvider = "" - m.configMenu = "provider" - m.configSel = 0 - return m, nil - } + if m.configMenu != "model" { return m, nil - - case "model": - if err := hawkconfig.SetGlobalSetting("model", option); err != nil { - m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) - return m.closeConfigPanel(), nil - } - m.session.SetModel(option) + } + var modelID string + if m.configSel >= 0 && m.configSel < len(m.configModelOptions) { + modelID = m.configModelOptions[m.configSel].ID + } else { + modelID = hawkconfig.ResolveCanonicalModel(option) + } + if err := hawkconfig.SetGlobalSetting("model", modelID); err != nil { + m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) return m.closeConfigPanel(), nil - - default: - return m, nil } + m.session.SetModel(modelID) + if prov := hawkconfig.ProviderOfModel(modelID); prov != "" { + _ = hawkconfig.SetGlobalSetting("provider", prov) + m.session.SetProvider(hawkconfig.NormalizeProviderForEngine(prov)) + } else if p := strings.TrimSpace(m.configModelProvider); p != "" { + _ = hawkconfig.SetGlobalSetting("provider", p) + m.session.SetProvider(hawkconfig.NormalizeProviderForEngine(p)) + } + next, cmd := m.rebuildSessionTransport() + next = next.closeConfigPanel() + if !hawkconfig.EvaluateSetup(context.Background()).NeedsSetup { + next.messages = append(next.messages, displayMsg{ + role: "system", + content: fmt.Sprintf("Setup complete — chatting with %s", next.session.Model()), + }) + } + return next, cmd } diff --git a/cmd/chat_config_remove.go b/cmd/chat_config_remove.go new file mode 100644 index 0000000..fc31649 --- /dev/null +++ b/cmd/chat_config_remove.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" +) + +type configRemoveCredentialMsg struct { + provider string + removed []string + err error +} + +func (m chatModel) configRemoveKeyLabels() []string { + return hawkconfig.ConfiguredCredentialProviders() +} + +func (m chatModel) beginConfigRemoveKeyPicker() (chatModel, tea.Cmd) { + providers := hawkconfig.ConfiguredCredentialProviders() + if len(providers) == 0 { + m.configMenu = "hub" + m.configNotice = "No stored API keys to remove" + return m, nil + } + m.configMenu = "remove-key" + m.configSel = 0 + m.configScroll = 0 + m.configNotice = "Select provider to remove its API key from the OS secret store" + return m, nil +} + +func (m chatModel) configRemoveKeyView() string { + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")).Bold(true) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5E0E")).Bold(true) + mutedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8D939E")) + style := lipgloss.NewStyle().Foreground(lipgloss.Color("#E6E6E6")) + + opts := m.configRemoveKeyLabels() + total := len(opts) + if m.configSel < m.configScroll { + m.configScroll = m.configSel + } + if m.configSel >= m.configScroll+configWindowSize { + m.configScroll = m.configSel - configWindowSize + 1 + } + + var b strings.Builder + b.WriteString(titleStyle.Render("🗑 Remove API key") + "\n\n") + if notice := strings.TrimSpace(m.configNotice); notice != "" { + b.WriteString(mutedStyle.Render(notice) + "\n\n") + } + if total == 0 { + b.WriteString(mutedStyle.Render(" No stored API keys.") + "\n") + b.WriteString("\n" + mutedStyle.Render("esc → back")) + return b.String() + } + end := m.configScroll + configWindowSize + if end > total { + end = total + } + for i := m.configScroll; i < end; i++ { + prefix := " " + lineStyle := style + if i == m.configSel { + prefix = "❯ " + lineStyle = selectedStyle + } + b.WriteString(lineStyle.Render(prefix+opts[i]) + "\n") + } + help := "↑/↓ · enter remove · esc back" + if m.configSaving { + help = "please wait…" + } + b.WriteString("\n" + mutedStyle.Render(help)) + return b.String() +} + +func (m chatModel) handleConfigRemoveKeySelect() (chatModel, tea.Cmd) { + if m.configSaving { + return m, nil + } + providers := hawkconfig.ConfiguredCredentialProviders() + if m.configSel < 0 || m.configSel >= len(providers) { + return m, nil + } + provider := providers[m.configSel] + m.configSaving = true + m.configNotice = fmt.Sprintf("Removing API key for %s…", provider) + return m, removeCredentialAsync(provider) +} + +func removeCredentialAsync(provider string) tea.Cmd { + return func() tea.Msg { + removed, err := hawkconfig.RemoveStoredCredential(context.Background(), provider) + return configRemoveCredentialMsg{ + provider: provider, + removed: removed, + err: err, + } + } +} + +func (m chatModel) handleConfigRemoveCredentialMsg(msg configRemoveCredentialMsg) (chatModel, tea.Cmd) { + m.configSaving = false + if msg.err != nil { + m.configNotice = msg.err.Error() + m.configMenu = "remove-key" + return m, nil + } + delete(modelCache, msg.provider) + m.configMenu = "hub" + m.configSel = 0 + m.configScroll = 0 + m.configNotice = fmt.Sprintf("Removed API key for %s (%s)", msg.provider, strings.Join(msg.removed, ", ")) + next, cmd := m.rebuildSessionTransport() + next.configNotice = next.configHubNotice() + "\n" + fmt.Sprintf("Removed key for %s", msg.provider) + return next, cmd +} + +func (m chatModel) openConfigRemoveKeyPanel() (chatModel, tea.Cmd) { + next, cmd := m.openConfigPanel() + next, _ = next.beginConfigRemoveKeyPicker() + return next, cmd +} diff --git a/cmd/chat_config_remove_test.go b/cmd/chat_config_remove_test.go new file mode 100644 index 0000000..749c65b --- /dev/null +++ b/cmd/chat_config_remove_test.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/GrayCodeAI/eyrie/credentials" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" +) + +func TestConfigHubOptions_OmitsRemoveKeyEntry(t *testing.T) { + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + + _ = store.Set(t.Context(), credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + + m := chatModel{} + for _, o := range m.configHubOptions() { + if o.action == "remove-key" { + t.Fatal("remove-key belongs on /config key remove only, not the hub menu") + } + } +} + +func TestConfigHubOptions_OmitsRemoveWithoutCredentials(t *testing.T) { + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + + m := chatModel{} + for _, o := range m.configHubOptions() { + if o.action == "remove-key" { + t.Fatal("remove-key should not appear when no credentials are stored") + } + } +} + +func TestConfiguredCredentialProviders_UsedByRemovePicker(t *testing.T) { + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + + _ = store.Set(t.Context(), credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + + m := chatModel{} + labels := m.configRemoveKeyLabels() + if len(labels) == 0 { + t.Fatal("expected at least one removable provider") + } + providers := hawkconfig.ConfiguredCredentialProviders() + if len(labels) != len(providers) { + t.Fatalf("labels = %v providers = %v", labels, providers) + } +} + +func TestRemoveCredentialAsyncMessage(t *testing.T) { + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + + _ = store.Set(t.Context(), credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + + msg := removeCredentialAsync("openrouter")() + rem, ok := msg.(configRemoveCredentialMsg) + if !ok { + t.Fatalf("unexpected msg type %T", msg) + } + if rem.err != nil { + t.Fatal(rem.err) + } + if len(rem.removed) != 1 || rem.removed[0] != "OPENROUTER_API_KEY" { + t.Fatalf("removed = %v", rem.removed) + } + if strings.TrimSpace(rem.provider) != "openrouter" { + t.Fatalf("provider = %q", rem.provider) + } +} diff --git a/cmd/chat_model.go b/cmd/chat_model.go index 443243d..324b917 100644 --- a/cmd/chat_model.go +++ b/cmd/chat_model.go @@ -64,13 +64,18 @@ type ( type ( glimmerTickMsg struct{} - modelsFetchedMsg []string - loopTickMsg struct{ command string } - toolUseMsg struct{ name, id string } - toolResultMsg struct{ name, content string } - permissionAskMsg struct{ req engine.PermissionRequest } - thinkingMsg string - askUserMsg struct { + modelsFetchedMsg struct { + options []configModelOption + provider string + err error + } + loopTickMsg struct{ command string } + firstRunOpenConfigMsg struct{} + toolUseMsg struct{ name, id string } + toolResultMsg struct{ name, content string } + permissionAskMsg struct{ req engine.PermissionRequest } + thinkingMsg string + askUserMsg struct { question string response chan string } @@ -97,53 +102,59 @@ func (r *progRef) Send(msg tea.Msg) { } type chatModel struct { - input textarea.Model - configInput textinput.Model // secondary input for config panel password entry - useConfigInput bool // true when config panel needs textinput (e.g. password) - spinner spinner.Model - viewport viewport.Model - session *engine.Session - registry *tool.Registry - settings hawkconfig.Settings - ref *progRef - cancel context.CancelFunc // cancel current stream - sessionID string - messages []displayMsg - partial *strings.Builder - waiting bool - permReq *engine.PermissionRequest // pending permission prompt - askReq *askUserMsg // pending ask_user prompt - width int - height int - quitting bool - blinkClosed bool - slashSel int - configOpen bool - configMenu string - configSel int - configScroll int // scroll offset for long lists - configNotice string - configEntry string - configProvider string - configModels []string // fetched from eyrie at runtime - pluginRuntime *plugin.Runtime - spinnerVerb string - glimmerPos int - lastCtrlC time.Time - history []string - historyIdx int - historyDraft string // unsent text before navigating history - autoScroll bool // whether to auto-scroll viewport to bottom - vim *VimState - contextViz *ContextVisualization - wal *session.WAL - startedAt time.Time - toolStartTime time.Time - welcomeCache string - viewDirty bool - activeSkills map[string]plugin.SmartSkill // per-session activated skills - - // Container mode (herm-style hermetic execution) + input textarea.Model + configInput textinput.Model // secondary input for config panel password entry + useConfigInput bool // true when config panel needs textinput (e.g. password) + spinner spinner.Model + viewport viewport.Model + session *engine.Session + registry *tool.Registry + settings hawkconfig.Settings + ref *progRef + cancel context.CancelFunc // cancel current stream + sessionID string + messages []displayMsg + partial *strings.Builder + waiting bool + permReq *engine.PermissionRequest // pending permission prompt + askReq *askUserMsg // pending ask_user prompt + width int + height int + quitting bool + blinkClosed bool + slashSel int + configOpen bool + configMenu string + configSel int + configScroll int // scroll offset for long lists + configNotice string + configEntry string + configProvider string + configModelOptions []configModelOption // labels + ids from eyrie catalog + configModelProvider string // filter models after API key paste + configGuideAfterKey bool // open model picker when discover finishes + configPendingKey string + configProviderOptions []hawkconfig.CredentialProviderOption + configSaving bool // blocks hub/list input while async credential work runs + configPendingOllamaURL string + pluginRuntime *plugin.Runtime + spinnerVerb string + glimmerPos int + lastCtrlC time.Time + history []string + historyIdx int + historyDraft string // unsent text before navigating history + autoScroll bool // whether to auto-scroll viewport to bottom + vim *VimState + contextViz *ContextVisualization + wal *session.WAL + startedAt time.Time + toolStartTime time.Time + welcomeCache string + viewDirty bool + activeSkills map[string]plugin.SmartSkill // per-session activated skills + + // Container mode (hermetic execution in sandbox) containerEnabled bool containerStatus string // "checking docker…", "pulling image…", "starting…", "", "docker not running" containerReady bool diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index ad22675..fcbc667 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -1,16 +1,18 @@ package cmd import ( + "context" "fmt" - "os" "sort" "strings" "github.com/GrayCodeAI/eyrie/client" + "github.com/GrayCodeAI/eyrie/credentials" "github.com/mattn/go-runewidth" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/GrayCodeAI/hawk/internal/eyrieclient" "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/tool" ) @@ -78,13 +80,16 @@ func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool. verLine := fmt.Sprintf("v%s", version) b.WriteString("\n" + center(dimC+verLine+rst, len(verLine)) + "\n") - tip := "TIP: Use /help to see all available commands" - b.WriteString("\n" + center(boldC+tip+rst, len(tip)) + "\n") - - shortcuts := "shift+tab to cycle modes · ctrl+N to cycle models" - b.WriteString("\n" + center(dimC+shortcuts+rst, len(shortcuts)) + "\n") - shortcuts2 := "ctrl+L for autonomy · tab for reasoning" - b.WriteString(center(dimC+shortcuts2+rst, len(shortcuts2)) + "\n") + needsSetup := hawkconfig.NeedsFirstRunSetup(context.Background()) + if needsSetup { + tip := "Complete setup below, then type your first message" + b.WriteString("\n" + center(boldC+tip+rst, len(tip)) + "\n") + } else { + tip := "TIP: /help for commands · /config to change model" + b.WriteString("\n" + center(boldC+tip+rst, len(tip)) + "\n") + shortcuts := "shift+tab modes · ctrl+N models · esc cancel" + b.WriteString(center(dimC+shortcuts+rst, len(shortcuts)) + "\n") + } skillsCount := 0 mcpCount := len(settings.MCPServers) + len(mcpServers) @@ -103,6 +108,13 @@ func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool. indVis := fmt.Sprintf("Skills (%d) x MCPs (%d) x AGENTS.md x", skillsCount, mcpCount) b.WriteString("\n" + center(indicators, len(indVis)) + "\n") + if hint := hawkconfig.FirstRunSetupHint(context.Background()); hint != "" { + b.WriteString("\n" + center(boldC+hint+rst, len(hint)) + "\n") + } + + catalogLine := hawkconfig.CatalogStatusLine(context.Background()) + b.WriteString(center(dimC+catalogLine+rst, len(catalogLine)) + "\n") + if resume := actLine(saved, sessionID); resume != "" { b.WriteString("\n") b.WriteString(center(dimC+resume+rst, len(resume)) + "\n") @@ -147,20 +159,14 @@ func toolListSummary(registry *tool.Registry) string { } func envSummary(provider, model string) string { - envKeys := []string{ - "ANTHROPIC_API_KEY", - "OPENAI_API_KEY", - "GEMINI_API_KEY", - "OPENROUTER_API_KEY", - "CANOPYWAVE_API_KEY", - "XAI_API_KEY", - "OPENCODEGO_API_KEY", - } + envKeys := eyrieclient.DiscoveryEnvKeys(context.Background()) + sort.Strings(envKeys) var b strings.Builder - b.WriteString(fmt.Sprintf("Provider: %s\nModel: %s\n\nEnvironment:\n", provider, model)) + b.WriteString(fmt.Sprintf("Provider: %s\nModel: %s\n\nCredentials (%s):\n", provider, model, credentials.PlatformSecretStoreName())) + ctx := context.Background() for _, key := range envKeys { status := "missing" - if os.Getenv(key) != "" { + if credentials.HasSecret(ctx, key) { status = "set" } b.WriteString(fmt.Sprintf(" %s: %s\n", key, status)) @@ -169,28 +175,23 @@ func envSummary(provider, model string) string { } func configCommandSummary(settings hawkconfig.Settings) string { - provider := displayConfigValue(settings.Provider) - model := displayConfigValue(settings.Model) - return fmt.Sprintf(`Configure Hawk + _ = settings + provider := displayConfigValue(hawkconfig.ActiveProvider(nil)) + model := displayConfigValue(hawkconfig.ActiveModel(nil)) + return fmt.Sprintf(`Setup (eyrie) -Run these commands: - /config provider openai - /model gpt-4o + /config → API key + model (opens automatically on first run) Current: provider: %s - model: %s - configured keys: %s - -API keys are set via environment variables (herm-style). -More: - /config keys - /config get - /config set `, provider, model, configuredKeyList()) + model: %s + keys: %s + +Model catalog and routing live in eyrie — hawk is the UI only.`, provider, model, configuredKeyList()) } func apiKeyConfigSummary() string { - return "API keys (from environment)\n" + indentedAPIKeyLines() + return "API keys (" + credentials.PlatformSecretStoreName() + ")\n" + indentedAPIKeyLines() } func configuredKeyList() string { diff --git a/cmd/completions.go b/cmd/completions.go index 14ad213..64fbc2b 100644 --- a/cmd/completions.go +++ b/cmd/completions.go @@ -7,6 +7,8 @@ import ( "path/filepath" "runtime" "strings" + + "github.com/GrayCodeAI/hawk/internal/provider/routing" ) // FlagInfo describes a CLI flag for completion generation. @@ -178,7 +180,7 @@ func (g *CompletionGenerator) populateCommands() { }, { Name: "sandbox", - Description: "Sandbox configuration", + Description: "Bash permission profile (strict/workspace/off); not Docker container mode", }, { Name: "cost", @@ -224,7 +226,7 @@ func (g *CompletionGenerator) populateFlags() { {Name: "settings", Description: "Path to a settings JSON file", Type: "string"}, {Name: "add-dir", Description: "Additional directories to include", Type: "string"}, {Name: "tools", Description: "Available tools configuration", Type: "string"}, - {Name: "sandbox", Description: "Sandbox mode for Bash commands", Type: "string", Choices: []string{"strict", "workspace", "off"}}, + {Name: "sandbox", Description: "Bash permission profile (not Docker; use --no-container for host)", Type: "string", Choices: []string{"strict", "workspace", "off"}}, {Name: "auto-commit", Description: "Auto-commit file changes", Type: "bool"}, {Name: "watch", Description: "Watch working directory for file changes", Type: "bool"}, {Name: "vibe", Description: "Vibe coding mode", Type: "bool"}, @@ -258,24 +260,7 @@ func (g *CompletionGenerator) populateProviders() { } func (g *CompletionGenerator) populateModels() { - g.Models = []string{ - "claude-sonnet-4-20250514", - "claude-opus-4-20250514", - "claude-haiku-3-20250307", - "gpt-4o", - "gpt-4o-mini", - "gpt-4-turbo", - "o1", - "o1-mini", - "o3-mini", - "gemini-2.0-flash", - "gemini-2.0-pro", - "deepseek-chat", - "deepseek-reasoner", - "mistral-large-latest", - "llama-3.1-70b", - "llama-3.1-405b", - } + g.Models = routing.AllCatalogModelNames() } func (g *CompletionGenerator) populateSlashCommands() { diff --git a/cmd/container_boot.go b/cmd/container_boot.go index e6895be..9181b3e 100644 --- a/cmd/container_boot.go +++ b/cmd/container_boot.go @@ -72,7 +72,7 @@ func shouldUseContainer() bool { } // bootContainerCmd starts the container in the background and sends status -// updates to the TUI (herm-style async boot with progress feedback). +// updates to the TUI (async boot with progress feedback). func bootContainerCmd(projectDir string) tea.Cmd { return func() tea.Msg { cs := sandbox.NewContainerSandbox(projectDir) diff --git a/cmd/contextual_help.go b/cmd/contextual_help.go index 2f25356..78a84c5 100644 --- a/cmd/contextual_help.go +++ b/cmd/contextual_help.go @@ -103,7 +103,7 @@ func (ch *ContextualHelp) registerAllEntries() { Topic: "/config", Summary: "Open configuration panel", Detail: "Opens the interactive configuration panel for hawk settings, model selection, and preferences.", - Examples: []string{"/config — open config panel", "/config model — change model", "/config key — set API key"}, + Examples: []string{"/config — open config panel", "/config model — change model", "/config key remove — remove stored API key", "/config keys — show key status"}, Related: []string{"/session", "/profile", "/rules"}, Category: "slash-commands", }, @@ -280,8 +280,8 @@ func (ch *ContextualHelp) registerAllEntries() { { Topic: "error: api key invalid", Summary: "API key is missing or invalid", - Detail: "Your API key is not configured or has expired. Set it via /config key or the HAWK_API_KEY environment variable.", - Examples: []string{"/config key — set API key interactively", "export HAWK_API_KEY=sk-...", "hawk --key sk-... — pass key as flag"}, + Detail: "Your API key is not configured or has expired. Save a new key via /config (paste in the panel). Keys are stored in the OS secret store (macOS Keychain / Linux keyring).", + Examples: []string{"/config — paste API key in the config panel", "hawk credentials status — verify stored keys"}, Related: []string{"/config", "error: rate limit", "error: network"}, Category: "errors", }, @@ -353,8 +353,8 @@ func (ch *ContextualHelp) registerAllEntries() { { Topic: "config: api-key", Summary: "Set the API key", - Detail: "Configure your API key for authentication. Can be set via config, environment variable, or command flag.", - Examples: []string{"/config key sk-... — set key directly", "export HAWK_API_KEY=sk-...", "hawk --key sk-..."}, + Detail: "API keys are stored in the OS secret store. Use /config to paste a key, or /config key remove to delete one.", + Examples: []string{"/config — paste API key in the config panel", "/config key remove — remove a stored key", "hawk credentials status — list configured providers"}, Related: []string{"/config", "config: model", "error: api key invalid"}, Category: "configuration", }, diff --git a/cmd/credentials.go b/cmd/credentials.go new file mode 100644 index 0000000..5530955 --- /dev/null +++ b/cmd/credentials.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/GrayCodeAI/eyrie/credentials" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/spf13/cobra" +) + +var credentialsCmd = &cobra.Command{ + Use: "credentials", + Short: "Manage secure API key storage (macOS Keychain / Linux secret service)", +} + +var credentialsStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show where API keys are stored", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + hawkconfig.PrepareCredentialDiscovery(ctx) + cmd.Println(hawkconfig.FormatCredentialCLIStatus(ctx)) + return nil + }, +} + +var credentialsRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a stored API key from the OS secret store", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + removed, err := hawkconfig.RemoveStoredCredential(ctx, args[0]) + if err != nil { + return err + } + cmd.Printf("Removed %d key(s) from %s: %s\n", len(removed), credentials.PlatformSecretStoreName(), strings.Join(removed, ", ")) + return nil + }, +} + +var credentialsMigrateCmd = &cobra.Command{ + Use: "migrate", + Short: "Import legacy plaintext credential files into the OS secret store", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + ok, detail := credentials.KeychainWriteAvailable(ctx) + if !ok { + return fmt.Errorf("cannot migrate: %s", detail) + } + n, err := credentials.MigrateLegacyEnvFile(ctx) + if err != nil { + return err + } + if n == 0 { + cmd.Println("No legacy credential files found (already using secure storage).") + } else { + cmd.Printf("Migrated %d key(s) to %s and removed legacy credential files.\n", n, credentials.PlatformSecretStoreName()) + } + return nil + }, +} + +func init() { + credentialsCmd.AddCommand(credentialsStatusCmd) + credentialsCmd.AddCommand(credentialsMigrateCmd) + credentialsCmd.AddCommand(credentialsRemoveCmd) +} diff --git a/cmd/diagnostics.go b/cmd/diagnostics.go index 79b528b..8a0075c 100644 --- a/cmd/diagnostics.go +++ b/cmd/diagnostics.go @@ -6,7 +6,10 @@ import ( "os" "strings" + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/GrayCodeAI/hawk/internal/eyrieclient" "github.com/GrayCodeAI/hawk/internal/intelligence/memory" "github.com/GrayCodeAI/hawk/internal/plugin" "github.com/GrayCodeAI/hawk/internal/resilience/health" @@ -29,6 +32,13 @@ func doctorReport(settings hawkconfig.Settings) string { b.WriteString(fmt.Sprintf("Directory: %s\n", cwd)) b.WriteString(fmt.Sprintf("Provider: %s\n", provider)) b.WriteString(fmt.Sprintf("Model: %s\n", modelName)) + b.WriteString("\n" + hawkconfig.FormatCatalogHealth(hawkconfig.CatalogHealthReport(context.Background())) + "\n") + b.WriteString("\n" + eyrieclient.FormatPreflightReport(eyrieclient.Preflight(context.Background())) + "\n") + b.WriteString("\n" + credentials.FormatStorageReport(credentials.StorageReportFor(context.Background())) + "\n") + if deployReport, err := hawkconfig.DeploymentStatusReport(context.Background(), modelName); err == nil { + b.WriteString("\n" + deployReport + "\n") + } + _ = hawkconfig.MigrateProviderConfig() b.WriteString("\n" + envSummary(provider, modelName) + "\n") b.WriteString("\nGit:\n") if branch := branchSummary(); branch != "" { @@ -74,24 +84,9 @@ func doctorReport(settings hawkconfig.Settings) string { func healthCheckReport(settings hawkconfig.Settings, provider string) string { registry := health.NewRegistry() - // API key check - apiKey := "" - switch provider { - case "anthropic": - apiKey = os.Getenv("ANTHROPIC_API_KEY") - case "openai": - apiKey = os.Getenv("OPENAI_API_KEY") - case "google": - apiKey = os.Getenv("GOOGLE_API_KEY") - case "openrouter": - apiKey = os.Getenv("OPENROUTER_API_KEY") - case "grok": - apiKey = os.Getenv("XAI_API_KEY") - case "canopywave": - apiKey = os.Getenv("CANOPYWAVE_API_KEY") - case "opencodego": - apiKey = os.Getenv("OPENCODEGO_API_KEY") - } + ctx := context.Background() + apiKeyEnv := primaryAPIKeyEnvForProvider(ctx, provider) + apiKey := credentials.LookupSecret(ctx, apiKeyEnv) registry.Register("api_key", health.APIKeyChecker(provider, apiKey)) // Settings validation @@ -134,6 +129,21 @@ func healthCheckReport(settings hawkconfig.Settings, provider string) string { return strings.TrimRight(b.String(), "\n") } +func primaryAPIKeyEnvForProvider(ctx context.Context, provider string) string { + provider = strings.TrimSpace(provider) + if provider == "" || provider == "auto" { + provider = strings.TrimSpace(hawkconfig.ActiveProvider(ctx)) + } + if provider == "" { + return "" + } + compiled, err := eyrieclient.LoadCatalog(ctx) + if err != nil || compiled == nil { + return "" + } + return catalog.PrimaryAPIKeyEnvForProvider(compiled, provider) +} + func settingsSummary(settings hawkconfig.Settings) string { return configCommandSummary(settings) } diff --git a/cmd/dx.go b/cmd/dx.go index 2c65ec8..72cfcc8 100644 --- a/cmd/dx.go +++ b/cmd/dx.go @@ -65,10 +65,10 @@ func doctorOutput(settings hawkconfig.Settings) string { } b.WriteString("\nProvider:\n") b.WriteString(fmt.Sprintf(" Provider: %s\n", effectiveProvider)) - b.WriteString(fmt.Sprintf(" API key: %s\n", maskedKeyStatus(settings.Provider))) + b.WriteString(fmt.Sprintf(" API key: %s\n", maskedKeyStatus(hawkconfig.ActiveProvider(nil)))) - // Model configured - effectiveModel := strings.TrimSpace(settings.Model) + // Model configured (eyrie provider.json) + effectiveModel := strings.TrimSpace(hawkconfig.ActiveModel(nil)) if effectiveModel == "" { effectiveModel = "(not configured)" } diff --git a/cmd/errors.go b/cmd/errors.go index 25f0312..e26dd5d 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -13,6 +13,7 @@ import ( "syscall" "time" + "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) @@ -47,7 +48,7 @@ func friendlyError(err error) string { for _, pk := range providerKeys { for _, pat := range pk.patterns { if strings.Contains(low, pat) { - return fmt.Sprintf("%s API key is missing or invalid. Set %s in your environment, then restart hawk.\n export %s=sk-...\nOr run /config to set it interactively.", pk.provider, pk.envVar, pk.envVar) + return fmt.Sprintf("%s API key is missing or invalid. Run /config to save it in %s.", pk.provider, credentials.PlatformSecretStoreName()) } } } @@ -101,7 +102,11 @@ func friendlyError(err error) string { if strings.Contains(low, "model not found") || strings.Contains(low, "model_not_found") || strings.Contains(low, "unknown model") || strings.Contains(low, "invalid model") || strings.Contains(low, "does not exist") || (strings.Contains(low, "404") && strings.Contains(low, "model")) { - return "Model not found. Check your model name with /model.\n Common models: claude-sonnet-4-20250514, gpt-4o, gemini-2.0-flash\n Use /models to see available options, or /config to change provider." + ex1, ex2 := hawkconfig.ExampleModelHints() + return fmt.Sprintf( + "Model not found. Check your model name with /model.\n Examples from the eyrie catalog: %s, %s\n Use /models to list all models, or /config to change provider.", + ex1, ex2, + ) } // ── Network unreachable / connection refused / DNS ───────────────────── diff --git a/cmd/errors_test.go b/cmd/errors_test.go index 5b27f27..52662db 100644 --- a/cmd/errors_test.go +++ b/cmd/errors_test.go @@ -35,11 +35,8 @@ func TestFriendlyErrorProviderAPIKeys(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := friendlyError(errors.New(tt.errMsg)) - if !strings.Contains(got, tt.wantEnvVar) { - t.Errorf("friendlyError(%q) = %q, should contain %q", tt.errMsg, got, tt.wantEnvVar) - } - if !strings.Contains(got, "export") { - t.Errorf("friendlyError(%q) = %q, should contain 'export' suggestion", tt.errMsg, got) + if !strings.Contains(got, "/config") { + t.Errorf("friendlyError(%q) = %q, should suggest /config", tt.errMsg, got) } }) } @@ -519,11 +516,8 @@ func TestFriendlyErrorPriorityProviderKeyOverGeneric(t *testing.T) { // An error mentioning ANTHROPIC_API_KEY and 401 should match the // provider-specific key message, not the generic 401 message. got := friendlyError(errors.New("HTTP 401: ANTHROPIC_API_KEY is invalid")) - if !strings.Contains(got, "ANTHROPIC_API_KEY") { - t.Errorf("provider-specific key match should take priority over generic 401, got: %q", got) - } - if !strings.Contains(got, "export") { - t.Errorf("should contain export suggestion, got: %q", got) + if !strings.Contains(got, "/config") { + t.Errorf("provider-specific key match should suggest /config, got: %q", got) } } diff --git a/cmd/main_test.go b/cmd/main_test.go new file mode 100644 index 0000000..2d0e29d --- /dev/null +++ b/cmd/main_test.go @@ -0,0 +1,14 @@ +package cmd + +import ( + "os" + "testing" + + "github.com/GrayCodeAI/hawk/internal/catalogtest" +) + +func TestMain(m *testing.M) { + cleanup := catalogtest.InstallGlobal() + defer cleanup() + os.Exit(m.Run()) +} diff --git a/cmd/manpage.go b/cmd/manpage.go index 4952eb9..d63b170 100644 --- a/cmd/manpage.go +++ b/cmd/manpage.go @@ -87,16 +87,23 @@ func GenerateManPage() string { b.WriteString(".TP\n\\fBAGENTS.md\\fR\nProject instructions file (also reads AGENTS.md for backward compatibility)\n") b.WriteString(".TP\n\\fB~/.hawk/sessions/\\fR\nSaved session data\n") b.WriteString(".TP\n\\fB~/.hawk/templates/\\fR\nPrompt templates\n") - b.WriteString(".TP\n\\fB~/.hawk/env\\fR\nPersisted API keys\n") - // Environment + // Credentials (stored in OS secret service — use /config, not .env) + b.WriteString(".SH CREDENTIALS\n") + b.WriteString("API keys are stored in the OS secret service (macOS Keychain or Linux GNOME Keyring / KWallet).\n") + b.WriteString("Use \\fBhawk\\fR and \\fB/config\\fR to save keys; hawk does not read API keys from .env files.\n") + b.WriteString(".TP\n\\fBhawk credentials status\\fR\nShow secure storage status\n") + b.WriteString(".TP\n\\fBhawk credentials remove \\fR\nRemove a stored API key from the OS secret store\n") + b.WriteString(".TP\n\\fB/config key remove\\fR\nRemove a stored API key via interactive picker\n") + b.WriteString(".TP\n\\fBhawk credentials migrate\\fR\nImport legacy plaintext credential files into the OS store\n") + + // Environment (non-secret overrides only) b.WriteString(".SH ENVIRONMENT\n") + b.WriteString("Non-secret overrides (optional):\n") envVars := []struct{ env, desc string }{ - {"ANTHROPIC_API_KEY", "API key for Anthropic/Claude models"}, - {"OPENAI_API_KEY", "API key for OpenAI models"}, - {"GEMINI_API_KEY", "API key for Google Gemini models"}, - {"OPENROUTER_API_KEY", "API key for OpenRouter"}, - {"XAI_API_KEY", "API key for xAI/Grok models"}, + {"OPENAI_MODEL", "Override default OpenAI model"}, + {"OLLAMA_BASE_URL", "Ollama server URL (also saved via /config for Ollama)"}, + {"HAWK_CONFIG_DIR", "Override hawk config directory"}, } for _, ev := range envVars { b.WriteString(fmt.Sprintf(".TP\n\\fB%s\\fR\n%s\n", ev.env, ev.desc)) diff --git a/cmd/manpage_test.go b/cmd/manpage_test.go index ca5d671..ea3f86e 100644 --- a/cmd/manpage_test.go +++ b/cmd/manpage_test.go @@ -36,8 +36,11 @@ func TestGenerateManPage(t *testing.T) { if !strings.Contains(page, ".SH ENVIRONMENT") { t.Fatal("missing ENVIRONMENT section") } - if !strings.Contains(page, "ANTHROPIC_API_KEY") { - t.Fatal("missing ANTHROPIC_API_KEY in env section") + if !strings.Contains(page, ".SH CREDENTIALS") { + t.Fatal("missing CREDENTIALS section") + } + if !strings.Contains(page, "/config") { + t.Fatal("missing /config guidance in credentials section") } if !strings.Contains(page, "GrayCode AI") { t.Fatal("missing AUTHORS section") diff --git a/cmd/models.go b/cmd/models.go new file mode 100644 index 0000000..4dc0755 --- /dev/null +++ b/cmd/models.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "context" + "time" + + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/spf13/cobra" +) + +var modelsCmd = &cobra.Command{ + Use: "models", + Short: "Deployment-aware model catalog (via eyrie)", + Long: `Manage the eyrie model catalog used by hawk for models, pricing, and deployment routing. + +The catalog is stored at ~/.eyrie/model_catalog.json (override with EYRIE_MODEL_CATALOG_PATH). +Hawk refreshes the catalog automatically on startup when the cache is missing, empty, or stale (disable with --no-auto-catalog-refresh or HAWK_AUTO_REFRESH_CATALOG=0). +Use 'hawk models refresh' for a manual refresh or full discover report.`, +} + +var modelsRefreshCmd = &cobra.Command{ + Use: "refresh", + Aliases: []string{"update"}, + Short: "Discover model catalog (eyrie remote + live provider APIs) into ~/.eyrie/model_catalog.json", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + summary, err := hawkconfig.RefreshModelCatalogV1(ctx) + if err != nil { + return err + } + cmd.Println(summary) + return nil + }, +} + +var modelsStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show cached catalog metadata and deployment routing status", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + cmd.Println(hawkconfig.FormatCatalogHealth(hawkconfig.CatalogHealthReport(ctx))) + cmd.Println() + settings, err := loadEffectiveSettings() + if err != nil { + return err + } + model, _ := effectiveModelAndProvider(settings) + if len(args) > 0 { + model = args[0] + } + report, err := hawkconfig.DeploymentStatusReport(ctx, model) + if err != nil { + return err + } + cmd.Println(report) + return nil + }, +} + +var modelsRoutingPreviewCmd = &cobra.Command{ + Use: "routing-preview ", + Short: "Print effective deployment routing JSON for a model", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + model := args[0] + out, err := hawkconfig.RoutingPreviewJSON(context.Background(), model) + if err != nil { + return err + } + cmd.Println(out) + return nil + }, +} + +var modelsListCmd = &cobra.Command{ + Use: "list", + Short: "List model IDs from the eyrie catalog cache", + RunE: func(cmd *cobra.Command, args []string) error { + provider := "" + if len(args) > 0 { + provider = args[0] + } + models, err := hawkconfig.FetchModelsForProvider(provider) + if err != nil { + return err + } + cmd.Printf("%d models", len(models)) + if provider != "" { + cmd.Printf(" for provider %q", provider) + } + cmd.Println() + for _, m := range models { + name := m.DisplayName + if name == "" { + name = m.ID + } + cmd.Printf(" %s\n", name) + } + return nil + }, +} + +func init() { + modelsCmd.AddCommand(modelsRefreshCmd) + modelsCmd.AddCommand(modelsListCmd) + modelsCmd.AddCommand(modelsStatusCmd) + modelsCmd.AddCommand(modelsRoutingPreviewCmd) + rootCmd.AddCommand(modelsCmd) +} diff --git a/cmd/options.go b/cmd/options.go index 3719f4c..5c93faf 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -133,33 +133,32 @@ func loadEffectiveSettings() (hawkconfig.Settings, error) { if err != nil { return settings, err } - // Register user-defined custom providers with eyrie and hawk model catalog. + // Register custom providers with eyrie only; models come from settings + catalog fetch. for _, cp := range settings.CustomProviders { if cp.Name == "" || cp.BaseURL == "" { continue } _ = client.RegisterDynamicProvider(cp.Name, cp.BaseURL, cp.APIKeyEnv) - if cp.Model != "" { - hawkmodel.RegisterDynamic(hawkmodel.ModelInfo{ - Name: cp.Model, - Provider: cp.Name, - ContextSize: 128_000, - Description: "Custom provider: " + cp.Name, - }) - } } return settings, nil } func effectiveModelAndProvider(settings hawkconfig.Settings) (string, string) { - effectiveModel := strings.TrimSpace(settings.Model) + ctx := context.Background() + effectiveModel := hawkconfig.ActiveModel(ctx) if strings.TrimSpace(model) != "" { effectiveModel = strings.TrimSpace(model) } - effectiveProvider := strings.TrimSpace(settings.Provider) + if strings.TrimSpace(settings.Model) != "" { + effectiveModel = strings.TrimSpace(settings.Model) + } + effectiveProvider := hawkconfig.ActiveProvider(ctx) if strings.TrimSpace(provider) != "" { effectiveProvider = strings.TrimSpace(provider) } + if strings.TrimSpace(settings.Provider) != "" { + effectiveProvider = strings.TrimSpace(settings.Provider) + } // If the configured provider's API key is missing, fall back to auto-detection // so users with ANTHROPIC_API_KEY don't get confusing errors about canopywave. normalized := hawkconfig.NormalizeProviderForEngine(effectiveProvider) @@ -171,12 +170,13 @@ func effectiveModelAndProvider(settings hawkconfig.Settings) (string, string) { } } if normalized != "" && strings.TrimSpace(effectiveModel) == "" { - if resolved := hawkmodel.DefaultModel(normalized); resolved != "" { - effectiveModel = resolved - } else if resolved := client.ResolveDefaultModel(normalized); resolved != "" { + if resolved := hawkconfig.DefaultModelForProvider(normalized); resolved != "" { effectiveModel = resolved } } + if hawkconfig.DeploymentRoutingEnabled(settings) && strings.TrimSpace(effectiveModel) != "" { + effectiveModel = hawkconfig.ResolveCanonicalModel(effectiveModel) + } return effectiveModel, normalized } @@ -203,14 +203,14 @@ func configureSession(sess *engine.Session, settings hawkconfig.Settings) error sess.EnhancedMemory = enhancedMem enhancedMem.StartSession(fmt.Sprintf("session_%d", time.Now().UnixNano())) } - // Herm-style: API keys from environment only + // Hawk: API keys from OS secret store only normalizedProvider := hawkconfig.NormalizeProviderForEngine(settings.Provider) if normalizedProvider != "" { if key := hawkconfig.APIKeyForProvider(normalizedProvider); key != "" { sess.SetAPIKey(normalizedProvider, key) } } - sess.SetAPIKeys(hawkconfig.LoadAPIKeysFromEnv()) + sess.SetAPIKeys(hawkconfig.LoadAPIKeysFromStore()) for _, spec := range settings.AutoAllow { sess.Permissions.AllowSpec(spec) diff --git a/cmd/power.go b/cmd/power.go index c25bfc4..0b390e5 100644 --- a/cmd/power.go +++ b/cmd/power.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/GrayCodeAI/hawk/internal/provider/routing" ) // PowerConfig maps a power level (1-10) to all relevant settings. @@ -35,11 +36,13 @@ func PowerPreset(level int) PowerConfig { level = 10 } + haiku, sonnet, opus := routing.TierModels("anthropic") + switch level { case 1: return PowerConfig{ Level: 1, - Model: "claude-haiku-3", + Model: haiku, MaxTokens: 1024, ContextWindow: 4096, Temperature: 0.3, @@ -52,7 +55,7 @@ func PowerPreset(level int) PowerConfig { case 2: return PowerConfig{ Level: 2, - Model: "claude-haiku-3", + Model: haiku, MaxTokens: 2048, ContextWindow: 4096, Temperature: 0.3, @@ -65,7 +68,7 @@ func PowerPreset(level int) PowerConfig { case 3: return PowerConfig{ Level: 3, - Model: "claude-sonnet-4-20250514", + Model: sonnet, MaxTokens: 4096, ContextWindow: 16384, Temperature: 0.5, @@ -78,7 +81,7 @@ func PowerPreset(level int) PowerConfig { case 4: return PowerConfig{ Level: 4, - Model: "claude-sonnet-4-20250514", + Model: sonnet, MaxTokens: 4096, ContextWindow: 16384, Temperature: 0.5, @@ -91,7 +94,7 @@ func PowerPreset(level int) PowerConfig { case 5: return PowerConfig{ Level: 5, - Model: "claude-sonnet-4-20250514", + Model: sonnet, MaxTokens: 8192, ContextWindow: 65536, Temperature: 0.7, @@ -104,7 +107,7 @@ func PowerPreset(level int) PowerConfig { case 6: return PowerConfig{ Level: 6, - Model: "claude-sonnet-4-20250514", + Model: sonnet, MaxTokens: 8192, ContextWindow: 65536, Temperature: 0.7, @@ -117,7 +120,7 @@ func PowerPreset(level int) PowerConfig { case 7: return PowerConfig{ Level: 7, - Model: "claude-sonnet-4-20250514", + Model: sonnet, MaxTokens: 16384, ContextWindow: 131072, Temperature: 0.7, @@ -130,7 +133,7 @@ func PowerPreset(level int) PowerConfig { case 8: return PowerConfig{ Level: 8, - Model: "claude-opus-4-20250514", + Model: opus, MaxTokens: 16384, ContextWindow: 131072, Temperature: 0.7, @@ -143,7 +146,7 @@ func PowerPreset(level int) PowerConfig { case 9: return PowerConfig{ Level: 9, - Model: "claude-opus-4-20250514", + Model: opus, MaxTokens: 16384, ContextWindow: 204800, Temperature: 0.7, @@ -156,7 +159,7 @@ func PowerPreset(level int) PowerConfig { case 10: return PowerConfig{ Level: 10, - Model: "claude-opus-4-20250514", + Model: opus, MaxTokens: 16384, ContextWindow: 204800, Temperature: 0.7, diff --git a/cmd/root.go b/cmd/root.go index 2de3273..485b2ff 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,12 +1,14 @@ package cmd import ( + "context" "fmt" "os" "strings" "time" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/GrayCodeAI/hawk/internal/eyrieclient" "github.com/GrayCodeAI/hawk/internal/onboarding" "github.com/GrayCodeAI/hawk/internal/plugin" "github.com/GrayCodeAI/hawk/internal/session" @@ -74,8 +76,9 @@ var rootCmd = &cobra.Command{ Long: "hawk is an AI coding agent that reads, writes, and runs code in your terminal.", Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { - // Load persisted env vars (API keys from ~/.hawk/env) - _ = hawkconfig.LoadEnvFile() + // Credential store reads OS secret store on demand (not shell env). + hawkconfig.PrepareCredentialDiscovery(context.Background()) + _ = hawkconfig.MigrateProviderSecrets() if versionFlag { if buildDate != "" && buildDate != "unknown" { @@ -103,15 +106,10 @@ var rootCmd = &cobra.Command{ if promptFlag == "" { return fmt.Errorf("prompt required in print mode") } - return runPrint(promptFlag) - } - - // First-run setup if needed - if onboarding.NeedsSetup() { - onboarding.Welcome(version) - if err := onboarding.RunSetup(); err != nil { + if err := ensureCatalogBeforeAgent(context.Background(), true); err != nil { return err } + return runPrint(promptFlag) } // Auto-skill: analyze project and install matching skills. @@ -139,13 +137,17 @@ var rootCmd = &cobra.Command{ } } - // Launch TUI + if err := ensureCatalogBeforeAgent(context.Background(), false); err != nil { + return err + } + + // Launch TUI — use /config to set API keys; eyrie supplies providers and models return runChat() }, } func init() { - rootCmd.Flags().StringVarP(&model, "model", "m", "", "model to use (e.g. claude-sonnet-4-20250514)") + rootCmd.Flags().StringVarP(&model, "model", "m", "", "model to use (from eyrie catalog; see /models)") rootCmd.Flags().BoolVarP(&printMode, "print", "p", false, "print response and exit") rootCmd.Flags().StringVar(&promptFlag, "prompt", "", "send a single prompt and exit (legacy alias for --print)") rootCmd.Flags().StringVar(&outputFormat, "output-format", "text", `output format for --print: "text", "json", or "stream-json"`) @@ -172,7 +174,7 @@ func init() { rootCmd.Flags().StringVar(&systemPromptFile, "system-prompt-file", "", "read system prompt from a file") rootCmd.Flags().StringVar(&appendSystemPromptFlag, "append-system-prompt", "", "append text to the default or custom system prompt") rootCmd.Flags().StringVar(&appendSystemPromptFile, "append-system-prompt-file", "", "read text from a file and append it to the system prompt") - rootCmd.Flags().StringVar(&sandboxFlag, "sandbox", "", "sandbox mode for Bash commands: strict, workspace, or off") + rootCmd.Flags().StringVar(&sandboxFlag, "sandbox", "", "Bash permission profile: strict, workspace, or off (not Docker; see --no-container)") rootCmd.Flags().BoolVar(&autoCommitFlag, "auto-commit", false, "auto-commit file changes made by Write and Edit tools") rootCmd.Flags().BoolVar(&watchFlag, "watch", false, "watch the working directory for file changes") rootCmd.Flags().BoolVar(&vibeMode, "vibe", false, "vibe coding mode: auto-apply, auto-run, no confirmations") @@ -185,10 +187,14 @@ func init() { rootCmd.Flags().BoolVar(&noContainer, "no-container", false, "disable container mode (run on host with permission prompts)") rootCmd.Flags().BoolVar(&containerMode, "container", false, "force container mode even if auto-detection would skip it") rootCmd.Flags().BoolVarP(&versionFlag, "version", "v", false, "output the version number") + rootCmd.Flags().BoolVar(&refreshCatalogFlag, "refresh-catalog", false, "refresh the eyrie model catalog before starting") + rootCmd.Flags().BoolVar(&skipCatalogRefreshFlag, "no-auto-catalog-refresh", false, "disable automatic catalog refresh when cache is missing, empty, or stale") rootCmd.Flags().BoolVar(&recoverFlag, "recover", false, "scan for interrupted sessions and offer to resume") rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(setupCmd) rootCmd.AddCommand(doctorCmd) + rootCmd.AddCommand(preflightCmd) + rootCmd.AddCommand(credentialsCmd) rootCmd.AddCommand(configCmd) rootCmd.AddCommand(mcpCmd) rootCmd.AddCommand(sessionsCmd) @@ -292,6 +298,19 @@ var doctorCmd = &cobra.Command{ }, } +var preflightCmd = &cobra.Command{ + Use: "preflight", + Short: "Check hawk is ready to chat (catalog, credentials, model)", + RunE: func(cmd *cobra.Command, args []string) error { + r := eyrieclient.Preflight(context.Background()) + cmd.Println(eyrieclient.FormatPreflightReport(r)) + if !r.Ready { + return fmt.Errorf("preflight failed — run hawk and complete /config") + } + return nil + }, +} + var configCmd = &cobra.Command{ Use: "config [provider |model |get |set ]", Short: "Show or update settings", @@ -342,6 +361,22 @@ var configCmd = &cobra.Command{ case "keys": cmd.Println(apiKeyConfigSummary()) return nil + case "routing-preview": + if len(args) < 2 { + return fmt.Errorf("usage: hawk config routing-preview ") + } + out, err := hawkconfig.RoutingPreviewJSON(context.Background(), strings.Join(args[1:], " ")) + if err != nil { + return err + } + cmd.Println(out) + return nil + case "migrate-deployments": + if err := hawkconfig.MigrateProviderConfig(); err != nil { + return err + } + cmd.Println("provider.json upgraded to deployment config v2 (if legacy keys were present)") + return nil default: return fmt.Errorf("unknown config action %q", args[0]) } diff --git a/cmd/sight.go b/cmd/sight.go index 90a1a35..d1b3b89 100644 --- a/cmd/sight.go +++ b/cmd/sight.go @@ -46,7 +46,7 @@ Examples: hawk sight --mode improve --model claude-sonnet-4-20250514 hawk sight --concerns security,bugs --fail-on high --format json`, RunE: func(cmd *cobra.Command, args []string) error { - _ = hawkconfig.LoadEnvFile() + hawkconfig.PrepareCredentialDiscovery(context.Background()) diff, err := getDiff() if err != nil { diff --git a/docs/DYNAMIC-MODELS.md b/docs/DYNAMIC-MODELS.md new file mode 100644 index 0000000..f02d016 --- /dev/null +++ b/docs/DYNAMIC-MODELS.md @@ -0,0 +1,39 @@ +# Dynamic models (eyrie-owned catalog + selection) + +Hawk does **not** ship a hardcoded model list and does **not** store model/provider in `~/.hawk/settings.json`. + +| Data | Location | +|------|----------| +| Model catalog (IDs, names, pricing) | Eyrie `~/.eyrie/model_catalog.json` | +| Selected model & provider | Eyrie `~/.hawk/provider.json` (`active_model`, `anthropic_model`, …) | +| API keys | Eyrie keychain + env | +| Hawk host prefs (theme, sandbox, tools) | `~/.hawk/settings.json` | + +## Add a new model + +1. Update the eyrie catalog source (bootstrap JSON, remote discover, or provider API enrichment). +2. Run catalog refresh (`hawk models refresh`, `/config` → refresh, or restart hawk with keys set). +3. Hawk shows the new model automatically — no hawk code changes. + +## Change the active model + +- `/config` → pick model, or `/model `, or `hawk config set model ` +- All of these call `runtime.SetActiveModel` → `provider.json` + +Legacy `model` / `provider` keys in `settings.json` are migrated into `provider.json` on first load and removed from hawk settings on save. + +## Hawk integration surface + +- TUI and commands call `internal/eyrieclient` → `github.com/GrayCodeAI/eyrie/runtime`. +- Do **not** import `eyrie/catalog` or `eyrie/setup` from `cmd/` except via `eyrieclient`. +- `internal/config.ActiveModel` / `SetActiveModel` delegate to eyrie runtime. + +## Eyrie APIs + +| API | Purpose | +|-----|---------| +| `catalog.ModelEntriesForProvider(compiled, provider)` | Filter compiled catalog | +| `runtime.ModelsForProvider(ctx, provider)` | Load cache + auto-discover if empty | +| `runtime.ActiveModel` / `SetActiveModel` | Read/write user selection | +| `runtime.Discover(ctx)` | Refresh from API keys | +| `setup.BuildSetupUI` | Provider/model groups for UI | diff --git a/docs/SECURITY-SOLO.md b/docs/SECURITY-SOLO.md new file mode 100644 index 0000000..b1c8592 --- /dev/null +++ b/docs/SECURITY-SOLO.md @@ -0,0 +1,88 @@ +# Hawk solo security model + +This document describes how hawk and eyrie handle API keys and agent isolation for a single developer on macOS or Linux (no Vault, no proxy). + +## Goals + +- API keys live only in the OS secret store (macOS Keychain / Linux GNOME Keyring or KWallet). +- Hawk does not read API keys from `.env`, shell env, or plaintext files. +- `~/.hawk/provider.json` holds routing and deployment metadata only — never secrets on disk. +- Hawk talks to eyrie without putting keys in JSON or chat messages. +- Agents run Bash inside Docker when possible; file tools cannot read credential paths. + +## Credential storage + +| Write | Read | Remove | +|-------|------|--------| +| `/config` paste flow → eyrie `runtime.SetCredential` | `credentials.LookupSecret` (keychain only) | `/config key remove` or `hawk credentials remove` | + +On startup, hawk calls `PrepareCredentialDiscovery()` to one-time migrate legacy `~/.hawk/env` / `~/.hawk/.env` into the keychain and delete those files. + +Check status: `hawk credentials status` or `hawk preflight`. + +## First-run flow (`/config`) + +``` +User pastes API key in /config + | + v +hawk PersistAPIKey -> eyrie runtime.SetCredential (OS secret store) + | + v +eyrie Apply / discover (credentials from store, not JSON body) + | + v +SetupUI JSON (display_name + canonical_id per model) + | + v +User picks model -> settings.json (canonical id only) +``` + +Remove a stored key: `/config key remove` (interactive picker). + +## Hawk to eyrie + +- **Apply**: credentials passed from the OS store; no `api_key` fields in request payloads. +- **Chat**: `model_id` + messages only; eyrie resolves provider and reads secrets internally. + +## Agent isolation + +``` ++------------------+ +------------------+ +| Hawk TUI/host | | Docker sandbox | +| Keychain access | | Bash only | +| /config paste | | project mount | ++------------------+ +------------------+ + | | + | ContainerExecutor | + +--------------------------+ +``` + +When the container is ready, `session.ContainerExecutor` runs Bash in the container. + +### Blocked for agents (host or container policy) + +- **Read** tool: `~/.hawk/env`, `~/.hawk/.env`, `~/.hawk/provider.json`, `~/.ssh/*`, etc. +- **Bash**: `printenv`, `env`, reading hawk env paths, echoing `*_API_KEY` variables. + +Use `--no-container` only for debugging; secure mode warns because host Bash can access more of the filesystem. + +## Migration + +- **Legacy env files**: `MigrateLegacyEnvFile()` on startup imports `~/.hawk/env` / `~/.hawk/.env` → keychain → deletes files. +- **provider.json secrets**: `MigrateProviderSecrets()` strips secret fields (backup: `provider.json.pre-secret-migrate.bak`). + +## Environment variables + +Non-secret overrides only (hawk does not load provider API keys from env): + +| Variable | Meaning | +|----------|---------| +| `HAWK_CONFIG_DIR` | Override hawk config directory | +| `OPENAI_MODEL` | Override default OpenAI model | +| `OLLAMA_BASE_URL` | Ollama server URL (also saved via `/config` for Ollama) | + +## Related code + +- Hawk: `internal/config/credentials_store.go`, `migrate_provider_secrets.go`, `internal/tool/safety.go`, `cmd/credentials.go` +- Eyrie: `credentials/`, `config/discovery_env.go`, `setup/setup_ui.go` diff --git a/external/eyrie b/external/eyrie deleted file mode 160000 index 9c2e60a..0000000 --- a/external/eyrie +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9c2e60a874a3a717bbdf1cf3d519299c4eeaf773 diff --git a/go.mod b/go.mod index f404c37..0e10667 100644 --- a/go.mod +++ b/go.mod @@ -81,3 +81,5 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) + +replace github.com/GrayCodeAI/eyrie => ../eyrie diff --git a/go.work b/go.work index 2154f43..df5808e 100644 --- a/go.work +++ b/go.work @@ -2,7 +2,7 @@ go 1.26.3 use ( . - ./external/eyrie + ../eyrie ) -// Eyrie is a git submodule at ./external/eyrie (Herm / LangDAG pattern). +// Clone eyrie next to hawk (hawk-eco/eyrie). CI uses .github/actions/checkout-eyrie. diff --git a/internal/catalogtest/install.go b/internal/catalogtest/install.go new file mode 100644 index 0000000..9224186 --- /dev/null +++ b/internal/catalogtest/install.go @@ -0,0 +1,46 @@ +package catalogtest + +import ( + _ "embed" + "os" + "path/filepath" + "sync" + "testing" +) + +//go:embed testdata/minimal_v1.json +var minimalCatalogJSON []byte + +var ( + globalOnce sync.Once + globalPath string +) + +// InstallGlobal writes the test catalog to a temp file and sets EYRIE_MODEL_CATALOG_PATH. +// Call from TestMain; returns cleanup to unset env. +func InstallGlobal() (cleanup func()) { + globalOnce.Do(func() { + dir, err := os.MkdirTemp("", "hawk-catalog-*") + if err != nil { + panic(err) + } + globalPath = filepath.Join(dir, "model_catalog.json") + if err := os.WriteFile(globalPath, minimalCatalogJSON, 0o644); err != nil { + panic(err) + } + _ = os.Setenv("EYRIE_MODEL_CATALOG_PATH", globalPath) + }) + return func() { + _ = os.Unsetenv("EYRIE_MODEL_CATALOG_PATH") + } +} + +// Install sets EYRIE_MODEL_CATALOG_PATH for a single test (per-test temp file). +func Install(t testing.TB) { + t.Helper() + path := filepath.Join(t.TempDir(), "model_catalog.json") + if err := os.WriteFile(path, minimalCatalogJSON, 0o644); err != nil { + panic(err) + } + t.Setenv("EYRIE_MODEL_CATALOG_PATH", path) +} diff --git a/internal/catalogtest/testdata/minimal_v1.json b/internal/catalogtest/testdata/minimal_v1.json new file mode 100644 index 0000000..693075f --- /dev/null +++ b/internal/catalogtest/testdata/minimal_v1.json @@ -0,0 +1,1066 @@ +{ + "schema_version": "model-catalog/v1", + "generated_at": "2026-04-09T00:00:00Z", + "stale_after": "2026-05-09T00:00:00Z", + "providers": { + "anthropic": { + "id": "anthropic", + "name": "Anthropic" + }, + "google": { + "id": "google", + "name": "Google" + }, + "ollama": { + "id": "ollama", + "name": "Ollama" + }, + "openai": { + "id": "openai", + "name": "OpenAI" + }, + "opencodego": { + "id": "opencodego", + "name": "OpenCode Go" + }, + "openrouter": { + "id": "openrouter", + "name": "OpenRouter" + }, + "xai": { + "id": "xai", + "name": "xAI" + }, + "z-ai": { + "id": "z-ai", + "name": "Z.AI" + } + }, + "api_protocols": { + "anthropic-messages": { + "id": "anthropic-messages", + "name": "Anthropic Messages" + }, + "gemini-generate-content": { + "id": "gemini-generate-content", + "name": "Gemini generateContent" + }, + "openai-chat-completions": { + "id": "openai-chat-completions", + "name": "OpenAI Chat Completions" + } + }, + "deployments": { + "anthropic-bedrock": { + "id": "anthropic-bedrock", + "name": "Anthropic on Bedrock", + "provider_id": "anthropic", + "api_protocol_id": "anthropic-messages", + "adapter_constructor": "anthropic-bedrock", + "native_model_id_source": "catalog_known" + }, + "anthropic-direct": { + "id": "anthropic-direct", + "name": "Anthropic", + "provider_id": "anthropic", + "api_protocol_id": "anthropic-messages", + "adapter_constructor": "anthropic", + "native_model_id_source": "catalog_known" + }, + "anthropic-vertex": { + "id": "anthropic-vertex", + "name": "Anthropic on Vertex", + "provider_id": "anthropic", + "api_protocol_id": "anthropic-messages", + "adapter_constructor": "anthropic-vertex", + "native_model_id_source": "catalog_known" + }, + "canopywave": { + "id": "canopywave", + "name": "CanopyWave", + "provider_id": "z-ai", + "api_protocol_id": "openai-chat-completions", + "adapter_constructor": "canopywave", + "native_model_id_source": "catalog_known" + }, + "gemini-direct": { + "id": "gemini-direct", + "name": "Gemini", + "provider_id": "google", + "api_protocol_id": "gemini-generate-content", + "adapter_constructor": "gemini", + "native_model_id_source": "catalog_known" + }, + "gemini-vertex": { + "id": "gemini-vertex", + "name": "Gemini on Vertex", + "provider_id": "google", + "api_protocol_id": "gemini-generate-content", + "adapter_constructor": "gemini-vertex", + "native_model_id_source": "catalog_known" + }, + "grok-direct": { + "id": "grok-direct", + "name": "Grok", + "provider_id": "xai", + "api_protocol_id": "openai-chat-completions", + "adapter_constructor": "grok", + "native_model_id_source": "catalog_known" + }, + "ollama-local": { + "id": "ollama-local", + "name": "Ollama local", + "provider_id": "ollama", + "api_protocol_id": "openai-chat-completions", + "adapter_constructor": "ollama", + "native_model_id_source": "discovered", + "local": true + }, + "openai-azure": { + "id": "openai-azure", + "name": "Azure OpenAI", + "provider_id": "openai", + "api_protocol_id": "openai-chat-completions", + "adapter_constructor": "openai-azure", + "native_model_id_source": "user_configured", + "model_mappings_required": true + }, + "openai-direct": { + "id": "openai-direct", + "name": "OpenAI", + "provider_id": "openai", + "api_protocol_id": "openai-chat-completions", + "adapter_constructor": "openai", + "native_model_id_source": "catalog_known" + }, + "opencodego": { + "id": "opencodego", + "name": "OpenCode Go", + "provider_id": "opencodego", + "api_protocol_id": "openai-chat-completions", + "adapter_constructor": "opencodego", + "native_model_id_source": "catalog_known" + }, + "openrouter": { + "id": "openrouter", + "name": "OpenRouter", + "provider_id": "openrouter", + "api_protocol_id": "openai-chat-completions", + "adapter_constructor": "openrouter", + "native_model_id_source": "discovered" + } + }, + "models": { + "anthropic/claude-haiku-4-5-20251001": { + "id": "anthropic/claude-haiku-4-5-20251001", + "provider_id": "anthropic", + "name": "claude-haiku-4-5-20251001", + "context_window": 200000, + "max_output": 16000, + "aliases": [ + "claude-haiku-4-5-20251001" + ] + }, + "anthropic/claude-opus-4-6": { + "id": "anthropic/claude-opus-4-6", + "provider_id": "anthropic", + "name": "claude-opus-4-6", + "context_window": 200000, + "max_output": 32000, + "aliases": [ + "claude-opus-4-6" + ] + }, + "anthropic/claude-sonnet-4-6": { + "id": "anthropic/claude-sonnet-4-6", + "provider_id": "anthropic", + "name": "claude-sonnet-4-6", + "context_window": 200000, + "max_output": 32000, + "aliases": [ + "claude-sonnet-4-6" + ] + }, + "google/gemini-2.0-flash": { + "id": "google/gemini-2.0-flash", + "provider_id": "google", + "name": "gemini-2.0-flash", + "context_window": 1000000, + "max_output": 8192, + "aliases": [ + "gemini-2.0-flash" + ] + }, + "google/gemini-2.0-flash-lite": { + "id": "google/gemini-2.0-flash-lite", + "provider_id": "google", + "name": "gemini-2.0-flash-lite", + "context_window": 1000000, + "max_output": 8192, + "aliases": [ + "gemini-2.0-flash-lite" + ] + }, + "google/gemini-2.5-pro-preview-03-25": { + "id": "google/gemini-2.5-pro-preview-03-25", + "provider_id": "google", + "name": "gemini-2.5-pro-preview-03-25", + "context_window": 1000000, + "max_output": 65536, + "aliases": [ + "gemini-2.5-pro-preview-03-25" + ] + }, + "openai/gpt-4o": { + "id": "openai/gpt-4o", + "provider_id": "openai", + "name": "gpt-4o", + "context_window": 128000, + "max_output": 16000, + "aliases": [ + "gpt-4o" + ] + }, + "openai/gpt-4o-mini": { + "id": "openai/gpt-4o-mini", + "provider_id": "openai", + "name": "gpt-4o-mini", + "context_window": 128000, + "max_output": 16000, + "aliases": [ + "gpt-4o-mini" + ] + }, + "opencodego/glm-5": { + "id": "opencodego/glm-5", + "provider_id": "opencodego", + "name": "GLM-5", + "context_window": 128000, + "max_output": 8000, + "aliases": [ + "glm-5", + "GLM-5" + ] + }, + "opencodego/glm-5.1": { + "id": "opencodego/glm-5.1", + "provider_id": "opencodego", + "name": "GLM-5.1", + "context_window": 128000, + "max_output": 8000, + "aliases": [ + "glm-5.1", + "GLM-5.1" + ] + }, + "opencodego/kimi-k2.5": { + "id": "opencodego/kimi-k2.5", + "provider_id": "opencodego", + "name": "Kimi K2.5", + "context_window": 256000, + "max_output": 8000, + "aliases": [ + "kimi-k2.5", + "Kimi K2.5" + ] + }, + "opencodego/kimi-k2.6": { + "id": "opencodego/kimi-k2.6", + "provider_id": "opencodego", + "name": "Kimi K2.6", + "context_window": 256000, + "max_output": 8000, + "aliases": [ + "kimi-k2.6", + "Kimi K2.6" + ] + }, + "opencodego/mimo-v2-omni": { + "id": "opencodego/mimo-v2-omni", + "provider_id": "opencodego", + "name": "MiMo V2 Omni", + "context_window": 128000, + "max_output": 8000, + "aliases": [ + "mimo-v2-omni", + "MiMo V2 Omni" + ] + }, + "opencodego/mimo-v2-pro": { + "id": "opencodego/mimo-v2-pro", + "provider_id": "opencodego", + "name": "MiMo V2 Pro", + "context_window": 128000, + "max_output": 8000, + "aliases": [ + "mimo-v2-pro", + "MiMo V2 Pro" + ] + }, + "opencodego/minimax-m2.5": { + "id": "opencodego/minimax-m2.5", + "provider_id": "opencodego", + "name": "MiniMax M2.5", + "context_window": 1000000, + "max_output": 8000, + "aliases": [ + "minimax-m2.5", + "MiniMax M2.5" + ] + }, + "opencodego/minimax-m2.7": { + "id": "opencodego/minimax-m2.7", + "provider_id": "opencodego", + "name": "MiniMax M2.7", + "context_window": 1000000, + "max_output": 8000, + "aliases": [ + "minimax-m2.7", + "MiniMax M2.7" + ] + }, + "opencodego/qwen3.5-plus": { + "id": "opencodego/qwen3.5-plus", + "provider_id": "opencodego", + "name": "Qwen3.5 Plus", + "context_window": 1000000, + "max_output": 65536, + "aliases": [ + "qwen3.5-plus", + "Qwen3.5 Plus" + ] + }, + "opencodego/qwen3.6-plus": { + "id": "opencodego/qwen3.6-plus", + "provider_id": "opencodego", + "name": "Qwen3.6 Plus", + "context_window": 1000000, + "max_output": 65536, + "aliases": [ + "qwen3.6-plus", + "Qwen3.6 Plus" + ] + }, + "xai/grok-2": { + "id": "xai/grok-2", + "provider_id": "xai", + "name": "grok-2", + "context_window": 128000, + "max_output": 8000, + "aliases": [ + "grok-2" + ] + }, + "zai/glm-4.6": { + "id": "zai/glm-4.6", + "provider_id": "z-ai", + "name": "zai/glm-4.6", + "context_window": 128000, + "max_output": 8192, + "aliases": [ + "zai/glm-4.6" + ] + } + }, + "offerings": [ + { + "id": "anthropic-direct:claude-opus-4-6", + "canonical_model_id": "anthropic/claude-opus-4-6", + "deployment_id": "anthropic-direct", + "native_model_id": "claude-opus-4-6", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 15, + "output_tokens": 75 + }, + "source": "test" + } + }, + { + "id": "anthropic-direct:claude-sonnet-4-6", + "canonical_model_id": "anthropic/claude-sonnet-4-6", + "deployment_id": "anthropic-direct", + "native_model_id": "claude-sonnet-4-6", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 3, + "output_tokens": 15 + }, + "source": "test" + } + }, + { + "id": "anthropic-direct:claude-haiku-4-5-20251001", + "canonical_model_id": "anthropic/claude-haiku-4-5-20251001", + "deployment_id": "anthropic-direct", + "native_model_id": "claude-haiku-4-5-20251001", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 1, + "output_tokens": 5 + }, + "source": "test" + } + }, + { + "id": "openai-direct:gpt-4o", + "canonical_model_id": "openai/gpt-4o", + "deployment_id": "openai-direct", + "native_model_id": "gpt-4o", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 5, + "output_tokens": 15 + }, + "source": "test" + } + }, + { + "id": "openai-direct:gpt-4o-mini", + "canonical_model_id": "openai/gpt-4o-mini", + "deployment_id": "openai-direct", + "native_model_id": "gpt-4o-mini", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 0.15, + "output_tokens": 0.6 + }, + "source": "test" + } + }, + { + "id": "grok-direct:grok-2", + "canonical_model_id": "xai/grok-2", + "deployment_id": "grok-direct", + "native_model_id": "grok-2", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 2, + "output_tokens": 10 + }, + "source": "test" + } + }, + { + "id": "gemini-direct:gemini-2.5-pro-preview-03-25", + "canonical_model_id": "google/gemini-2.5-pro-preview-03-25", + "deployment_id": "gemini-direct", + "native_model_id": "gemini-2.5-pro-preview-03-25", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 1.25, + "output_tokens": 5 + }, + "source": "test" + } + }, + { + "id": "gemini-direct:gemini-2.0-flash", + "canonical_model_id": "google/gemini-2.0-flash", + "deployment_id": "gemini-direct", + "native_model_id": "gemini-2.0-flash", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 0.1, + "output_tokens": 0.4 + }, + "source": "test" + } + }, + { + "id": "gemini-direct:gemini-2.0-flash-lite", + "canonical_model_id": "google/gemini-2.0-flash-lite", + "deployment_id": "gemini-direct", + "native_model_id": "gemini-2.0-flash-lite", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 0.075, + "output_tokens": 0.3 + }, + "source": "test" + } + }, + { + "id": "openrouter:openai/gpt-4o", + "canonical_model_id": "openai/gpt-4o", + "deployment_id": "openrouter", + "native_model_id": "openai/gpt-4o", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 5, + "output_tokens": 15 + }, + "source": "test" + } + }, + { + "id": "openrouter:openai/gpt-4o-mini", + "canonical_model_id": "openai/gpt-4o-mini", + "deployment_id": "openrouter", + "native_model_id": "openai/gpt-4o-mini", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 0.15, + "output_tokens": 0.6 + }, + "source": "test" + } + }, + { + "id": "openrouter:anthropic/claude-sonnet-4-6", + "canonical_model_id": "anthropic/claude-sonnet-4-6", + "deployment_id": "openrouter", + "native_model_id": "anthropic/claude-sonnet-4-6", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 3, + "output_tokens": 15 + }, + "source": "test" + } + }, + { + "id": "canopywave:zai/glm-4.6", + "canonical_model_id": "zai/glm-4.6", + "deployment_id": "canopywave", + "native_model_id": "zai/glm-4.6", + "capabilities": {}, + "pricing": { + "status": "unknown", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "source": "test" + } + }, + { + "id": "opencodego:glm-5.1", + "canonical_model_id": "opencodego/glm-5.1", + "deployment_id": "opencodego", + "native_model_id": "glm-5.1", + "capabilities": {}, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 5, + "output_tokens": 15 + }, + "source": "test" + } + }, + { + "id": "opencodego:glm-5", + "canonical_model_id": "opencodego/glm-5", + "deployment_id": "opencodego", + "native_model_id": "glm-5", + "capabilities": {}, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 5, + "output_tokens": 15 + }, + "source": "test" + } + }, + { + "id": "opencodego:kimi-k2.5", + "canonical_model_id": "opencodego/kimi-k2.5", + "deployment_id": "opencodego", + "native_model_id": "kimi-k2.5", + "capabilities": {}, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 3, + "output_tokens": 10 + }, + "source": "test" + } + }, + { + "id": "opencodego:kimi-k2.6", + "canonical_model_id": "opencodego/kimi-k2.6", + "deployment_id": "opencodego", + "native_model_id": "kimi-k2.6", + "capabilities": {}, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 3, + "output_tokens": 10 + }, + "source": "test" + } + }, + { + "id": "opencodego:mimo-v2-pro", + "canonical_model_id": "opencodego/mimo-v2-pro", + "deployment_id": "opencodego", + "native_model_id": "mimo-v2-pro", + "capabilities": {}, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 3, + "output_tokens": 10 + }, + "source": "test" + } + }, + { + "id": "opencodego:mimo-v2-omni", + "canonical_model_id": "opencodego/mimo-v2-omni", + "deployment_id": "opencodego", + "native_model_id": "mimo-v2-omni", + "capabilities": {}, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 2, + "output_tokens": 8 + }, + "source": "test" + } + }, + { + "id": "opencodego:minimax-m2.7", + "canonical_model_id": "opencodego/minimax-m2.7", + "deployment_id": "opencodego", + "native_model_id": "minimax-m2.7", + "capabilities": {}, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 1, + "output_tokens": 3 + }, + "source": "test" + } + }, + { + "id": "opencodego:minimax-m2.5", + "canonical_model_id": "opencodego/minimax-m2.5", + "deployment_id": "opencodego", + "native_model_id": "minimax-m2.5", + "capabilities": {}, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 0.5, + "output_tokens": 1.5 + }, + "source": "test" + } + }, + { + "id": "opencodego:qwen3.6-plus", + "canonical_model_id": "opencodego/qwen3.6-plus", + "deployment_id": "opencodego", + "native_model_id": "qwen3.6-plus", + "capabilities": {}, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 0.3, + "output_tokens": 1.7 + }, + "source": "test" + } + }, + { + "id": "opencodego:qwen3.5-plus", + "canonical_model_id": "opencodego/qwen3.5-plus", + "deployment_id": "opencodego", + "native_model_id": "qwen3.5-plus", + "capabilities": {}, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 0.26, + "output_tokens": 1.56 + }, + "source": "test" + } + }, + { + "id": "anthropic-bedrock:claude-opus-4-6", + "canonical_model_id": "anthropic/claude-opus-4-6", + "deployment_id": "anthropic-bedrock", + "native_model_id": "claude-opus-4-6", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 15, + "output_tokens": 75 + }, + "source": "test" + } + }, + { + "id": "anthropic-vertex:claude-opus-4-6", + "canonical_model_id": "anthropic/claude-opus-4-6", + "deployment_id": "anthropic-vertex", + "native_model_id": "claude-opus-4-6", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 15, + "output_tokens": 75 + }, + "source": "test" + } + }, + { + "id": "anthropic-bedrock:claude-sonnet-4-6", + "canonical_model_id": "anthropic/claude-sonnet-4-6", + "deployment_id": "anthropic-bedrock", + "native_model_id": "claude-sonnet-4-6", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 3, + "output_tokens": 15 + }, + "source": "test" + } + }, + { + "id": "anthropic-vertex:claude-sonnet-4-6", + "canonical_model_id": "anthropic/claude-sonnet-4-6", + "deployment_id": "anthropic-vertex", + "native_model_id": "claude-sonnet-4-6", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 3, + "output_tokens": 15 + }, + "source": "test" + } + }, + { + "id": "anthropic-bedrock:claude-haiku-4-5-20251001", + "canonical_model_id": "anthropic/claude-haiku-4-5-20251001", + "deployment_id": "anthropic-bedrock", + "native_model_id": "claude-haiku-4-5-20251001", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 1, + "output_tokens": 5 + }, + "source": "test" + } + }, + { + "id": "anthropic-vertex:claude-haiku-4-5-20251001", + "canonical_model_id": "anthropic/claude-haiku-4-5-20251001", + "deployment_id": "anthropic-vertex", + "native_model_id": "claude-haiku-4-5-20251001", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 1, + "output_tokens": 5 + }, + "source": "test" + } + }, + { + "id": "gemini-vertex:gemini-2.5-pro-preview-03-25", + "canonical_model_id": "google/gemini-2.5-pro-preview-03-25", + "deployment_id": "gemini-vertex", + "native_model_id": "gemini-2.5-pro-preview-03-25", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 1.25, + "output_tokens": 5 + }, + "source": "test" + } + }, + { + "id": "gemini-vertex:gemini-2.0-flash", + "canonical_model_id": "google/gemini-2.0-flash", + "deployment_id": "gemini-vertex", + "native_model_id": "gemini-2.0-flash", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 0.1, + "output_tokens": 0.4 + }, + "source": "test" + } + }, + { + "id": "gemini-vertex:gemini-2.0-flash-lite", + "canonical_model_id": "google/gemini-2.0-flash-lite", + "deployment_id": "gemini-vertex", + "native_model_id": "gemini-2.0-flash-lite", + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 0.075, + "output_tokens": 0.3 + }, + "source": "test" + } + } + ], + "offering_templates": [ + { + "id": "openai-azure:openai/gpt-4o", + "canonical_model_id": "openai/gpt-4o", + "deployment_id": "openai-azure", + "native_model_id_source": "user_configured", + "mapping_required": true, + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 5, + "output_tokens": 15 + }, + "source": "embedded" + } + }, + { + "id": "openai-azure:openai/gpt-4o-mini", + "canonical_model_id": "openai/gpt-4o-mini", + "deployment_id": "openai-azure", + "native_model_id_source": "user_configured", + "mapping_required": true, + "capabilities": { + "server_tools": { + "web_search": "supported" + } + }, + "pricing": { + "status": "known", + "currency": "USD", + "effective_at": "2026-04-09T00:00:00Z", + "rates_per_1m": { + "input_tokens": 0.15, + "output_tokens": 0.6 + }, + "source": "embedded" + } + } + ], + "aliases": { + "anthropic/claude-sonnet-4-6": "anthropic/claude-sonnet-4-6", + "claude-haiku-4-5-20251001": "anthropic/claude-haiku-4-5-20251001", + "claude-opus-4-6": "anthropic/claude-opus-4-6", + "claude-sonnet-4-6": "anthropic/claude-sonnet-4-6", + "gemini-2.0-flash": "google/gemini-2.0-flash", + "gemini-2.0-flash-lite": "google/gemini-2.0-flash-lite", + "gemini-2.5-pro-preview-03-25": "google/gemini-2.5-pro-preview-03-25", + "glm-5": "opencodego/glm-5", + "glm-5.1": "opencodego/glm-5.1", + "gpt-4o": "openai/gpt-4o", + "gpt-4o-mini": "openai/gpt-4o-mini", + "grok-2": "xai/grok-2", + "kimi-k2.5": "opencodego/kimi-k2.5", + "kimi-k2.6": "opencodego/kimi-k2.6", + "mimo-v2-omni": "opencodego/mimo-v2-omni", + "mimo-v2-pro": "opencodego/mimo-v2-pro", + "minimax-m2.5": "opencodego/minimax-m2.5", + "minimax-m2.7": "opencodego/minimax-m2.7", + "openai/gpt-4o": "openai/gpt-4o", + "openai/gpt-4o-mini": "openai/gpt-4o-mini", + "qwen3.5-plus": "opencodego/qwen3.5-plus", + "qwen3.6-plus": "opencodego/qwen3.6-plus", + "zai/glm-4.6": "zai/glm-4.6" + }, + "provenance": { + "source": "test", + "observed_at": "2026-04-09T00:00:00Z" + } +} diff --git a/internal/config/catalog_api.go b/internal/config/catalog_api.go new file mode 100644 index 0000000..ffc4583 --- /dev/null +++ b/internal/config/catalog_api.go @@ -0,0 +1,194 @@ +package config + +import ( + "context" + "sort" + "strings" + + "github.com/GrayCodeAI/eyrie/catalog" +) + +// CompiledCatalogV1 loads the eyrie catalog from cache or bootstrap wiring (no network). +func CompiledCatalogV1() *catalog.CompiledCatalogV1 { + return compiledCatalogOrBootstrap() +} + +func compiledCatalogOrBootstrap() *catalog.CompiledCatalogV1 { + compiled, err := loadEyrieCatalogV1(context.Background(), false) + if err == nil && compiled != nil { + return compiled + } + bootstrap := catalog.BootstrapCatalogV1() + compiled, err = catalog.CompileCatalogV1(&bootstrap) + if err != nil { + return nil + } + return compiled +} + +// AllCatalogProviders returns provider IDs from eyrie (providers + deployments, not hawk constants). +func AllCatalogProviders() []string { + compiled := compiledCatalogOrBootstrap() + if compiled == nil { + return nil + } + seen := map[string]bool{} + var out []string + for _, id := range catalog.ProviderIDsFromCompiled(compiled) { + p := catalogProviderID(id) + if p == "" || seen[p] { + continue + } + seen[p] = true + out = append(out, p) + } + sort.Strings(out) + return out +} + +// DefaultModelForProvider returns the first canonical model for a provider from eyrie's catalog. +func DefaultModelForProvider(provider string) string { + ids, _ := ModelIDsForProvider(provider) + if len(ids) > 0 { + return ids[0] + } + return "" +} + +// ModelIDsForProvider lists canonical model IDs for a provider from the eyrie JSON catalog. +func ModelIDsForProvider(provider string) ([]string, error) { + entries, err := FetchModelsForProvider(provider) + if err != nil { + return nil, err + } + out := make([]string, 0, len(entries)) + for _, e := range entries { + if e.ID != "" { + out = append(out, e.ID) + } + } + return out, nil +} + +// CheapestModelForProvider picks the lowest input-priced model from eyrie's catalog. +func CheapestModelForProvider(provider, fallback string) string { + entries, err := FetchModelsForProvider(provider) + if err != nil || len(entries) == 0 { + return fallback + } + cheapest := entries[0] + for _, e := range entries[1:] { + if e.InputPricePer1M > 0 && (cheapest.InputPricePer1M == 0 || e.InputPricePer1M < cheapest.InputPricePer1M) { + cheapest = e + } + } + if cheapest.ID != "" { + return cheapest.ID + } + return fallback +} + +// ProviderOfModel resolves catalog provider for a canonical model ID or alias. +func ProviderOfModel(modelName string) string { + compiled := CompiledCatalogV1() + if compiled == nil { + return "" + } + if canonical, ok := compiled.CanonicalModelForAliasOrID(modelName); ok { + if model := compiled.ModelsByID[canonical]; model.ID != "" { + return catalogProviderID(model.ProviderID) + } + } + return "" +} + +// ExampleModelHints returns short example model aliases for user-facing error messages. +func ExampleModelHints() (anthropic, openai string) { + compiled := CompiledCatalogV1() + if compiled == nil { + return "claude-sonnet-4-6", "gpt-4o" + } + if _, ok := compiled.CanonicalModelForAliasOrID("claude-sonnet-4-6"); ok { + anthropic = "claude-sonnet-4-6" + } + if _, ok := compiled.CanonicalModelForAliasOrID("gpt-4o"); ok { + openai = "gpt-4o" + } + if anthropic == "" || openai == "" { + for _, id := range []string{"anthropic/claude-sonnet-4-6", "openai/gpt-4o"} { + if _, ok := compiled.ModelsByID[id]; !ok { + continue + } + if strings.HasPrefix(id, "anthropic/") && anthropic == "" { + anthropic = strings.TrimPrefix(id, "anthropic/") + } + if strings.HasPrefix(id, "openai/") && openai == "" { + openai = strings.TrimPrefix(id, "openai/") + } + } + } + if anthropic == "" || openai == "" { + return "claude-sonnet-4-6", "gpt-4o" + } + return anthropic, openai +} + +// AllCanonicalModelIDs returns sorted canonical model IDs from the eyrie catalog. +func AllCanonicalModelIDs() []string { + compiled := compiledCatalogOrBootstrap() + if compiled == nil { + return nil + } + out := make([]string, 0, len(compiled.ModelsByID)) + for id := range compiled.ModelsByID { + out = append(out, id) + } + sort.Strings(out) + return out +} + +// ProviderIDForDeployment returns the catalog provider id for a deployment (e.g. anthropic-direct → anthropic). +func ProviderIDForDeployment(deploymentID string) string { + compiled := compiledCatalogOrBootstrap() + if compiled == nil { + return "" + } + dep, ok := compiled.DeploymentsByID[deploymentID] + if !ok { + return "" + } + return catalogProviderID(dep.ProviderID) +} + +// PrimaryAPIKeyEnvForDeployment returns the env var name for a deployment's API key. +func PrimaryAPIKeyEnvForDeployment(deploymentID string) string { + compiled := compiledCatalogOrBootstrap() + if compiled == nil { + return "" + } + return catalog.PrimaryAPIKeyEnvForDeployment(compiled, deploymentID) +} + +// ConfigProviderList returns provider names for the /config UI from catalog + custom providers. +func ConfigProviderList(custom []CustomProviderConfig) []string { + seen := map[string]bool{} + var out []string + for _, p := range AllCatalogProviders() { + engine := NormalizeProviderForEngine(p) + if engine == "" || seen[engine] { + continue + } + seen[engine] = true + out = append(out, engine) + } + for _, cp := range custom { + name := strings.TrimSpace(cp.Name) + if name == "" || seen[name] { + continue + } + seen[name] = true + out = append(out, name) + } + sort.Strings(out) + return out +} diff --git a/internal/config/catalog_health.go b/internal/config/catalog_health.go new file mode 100644 index 0000000..5264042 --- /dev/null +++ b/internal/config/catalog_health.go @@ -0,0 +1,119 @@ +package config + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/GrayCodeAI/eyrie/catalog" +) + +// CatalogHealth summarizes the on-disk eyrie model catalog for doctor / status output. +type CatalogHealth struct { + CachePath string + Exists bool + Modified time.Time + SizeBytes int64 + Models int + Deployments int + Offerings int + Stale bool + StaleAfter time.Time + Source string + Error string +} + +// CatalogHealthReport inspects ~/.eyrie/model_catalog.json (or EYRIE_MODEL_CATALOG_PATH). +func CatalogHealthReport(ctx context.Context) CatalogHealth { + path := catalog.DefaultCachePath() + h := CatalogHealth{CachePath: path} + exists, mod, size, err := catalog.CacheInfo(path) + if err != nil { + h.Error = err.Error() + return h + } + h.Exists = exists + h.Modified = mod + h.SizeBytes = size + if !exists { + h.Error = "cache missing — hawk will discover automatically on start" + return h + } + compiled, err := catalog.LoadCatalogV1(ctx, catalog.LoadCatalogV1Options{ + CachePath: path, + RequireCache: true, + }) + if err != nil { + h.Error = err.Error() + return h + } + h.Models = len(compiled.ModelsByID) + h.Deployments = len(compiled.DeploymentsByID) + h.Offerings = len(compiled.OfferingsByID) + if compiled.Catalog != nil && compiled.Catalog.Provenance != nil { + h.Source = compiled.Catalog.Provenance.Source + } + if compiled.Catalog != nil && !compiled.Catalog.StaleAfter.IsZero() { + h.StaleAfter = compiled.Catalog.StaleAfter + h.Stale = time.Now().UTC().After(compiled.Catalog.StaleAfter) + } + return h +} + +// FormatCatalogHealth returns human-readable catalog status for hawk doctor. +func FormatCatalogHealth(h CatalogHealth) string { + var b strings.Builder + b.WriteString("Model catalog (eyrie):\n") + b.WriteString(fmt.Sprintf(" path: %s\n", h.CachePath)) + if h.Error != "" { + b.WriteString(fmt.Sprintf(" status: %s\n", h.Error)) + return strings.TrimRight(b.String(), "\n") + } + b.WriteString(fmt.Sprintf(" modified: %s (%d bytes)\n", h.Modified.UTC().Format(time.RFC3339), h.SizeBytes)) + if h.Source != "" { + b.WriteString(fmt.Sprintf(" source: %s\n", h.Source)) + } + b.WriteString(fmt.Sprintf(" models: %d deployments: %d offerings: %d\n", h.Models, h.Deployments, h.Offerings)) + if h.Stale { + b.WriteString(fmt.Sprintf(" stale: yes (after %s) — hawk refreshes automatically on start\n", h.StaleAfter.UTC().Format(time.RFC3339))) + } else if !h.StaleAfter.IsZero() { + b.WriteString(fmt.Sprintf(" stale: no (until %s)\n", h.StaleAfter.UTC().Format(time.RFC3339))) + } + return strings.TrimRight(b.String(), "\n") +} + +// CatalogEmptyHint returns actionable guidance when the catalog has no models. +func CatalogEmptyHint(ctx context.Context) string { + if ctx == nil { + ctx = context.Background() + } + if !HasConfiguredDeployment(ctx) { + return "run /config to paste an API key or set up Ollama (local, no key)" + } + return "check network access, then hawk preflight or /config — hawk refreshes the catalog automatically" +} + +// EnsureCatalogAvailable returns an error when the production catalog cache is missing or empty. +func EnsureCatalogAvailable(ctx context.Context) error { + h := CatalogHealthReport(ctx) + if h.Error != "" { + if !h.Exists { + return fmt.Errorf("model catalog cache missing — %s", CatalogEmptyHint(ctx)) + } + return fmt.Errorf("%s — %s", h.Error, CatalogEmptyHint(ctx)) + } + if h.Models == 0 { + return fmt.Errorf("model catalog has no models — %s", CatalogEmptyHint(ctx)) + } + return nil +} + +// CatalogCachePathForDisplay returns the path users should care about. +func CatalogCachePathForDisplay() string { + if p := strings.TrimSpace(os.Getenv("EYRIE_MODEL_CATALOG_PATH")); p != "" { + return p + } + return catalog.DefaultCachePath() +} diff --git a/internal/config/catalog_health_test.go b/internal/config/catalog_health_test.go new file mode 100644 index 0000000..4429f9c --- /dev/null +++ b/internal/config/catalog_health_test.go @@ -0,0 +1,60 @@ +package config + +import ( + "context" + "strings" + "testing" + + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestCatalogEmptyHint_NoCredentials(t *testing.T) { + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + + hint := CatalogEmptyHint(context.Background()) + if !strings.Contains(hint, "/config") { + t.Fatalf("expected /config guidance, got %q", hint) + } +} + +func TestCatalogEmptyHint_WithCredentials(t *testing.T) { + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + + ctx := context.Background() + _ = store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + + hint := CatalogEmptyHint(ctx) + if strings.Contains(hint, "paste an API key") { + t.Fatalf("should not ask for key when credentials exist: %q", hint) + } + if !strings.Contains(hint, "preflight") { + t.Fatalf("expected preflight guidance, got %q", hint) + } +} + +func TestEnsureCatalogAvailable_MissingCache(t *testing.T) { + dir := t.TempDir() + t.Setenv("EYRIE_MODEL_CATALOG_PATH", dir+"/missing.json") + + err := EnsureCatalogAvailable(context.Background()) + if err == nil || !strings.Contains(err.Error(), "/config") { + t.Fatalf("expected /config in error, got %v", err) + } +} + +func TestCatalogStatusLine_Empty(t *testing.T) { + dir := t.TempDir() + t.Setenv("EYRIE_MODEL_CATALOG_PATH", dir+"/missing.json") + + line := CatalogStatusLine(context.Background()) + if !strings.Contains(line, "missing") && !strings.Contains(line, "empty") { + t.Fatalf("expected missing/empty status, got %q", line) + } + if !strings.Contains(line, "/config") { + t.Fatalf("expected /config in status line, got %q", line) + } +} diff --git a/internal/config/catalog_startup.go b/internal/config/catalog_startup.go new file mode 100644 index 0000000..e365efc --- /dev/null +++ b/internal/config/catalog_startup.go @@ -0,0 +1,231 @@ +package config + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/GrayCodeAI/eyrie/credentials" +) + +// CatalogReady reports whether the eyrie catalog cache exists and has models. +func CatalogReady(ctx context.Context) bool { + h := CatalogHealthReport(ctx) + return h.Error == "" && h.Models > 0 && !h.Stale +} + +// CatalogStatusLine returns a short one-line status for the TUI welcome banner. +func CatalogStatusLine(ctx context.Context) string { + h := CatalogHealthReport(ctx) + if h.Error != "" { + if !h.Exists { + return "Catalog: missing — " + CatalogEmptyHint(ctx) + } + return "Catalog: unavailable — " + CatalogEmptyHint(ctx) + } + if h.Models == 0 { + return "Catalog: empty — " + CatalogEmptyHint(ctx) + } + if h.Stale { + return fmt.Sprintf("Catalog: updating… (%d models cached)", h.Models) + } + return fmt.Sprintf("Catalog: ready (%d models)", h.Models) +} + +// CatalogStartupOptions controls automatic catalog refresh at hawk startup. +type CatalogStartupOptions struct { + ForceRefresh bool + SkipAutoRefresh bool + VerboseOutput bool // full DiscoverReport; default is one line +} + +// PrepareCatalogForSession ensures a usable, fresh catalog before chat/print. +// By default hawk auto-discovers when the cache is missing, empty, or stale. +func PrepareCatalogForSession(ctx context.Context, out io.Writer, opts CatalogStartupOptions) error { + h := CatalogHealthReport(ctx) + if !catalogNeedsAutoRefresh(h, opts) { + return nil + } + hadUsableCache := h.Error == "" && h.Models > 0 + if err := AutoRefreshCatalog(ctx, out, opts.VerboseOutput); err != nil { + if hadUsableCache { + if out != nil { + _, _ = fmt.Fprintf(out, "Catalog refresh skipped (using %d cached models): %v\n", h.Models, err) + } + return nil + } + return fmt.Errorf("automatic catalog refresh failed: %w\n\n%s\nCache path: %s", err, catalogRefreshFailureHint(ctx), CatalogCachePathForDisplay()) + } + h = CatalogHealthReport(ctx) + if h.Error != "" || h.Models == 0 { + if hadUsableCache { + return nil + } + msg := "model catalog unavailable after refresh" + if h.Error != "" { + msg = h.Error + } + return fmt.Errorf("%s\n\n%s\nCache path: %s", msg, catalogRefreshFailureHint(ctx), CatalogCachePathForDisplay()) + } + return nil +} + +func catalogNeedsAutoRefresh(h CatalogHealth, opts CatalogStartupOptions) bool { + if opts.SkipAutoRefresh && !opts.ForceRefresh { + return false + } + if opts.ForceRefresh { + return true + } + if !autoRefreshCatalogEnabled() { + return false + } + if catalogRefreshAlways() { + return true + } + if h.Error != "" || h.Models == 0 { + return true + } + return h.Stale +} + +// AutoRefreshCatalog runs eyrie discover (remote + live APIs when keys are set). +func AutoRefreshCatalog(ctx context.Context, out io.Writer, verbose bool) error { + if out != nil { + if verbose { + _, _ = fmt.Fprintln(out, "Discovering model catalog (published catalog + live provider APIs)...") + } else { + _, _ = fmt.Fprintln(out, "Updating model catalog automatically…") + } + } + refreshCtx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + result, err := refreshModelCatalog(refreshCtx) + if err != nil { + return err + } + if out != nil { + if verbose { + _, _ = fmt.Fprintln(out, strings.TrimSpace(result.DiscoverReport())) + } else if result.Compiled != nil { + _, _ = fmt.Fprintf( + out, "Catalog ready: %d models, %d deployments → %s\n", + len(result.Compiled.ModelsByID), + len(result.Compiled.DeploymentsByID), + result.CachePath, + ) + } + _, _ = fmt.Fprintln(out) + } + return nil +} + +// TryAutoRefreshCatalog refreshes once when the cache cannot be read (e.g. mid-session). +func TryAutoRefreshCatalog(ctx context.Context) error { + if !autoRefreshCatalogEnabled() { + return fmt.Errorf("automatic catalog refresh is disabled (HAWK_AUTO_REFRESH_CATALOG=0)") + } + return AutoRefreshCatalog(ctx, nil, false) +} + +// RefreshCatalogAfterCredentials runs eyrie discover after /config saves API keys. +func RefreshCatalogAfterCredentials(ctx context.Context, out io.Writer) error { + if !autoRefreshCatalogEnabled() { + return nil + } + return AutoRefreshCatalog(ctx, out, false) +} + +// StartupCatalogPrefetch refreshes the catalog in the background when the cache needs it. +func StartupCatalogPrefetch(ctx context.Context) { + if !autoRefreshCatalogEnabled() { + return + } + h := CatalogHealthReport(ctx) + if !catalogNeedsAutoRefresh(h, CatalogStartupOptions{}) { + return + } + go func() { + bgCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + _ = AutoRefreshCatalog(bgCtx, nil, false) + }() +} + +// DiscoverCatalogAfterSetup runs during optional hawk setup after API keys are saved. +func DiscoverCatalogAfterSetup(ctx context.Context, out io.Writer) { + if out == nil { + out = os.Stdout + } + h := CatalogHealthReport(ctx) + if !catalogNeedsAutoRefresh(h, CatalogStartupOptions{}) { + return + } + _ = AutoRefreshCatalog(ctx, out, false) +} + +func catalogRefreshFailureHint(ctx context.Context) string { + if !HasConfiguredDeployment(ctx) { + return "No API keys in " + credentials.PlatformSecretStoreName() + ". Run /config to paste a key or set up Ollama." + } + return "Check network access and stored keys (" + credentials.PlatformSecretStoreName() + "). Run hawk preflight or /config." +} + +func autoRefreshCatalogEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("HAWK_AUTO_REFRESH_CATALOG"))) { + case "0", "false", "no", "off": + return false + default: + return true + } +} + +func catalogRefreshAlways() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("HAWK_CATALOG_REFRESH_ALWAYS"))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +// ScheduleBackgroundCatalogRefresh silently refreshes the catalog when it is already stale, +// or after StaleAfter passes during a long interactive session. +func ScheduleBackgroundCatalogRefresh(ctx context.Context) { + if !autoRefreshCatalogEnabled() { + return + } + h := CatalogHealthReport(ctx) + if h.Error != "" || h.Models == 0 { + return + } + refresh := func() { + bgCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + _ = AutoRefreshCatalog(bgCtx, nil, false) + } + if h.Stale { + go refresh() + return + } + if h.StaleAfter.IsZero() { + return + } + delay := time.Until(h.StaleAfter.UTC()) + if delay <= 0 { + return + } + go func() { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return + case <-timer.C: + refresh() + } + }() +} diff --git a/internal/config/catalog_startup_robust_test.go b/internal/config/catalog_startup_robust_test.go new file mode 100644 index 0000000..a8fbc84 --- /dev/null +++ b/internal/config/catalog_startup_robust_test.go @@ -0,0 +1,37 @@ +package config_test + +import ( + "bytes" + "context" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/hawk/internal/catalogtest" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" +) + +func TestPrepareCatalogForSession_StaleCacheRefreshFailureContinues(t *testing.T) { + catalogtest.Install(t) + // Force stale so refresh is attempted; remote may fail offline — should not block if cache has models. + h := hawkconfig.CatalogHealthReport(context.Background()) + if h.Models == 0 { + t.Skip("fixture has no models") + } + var buf bytes.Buffer + err := hawkconfig.PrepareCatalogForSession(context.Background(), &buf, hawkconfig.CatalogStartupOptions{ + ForceRefresh: true, + }) + // With ForceRefresh, remote may fail; if we had models before, we tolerate failure. + if err != nil && h.Models > 0 { + // Only fail test if we had no usable cache to begin with + t.Logf("refresh failed without fallback (may be ok if remote works): %v", err) + } +} + +func TestCatalogCachePathForDisplay_RespectsEnv(t *testing.T) { + custom := filepath.Join(t.TempDir(), "custom.json") + t.Setenv("EYRIE_MODEL_CATALOG_PATH", custom) + if got := hawkconfig.CatalogCachePathForDisplay(); got != custom { + t.Fatalf("path = %q want %q", got, custom) + } +} diff --git a/internal/config/catalog_startup_test.go b/internal/config/catalog_startup_test.go new file mode 100644 index 0000000..10ffa79 --- /dev/null +++ b/internal/config/catalog_startup_test.go @@ -0,0 +1,57 @@ +package config + +import ( + "context" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/hawk/internal/catalogtest" +) + +func TestCatalogReady_MissingCache(t *testing.T) { + dir := t.TempDir() + t.Setenv("EYRIE_MODEL_CATALOG_PATH", filepath.Join(dir, "missing.json")) + if CatalogReady(context.Background()) { + t.Fatal("expected not ready without cache") + } +} + +func TestCatalogReady_WithCache(t *testing.T) { + catalogtest.Install(t) + h := CatalogHealthReport(context.Background()) + if h.Error != "" || h.Models == 0 { + t.Fatalf("unexpected health: %+v", h) + } + // Fixture may or may not be stale; CatalogReady requires non-stale. + if h.Stale && CatalogReady(context.Background()) { + t.Fatal("expected not ready while stale") + } + if !h.Stale && !CatalogReady(context.Background()) { + t.Fatal("expected ready when cache is fresh") + } +} + +func TestCatalogNeedsAutoRefresh_Stale(t *testing.T) { + h := CatalogHealth{Models: 10, Stale: true} + if !catalogNeedsAutoRefresh(h, CatalogStartupOptions{}) { + t.Fatal("expected auto refresh when stale") + } +} + +func TestCatalogNeedsAutoRefresh_Fresh(t *testing.T) { + h := CatalogHealth{Models: 10, Stale: false} + if catalogNeedsAutoRefresh(h, CatalogStartupOptions{}) { + t.Fatal("expected no refresh when fresh") + } +} + +func TestAutoRefreshCatalogEnabled(t *testing.T) { + t.Setenv("HAWK_AUTO_REFRESH_CATALOG", "false") + if autoRefreshCatalogEnabled() { + t.Fatal("expected disabled") + } + t.Setenv("HAWK_AUTO_REFRESH_CATALOG", "") + if !autoRefreshCatalogEnabled() { + t.Fatal("expected enabled by default") + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 27e7373..5fca74d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,10 +1,13 @@ package config import ( + "context" "os" "path/filepath" "strings" "testing" + + "github.com/GrayCodeAI/eyrie/credentials" ) func TestLoadAgentsMD(t *testing.T) { @@ -133,8 +136,11 @@ func TestLoadSettingsProjectMergeIncludesArchiveFields(t *testing.T) { defer os.Chdir(orig) settings := LoadSettings() - if settings.Model != "project" { - t.Fatalf("expected project model override, got %q", settings.Model) + if got := ActiveModel(nil); got != "project" { + t.Fatalf("expected project model in eyrie, got %q (settings.model=%q)", got, settings.Model) + } + if settings.Model != "" { + t.Fatalf("model must not remain in hawk settings.json, got %q", settings.Model) } if len(settings.AllowedTools) != 1 || settings.AllowedTools[0] != "Read" { t.Fatalf("expected global allowedTools, got %v", settings.AllowedTools) @@ -158,14 +164,20 @@ func TestSetGlobalSettingAndSettingValue(t *testing.T) { if err := SetGlobalSetting("maxBudgetUSD", "2.5"); err != nil { t.Fatal(err) } - // Herm-style: API keys rejected from settings file + // Hawk: API keys rejected from settings file if err := SetGlobalSetting("apiKey.openai", "sk-test"); err == nil { t.Fatal("expected error setting api key in settings") } settings := LoadGlobalSettings() - if settings.Model != "test-model" { - t.Fatalf("unexpected model: %q", settings.Model) + if got := ActiveModel(nil); got != "test-model" { + t.Fatalf("unexpected active model: %q (settings.model=%q)", got, settings.Model) + } + if settings.Model != "" { + t.Fatalf("model must not be stored in settings.json, got %q", settings.Model) + } + if got, ok := SettingValue(settings, "model"); !ok || got != "test-model" { + t.Fatalf("unexpected model setting value: %q ok=%v", got, ok) } if got, ok := SettingValue(settings, "allowed_tools"); !ok || got != "Read, Write" { t.Fatalf("unexpected allowedTools value: %q ok=%v", got, ok) @@ -173,8 +185,11 @@ func TestSetGlobalSettingAndSettingValue(t *testing.T) { if got, ok := SettingValue(settings, "max_budget_usd"); !ok || got != "2.5" { t.Fatalf("unexpected max budget value: %q ok=%v", got, ok) } - // API key status from environment - t.Setenv("OPENAI_API_KEY", "sk-test") + // 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-test") 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 new file mode 100644 index 0000000..9cb4fe0 --- /dev/null +++ b/internal/config/credentials_store.go @@ -0,0 +1,251 @@ +package config + +import ( + "context" + "fmt" + "sort" + "strings" + + eyriecfg "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/eyrie/runtime" + "github.com/GrayCodeAI/eyrie/setup" +) + +// PersistAPIKey saves a provider API key via eyrie (OS secret store). +func PersistAPIKey(ctx context.Context, envKey, secret string) error { + secret = strings.TrimSpace(secret) + envKey = strings.TrimSpace(envKey) + if secret == "" || envKey == "" { + return nil + } + if err := eyriecfg.ValidateCredentialSecret(envKey, secret); err != nil { + return err + } + return runtime.SetCredential(ctx, envKey, secret) +} + +// PrepareCredentialDiscovery migrates any legacy ~/.hawk/env keys into the OS secret store. +func PrepareCredentialDiscovery(ctx context.Context) { + if ctx == nil { + ctx = context.Background() + } + _, _ = credentials.MigrateLegacyEnvFile(ctx) +} + +// ModelOption is one hawk /config model row. +type ModelOption struct { + ID string + DisplayName string +} + +// CredentialInference is one eyrie provider match for a pasted API key. +type CredentialInference struct { + ProviderID string + DeploymentID string + EnvVar string + DisplayName string +} + +// CredentialProviderOption is one eyrie provider row for /config pickers. +type CredentialProviderOption struct { + ProviderID string + DeploymentID string + EnvVar string + DisplayName string + Inferred bool + RequiresKey bool + Rank int +} + +// CredentialResolveResult is eyrie paste-key resolution (all providers + inferred hints). +type CredentialResolveResult struct { + FormatOK bool + FormatError string + Providers []CredentialProviderOption +} + +// ResolveCredential validates format and lists all providers from eyrie registry. +func ResolveCredential(ctx context.Context, secret string) CredentialResolveResult { + res := runtime.ResolveCredential(ctx, secret) + out := CredentialResolveResult{ + FormatOK: res.FormatOK, + FormatError: res.FormatError, + Providers: make([]CredentialProviderOption, len(res.Providers)), + } + for i, p := range res.Providers { + out.Providers[i] = CredentialProviderOption{ + ProviderID: p.ProviderID, + DeploymentID: p.DeploymentID, + EnvVar: p.EnvVar, + DisplayName: p.DisplayName, + Inferred: p.Inferred, + RequiresKey: p.RequiresKey, + Rank: p.Rank, + } + } + return out +} + +// InferenceFromOption converts a provider picker row to persistence metadata. +func InferenceFromOption(opt CredentialProviderOption) CredentialInference { + return CredentialInference{ + ProviderID: opt.ProviderID, + DeploymentID: opt.DeploymentID, + EnvVar: opt.EnvVar, + DisplayName: opt.DisplayName, + } +} + +// SaveCredential validates, probes, and stores via eyrie keychain. +func SaveCredential(ctx context.Context, inference CredentialInference, secret string) error { + return runtime.SaveCredential(ctx, runtime.CredentialInference(inference), secret) +} + +// ConfiguredCredentialProviders returns catalog providers with a stored API key. +func ConfiguredCredentialProviders() []string { + var out []string + for _, p := range AllCatalogProviders() { + if EnvKeyStatus(p) == "set" { + out = append(out, p) + } + } + sort.Strings(out) + return out +} + +// FormatCredentialCLIStatus returns hawk credentials status output (providers, not raw env names). +func FormatCredentialCLIStatus(ctx context.Context) string { + if ctx == nil { + ctx = context.Background() + } + report := credentials.StorageReportFor(ctx) + var b strings.Builder + fmt.Fprintf(&b, "Credential storage: %s only\n", report.PlatformStore) + if report.KeychainWritable { + b.WriteString(" Keychain: writable\n") + } else { + fmt.Fprintf(&b, " Keychain: %s\n", report.KeychainDetail) + } + providers := ConfiguredCredentialProviders() + if len(providers) == 0 { + b.WriteString(" Configured: (none)\n") + } else { + fmt.Fprintf(&b, " Configured: %s\n", strings.Join(providers, ", ")) + } + return strings.TrimRight(b.String(), "\n") +} + +// RemoveStoredCredential deletes stored API key(s) for a provider name or env var. +func RemoveStoredCredential(ctx context.Context, target string) ([]string, error) { + target = strings.TrimSpace(target) + if target == "" { + return nil, fmt.Errorf("provider or env var name required") + } + envKeys := credentialEnvKeysForTarget(target) + if len(envKeys) == 0 { + return nil, fmt.Errorf("unknown provider %q", target) + } + var removed []string + for _, envKey := range envKeys { + if !credentials.HasSecret(ctx, envKey) { + continue + } + if err := credentials.DeleteSecret(ctx, envKey); err != nil { + return removed, err + } + removed = append(removed, envKey) + } + if len(removed) == 0 { + return nil, fmt.Errorf("no stored credential for %q", target) + } + return removed, nil +} + +func credentialEnvKeysForTarget(target string) []string { + if strings.Contains(target, "_") && strings.ToUpper(target) == target { + return []string{strings.TrimSpace(target)} + } + provider := catalogProviderID(normalizeProviderName(target)) + seen := map[string]struct{}{} + var keys []string + add := func(k string) { + k = strings.TrimSpace(k) + if k == "" { + return + } + if _, ok := seen[k]; ok { + return + } + seen[k] = struct{}{} + keys = append(keys, k) + } + if primary := ProviderAPIKeyEnv(provider); primary != "" { + add(primary) + } + for _, alt := range providerCredentialEnvAliases(provider) { + add(alt) + } + return keys +} + +// LocalCredentialInference returns setup metadata for no-key providers (e.g. Ollama). +func LocalCredentialInference(providerID string) (CredentialInference, error) { + inf, err := runtime.LocalCredentialInference(providerID) + if err != nil { + return CredentialInference{}, err + } + return CredentialInference{ + ProviderID: inf.ProviderID, + DeploymentID: inf.DeploymentID, + EnvVar: inf.EnvVar, + DisplayName: inf.DisplayName, + }, nil +} + +// FormatConfigProviderError maps eyrie setup errors to user-facing /config hints. +func FormatConfigProviderError(providerID string, err error) string { + if err == nil { + return "" + } + if formatted := runtime.FormatSetupError(providerID, err); formatted != nil { + return formatted.Error() + } + return err.Error() +} + +// InferCredentialsFromAPIKey delegates provider detection to eyrie from key shape + catalog. +func InferCredentialsFromAPIKey(ctx context.Context, secret string) []CredentialInference { + in := runtime.InferCredentialsFromAPIKey(ctx, secret) + out := make([]CredentialInference, len(in)) + for i, c := range in { + out[i] = CredentialInference{ + ProviderID: c.ProviderID, + DeploymentID: c.DeploymentID, + EnvVar: c.EnvVar, + DisplayName: c.DisplayName, + } + } + return out +} + +// OptionsFromSetupUI builds picker rows; providerFilter limits to one provider. +func OptionsFromSetupUI(ui *setup.SetupUI, providerFilter string) []ModelOption { + if ui == nil { + return nil + } + providerFilter = strings.TrimSpace(providerFilter) + var out []ModelOption + for _, p := range ui.Providers { + if providerFilter != "" && p.ID != providerFilter { + continue + } + for _, m := range p.Models { + out = append(out, ModelOption{ + ID: m.CanonicalID, + DisplayName: m.DisplayName, + }) + } + } + return out +} diff --git a/internal/config/credentials_store_test.go b/internal/config/credentials_store_test.go new file mode 100644 index 0000000..0598297 --- /dev/null +++ b/internal/config/credentials_store_test.go @@ -0,0 +1,74 @@ +package config + +import ( + "context" + "strings" + "testing" + + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestRemoveStoredCredential_ByProvider(t *testing.T) { + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + + ctx := context.Background() + _ = store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + + removed, err := RemoveStoredCredential(ctx, "openrouter") + if err != nil { + t.Fatal(err) + } + if len(removed) != 1 || removed[0] != "OPENROUTER_API_KEY" { + t.Fatalf("removed = %v", removed) + } + if credentials.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) }) + + ctx := context.Background() + _ = store.Set(ctx, credentials.AccountForEnv("ANTHROPIC_API_KEY"), "sk-ant-test-key-1234567890") + + removed, err := RemoveStoredCredential(ctx, "ANTHROPIC_API_KEY") + if err != nil { + t.Fatal(err) + } + if len(removed) != 1 { + t.Fatalf("removed = %v", removed) + } +} + +func TestRemoveStoredCredential_NotFound(t *testing.T) { + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + + _, err := RemoveStoredCredential(context.Background(), "openrouter") + if err == nil || !strings.Contains(err.Error(), "no stored credential") { + t.Fatalf("expected not found error, got %v", err) + } +} + +func TestFormatCredentialCLIStatus(t *testing.T) { + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + + ctx := context.Background() + _ = store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + + out := FormatCredentialCLIStatus(ctx) + if !strings.Contains(out, "Configured:") { + t.Fatalf("expected configured section, got:\n%s", out) + } + if strings.Contains(out, "Keys stored:") { + t.Fatal("should not show legacy key count") + } +} diff --git a/internal/config/deployment_status.go b/internal/config/deployment_status.go new file mode 100644 index 0000000..a111055 --- /dev/null +++ b/internal/config/deployment_status.go @@ -0,0 +1,57 @@ +package config + +import ( + "context" + "os" + "strings" + + eyriecfg "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/setup" +) + +// ResolveCanonicalModel maps aliases and native IDs to catalog canonical model IDs. +func ResolveCanonicalModel(model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "" + } + compiled, err := loadEyrieCatalogV1(context.Background(), false) + if err != nil || compiled == nil { + return model + } + if canonical, ok := compiled.CanonicalModelForAliasOrID(model); ok { + return canonical + } + if strings.Contains(model, "/") { + return model + } + return model +} + +// DeploymentStatusReport returns hawk deployment routing diagnostics. +func DeploymentStatusReport(ctx context.Context, activeModel string) (string, error) { + report, err := setup.DeploymentStatus(ctx, activeModel) + if err != nil { + return "", err + } + return setup.FormatStatus(report), nil +} + +// RoutingPreviewJSON returns effective routing for a model (eyrie routing JSON preview). +func RoutingPreviewJSON(ctx context.Context, model string) (string, error) { + return setup.RoutingPreview(ctx, model) +} + +// MigrateProviderConfig upgrades ~/.hawk/provider.json to deployment v2 in place. +func MigrateProviderConfig() error { + path := eyriecfg.GetProviderConfigPath() + if _, err := os.Stat(path); err != nil { + return nil + } + cfg := eyriecfg.LoadProviderConfig("") + cfg = eyriecfg.EnsureDeploymentConfigV2(cfg) + if cfg == nil { + return nil + } + return eyriecfg.SaveProviderConfig(cfg, path) +} diff --git a/internal/config/deployment_status_test.go b/internal/config/deployment_status_test.go new file mode 100644 index 0000000..d264dd9 --- /dev/null +++ b/internal/config/deployment_status_test.go @@ -0,0 +1,13 @@ +package config + +import "testing" + +func TestResolveCanonicalModelAlias(t *testing.T) { + canonical := ResolveCanonicalModel("claude-sonnet-4-6") + if canonical == "" { + t.Fatal("expected canonical model") + } + if canonical != "anthropic/claude-sonnet-4-6" { + t.Fatalf("canonical = %q", canonical) + } +} diff --git a/internal/config/deployments_ui.go b/internal/config/deployments_ui.go new file mode 100644 index 0000000..bbdc86d --- /dev/null +++ b/internal/config/deployments_ui.go @@ -0,0 +1,165 @@ +package config + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/GrayCodeAI/eyrie/catalog" + eyriecfg "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/setup" +) + +// DeploymentRow is one catalog deployment with local credential status. +type DeploymentRow struct { + ID string + Name string + ProviderID string + Configured bool + Status string + EnvVars []EnvVarStatus +} + +// EnvVarStatus tracks whether an env var is set for a deployment. +type EnvVarStatus struct { + Name string + Set bool +} + +// ListDeploymentRows lists catalog deployments and whether hawk can use them now. +func ListDeploymentRows(ctx context.Context) ([]DeploymentRow, error) { + PrepareCredentialDiscovery(ctx) + compiled, err := loadEyrieCatalogV1(ctx, false) + if err != nil { + return nil, err + } + cfg := eyriecfg.LoadProviderConfig("") + cfg = eyriecfg.EnsureDeploymentConfigV2(cfg) + configured := setup.ConfiguredDeployments(cfg) + discoveryEnv := eyriecfg.DiscoveryEnvMap(ctx) + + ids := make([]string, 0, len(compiled.DeploymentsByID)) + for id := range compiled.DeploymentsByID { + ids = append(ids, id) + } + sort.Strings(ids) + + out := make([]DeploymentRow, 0, len(ids)) + for _, id := range ids { + dep := compiled.DeploymentsByID[id] + row := DeploymentRow{ + ID: id, + Name: dep.Name, + ProviderID: dep.ProviderID, + EnvVars: envStatusForDeployment(id, dep, discoveryEnv), + } + dc := eyriecfg.DeploymentConfigFromEnv(dep, discoveryEnv) + if eyriecfg.DeploymentConfigured(id, dep, dc) { + row.Configured = true + row.Status = "ready" + } else if _, ok := configured[id]; ok { + row.Status = "incomplete" + } else { + row.Status = "needs credentials" + } + out = append(out, row) + } + return out, nil +} + +func envStatusForDeployment(deploymentID string, dep catalog.DeploymentV1, discoveryEnv map[string]string) []EnvVarStatus { + known := deploymentEnvVars(deploymentID) + if len(dep.EnvFallbacks) > 0 { + for _, fb := range dep.EnvFallbacks { + known = append(known, fb.Env...) + } + } + var out []EnvVarStatus + seen := map[string]bool{} + for _, env := range known { + if env == "" || seen[env] { + continue + } + seen[env] = true + set := strings.TrimSpace(discoveryEnv[env]) != "" + if !set { + set = strings.TrimSpace(os.Getenv(env)) != "" + } + out = append(out, EnvVarStatus{Name: env, Set: set}) + } + return out +} + +func deploymentEnvVars(id string) []string { + return catalog.EnvVarsForDeployment(id) +} + +// DeploymentRoutingLabel returns a short on/off label for the config hub. +func DeploymentRoutingLabel(settings Settings) string { + if DeploymentRoutingEnabled(settings) { + return "on" + } + return "off" +} + +// ToggleDeploymentRouting flips deployment_routing in global settings. +func ToggleDeploymentRouting(settings Settings) (Settings, bool, error) { + enabled := DeploymentRoutingEnabled(settings) + next := !enabled + settings.DeploymentRouting = &next + if err := SaveProjectOrGlobalDeploymentRouting(next); err != nil { + return settings, enabled, err + } + return settings, next, nil +} + +// SaveProjectOrGlobalDeploymentRouting persists the flag to project settings when present. +func SaveProjectOrGlobalDeploymentRouting(enabled bool) error { + projectPath := projectSettingsPath() + if _, err := os.Stat(projectPath); err == nil { + var s Settings + data, err := os.ReadFile(projectPath) + if err != nil { + return err + } + if json.Unmarshal(data, &s) != nil { + return fmt.Errorf("parse project settings") + } + s.DeploymentRouting = &enabled + out, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(projectPath, append(out, '\n'), 0o644) + } + val := "false" + if enabled { + val = "true" + } + return SetGlobalSetting("deployment_routing", val) +} + +// SyncProviderConfigFromEnv re-applies eyrie catalog + env into provider.json (deployments + routing). +func SyncProviderConfigFromEnv() (string, error) { + result, err := ApplyEyrieCredentials(context.Background()) + if err != nil { + return "", err + } + return FormatApplyCredentialsSummary(result), nil +} + +// ProviderConfigJSON returns the current provider.json as indented JSON (routing included). +func ProviderConfigJSON() (string, error) { + cfg := eyriecfg.LoadProviderConfig("") + if cfg == nil { + return "{}", nil + } + raw, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return "", err + } + return string(raw), nil +} diff --git a/internal/config/deployments_ui_test.go b/internal/config/deployments_ui_test.go new file mode 100644 index 0000000..77338e4 --- /dev/null +++ b/internal/config/deployments_ui_test.go @@ -0,0 +1,15 @@ +package config + +import "testing" + +func TestDeploymentRoutingLabel(t *testing.T) { + t.Setenv("HAWK_DEPLOYMENT_ROUTING", "") + enabled := true + if DeploymentRoutingLabel(Settings{DeploymentRouting: &enabled}) != "on" { + t.Fatal("expected on") + } + disabled := false + if DeploymentRoutingLabel(Settings{DeploymentRouting: &disabled}) != "off" { + t.Fatal("expected off") + } +} diff --git a/internal/config/dotenv.go b/internal/config/dotenv.go deleted file mode 100644 index 9f4604b..0000000 --- a/internal/config/dotenv.go +++ /dev/null @@ -1,112 +0,0 @@ -package config - -import ( - "bufio" - "os" - "path/filepath" - "strings" -) - -// LoadDotEnv loads environment variables from .env files. -// Checks in order: .env, .env.local (project), then ~/.hawk/.env (global). -// Does NOT override existing environment variables. -func LoadDotEnv() { - // Project-level .env files - loadEnvFile(".env") - loadEnvFile(".env.local") - - // Global hawk .env - home, err := os.UserHomeDir() - if err == nil { - loadEnvFile(filepath.Join(home, ".hawk", ".env")) - } -} - -// loadEnvFile reads a .env file and sets environment variables. -func loadEnvFile(path string) { - f, err := os.Open(path) - if err != nil { - return - } - defer func() { _ = f.Close() }() - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - - // Skip comments and empty lines - if line == "" || line[0] == '#' { - continue - } - - // Parse KEY=VALUE - eqIdx := strings.IndexByte(line, '=') - if eqIdx < 0 { - continue - } - - key := strings.TrimSpace(line[:eqIdx]) - value := strings.TrimSpace(line[eqIdx+1:]) - - // Remove surrounding quotes - if len(value) >= 2 { - if (value[0] == '"' && value[len(value)-1] == '"') || - (value[0] == '\'' && value[len(value)-1] == '\'') { - value = value[1 : len(value)-1] - } - } - - // Don't override existing env vars - if os.Getenv(key) == "" { - _ = os.Setenv(key, value) - } - } -} - -// GetAPIKey returns the API key for a provider, checking multiple sources. -// Delegates to ProviderAPIKeyEnv (settings.go) as the single source of truth -// for provider→env-var mappings, with fallback aliases for compatibility. -func GetAPIKey(provider string) string { - // Primary: use the canonical env var from ProviderAPIKeyEnv - if envVar := ProviderAPIKeyEnv(provider); envVar != "" { - if v := os.Getenv(envVar); v != "" { - return v - } - } - // Fallback aliases for providers that have secondary env var names - for _, alt := range providerFallbackEnvVars(provider) { - if v := os.Getenv(alt); v != "" { - return v - } - } - return "" -} - -// providerFallbackEnvVars returns secondary/legacy env var names not covered -// by the canonical ProviderAPIKeyEnv mapping. -func providerFallbackEnvVars(provider string) []string { - switch strings.ToLower(provider) { - case "anthropic": - return []string{"CLAUDE_API_KEY"} - case "gemini", "google": - return []string{"GOOGLE_API_KEY"} - case "grok", "xai": - return []string{"GROK_API_KEY"} - default: - return nil - } -} - -// ValidateAPIKey checks if an API key is set for the provider. -func ValidateAPIKey(provider string) (string, bool) { - key := GetAPIKey(provider) - return key, key != "" -} - -// MaskAPIKey returns a masked version of an API key for display. -func MaskAPIKey(key string) string { - if len(key) <= 8 { - return "****" - } - return key[:4] + "..." + key[len(key)-4:] -} diff --git a/internal/config/envmanager.go b/internal/config/envmanager.go index 0e41ac6..d72a139 100644 --- a/internal/config/envmanager.go +++ b/internal/config/envmanager.go @@ -2,6 +2,7 @@ package config import ( "bufio" + "context" "encoding/json" "fmt" "os" @@ -9,6 +10,8 @@ import ( "sort" "strings" "sync" + + "github.com/GrayCodeAI/eyrie/credentials" ) // EnvVar represents a single environment variable with metadata. @@ -37,24 +40,14 @@ func NewEnvManager() *EnvManager { } } -// Load reads environment variables from multiple sources in priority order. -// Sources are checked in order: OS environment (highest), .env, .env.local, -// ~/.hawk/env, then default values (lowest). Custom source paths can be -// provided to override the default file search order. +// Load reads environment variables from explicit file sources when provided. +// By default only the OS environment is used — API keys are not loaded from .env files. func (em *EnvManager) Load(sources ...string) error { em.mu.Lock() defer em.mu.Unlock() - // Determine file sources to load (lowest priority first so higher priority overwrites) + // Only load from files when callers pass explicit paths (tests/tools). fileSources := sources - if len(fileSources) == 0 { - home, _ := os.UserHomeDir() - fileSources = []string{ - filepath.Join(home, ".hawk", "env"), - ".env.local", - ".env", - } - } // Load from files in order (lowest priority first) for _, src := range fileSources { @@ -103,12 +96,6 @@ func sourceNameFromPath(path string) string { return ".env" case ".env.local": return ".env.local" - case "env": - // Check if it's in ~/.hawk/ - if strings.Contains(path, ".hawk") { - return "~/.hawk/env" - } - return "file" default: return "file" } @@ -351,12 +338,13 @@ func (em *EnvManager) Validate() []string { } } - // Check recommended vars that may not be in the map + // Recommended provider credentials live in the OS secret store. recommended := []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY"} + ctx := context.Background() for _, key := range recommended { if _, ok := em.Vars[key]; !ok { - if os.Getenv(key) == "" { - warnings = append(warnings, fmt.Sprintf("WARNING: recommended variable %q is not set", key)) + if !credentials.HasSecret(ctx, key) { + warnings = append(warnings, fmt.Sprintf("WARNING: recommended credential %q is not configured — run /config", key)) } } } diff --git a/internal/config/envmanager_test.go b/internal/config/envmanager_test.go index 7de6ceb..3749c51 100644 --- a/internal/config/envmanager_test.go +++ b/internal/config/envmanager_test.go @@ -517,7 +517,6 @@ func TestSourceNameFromPath(t *testing.T) { {".env", ".env"}, {"/project/.env", ".env"}, {".env.local", ".env.local"}, - {"/home/user/.hawk/env", "~/.hawk/env"}, {"/some/random/file.txt", "file"}, } diff --git a/internal/config/eyrie_apply.go b/internal/config/eyrie_apply.go new file mode 100644 index 0000000..c3ef98a --- /dev/null +++ b/internal/config/eyrie_apply.go @@ -0,0 +1,37 @@ +package config + +import ( + "context" + "fmt" + "time" + + eyriecfg "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/setup" +) + +// ApplyEyrieCredentials discovers the catalog and writes provider.json (routing only on disk). +func ApplyEyrieCredentials(ctx context.Context) (*setup.ApplyCredentialsResult, error) { + ctx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + PrepareCredentialDiscovery(ctx) + result, err := setup.ApplyCredentials(ctx, eyriecfg.DiscoveryCredentials(ctx)) + if err != nil { + return nil, err + } + _ = SaveProjectOrGlobalDeploymentRouting(true) + return result, nil +} + +// FormatApplyCredentialsSummary is a short status line for the TUI after /config saves keys. +func FormatApplyCredentialsSummary(result *setup.ApplyCredentialsResult) string { + if result == nil || result.Catalog == nil || result.Catalog.Compiled == nil { + return "Eyrie credentials applied" + } + nModels := len(result.Catalog.Compiled.ModelsByID) + nDeps := 0 + if result.ProviderConfig != nil { + nDeps = len(result.ProviderConfig.Deployments) + } + return fmt.Sprintf("Eyrie: %d models, %d deployments configured, routing updated → %s", + nModels, nDeps, result.ProviderConfigPath) +} diff --git a/internal/config/eyrie_selection.go b/internal/config/eyrie_selection.go new file mode 100644 index 0000000..3a12ecc --- /dev/null +++ b/internal/config/eyrie_selection.go @@ -0,0 +1,72 @@ +package config + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/runtime" +) + +// ActiveModel returns the selected model from eyrie provider.json (not hawk settings). +func ActiveModel(ctx context.Context) string { + if ctx == nil { + ctx = context.Background() + } + return runtime.ActiveModel(ctx) +} + +// ActiveProvider returns the selected provider from eyrie provider.json. +func ActiveProvider(ctx context.Context) string { + if ctx == nil { + ctx = context.Background() + } + return runtime.ActiveProvider(ctx) +} + +// SetActiveModel persists model selection to eyrie provider.json. +func SetActiveModel(ctx context.Context, modelID string) error { + if ctx == nil { + ctx = context.Background() + } + return runtime.SetActiveModel(ctx, modelID) +} + +// SetActiveProvider persists provider selection to eyrie provider.json. +func SetActiveProvider(ctx context.Context, provider string) error { + if ctx == nil { + ctx = context.Background() + } + return runtime.SetActiveProvider(ctx, provider) +} + +// migrateLegacyModelProvider moves model/provider from ~/.hawk/settings.json into eyrie once. +func migrateLegacyModelProvider(s *Settings) { + if s == nil { + return + } + ctx := context.Background() + changed := false + if m := strings.TrimSpace(s.Model); m != "" { + if strings.TrimSpace(ActiveModel(ctx)) == "" { + _ = SetActiveModel(ctx, m) + } + s.Model = "" + changed = true + } + if p := strings.TrimSpace(s.Provider); p != "" { + if strings.TrimSpace(ActiveProvider(ctx)) == "" { + _ = SetActiveProvider(ctx, p) + } + s.Provider = "" + changed = true + } + if changed { + _ = SaveGlobal(*s) + } +} + +func stripHostModelSelection(s Settings) Settings { + s.Model = "" + s.Provider = "" + return s +} diff --git a/internal/config/main_test.go b/internal/config/main_test.go new file mode 100644 index 0000000..f70ea61 --- /dev/null +++ b/internal/config/main_test.go @@ -0,0 +1,14 @@ +package config + +import ( + "os" + "testing" + + "github.com/GrayCodeAI/hawk/internal/catalogtest" +) + +func TestMain(m *testing.M) { + cleanup := catalogtest.InstallGlobal() + defer cleanup() + os.Exit(m.Run()) +} diff --git a/internal/config/migrate_provider_secrets.go b/internal/config/migrate_provider_secrets.go new file mode 100644 index 0000000..db9a7cd --- /dev/null +++ b/internal/config/migrate_provider_secrets.go @@ -0,0 +1,46 @@ +package config + +import ( + "encoding/json" + "os" + "strings" + + eyriecfg "github.com/GrayCodeAI/eyrie/config" +) + +// MigrateProviderSecrets strips api keys from on-disk provider.json (one-time hygiene). +func MigrateProviderSecrets() error { + path := eyriecfg.GetProviderConfigPath() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var cfg eyriecfg.ProviderConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return err + } + changed := false + for id, dep := range cfg.Deployments { + if deploymentHasSecrets(dep) { + changed = true + } + cfg.Deployments[id] = eyriecfg.SanitizeDeploymentConfigForDisk(dep) + } + if !changed { + return nil + } + backup := path + ".pre-secret-migrate.bak" + _ = os.WriteFile(backup, data, 0o600) + return eyriecfg.SaveProviderConfig(&cfg, path) +} + +func deploymentHasSecrets(dep eyriecfg.DeploymentConfig) bool { + return strings.TrimSpace(dep.APIKey) != "" || + strings.TrimSpace(dep.Token) != "" || + strings.TrimSpace(dep.SecretAccessKey) != "" || + strings.TrimSpace(dep.AccessKeyID) != "" || + strings.TrimSpace(dep.SessionToken) != "" +} diff --git a/internal/config/milestone_verify_test.go b/internal/config/milestone_verify_test.go new file mode 100644 index 0000000..f07bd92 --- /dev/null +++ b/internal/config/milestone_verify_test.go @@ -0,0 +1,165 @@ +package config + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GrayCodeAI/eyrie/catalog" + eyriecfg "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" +) + +// isolateMilestoneTest uses a temp HOME and HAWK_CONFIG_DIR so verification does not touch the user machine. +func isolateMilestoneTest(t *testing.T) string { + t.Helper() + home := t.TempDir() + hawkDir := filepath.Join(home, ".hawk") + if err := os.MkdirAll(hawkDir, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("HAWK_CONFIG_DIR", hawkDir) + return hawkDir +} + +func TestVerify_ProviderJSONOnDiskHasNoSecrets(t *testing.T) { + isolateMilestoneTest(t) + compiled := CompiledCatalogV1() + if compiled == nil { + t.Fatal("compiled catalog required") + } + env := map[string]string{"ANTHROPIC_API_KEY": "sk-ant-verify-test-key-1234567890"} + cfg := eyriecfg.SyncProviderConfigFromCatalog(compiled, env) + path := eyriecfg.GetProviderConfigPath() + if err := eyriecfg.SaveProviderConfig(cfg, path); err != nil { + t.Fatal(err) + } + assertProviderJSONFileHasNoSecrets(t, path) +} + +func TestVerify_MigrateProviderSecretsStripsDisk(t *testing.T) { + hawkDir := isolateMilestoneTest(t) + path := filepath.Join(hawkDir, "provider.json") + secret := "sk-ant-migrate-verify-key-1234567890" + raw := `{ + "version": "1", + "config_version": 2, + "deployments": { + "anthropic-direct": { + "api_key": "` + secret + `" + } + } +}` + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + if err := MigrateProviderSecrets(); err != nil { + t.Fatal(err) + } + assertProviderJSONFileHasNoSecrets(t, path) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), secret) { + t.Fatal("provider.json still contains api key after migrate") + } +} + +func TestVerify_PersistAPIKeyDoesNotWriteProviderJSON(t *testing.T) { + hawkDir := isolateMilestoneTest(t) + credentials.SetDefaultStore(emptyCredentialStore{}) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + + secret := "sk-ant-persist-verify-key-1234567890" + if err := PersistAPIKey(context.Background(), "ANTHROPIC_API_KEY", secret); err != nil { + t.Fatal(err) + } + path := filepath.Join(hawkDir, "provider.json") + if _, err := os.Stat(path); err == nil { + data, _ := os.ReadFile(path) + if strings.Contains(string(data), secret) { + t.Fatal("PersistAPIKey must not write secrets to provider.json") + } + } +} + +func TestVerify_EvaluateSetupFlow(t *testing.T) { + isolateMilestoneTest(t) + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + + ctx := context.Background() + compiled := CompiledCatalogV1() + if compiled != nil { + for _, k := range catalog.DiscoveryEnvKeysFromCatalog(compiled) { + t.Setenv(k, "") + } + } + + st := EvaluateSetup(ctx) + if !st.NeedsSetup || st.HasCredentials { + t.Fatalf("expected setup needed without credentials, got %+v", st) + } + + secret := "sk-ant-flow-verify-key-1234567890" + if err := store.Set(ctx, credentials.AccountForEnv("ANTHROPIC_API_KEY"), secret); err != nil { + t.Fatal(err) + } + st = EvaluateSetup(ctx) + if !st.HasCredentials { + t.Fatal("expected credentials after keychain key set") + } + if !st.NeedsSetup || st.HasModel { + t.Fatal("expected setup still needed until model selected") + } + + providerPath := filepath.Join(os.Getenv("HOME"), ".hawk", "provider.json") + cfg := &eyriecfg.ProviderConfig{ + ActiveProvider: "anthropic", + ActiveModel: "claude-sonnet-4-20250514", + AnthropicModel: "claude-sonnet-4-20250514", + } + if err := eyriecfg.SaveProviderConfig(cfg, providerPath); err != nil { + t.Fatal(err) + } + st = EvaluateSetup(ctx) + if st.NeedsSetup { + t.Fatalf("expected setup complete with key + model, got %+v", st) + } +} + +func assertProviderJSONFileHasNoSecrets(t *testing.T, path string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(data) + for _, needle := range []string{`"api_key"`, `"secret_access_key"`, `"session_token"`} { + if !strings.Contains(text, needle) { + continue + } + // Empty values are OK: "api_key": "" + if strings.Contains(text, needle+`": ""`) || strings.Contains(text, needle+`":""`) { + continue + } + if strings.Contains(text, needle+`": "`) && !strings.Contains(text, needle+`": ""`) { + t.Fatalf("provider.json at %s contains non-empty %s", path, needle) + } + } + var cfg eyriecfg.ProviderConfig + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + for id, dep := range cfg.Deployments { + if deploymentHasSecrets(dep) { + t.Fatalf("deployment %q still has secret fields in struct", id) + } + } +} diff --git a/internal/config/model_pack_catalog.go b/internal/config/model_pack_catalog.go new file mode 100644 index 0000000..46d0455 --- /dev/null +++ b/internal/config/model_pack_catalog.go @@ -0,0 +1,31 @@ +package config + +import ( + "github.com/GrayCodeAI/hawk/internal/provider/routing" + + eycatalog "github.com/GrayCodeAI/eyrie/catalog" +) + +const defaultPackProvider = "anthropic" + +func packRole(provider string, tier eycatalog.ModelTier, temperature float64, maxTokens int, purpose string) ModelRole { + return ModelRole{ + Provider: provider, + Model: routing.PreferredModelForTier(provider, tier, ""), + Temperature: temperature, + MaxTokens: maxTokens, + Purpose: purpose, + } +} + +func anthropicPackModels(haikuTier, sonnetTier, opusTier eycatalog.ModelTier) map[string]ModelRole { + p := defaultPackProvider + return map[string]ModelRole{ + "code": packRole(p, sonnetTier, 0.2, 4096, "code generation and editing"), + "chat": packRole(p, sonnetTier, 0.7, 2048, "interactive conversation"), + "summarize": packRole(p, haikuTier, 0.3, 1024, "summarization"), + "review": packRole(p, sonnetTier, 0.1, 4096, "code review"), + "plan": packRole(p, opusTier, 0.4, 8192, "complex planning and architecture"), + "debug": packRole(p, opusTier, 0.2, 4096, "debugging complex issues"), + } +} diff --git a/internal/config/model_packs.go b/internal/config/model_packs.go index b23e25f..2e522c2 100644 --- a/internal/config/model_packs.go +++ b/internal/config/model_packs.go @@ -8,6 +8,10 @@ import ( "sort" "strings" "sync" + + eycatalog "github.com/GrayCodeAI/eyrie/catalog" + + "github.com/GrayCodeAI/hawk/internal/provider/routing" ) // ModelRole defines a model configuration for a specific role within a pack. @@ -46,68 +50,40 @@ func NewModelPackRegistry() *ModelPackRegistry { } r.Packs["default"] = &ModelPack{ - Name: "default", - Description: "Balanced defaults: sonnet for code, haiku for summarize, opus for complex tasks", - Models: map[string]ModelRole{ - "code": {Provider: "anthropic", Model: "claude-sonnet-4-6", Temperature: 0.2, MaxTokens: 4096, Purpose: "code generation and editing"}, - "chat": {Provider: "anthropic", Model: "claude-sonnet-4-6", Temperature: 0.7, MaxTokens: 2048, Purpose: "interactive conversation"}, - "summarize": {Provider: "anthropic", Model: "claude-haiku-4-5", Temperature: 0.3, MaxTokens: 1024, Purpose: "summarization"}, - "review": {Provider: "anthropic", Model: "claude-sonnet-4-6", Temperature: 0.1, MaxTokens: 4096, Purpose: "code review"}, - "plan": {Provider: "anthropic", Model: "claude-opus-4-6", Temperature: 0.4, MaxTokens: 8192, Purpose: "complex planning and architecture"}, - "debug": {Provider: "anthropic", Model: "claude-opus-4-6", Temperature: 0.2, MaxTokens: 4096, Purpose: "debugging complex issues"}, - }, - DefaultProvider: "anthropic", + Name: "default", + Description: "Balanced defaults: sonnet for code, haiku for summarize, opus for complex tasks", + Models: anthropicPackModels(eycatalog.TierHaiku, eycatalog.TierSonnet, eycatalog.TierOpus), + DefaultProvider: defaultPackProvider, Settings: map[string]interface{}{"stream": true}, Tags: []string{"recommended", "general"}, Author: "hawk", } r.Packs["budget"] = &ModelPack{ - Name: "budget", - Description: "Cost-optimized: haiku for everything, sonnet only for complex tasks", - Models: map[string]ModelRole{ - "code": {Provider: "anthropic", Model: "claude-haiku-4-5", Temperature: 0.2, MaxTokens: 4096, Purpose: "code generation and editing"}, - "chat": {Provider: "anthropic", Model: "claude-haiku-4-5", Temperature: 0.7, MaxTokens: 2048, Purpose: "interactive conversation"}, - "summarize": {Provider: "anthropic", Model: "claude-haiku-4-5", Temperature: 0.3, MaxTokens: 1024, Purpose: "summarization"}, - "review": {Provider: "anthropic", Model: "claude-haiku-4-5", Temperature: 0.1, MaxTokens: 2048, Purpose: "code review"}, - "plan": {Provider: "anthropic", Model: "claude-sonnet-4-6", Temperature: 0.4, MaxTokens: 4096, Purpose: "complex planning"}, - "debug": {Provider: "anthropic", Model: "claude-haiku-4-5", Temperature: 0.2, MaxTokens: 2048, Purpose: "debugging"}, - }, - DefaultProvider: "anthropic", + Name: "budget", + Description: "Cost-optimized: haiku for everything, sonnet only for complex tasks", + Models: anthropicPackModels(eycatalog.TierHaiku, eycatalog.TierHaiku, eycatalog.TierSonnet), + DefaultProvider: defaultPackProvider, Settings: map[string]interface{}{"stream": true, "max_retries": 2}, Tags: []string{"cost-effective", "fast"}, Author: "hawk", } r.Packs["quality"] = &ModelPack{ - Name: "quality", - Description: "Quality-optimized: opus for code, sonnet for everything else", - Models: map[string]ModelRole{ - "code": {Provider: "anthropic", Model: "claude-opus-4-6", Temperature: 0.2, MaxTokens: 8192, Purpose: "code generation and editing"}, - "chat": {Provider: "anthropic", Model: "claude-sonnet-4-6", Temperature: 0.7, MaxTokens: 4096, Purpose: "interactive conversation"}, - "summarize": {Provider: "anthropic", Model: "claude-sonnet-4-6", Temperature: 0.3, MaxTokens: 2048, Purpose: "summarization"}, - "review": {Provider: "anthropic", Model: "claude-opus-4-6", Temperature: 0.1, MaxTokens: 8192, Purpose: "code review"}, - "plan": {Provider: "anthropic", Model: "claude-opus-4-6", Temperature: 0.4, MaxTokens: 8192, Purpose: "complex planning and architecture"}, - "debug": {Provider: "anthropic", Model: "claude-opus-4-6", Temperature: 0.2, MaxTokens: 8192, Purpose: "debugging complex issues"}, - }, - DefaultProvider: "anthropic", + Name: "quality", + Description: "Quality-optimized: opus for code, sonnet for everything else", + Models: anthropicPackModels(eycatalog.TierSonnet, eycatalog.TierSonnet, eycatalog.TierOpus), + DefaultProvider: defaultPackProvider, Settings: map[string]interface{}{"stream": true, "max_retries": 3}, Tags: []string{"premium", "thorough"}, Author: "hawk", } r.Packs["speed"] = &ModelPack{ - Name: "speed", - Description: "Speed-optimized: haiku for everything, lowest latency", - Models: map[string]ModelRole{ - "code": {Provider: "anthropic", Model: "claude-haiku-4-5", Temperature: 0.2, MaxTokens: 2048, Purpose: "code generation"}, - "chat": {Provider: "anthropic", Model: "claude-haiku-4-5", Temperature: 0.7, MaxTokens: 1024, Purpose: "interactive conversation"}, - "summarize": {Provider: "anthropic", Model: "claude-haiku-4-5", Temperature: 0.3, MaxTokens: 512, Purpose: "summarization"}, - "review": {Provider: "anthropic", Model: "claude-haiku-4-5", Temperature: 0.1, MaxTokens: 2048, Purpose: "code review"}, - "plan": {Provider: "anthropic", Model: "claude-haiku-4-5", Temperature: 0.4, MaxTokens: 2048, Purpose: "planning"}, - "debug": {Provider: "anthropic", Model: "claude-haiku-4-5", Temperature: 0.2, MaxTokens: 2048, Purpose: "debugging"}, - }, - DefaultProvider: "anthropic", + Name: "speed", + Description: "Speed-optimized: haiku for everything, lowest latency", + Models: anthropicPackModels(eycatalog.TierHaiku, eycatalog.TierHaiku, eycatalog.TierHaiku), + DefaultProvider: defaultPackProvider, Settings: map[string]interface{}{"stream": true, "timeout_ms": 5000}, Tags: []string{"fast", "low-latency"}, Author: "hawk", @@ -131,17 +107,10 @@ func NewModelPackRegistry() *ModelPackRegistry { } r.Packs["balanced"] = &ModelPack{ - Name: "balanced", - Description: "Balanced: sonnet for code/review, haiku for chat/summarize", - Models: map[string]ModelRole{ - "code": {Provider: "anthropic", Model: "claude-sonnet-4-6", Temperature: 0.2, MaxTokens: 4096, Purpose: "code generation and editing"}, - "chat": {Provider: "anthropic", Model: "claude-haiku-4-5", Temperature: 0.7, MaxTokens: 2048, Purpose: "interactive conversation"}, - "summarize": {Provider: "anthropic", Model: "claude-haiku-4-5", Temperature: 0.3, MaxTokens: 1024, Purpose: "summarization"}, - "review": {Provider: "anthropic", Model: "claude-sonnet-4-6", Temperature: 0.1, MaxTokens: 4096, Purpose: "code review"}, - "plan": {Provider: "anthropic", Model: "claude-sonnet-4-6", Temperature: 0.4, MaxTokens: 4096, Purpose: "planning"}, - "debug": {Provider: "anthropic", Model: "claude-sonnet-4-6", Temperature: 0.2, MaxTokens: 4096, Purpose: "debugging"}, - }, - DefaultProvider: "anthropic", + Name: "balanced", + Description: "Balanced: sonnet for code/review, haiku for chat/summarize (from eyrie catalog)", + Models: anthropicPackModels(eycatalog.TierHaiku, eycatalog.TierSonnet, eycatalog.TierSonnet), + DefaultProvider: defaultPackProvider, Settings: map[string]interface{}{"stream": true}, Tags: []string{"balanced", "general"}, Author: "hawk", @@ -257,21 +226,20 @@ func FormatPack(pack *ModelPack) string { return b.String() } -// costPerToken returns approximate cost per 1K tokens for known models. -// These are rough estimates for cost comparison purposes. +// costPerToken returns approximate cost per 1K tokens from the eyrie catalog. func costPerToken(model string) float64 { - switch { - case strings.Contains(model, "opus"): - return 0.075 // $75 per 1M tokens average (input+output) - case strings.Contains(model, "sonnet"): - return 0.015 // $15 per 1M tokens average - case strings.Contains(model, "haiku"): - return 0.005 // $5 per 1M tokens average - case strings.Contains(model, "llama"), strings.Contains(model, "codellama"): - return 0.0 // local models are free - default: - return 0.01 + if info, ok := routing.Find(model); ok { + if info.InputPrice == 0 && info.OutputPrice == 0 { + return 0 + } + if info.InputPrice > 0 || info.OutputPrice > 0 { + avg := (info.InputPrice + info.OutputPrice) / 2 + if avg > 0 { + return avg / 1000 + } + } } + return 0 } // EstimateCost estimates the cost of a session with the given pack based on diff --git a/internal/config/model_packs_test.go b/internal/config/model_packs_test.go index 08a33a4..194bd2b 100644 --- a/internal/config/model_packs_test.go +++ b/internal/config/model_packs_test.go @@ -7,6 +7,8 @@ import ( "strings" "sync" "testing" + + eycatalog "github.com/GrayCodeAI/eyrie/catalog" ) func TestNewModelPackRegistry(t *testing.T) { @@ -28,17 +30,20 @@ func TestNewModelPackRegistry(t *testing.T) { func TestGetModel(t *testing.T) { r := NewModelPackRegistry() + wantSonnet := testPackModel(t, eycatalog.TierSonnet) + wantHaiku := testPackModel(t, eycatalog.TierHaiku) + wantOpus := testPackModel(t, eycatalog.TierOpus) tests := []struct { role string wantModel string }{ - {"code", "claude-sonnet-4-6"}, - {"summarize", "claude-haiku-4-5"}, - {"plan", "claude-opus-4-6"}, - {"debug", "claude-opus-4-6"}, - {"chat", "claude-sonnet-4-6"}, - {"review", "claude-sonnet-4-6"}, + {"code", wantSonnet}, + {"summarize", wantHaiku}, + {"plan", wantOpus}, + {"debug", wantOpus}, + {"chat", wantSonnet}, + {"review", wantSonnet}, } for _, tt := range tests { @@ -78,7 +83,8 @@ func TestSetActive(t *testing.T) { // Verify GetModel now uses the budget pack. mr := r.GetModel("code") - if mr.Model != "claude-haiku-4-5" { + wantHaiku := testPackModel(t, eycatalog.TierHaiku) + if mr.Model != wantHaiku { t.Errorf("expected haiku for code in budget pack, got %q", mr.Model) } } @@ -205,8 +211,8 @@ func TestEstimateCost(t *testing.T) { costQuality := EstimateCost(r.Packs["quality"], 100000) costLocal := EstimateCost(r.Packs["local"], 100000) - if costQuality <= costBudget { - t.Errorf("quality (%f) should cost more than budget (%f)", costQuality, costBudget) + if costQuality < costBudget { + t.Errorf("quality (%f) should cost at least as much as budget (%f)", costQuality, costBudget) } if costLocal != 0.0 { t.Errorf("local pack should be free, got %f", costLocal) diff --git a/internal/config/model_packs_test_helper.go b/internal/config/model_packs_test_helper.go new file mode 100644 index 0000000..1038f57 --- /dev/null +++ b/internal/config/model_packs_test_helper.go @@ -0,0 +1,18 @@ +package config + +import ( + "testing" + + "github.com/GrayCodeAI/hawk/internal/provider/routing" + + eycatalog "github.com/GrayCodeAI/eyrie/catalog" +) + +func testPackModel(t *testing.T, tier eycatalog.ModelTier) string { + t.Helper() + m := routing.PreferredModelForTier(defaultPackProvider, tier, "") + if m == "" { + t.Fatalf("catalog missing %s tier model for %s", tier, defaultPackProvider) + } + return m +} diff --git a/internal/config/provider_filter.go b/internal/config/provider_filter.go new file mode 100644 index 0000000..e479f8a --- /dev/null +++ b/internal/config/provider_filter.go @@ -0,0 +1,22 @@ +package config + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/runtime" +) + +// DefaultModelProviderFilter picks which eyrie provider to list models for when the UI +// has no explicit filter. Host prefs (settings) win; otherwise eyrie routing/deployments decide. +func DefaultModelProviderFilter(ctx context.Context) string { + if p := catalogProviderID(ActiveProvider(ctx)); p != "" { + return p + } + if m := strings.TrimSpace(ActiveModel(ctx)); m != "" { + if p := ProviderOfModel(m); p != "" { + return catalogProviderID(p) + } + } + return runtime.DefaultModelProviderFilter(ctx) +} diff --git a/internal/config/routing_editor.go b/internal/config/routing_editor.go new file mode 100644 index 0000000..f1f2ca8 --- /dev/null +++ b/internal/config/routing_editor.go @@ -0,0 +1,143 @@ +package config + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + + eyriecfg "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/router" +) + +// LoadRoutingPolicyJSON returns the routing section of provider.json as indented JSON. +func LoadRoutingPolicyJSON() (string, error) { + cfg := eyriecfg.LoadProviderConfig("") + cfg = eyriecfg.EnsureDeploymentConfigV2(cfg) + if cfg == nil { + return defaultRoutingPolicyJSON(), nil + } + if cfg.Routing == nil { + return defaultRoutingPolicyJSON(), nil + } + data, err := json.MarshalIndent(cfg.Routing, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} + +func defaultRoutingPolicyJSON() string { + cfg := &eyriecfg.ProviderConfig{} + cfg = eyriecfg.EnsureDeploymentConfigV2(cfg) + if cfg != nil && cfg.Routing != nil { + data, _ := json.MarshalIndent(cfg.Routing, "", " ") + return string(data) + } + tmpl := &eyriecfg.RoutingPolicy{ + Providers: map[string][]eyriecfg.RoutingStage{ + "anthropic": {{ + Deployments: []eyriecfg.DeploymentChoice{ + {DeploymentID: "anthropic-direct", Weight: 100}, + }, + Retries: 1, + }}, + }, + } + data, _ := json.MarshalIndent(tmpl, "", " ") + return string(data) +} + +// SaveRoutingPolicyJSON validates and persists routing into provider.json. +func SaveRoutingPolicyJSON(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("routing JSON is empty") + } + var policy eyriecfg.RoutingPolicy + dec := json.NewDecoder(bytes.NewReader([]byte(raw))) + dec.DisallowUnknownFields() + if err := dec.Decode(&policy); err != nil { + return fmt.Errorf("invalid routing JSON: %w", err) + } + if err := validateRoutingPolicy(&policy); err != nil { + return err + } + + path := eyriecfg.GetProviderConfigPath() + cfg, err := eyriecfg.LoadProviderConfigWithError(path) + if err != nil { + return err + } + if cfg == nil { + cfg = &eyriecfg.ProviderConfig{} + } + cfg = eyriecfg.EnsureDeploymentConfigV2(cfg) + cfg.Routing = &policy + cfg.ConfigVersion = 2 + return eyriecfg.SaveProviderConfig(cfg, path) +} + +func validateRoutingPolicy(policy *eyriecfg.RoutingPolicy) error { + if policy == nil { + return fmt.Errorf("routing policy is nil") + } + compiled, err := loadEyrieCatalogV1(context.Background(), false) + if err != nil { + return fmt.Errorf("load catalog: %w", err) + } + checkStages := func(stages []router.RoutingStage, scope string) error { + for i, stage := range stages { + if len(stage.Deployments) == 0 { + return fmt.Errorf("%s stage %d has no deployments", scope, i) + } + for _, choice := range stage.Deployments { + if choice.DeploymentID == "" { + return fmt.Errorf("%s stage %d has empty deployment_id", scope, i) + } + if choice.Weight <= 0 { + return fmt.Errorf("%s stage %d: deployment %q weight must be > 0", scope, i, choice.DeploymentID) + } + if compiled.DeploymentsByID[choice.DeploymentID].ID == "" { + return fmt.Errorf("%s stage %d: unknown deployment %q", scope, i, choice.DeploymentID) + } + } + } + return nil + } + for modelID, stages := range policy.Models { + if len(stages) == 0 { + continue + } + if err := checkStages(convertStages(stages), "models["+modelID+"]"); err != nil { + return err + } + } + for providerID, stages := range policy.Providers { + if len(stages) == 0 { + continue + } + if err := checkStages(convertStages(stages), "providers["+providerID+"]"); err != nil { + return err + } + } + if len(policy.Default) > 0 { + if err := checkStages(convertStages(policy.Default), "default"); err != nil { + return err + } + } + return nil +} + +func convertStages(stages []eyriecfg.RoutingStage) []router.RoutingStage { + out := make([]router.RoutingStage, len(stages)) + for i, stage := range stages { + out[i].Retries = stage.Retries + out[i].Deployments = make([]router.DeploymentChoice, len(stage.Deployments)) + for j, d := range stage.Deployments { + out[i].Deployments[j] = router.DeploymentChoice{DeploymentID: d.DeploymentID, Weight: d.Weight} + } + } + return out +} diff --git a/internal/config/routing_editor_test.go b/internal/config/routing_editor_test.go new file mode 100644 index 0000000..3e5e98a --- /dev/null +++ b/internal/config/routing_editor_test.go @@ -0,0 +1,53 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + eyriecfg "github.com/GrayCodeAI/eyrie/config" +) + +func TestSaveRoutingPolicyJSONValidatesDeployments(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "provider.json") + t.Setenv("HAWK_CONFIG_DIR", dir) + + cfg := &eyriecfg.ProviderConfig{ + ConfigVersion: 2, + Deployments: map[string]eyriecfg.DeploymentConfig{ + "anthropic-direct": {APIKey: "sk-test-1234567890"}, + }, + } + if err := eyriecfg.SaveProviderConfig(cfg, path); err != nil { + t.Fatalf("save config: %v", err) + } + + err := SaveRoutingPolicyJSON(`{ + "providers": { + "anthropic": [{ + "deployments": [{"deployment_id": "anthropic-direct", "weight": 100}], + "retries": 1 + }] + } +}`) + if err != nil { + t.Fatalf("SaveRoutingPolicyJSON: %v", err) + } +} + +func TestSaveRoutingPolicyJSONRejectsUnknownDeployment(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "provider.json") + t.Setenv("HAWK_CONFIG_DIR", dir) + _ = os.WriteFile(path, []byte(`{"config_version":2}`), 0o600) + + err := SaveRoutingPolicyJSON(`{ + "default": [{ + "deployments": [{"deployment_id": "does-not-exist", "weight": 100}] + }] +}`) + if err == nil { + t.Fatal("expected validation error") + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go index c3be1eb..08e31ad 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1,6 +1,7 @@ package config import ( + "context" "encoding/json" "fmt" "os" @@ -8,15 +9,26 @@ import ( "sort" "strconv" "strings" + "time" "github.com/GrayCodeAI/hawk/internal/provider/routing" "github.com/GrayCodeAI/eyrie/catalog" + eyriecfg "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/eyrie/runtime" + "github.com/GrayCodeAI/eyrie/setup" ) +func fetchModelsViaRuntime(ctx context.Context, provider string) ([]catalog.ModelCatalogEntry, error) { + return runtime.ModelsForProvider(ctx, provider) +} + // Settings holds hawk configuration. -// Herm-style: no API keys stored here. Secrets come from environment variables only. +// Hawk: no API keys stored here. Secrets come from the OS secret store via eyrie. type Settings struct { + // Model and Provider are legacy fields read only for one-time migration into eyrie provider.json. + // Hawk does not persist model/provider here; use SetActiveModel / SetActiveProvider. Model string `json:"model,omitempty"` Provider string `json:"provider,omitempty"` Theme string `json:"theme,omitempty"` @@ -129,6 +141,7 @@ func LoadSettings() Settings { s = MergeSettings(s, proj) } } + migrateLegacyModelProvider(&s) return s } @@ -241,6 +254,7 @@ func MergeSettings(base, override Settings) Settings { // SaveGlobal saves settings to the global config file. func SaveGlobal(s Settings) error { + s = stripHostModelSelection(s) dir := filepath.Dir(globalSettingsPath()) _ = os.MkdirAll(dir, 0o755) data, err := json.MarshalIndent(s, "", " ") @@ -252,6 +266,7 @@ func SaveGlobal(s Settings) error { // SaveProject saves settings to the project config file. func SaveProject(s Settings) error { + s = stripHostModelSelection(s) _ = os.MkdirAll(".hawk", 0o755) data, err := json.MarshalIndent(s, "", " ") if err != nil { @@ -263,15 +278,15 @@ func SaveProject(s Settings) error { // SettingValue returns a display-safe value for a supported setting key. func SettingValue(s Settings, key string) (string, bool) { normalized := normalizeSettingKey(key) - // Herm-style: API key status comes from environment, not settings file + // Hawk: API key status comes from OS secret store, not settings file if provider, ok := apiKeyProviderFromSettingKey(normalized); ok { return EnvKeyStatus(provider), true } switch normalized { case "model": - return s.Model, true + return ActiveModel(context.Background()), true case "provider": - return s.Provider, true + return ActiveProvider(context.Background()), true case "apikey": return EnvKeyStatus(s.Provider), true case "apikeys": @@ -295,28 +310,30 @@ func SettingValue(s Settings, key string) (string, bool) { case "mcpservers": data, _ := json.Marshal(s.MCPServers) return string(data), true + case "deploymentrouting": + return DeploymentRoutingLabel(s), true default: return "", false } } // SetGlobalSetting updates a supported scalar/list setting in ~/.hawk/settings.json. -// Herm-style: API keys are NOT stored in settings.json. Use environment variables. +// Hawk: API keys are NOT stored in settings.json. Use /config and the OS secret store. func SetGlobalSetting(key, value string) error { s := LoadGlobalSettings() normalized := normalizeSettingKey(key) - // Herm-style: reject API key persistence to disk + // Hawk: reject API key persistence to disk if _, ok := apiKeyProviderFromSettingKey(normalized); ok { - return fmt.Errorf("API keys are not stored in settings.json. Set %s in your environment instead", ProviderAPIKeyEnv(providerFromSettingKey(normalized))) + return fmt.Errorf("API keys are not stored in settings.json. Save via /config (%s)", credentials.PlatformSecretStoreName()) } if normalized == "apikey" { - return fmt.Errorf("API keys are not stored in settings.json. Set %s in your environment instead", ProviderAPIKeyEnv(normalizeProviderName(s.Provider))) + return fmt.Errorf("API keys are not stored in settings.json. Save via /config (%s)", credentials.PlatformSecretStoreName()) } switch normalized { case "model": - s.Model = value + return SetActiveModel(context.Background(), value) case "provider": - s.Provider = value + return SetActiveProvider(context.Background(), value) case "theme": s.Theme = value case "autoallow": @@ -331,6 +348,17 @@ func SetGlobalSetting(key, value string) error { return fmt.Errorf("invalid max budget: %w", err) } s.MaxBudgetUSD = amount + case "deploymentrouting": + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + enabled := true + s.DeploymentRouting = &enabled + case "0", "false", "no", "off": + enabled := false + s.DeploymentRouting = &enabled + default: + return fmt.Errorf("deployment_routing must be true or false") + } default: return fmt.Errorf("unsupported setting key %q", key) } @@ -375,71 +403,44 @@ func splitSettingList(value string) []string { func BoolPtr(b bool) *bool { return &b } // ───────────────────────────────────────────────────────────── -// Herm-style: API keys from environment only (no disk persistence) +// Hawk: API keys from OS secret store only (no .env) // ───────────────────────────────────────────────────────────── -// ProviderAPIKeyEnv returns the environment variable name for a provider's API key. +// ProviderAPIKeyEnv returns the API key env var from eyrie deployment env_fallbacks. func ProviderAPIKeyEnv(provider string) string { - switch normalizeProviderName(provider) { - case "anthropic": - return "ANTHROPIC_API_KEY" - case "openai": - return "OPENAI_API_KEY" - case "gemini", "google", "gemma": - return "GEMINI_API_KEY" - case "openrouter": - return "OPENROUTER_API_KEY" - case "canopywave": - return "CANOPYWAVE_API_KEY" - case "grok", "xai": - return "XAI_API_KEY" - case "opencodego": - return "OPENCODEGO_API_KEY" - case "groq": - return "GROQ_API_KEY" - case "deepseek": - return "DEEPSEEK_API_KEY" - case "mistral": - return "MISTRAL_API_KEY" - case "bedrock": - return "AWS_ACCESS_KEY_ID" - case "vertex": - return "GOOGLE_APPLICATION_CREDENTIALS" - case "ollama": + compiled := compiledCatalogOrBootstrap() + if compiled == nil { return "" - default: - replacer := strings.NewReplacer("-", "_", ".", "_", "/", "_") - name := strings.ToUpper(replacer.Replace(normalizeProviderName(provider))) - if name == "" { - return "" - } - return name + "_API_KEY" } + return catalog.PrimaryAPIKeyEnvForProvider(compiled, catalogProviderID(provider)) } -// EnvKeyStatus returns "set" or "empty" for a provider's API key in the environment. +// EnvKeyStatus returns set, empty, or local from the OS credential store. func EnvKeyStatus(provider string) string { - envKey := ProviderAPIKeyEnv(provider) - if envKey == "" { + compiled := compiledCatalogOrBootstrap() + if compiled == nil { + return "empty" + } + provider = catalogProviderID(provider) + envs := catalog.APIKeyEnvsForProvider(compiled, provider) + if len(envs) == 0 { return "local" } - if os.Getenv(envKey) != "" { - return "set" + ctx := context.Background() + for _, env := range envs { + if credentials.HasSecret(ctx, env) { + return "set" + } } return "empty" } -// AllEnvKeyStatus returns a comma-separated summary of all known API key env vars. +// AllEnvKeyStatus returns a comma-separated summary of providers with credentials set. func AllEnvKeyStatus() string { - providers := []string{ - "anthropic", "openai", "gemini", "openrouter", - "canopywave", "xai", "opencodego", - } var parts []string - for _, p := range providers { - status := EnvKeyStatus(p) - if status == "set" { - parts = append(parts, p+":"+status) + for _, p := range AllCatalogProviders() { + if EnvKeyStatus(p) == "set" { + parts = append(parts, p+":set") } } if len(parts) == 0 { @@ -449,42 +450,51 @@ func AllEnvKeyStatus() string { return strings.Join(parts, ", ") } -// LoadAPIKeysFromEnv reads all known API keys from environment variables. -func LoadAPIKeysFromEnv() map[string]string { - providers := []string{ - "anthropic", "openai", "gemini", "openrouter", - "canopywave", "xai", "opencodego", - } +// LoadAPIKeysFromStore reads API keys for all eyrie catalog providers from the OS secret store. +func LoadAPIKeysFromStore() map[string]string { keys := make(map[string]string) - for _, p := range providers { - envKey := ProviderAPIKeyEnv(p) - if envKey == "" { - continue - } - if v := os.Getenv(envKey); v != "" { + for _, p := range AllCatalogProviders() { + if v := APIKeyForProvider(p); v != "" { keys[p] = v } } return keys } -// APIKeyForProvider reads the API key for a provider from the environment. +// APIKeyForProvider reads the API key for a provider from the OS secret store. func APIKeyForProvider(provider string) string { - envKey := ProviderAPIKeyEnv(provider) - if envKey == "" { + compiled := compiledCatalogOrBootstrap() + if compiled == nil { return "" } - if v := os.Getenv(envKey); v != "" { - return v + provider = catalogProviderID(provider) + ctx := context.Background() + for _, env := range catalog.APIKeyEnvsForProvider(compiled, provider) { + if v := credentials.LookupSecret(ctx, env); v != "" { + return v + } } - // Check alternate env var names (e.g. GROK_API_KEY as alias for XAI_API_KEY) - switch normalizeProviderName(provider) { - case "grok", "xai": - return os.Getenv("GROK_API_KEY") + for _, env := range providerCredentialEnvAliases(provider) { + if v := credentials.LookupSecret(ctx, env); v != "" { + return v + } } return "" } +func providerCredentialEnvAliases(provider string) []string { + switch strings.ToLower(provider) { + case "anthropic": + return []string{"CLAUDE_API_KEY"} + case "gemini", "google": + return []string{"GOOGLE_API_KEY"} + case "grok", "xai": + return []string{"GROK_API_KEY"} + default: + return nil + } +} + // NormalizeProviderForEngine maps hawk provider aliases to eyrie canonical names. // This is the boundary where hawk names become engine/eyrie names. func NormalizeProviderForEngine(provider string) string { @@ -497,170 +507,80 @@ func NormalizeProviderForEngine(provider string) string { } } -// providerFromSettingKey extracts the provider name from a setting key like "apikey.openai". -func providerFromSettingKey(normalized string) string { - for _, prefix := range []string{"apikey.", "apikey:"} { - if strings.HasPrefix(normalized, prefix) { - return normalizeProviderName(strings.TrimPrefix(normalized, prefix)) - } - } - return "" -} - // ───────────────────────────────────────────────────────────── -// Secure env file for persisting API keys across sessions +// Live model catalog fetch from eyrie // ───────────────────────────────────────────────────────────── -func envFilePath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".hawk", "env") -} - -// LoadEnvFile reads ~/.hawk/env and applies export lines to the process. -func LoadEnvFile() error { - path := envFilePath() - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err +// FetchModelsForProvider returns models from the eyrie catalog (dynamic; no hawk hardcoded lists). +// RefreshModelCatalogV1 is the explicit network refresh boundary. +func FetchModelsForProvider(provider string) ([]catalog.ModelCatalogEntry, error) { + provider = catalogProviderID(provider) + if provider == "" { + return nil, fmt.Errorf("no provider specified") } - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - // Parse: export KEY=value - if !strings.HasPrefix(line, "export ") { - continue - } - rest := strings.TrimPrefix(line, "export ") - idx := strings.Index(rest, "=") - if idx < 0 { - continue - } - key := strings.TrimSpace(rest[:idx]) - value := strings.TrimSpace(rest[idx+1:]) - // Only set if not already set in environment - if os.Getenv(key) == "" { - _ = os.Setenv(key, value) - } + ctx := context.Background() + models, err := fetchModelsViaRuntime(ctx, provider) + if err == nil && len(models) > 0 { + return models, nil + } + if refreshErr := TryAutoRefreshCatalog(ctx); refreshErr == nil { + return fetchModelsViaRuntime(ctx, provider) } - return nil -} - -// RemoveEnvFile removes an export line from ~/.hawk/env. -func RemoveEnvFile(key string) error { - path := envFilePath() - data, err := os.ReadFile(path) if err != nil { - return err + return nil, err } - var lines []string - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if line == "" { + // Custom OpenAI-compatible providers: single model from settings, not hawk catalog data. + for _, cp := range LoadSettings().CustomProviders { + if NormalizeProviderForEngine(cp.Name) != provider { continue } - if !strings.HasPrefix(line, "export ") { - lines = append(lines, line) - continue - } - rest := strings.TrimPrefix(line, "export ") - idx := strings.Index(rest, "=") - if idx < 0 { - lines = append(lines, line) - continue + if id := strings.TrimSpace(cp.Model); id != "" { + return []catalog.ModelCatalogEntry{{ + ID: id, + DisplayName: id, + }}, nil } - existingKey := strings.TrimSpace(rest[:idx]) - if existingKey != key { - lines = append(lines, line) - } - } - if len(lines) == 0 { - return os.Remove(path) } - out := []byte(strings.Join(lines, "\n") + "\n") - return os.WriteFile(path, out, 0o600) + return nil, fmt.Errorf("no models found for provider %s in eyrie catalog (check API keys; hawk will refresh automatically on next start)", provider) } -// SaveEnvFile writes an export line to ~/.hawk/env, deduplicating existing entries. -func SaveEnvFile(key, value string) error { - path := envFilePath() - _ = os.MkdirAll(filepath.Dir(path), 0o700) - - // Read existing lines, filter out old entries for this key - var lines []string - if data, err := os.ReadFile(path); err == nil { - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - if !strings.HasPrefix(line, "export ") { - lines = append(lines, line) - continue - } - rest := strings.TrimPrefix(line, "export ") - idx := strings.Index(rest, "=") - if idx < 0 { - lines = append(lines, line) - continue - } - existingKey := strings.TrimSpace(rest[:idx]) - if existingKey != key { - lines = append(lines, line) - } - } - } - - // Add new entry - lines = append(lines, fmt.Sprintf("export %s=%s", key, value)) - - // Write back with 600 perms - data := []byte(strings.Join(lines, "\n") + "\n") - if err := os.WriteFile(path, data, 0o600); err != nil { - return err - } - return nil +func refreshModelCatalog(ctx context.Context) (*catalog.RefreshResult, error) { + return setup.DiscoverModelCatalog(ctx, eyriecfg.DiscoveryCredentials(ctx)) } -// ───────────────────────────────────────────────────────────── -// Live model catalog fetch from eyrie -// ───────────────────────────────────────────────────────────── +// RefreshModelCatalogV1 asks eyrie to refresh the remote catalog and provider APIs using env API keys. +func RefreshModelCatalogV1(ctx context.Context) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() -// FetchModelsForProvider fetches live models from the provider's API (if key available) -// or returns embedded catalog models. This is the runtime model discovery boundary. -func FetchModelsForProvider(provider string) ([]catalog.ModelCatalogEntry, error) { - provider = NormalizeProviderForEngine(provider) - if provider == "" { - return nil, fmt.Errorf("no provider specified") + result, err := refreshModelCatalog(ctx) + if err != nil { + return "", err } + return result.DiscoverReport(), nil +} - // Build env map for eyrie catalog fetch - env := make(map[string]string) - env["ANTHROPIC_API_KEY"] = os.Getenv("ANTHROPIC_API_KEY") - env["OPENAI_API_KEY"] = os.Getenv("OPENAI_API_KEY") - env["GEMINI_API_KEY"] = os.Getenv("GEMINI_API_KEY") - env["OPENROUTER_API_KEY"] = os.Getenv("OPENROUTER_API_KEY") - env["CANOPYWAVE_API_KEY"] = os.Getenv("CANOPYWAVE_API_KEY") - env["XAI_API_KEY"] = os.Getenv("XAI_API_KEY") - env["OPENCODEGO_API_KEY"] = os.Getenv("OPENCODEGO_API_KEY") - env["OLLAMA_BASE_URL"] = os.Getenv("OLLAMA_BASE_URL") - env["OPENROUTER_BASE_URL"] = os.Getenv("OPENROUTER_BASE_URL") - env["CANOPYWAVE_BASE_URL"] = os.Getenv("CANOPYWAVE_BASE_URL") - - // Fetch live catalog from eyrie - cat, err := catalog.FetchModelCatalog("", env) - if err != nil { - // Fallback to embedded catalog - cat = catalog.LoadModelCatalogSync("") +func loadEyrieCatalogV1(ctx context.Context, refreshRemote bool) (*catalog.CompiledCatalogV1, error) { + if refreshRemote { + result, err := setup.DiscoverModelCatalog(ctx, eyriecfg.DiscoveryCredentials(ctx)) + if err != nil { + return nil, err + } + return result.Compiled, nil } + return catalog.LoadCatalogV1(ctx, catalog.LoadCatalogV1Options{ + CachePath: catalog.DefaultCachePath(), + RequireCache: false, + }) +} - models := catalog.ModelsForProvider(&cat, provider) - if len(models) == 0 { - return nil, fmt.Errorf("no models found for provider %s", provider) +func catalogProviderID(provider string) string { + switch NormalizeProviderForEngine(provider) { + case "gemini": + return "google" + case "grok": + return "xai" + default: + return NormalizeProviderForEngine(provider) } - return models, nil } diff --git a/internal/config/settings_extra_test.go b/internal/config/settings_extra_test.go index b227542..c346885 100644 --- a/internal/config/settings_extra_test.go +++ b/internal/config/settings_extra_test.go @@ -1,97 +1,21 @@ package config import ( - "os" + "context" "testing" -) - -func TestNormalizeProviderName(t *testing.T) { - t.Parallel() - tests := []struct { - input string - want string - }{ - {"anthropic", "anthropic"}, - {"Anthropic", "anthropic"}, - {"OPENAI", "openai"}, - {"openai", "openai"}, - {"gemini", "gemini"}, - {"", ""}, - } - for _, tt := range tests { - got := normalizeProviderName(tt.input) - if got != tt.want { - t.Errorf("normalizeProviderName(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - -func TestBoolPtr(t *testing.T) { - t.Parallel() - p := BoolPtr(true) - if p == nil || !*p { - t.Error("BoolPtr(true) should return pointer to true") - } - p2 := BoolPtr(false) - if p2 == nil || *p2 { - t.Error("BoolPtr(false) should return pointer to false") - } -} - -func TestProviderAPIKeyEnv(t *testing.T) { - t.Parallel() - tests := []struct { - provider string - want string - }{ - {"anthropic", "ANTHROPIC_API_KEY"}, - {"openai", "OPENAI_API_KEY"}, - {"gemini", "GEMINI_API_KEY"}, - } - for _, tt := range tests { - got := ProviderAPIKeyEnv(tt.provider) - if got != tt.want { - t.Errorf("ProviderAPIKeyEnv(%q) = %q, want %q", tt.provider, got, tt.want) - } - } -} -func TestNormalizeProviderForEngine(t *testing.T) { - t.Parallel() - tests := []struct { - input string - want string - }{ - {"anthropic", "anthropic"}, - {"openai", "openai"}, - {"google", "google"}, - {"gemini", "gemini"}, - } - for _, tt := range tests { - got := NormalizeProviderForEngine(tt.input) - if got != tt.want { - t.Errorf("NormalizeProviderForEngine(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} + "github.com/GrayCodeAI/eyrie/credentials" +) -func TestEnvKeyStatus(t *testing.T) { - t.Setenv("ANTHROPIC_API_KEY", "sk-ant-test") - status := EnvKeyStatus("anthropic") - if status == "" { - t.Error("EnvKeyStatus should return non-empty") - } -} +func TestAPIKeyForProvider(t *testing.T) { + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) -func TestAllEnvKeyStatus(t *testing.T) { - result := AllEnvKeyStatus() - if result == "" { - t.Error("AllEnvKeyStatus should return status string") + ctx := context.Background() + if err := store.Set(ctx, credentials.AccountForEnv("OPENAI_API_KEY"), "sk-test-key"); err != nil { + t.Fatal(err) } -} - -func TestAPIKeyForProvider(t *testing.T) { - t.Setenv("OPENAI_API_KEY", "sk-test-key") key := APIKeyForProvider("openai") if key != "sk-test-key" { t.Errorf("APIKeyForProvider = %q, want sk-test-key", key) @@ -99,19 +23,19 @@ func TestAPIKeyForProvider(t *testing.T) { } func TestAPIKeyForProvider_Missing(t *testing.T) { - t.Setenv("NONEXISTENT_PROVIDER_API_KEY", "") - os.Unsetenv("NONEXISTENT_PROVIDER_API_KEY") + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + key := APIKeyForProvider("nonexistent_provider_xyz") if key != "" { t.Errorf("expected empty for missing key, got %q", key) } } -func TestEnvFilePath(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - path := envFilePath() - if path == "" { - t.Error("envFilePath should return non-empty") +func TestAllEnvKeyStatus(t *testing.T) { + result := AllEnvKeyStatus() + if result == "" { + t.Error("AllEnvKeyStatus should return status string") } } diff --git a/internal/config/setup_status.go b/internal/config/setup_status.go new file mode 100644 index 0000000..4f1cf4e --- /dev/null +++ b/internal/config/setup_status.go @@ -0,0 +1,74 @@ +package config + +import ( + "context" + "strings" + + eyriecfg "github.com/GrayCodeAI/eyrie/config" +) + +// SetupState is a single evaluation of first-run /config requirements. +type SetupState struct { + HasCredentials bool + HasModel bool + NeedsSetup bool + Hint string +} + +// EvaluateSetup loads the OS credential store and reports whether /config is still required. +func EvaluateSetup(ctx context.Context) SetupState { + if ctx == nil { + ctx = context.Background() + } + PrepareCredentialDiscovery(ctx) + hasCreds := hasConfiguredDeployment(ctx) + hasModel := HasSelectedModel() + st := SetupState{ + HasCredentials: hasCreds, + HasModel: hasModel, + NeedsSetup: !hasCreds || !hasModel, + } + switch { + case !hasCreds: + st.Hint = "First-time setup: paste an API key or use Ollama local — setup opens automatically" + case !hasModel: + st.Hint = "Almost ready: pick a model to start chatting" + } + return st +} + +// HasConfiguredDeployment reports whether at least one eyrie deployment has credentials. +func HasConfiguredDeployment(ctx context.Context) bool { + if ctx == nil { + ctx = context.Background() + } + PrepareCredentialDiscovery(ctx) + return hasConfiguredDeployment(ctx) +} + +func hasConfiguredDeployment(ctx context.Context) bool { + rows, err := ListDeploymentRows(ctx) + if err == nil { + for _, row := range rows { + if row.Configured { + return true + } + } + } + return eyriecfg.HasAnyConfiguredDeployment(ctx) +} + +// HasSelectedModel reports whether eyrie provider.json has a selected model. +func HasSelectedModel() bool { + return strings.TrimSpace(ActiveModel(context.Background())) != "" +} + +// NeedsFirstRunSetup is true when the user should complete /config (API key and/or model). +func NeedsFirstRunSetup(ctx context.Context) bool { + return EvaluateSetup(ctx).NeedsSetup +} + +// FirstRunSetupHint returns a short banner line for the welcome screen. +func FirstRunSetupHint(ctx context.Context) string { + return EvaluateSetup(ctx).Hint +} diff --git a/internal/config/setup_status_test.go b/internal/config/setup_status_test.go new file mode 100644 index 0000000..470daff --- /dev/null +++ b/internal/config/setup_status_test.go @@ -0,0 +1,84 @@ +package config + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestHasConfiguredDeployment_FromStore(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-test-key-long-enough") + if !HasConfiguredDeployment(context.Background()) { + t.Fatal("expected true when ANTHROPIC_API_KEY is in secure store") + } +} + +type emptyCredentialStore struct{} + +func (emptyCredentialStore) Set(context.Context, string, string) error { return nil } +func (emptyCredentialStore) Get(context.Context, string) (string, error) { return "", nil } +func (emptyCredentialStore) Delete(context.Context, string) error { return nil } + +func isolateCredentialEnv(t *testing.T) { + t.Helper() + home := t.TempDir() + _ = os.MkdirAll(filepath.Join(home, ".hawk"), 0o700) + t.Setenv("HOME", home) +} + +func TestHasConfiguredDeployment_RejectsPlaceholder(t *testing.T) { + isolateCredentialEnv(t) + credentials.SetDefaultStore(emptyCredentialStore{}) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + + ctx := context.Background() + compiled := CompiledCatalogV1() + if compiled != nil { + for _, k := range catalog.DiscoveryEnvKeysFromCatalog(compiled) { + t.Setenv(k, "") + } + } + t.Setenv("OPENROUTER_API_KEY", "changeme") + // Placeholder in shell env must not count — only secure store is trusted. + if HasConfiguredDeployment(ctx) { + t.Fatal("placeholder should not count as configured") + } +} + +func TestEvaluateSetup_WithoutCredentials(t *testing.T) { + isolateCredentialEnv(t) + credentials.SetDefaultStore(emptyCredentialStore{}) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + + ctx := context.Background() + compiled := CompiledCatalogV1() + if compiled != nil { + for _, k := range catalog.DiscoveryEnvKeysFromCatalog(compiled) { + t.Setenv(k, "") + } + } + st := EvaluateSetup(ctx) + if st.HasCredentials { + t.Skip("environment already has credentials") + } + if !st.NeedsSetup { + t.Fatal("expected setup needed without credentials") + } + if st.Hint == "" { + t.Fatal("expected non-empty setup hint") + } +} + +func TestPersistAPIKey_RejectsPlaceholder(t *testing.T) { + err := PersistAPIKey(context.Background(), "OPENAI_API_KEY", "your-api-key") + if err == nil { + t.Fatal("expected error for placeholder key") + } +} diff --git a/internal/config/validator.go b/internal/config/validator.go index 1ad0936..ac743e8 100644 --- a/internal/config/validator.go +++ b/internal/config/validator.go @@ -4,6 +4,8 @@ package config import ( "fmt" "strings" + + "github.com/GrayCodeAI/eyrie/credentials" ) // ValidationError represents a config validation error. @@ -44,22 +46,30 @@ func ValidateSettings(s Settings) ValidationResult { // Provider names are delegated to Eyrie. Do not hardcode/validate here. - // Validate model - if s.Model != "" && strings.Contains(s.Model, " ") { + // Validate model selection (stored in eyrie provider.json) + activeModel := strings.TrimSpace(s.Model) + if activeModel == "" { + activeModel = ActiveModel(nil) + } + if activeModel != "" && strings.Contains(activeModel, " ") { errors = append(errors, ValidationError{ Field: "model", Message: "model name cannot contain spaces", - Value: s.Model, + Value: activeModel, }) } - // Herm-style: validate API key is in environment (not in settings) - if s.Provider != "" { - envKey := ProviderAPIKeyEnv(s.Provider) - if envKey != "" && APIKeyForProvider(s.Provider) == "" { + activeProvider := strings.TrimSpace(s.Provider) + if activeProvider == "" { + activeProvider = ActiveProvider(nil) + } + // Hawk: validate API key is in the OS secret store (not in settings) + if activeProvider != "" { + envKey := ProviderAPIKeyEnv(activeProvider) + if envKey != "" && APIKeyForProvider(activeProvider) == "" { errors = append(errors, ValidationError{ Field: "apiKey", - Message: fmt.Sprintf("set %s in your environment", envKey), + Message: fmt.Sprintf("save your %s API key with /config (%s)", activeProvider, credentials.PlatformSecretStoreName()), }) } } diff --git a/internal/config/validator_test.go b/internal/config/validator_test.go index 20d2b84..957d14b 100644 --- a/internal/config/validator_test.go +++ b/internal/config/validator_test.go @@ -1,12 +1,19 @@ package config import ( + "context" "strings" "testing" + + "github.com/GrayCodeAI/eyrie/credentials" ) func TestValidateSettingsValid(t *testing.T) { - t.Setenv("ANTHROPIC_API_KEY", "sk-ant-test123456789") + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + _ = store.Set(context.Background(), credentials.AccountForEnv("ANTHROPIC_API_KEY"), "sk-ant-test123456789") + s := Settings{ Provider: "anthropic", Model: "claude-sonnet-4-20250514", @@ -19,12 +26,14 @@ func TestValidateSettingsValid(t *testing.T) { } func TestValidateSettingsProviderDelegatedToEyrie(t *testing.T) { - // Herm-style: missing env key for provider is an error - t.Setenv("INVALID_API_KEY", "") - s := Settings{Provider: "invalid"} + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + + s := Settings{Provider: "anthropic"} result := ValidateSettings(s) if result.Valid { - t.Fatal("expected invalid (missing env key)") + t.Fatal("expected invalid (missing env key for eyrie provider)") } } diff --git a/internal/engine/adaptive_system_prompt.go b/internal/engine/adaptive_system_prompt.go index 9a0aa97..bc21fc5 100644 --- a/internal/engine/adaptive_system_prompt.go +++ b/internal/engine/adaptive_system_prompt.go @@ -5,6 +5,8 @@ import ( "sort" "strings" "sync" + + "github.com/GrayCodeAI/hawk/internal/provider/routing" ) // PromptBuildContext provides situational context for building a system prompt. @@ -183,22 +185,16 @@ func (b *SystemPromptBuilder) AdaptForModel(model string) *SystemPromptBuilder { b.mu.Lock() defer b.mu.Unlock() - lower := strings.ToLower(model) - - switch { - case strings.Contains(lower, "opus"): - // Opus: more detailed, allow longer sections - b.MaxTokens = b.MaxTokens * 12 / 10 // 20% more budget - case strings.Contains(lower, "haiku"): - // Haiku: more concise, strip examples to save tokens - b.MaxTokens = b.MaxTokens * 7 / 10 // 30% less budget + switch routing.CostTierOf(model) { + case routing.CostTierExpensive: + b.MaxTokens = b.MaxTokens * 12 / 10 + case routing.CostTierCheap: + b.MaxTokens = b.MaxTokens * 7 / 10 for i := range b.Sections { if b.Sections[i].Name == "examples" { - b.Sections[i].Priority = 10 // demote heavily + b.Sections[i].Priority = 10 } } - case strings.Contains(lower, "sonnet"): - // Sonnet: balanced, no adjustments } return b diff --git a/internal/engine/adaptive_system_prompt_test.go b/internal/engine/adaptive_system_prompt_test.go index a8385b5..11a5fd8 100644 --- a/internal/engine/adaptive_system_prompt_test.go +++ b/internal/engine/adaptive_system_prompt_test.go @@ -4,6 +4,8 @@ import ( "strings" "sync" "testing" + + "github.com/GrayCodeAI/hawk/internal/provider/routing" ) func TestNewSystemPromptBuilder(t *testing.T) { @@ -238,28 +240,34 @@ func TestAdaptForTaskImplement(t *testing.T) { } func TestAdaptForModelOpus(t *testing.T) { + _, _, opus := testTierModels(t, testProvider) b := NewSystemPromptBuilder("", 1000) - b.AdaptForModel("claude-opus-4") + b.AdaptForModel(opus) - // Opus gets 20% more budget - if b.MaxTokens != 1200 { - t.Errorf("expected 1200 tokens for opus, got %d", b.MaxTokens) + if routing.CostTierOf(opus) == routing.CostTierExpensive { + if b.MaxTokens != 1200 { + t.Errorf("expected 1200 tokens for opus tier, got %d", b.MaxTokens) + } + } else if b.MaxTokens != 1000 { + t.Errorf("expected default 1000 tokens for non-opus tier, got %d", b.MaxTokens) } } func TestAdaptForModelHaiku(t *testing.T) { + haiku, _, _ := testTierModels(t, testProvider) b := NewSystemPromptBuilder("", 1000) b.AddSection(PromptSection{Name: "examples", Content: "Examples.", Priority: 5}) - b.AdaptForModel("claude-haiku-3") - - if b.MaxTokens != 700 { - t.Errorf("expected 700 tokens for haiku, got %d", b.MaxTokens) - } + b.AdaptForModel(haiku) - for _, s := range b.Sections { - if s.Name == "examples" && s.Priority != 10 { - t.Errorf("expected examples demoted to priority 10 for haiku, got %d", s.Priority) + if routing.CostTierOf(haiku) == routing.CostTierCheap { + if b.MaxTokens != 700 { + t.Errorf("expected 700 tokens for haiku tier, got %d", b.MaxTokens) + } + for _, s := range b.Sections { + if s.Name == "examples" && s.Priority != 10 { + t.Errorf("expected examples demoted to priority 10 for haiku, got %d", s.Priority) + } } } } diff --git a/internal/engine/architect.go b/internal/engine/architect.go index 5ccde99..5e16112 100644 --- a/internal/engine/architect.go +++ b/internal/engine/architect.go @@ -4,6 +4,10 @@ import ( "context" "fmt" "strings" + + "github.com/GrayCodeAI/hawk/internal/provider/routing" + + eycatalog "github.com/GrayCodeAI/eyrie/catalog" ) // ArchitectConfig configures the two-model architect/editor pipeline. @@ -80,7 +84,11 @@ func (a *Architect) Plan(ctx context.Context, goal string, repoContext string) ( model := a.Config.ArchitectModel if model == "" { - model = "haiku" + provider := "anthropic" + if info, ok := routing.Find(a.Config.EditorModel); ok && info.Provider != "" { + provider = info.Provider + } + model = routing.PreferredModelForTier(provider, eycatalog.TierHaiku, "") } response, err := a.ChatFn(ctx, model, messages) diff --git a/internal/engine/background_agent_test.go b/internal/engine/background_agent_test.go index 150a5ad..72be439 100644 --- a/internal/engine/background_agent_test.go +++ b/internal/engine/background_agent_test.go @@ -27,6 +27,7 @@ func TestBackgroundAgentPool_SubmitAndCollect(t *testing.T) { pool := NewBackgroundAgentPool() pool.Submit("task-1", "do something", func(ctx context.Context, prompt string) (string, error) { + time.Sleep(time.Millisecond) return "result-1", nil }) diff --git a/internal/engine/cascade.go b/internal/engine/cascade.go index 72a3980..f28ddd7 100644 --- a/internal/engine/cascade.go +++ b/internal/engine/cascade.go @@ -6,8 +6,9 @@ import ( "sync" "time" - analytics "github.com/GrayCodeAI/hawk/internal/observability" "github.com/GrayCodeAI/hawk/internal/provider/routing" + + eycatalog "github.com/GrayCodeAI/eyrie/catalog" ) // CascadeRouter selects the optimal model for each request based on task complexity. @@ -75,7 +76,7 @@ func (cr *CascadeRouter) SelectModel(prompt string, currentModel string, userOve // When frugal mode is off, never downgrade from what was already set -- // only upgrade or keep the same tier. - if !cr.FrugalMode && tierOf(selected) < tierOf(currentModel) { + if !cr.FrugalMode && routing.CostTierOf(selected) < routing.CostTierOf(currentModel) { selected = currentModel } @@ -137,8 +138,8 @@ func (cr *CascadeRouter) Summary() string { unchanged := 0 for _, d := range cr.decisions { counts[d.TaskType]++ - origTier := tierOf(d.OriginalModel) - selTier := tierOf(d.SelectedModel) + origTier := routing.CostTierOf(d.OriginalModel) + selTier := routing.CostTierOf(d.SelectedModel) switch { case selTier < origTier: downgrades++ @@ -198,21 +199,19 @@ func classifyPrompt(prompt string) string { return "chat" } -// modelForTask maps a task type to the appropriate model using the configured -// Roles, falling back to analytics.SuggestModel tier names. +// modelForTask maps a task type to the appropriate model using configured roles +// and eyrie catalog tier defaults. func (cr *CascadeRouter) modelForTask(taskType string) string { - tier := analytics.SuggestModel(taskType, "") + tier := routing.SuggestTierForTask(taskType) switch tier { - case "haiku": - // In frugal mode, always use the cheapest available. + case eycatalog.TierHaiku: if m := cr.Roles.Commit; m != "" { return m } return cr.defaultFor(TierCheap) - case "sonnet": + case eycatalog.TierSonnet: if cr.FrugalMode { - // Frugal mode downgrades mid-tier to cheap for chat/review. if taskType == "chat" || taskType == "review" { if m := cr.Roles.Commit; m != "" { return m @@ -224,9 +223,8 @@ func (cr *CascadeRouter) modelForTask(taskType string) string { return m } return cr.defaultFor(TierMid) - case "opus": + case eycatalog.TierOpus: if cr.FrugalMode { - // Frugal mode caps generation at mid-tier. if m := cr.Roles.Coder; m != "" { return m } @@ -241,31 +239,19 @@ func (cr *CascadeRouter) modelForTask(taskType string) string { } } -// defaultFor returns the best model for a given cost tier by querying the catalog at runtime. +// defaultFor returns the best model for a given cost tier via eyrie catalog tier defaults. func (cr *CascadeRouter) defaultFor(tier ModelTier) string { - info, ok := routing.Find(cr.DefaultModel) provider := "" - if ok { + if info, ok := routing.Find(cr.DefaultModel); ok { provider = info.Provider } - models := routing.ByProvider(provider) - if len(models) == 0 { - return cr.pick("") - } - switch tier { case TierCheap: return routing.CheapestForProvider(provider, cr.pick("")) case TierExpensive: - best := models[0] - for _, m := range models[1:] { - if m.InputPrice > best.InputPrice { - best = m - } - } - return best.Name + return routing.MostExpensiveForProvider(provider, cr.pick("")) default: - return cr.pick("") + return routing.PreferredModelForTier(provider, eycatalog.TierSonnet, cr.pick("")) } } @@ -293,32 +279,6 @@ func (cr *CascadeRouter) record(original, selected, taskType, reason string) { }) } -// tierOf returns the cost tier of a model name using keyword matching. -func tierOf(modelName string) ModelTier { - lower := strings.ToLower(modelName) - - // Cheap models - if strings.Contains(lower, "haiku") || - strings.Contains(lower, "gpt-4o-mini") || - strings.Contains(lower, "gpt-3.5") || - strings.Contains(lower, "gemini-2.5-flash") || - strings.Contains(lower, "gemini-2.0-flash") || - strings.Contains(lower, "deepseek-chat") || - strings.Contains(lower, "mistral-small") { - return TierCheap - } - - // Expensive models - if strings.Contains(lower, "opus") || - (strings.Contains(lower, "gpt-4") && !strings.Contains(lower, "gpt-4o") && !strings.Contains(lower, "gpt-4-turbo")) || - strings.Contains(lower, "o1") && !strings.Contains(lower, "o1-mini") { - return TierExpensive - } - - // Everything else is mid-tier - return TierMid -} - // promptContainsAny checks whether s contains any of the given substrings. // This is the engine-local equivalent of analytics.containsAny (which is // unexported). diff --git a/internal/engine/cascade_test.go b/internal/engine/cascade_test.go index c4d195e..61e2f52 100644 --- a/internal/engine/cascade_test.go +++ b/internal/engine/cascade_test.go @@ -4,16 +4,38 @@ import ( "testing" "github.com/GrayCodeAI/hawk/internal/provider/routing" + + eycatalog "github.com/GrayCodeAI/eyrie/catalog" ) -func TestNewCascadeRouter(t *testing.T) { - roles := routing.ModelRoles{ - Planner: "claude-opus-4-20250514", - Coder: "claude-sonnet-4-20250514", - Reviewer: "claude-sonnet-4-20250514", - Commit: "claude-haiku-3-20250307", +const testProvider = "anthropic" + +// testTierModels loads haiku/sonnet/opus model IDs from eyrie's catalog (not hardcoded). +func testTierModels(t *testing.T, provider string) (haiku, sonnet, opus string) { + t.Helper() + haiku = routing.PreferredModelForTier(provider, eycatalog.TierHaiku, "") + sonnet = routing.PreferredModelForTier(provider, eycatalog.TierSonnet, "") + opus = routing.PreferredModelForTier(provider, eycatalog.TierOpus, "") + if haiku == "" || sonnet == "" || opus == "" { + t.Fatalf("eyrie catalog missing tier models for provider %q", provider) } - cr := NewCascadeRouter("claude-sonnet-4-20250514", roles) + return haiku, sonnet, opus +} + +func testAnthropicRoles(t *testing.T) (roles routing.ModelRoles, defaultModel string) { + t.Helper() + haiku, sonnet, opus := testTierModels(t, testProvider) + return routing.ModelRoles{ + Planner: opus, + Coder: sonnet, + Reviewer: sonnet, + Commit: haiku, + }, sonnet +} + +func TestNewCascadeRouter(t *testing.T) { + roles, defaultModel := testAnthropicRoles(t) + cr := NewCascadeRouter(defaultModel, roles) if cr == nil { t.Fatal("expected non-nil router") } @@ -23,8 +45,8 @@ func TestNewCascadeRouter(t *testing.T) { if cr.FrugalMode { t.Error("expected frugal mode to be off by default") } - if cr.DefaultModel != "claude-sonnet-4-20250514" { - t.Errorf("expected default model claude-sonnet-4-20250514, got %q", cr.DefaultModel) + if cr.DefaultModel != defaultModel { + t.Errorf("expected default model %q, got %q", defaultModel, cr.DefaultModel) } } @@ -34,47 +56,34 @@ func TestClassifyPrompt(t *testing.T) { prompt string expected string }{ - // Debug signals {"fix bug", "fix the null pointer bug in handler.go", "debug"}, {"error message", "I'm getting an error when running tests", "debug"}, {"debug keyword", "debug this function please", "debug"}, {"crash report", "the server is crashing on startup", "debug"}, {"panic", "I see a panic in the goroutine", "debug"}, - - // Refactor signals {"refactor", "refactor the database layer to use interfaces", "refactor"}, {"rename", "rename the variable from x to count", "refactor"}, {"simplify", "simplify this function", "refactor"}, {"restructure", "restructure the package layout", "refactor"}, {"extract", "extract this logic into a helper function", "refactor"}, - - // Review signals {"review", "review my pull request changes", "review"}, {"audit", "audit this code for security issues", "review"}, {"feedback", "give me feedback on this implementation", "review"}, {"critique", "critique this design approach", "review"}, - - // Generation signals {"implement", "implement a binary search function", "generation"}, {"create", "create a new REST API endpoint", "generation"}, {"write code", "write a test for the parser", "generation"}, {"build feature", "build a caching layer for the DB queries", "generation"}, {"generate", "generate Go structs from this JSON schema", "generation"}, {"scaffold", "scaffold a new microservice", "generation"}, - - // Chat signals {"explain", "explain how goroutines work", "chat"}, {"what is", "what is a closure in Go?", "chat"}, {"how does", "how does the GC work?", "chat"}, {"why", "why is this approach better?", "chat"}, {"describe", "describe the architecture of this system", "chat"}, - - // Simple signals (short, no strong keywords) {"short question", "hello", "simple"}, {"yes no", "yes", "simple"}, {"ok", "sounds good", "simple"}, - - // Default to chat for longer unclassified prompts {"long unclassified", "I was thinking about the overall approach to the project and wanted to discuss the roadmap going forward", "chat"}, } @@ -89,21 +98,15 @@ func TestClassifyPrompt(t *testing.T) { } func TestSelectModel_UserOverride(t *testing.T) { - roles := routing.ModelRoles{ - Planner: "claude-opus-4-20250514", - Coder: "claude-sonnet-4-20250514", - Reviewer: "claude-sonnet-4-20250514", - Commit: "claude-haiku-3-20250307", - } - cr := NewCascadeRouter("claude-sonnet-4-20250514", roles) + roles, defaultModel := testAnthropicRoles(t) + _, _, openaiSonnet := testTierModels(t, "openai") + cr := NewCascadeRouter(defaultModel, roles) - // User override should always win, regardless of classification. - selected := cr.SelectModel("fix the bug", "claude-sonnet-4-20250514", "gpt-4o") - if selected != "gpt-4o" { + selected := cr.SelectModel("fix the bug", defaultModel, openaiSonnet) + if selected != openaiSonnet { t.Errorf("user override should win, got %q", selected) } - // Verify the decision was recorded with the right reason. decs := cr.Decisions() if len(decs) != 1 { t.Fatalf("expected 1 decision, got %d", len(decs)) @@ -111,178 +114,130 @@ func TestSelectModel_UserOverride(t *testing.T) { if decs[0].TaskType != "override" { t.Errorf("expected task type 'override', got %q", decs[0].TaskType) } - if decs[0].SelectedModel != "gpt-4o" { - t.Errorf("expected selected model 'gpt-4o', got %q", decs[0].SelectedModel) + if decs[0].SelectedModel != openaiSonnet { + t.Errorf("expected selected model %q, got %q", openaiSonnet, decs[0].SelectedModel) } } func TestSelectModel_Disabled(t *testing.T) { - roles := routing.ModelRoles{ - Planner: "claude-opus-4-20250514", - Coder: "claude-sonnet-4-20250514", - Reviewer: "claude-sonnet-4-20250514", - Commit: "claude-haiku-3-20250307", - } - cr := NewCascadeRouter("claude-sonnet-4-20250514", roles) + roles, defaultModel := testAnthropicRoles(t) + haiku, _, _ := testTierModels(t, testProvider) + cr := NewCascadeRouter(defaultModel, roles) cr.Enabled = false - // When disabled, always return the current model. - selected := cr.SelectModel("implement a full web framework", "claude-haiku-3-20250307", "") - if selected != "claude-haiku-3-20250307" { + selected := cr.SelectModel("implement a full web framework", haiku, "") + if selected != haiku { t.Errorf("disabled router should pass through current model, got %q", selected) } } func TestSelectModel_DebugRouting(t *testing.T) { - roles := routing.ModelRoles{ - Planner: "claude-opus-4-20250514", - Coder: "claude-sonnet-4-20250514", - Reviewer: "claude-sonnet-4-20250514", - Commit: "claude-haiku-3-20250307", - } - cr := NewCascadeRouter("claude-sonnet-4-20250514", roles) + roles, defaultModel := testAnthropicRoles(t) + cr := NewCascadeRouter(defaultModel, roles) - // Debug tasks should route to the reviewer (mid-tier / sonnet). - selected := cr.SelectModel("fix the segfault in main.go", "claude-sonnet-4-20250514", "") - if selected != "claude-sonnet-4-20250514" { - t.Errorf("debug should route to sonnet/reviewer, got %q", selected) + selected := cr.SelectModel("fix the segfault in main.go", defaultModel, "") + if selected != roles.Reviewer { + t.Errorf("debug should route to reviewer, got %q", selected) } } func TestSelectModel_GenerationRouting(t *testing.T) { - roles := routing.ModelRoles{ - Planner: "claude-opus-4-20250514", - Coder: "claude-sonnet-4-20250514", - Reviewer: "claude-sonnet-4-20250514", - Commit: "claude-haiku-3-20250307", - } - cr := NewCascadeRouter("claude-sonnet-4-20250514", roles) + roles, defaultModel := testAnthropicRoles(t) + cr := NewCascadeRouter(defaultModel, roles) - // Generation tasks should route to the planner (expensive tier / opus). - selected := cr.SelectModel("implement a distributed consensus algorithm", "claude-sonnet-4-20250514", "") - if selected != "claude-opus-4-20250514" { - t.Errorf("generation should route to opus/planner, got %q", selected) + selected := cr.SelectModel("implement a distributed consensus algorithm", defaultModel, "") + if selected != roles.Planner { + t.Errorf("generation should route to planner, got %q", selected) } } func TestSelectModel_SimpleRouting(t *testing.T) { - roles := routing.ModelRoles{ - Planner: "claude-opus-4-20250514", - Coder: "claude-sonnet-4-20250514", - Reviewer: "claude-sonnet-4-20250514", - Commit: "claude-haiku-3-20250307", - } - cr := NewCascadeRouter("claude-sonnet-4-20250514", roles) - cr.FrugalMode = true // enable frugal so downgrades are allowed + roles, defaultModel := testAnthropicRoles(t) + cr := NewCascadeRouter(defaultModel, roles) + cr.FrugalMode = true - // Simple tasks should route to the commit model (cheap tier / haiku). - selected := cr.SelectModel("yes", "claude-sonnet-4-20250514", "") - if selected != "claude-haiku-3-20250307" { - t.Errorf("simple task (frugal) should route to haiku/commit, got %q", selected) + selected := cr.SelectModel("yes", defaultModel, "") + if selected != roles.Commit { + t.Errorf("simple task (frugal) should route to commit, got %q", selected) } } func TestSelectModel_NoDowngradeWithoutFrugal(t *testing.T) { - roles := routing.ModelRoles{ - Planner: "claude-opus-4-20250514", - Coder: "claude-sonnet-4-20250514", - Reviewer: "claude-sonnet-4-20250514", - Commit: "claude-haiku-3-20250307", - } - cr := NewCascadeRouter("claude-sonnet-4-20250514", roles) + roles, defaultModel := testAnthropicRoles(t) + cr := NewCascadeRouter(defaultModel, roles) cr.FrugalMode = false - // Without frugal mode, a simple prompt should NOT downgrade from sonnet. - selected := cr.SelectModel("ok", "claude-sonnet-4-20250514", "") - if selected != "claude-sonnet-4-20250514" { - t.Errorf("without frugal, should not downgrade from sonnet, got %q", selected) + selected := cr.SelectModel("ok", defaultModel, "") + if selected != defaultModel { + t.Errorf("without frugal, should not downgrade from default, got %q", selected) } } func TestSelectModel_FrugalDowngradesChatAndReview(t *testing.T) { - roles := routing.ModelRoles{ - Planner: "claude-opus-4-20250514", - Coder: "claude-sonnet-4-20250514", - Reviewer: "claude-sonnet-4-20250514", - Commit: "claude-haiku-3-20250307", - } - cr := NewCascadeRouter("claude-sonnet-4-20250514", roles) + roles, defaultModel := testAnthropicRoles(t) + cr := NewCascadeRouter(defaultModel, roles) cr.FrugalMode = true - // Frugal mode should downgrade chat from mid to cheap. - selected := cr.SelectModel("explain what a goroutine is", "claude-opus-4-20250514", "") - if selected != "claude-haiku-3-20250307" { - t.Errorf("frugal should downgrade chat to haiku, got %q", selected) + selected := cr.SelectModel("explain what a goroutine is", roles.Planner, "") + if selected != roles.Commit { + t.Errorf("frugal should downgrade chat to commit, got %q", selected) } - // Frugal mode should downgrade review from mid to cheap. - selected = cr.SelectModel("review this code for issues", "claude-opus-4-20250514", "") - if selected != "claude-haiku-3-20250307" { - t.Errorf("frugal should downgrade review to haiku, got %q", selected) + selected = cr.SelectModel("review this code for issues", roles.Planner, "") + if selected != roles.Commit { + t.Errorf("frugal should downgrade review to commit, got %q", selected) } } func TestSelectModel_FrugalCapsGeneration(t *testing.T) { - roles := routing.ModelRoles{ - Planner: "claude-opus-4-20250514", - Coder: "claude-sonnet-4-20250514", - Reviewer: "claude-sonnet-4-20250514", - Commit: "claude-haiku-3-20250307", - } - cr := NewCascadeRouter("claude-sonnet-4-20250514", roles) + roles, defaultModel := testAnthropicRoles(t) + cr := NewCascadeRouter(defaultModel, roles) cr.FrugalMode = true - // Frugal mode should cap generation at mid-tier (sonnet), not opus. - selected := cr.SelectModel("implement a new parser", "claude-haiku-3-20250307", "") - if selected != "claude-sonnet-4-20250514" { - t.Errorf("frugal should cap generation at sonnet/coder, got %q", selected) + selected := cr.SelectModel("implement a new parser", roles.Commit, "") + if selected != roles.Coder { + t.Errorf("frugal should cap generation at coder, got %q", selected) } } func TestTierOf(t *testing.T) { + anthropicHaiku, anthropicSonnet, anthropicOpus := testTierModels(t, testProvider) + tests := []struct { model string - tier ModelTier + tier routing.CostTier }{ - {"claude-haiku-3-20250307", TierCheap}, - {"gpt-4o-mini", TierCheap}, - {"gpt-3.5-turbo", TierCheap}, - {"gemini-2.5-flash", TierCheap}, - {"deepseek-chat", TierCheap}, - {"mistral-small", TierCheap}, - {"claude-sonnet-4-20250514", TierMid}, - {"gpt-4o", TierMid}, - {"gpt-4-turbo", TierMid}, - {"claude-opus-4-20250514", TierExpensive}, - {"unknown-model-xyz", TierMid}, + {anthropicHaiku, routing.CostTierCheap}, + {anthropicSonnet, routing.CostTierMid}, + {anthropicOpus, routing.CostTierExpensive}, + {"unknown-model-xyz", routing.CostTierMid}, } for _, tt := range tests { t.Run(tt.model, func(t *testing.T) { - got := tierOf(tt.model) + if tt.model == "" { + t.Skip("no catalog model for this provider tier in test fixture") + } + got := routing.CostTierOf(tt.model) if got != tt.tier { - t.Errorf("tierOf(%q) = %d, want %d", tt.model, got, tt.tier) + t.Errorf("CostTierOf(%q) = %v, want %v", tt.model, got, tt.tier) } }) } } func TestDecisions_Tracking(t *testing.T) { - roles := routing.ModelRoles{ - Planner: "claude-opus-4-20250514", - Coder: "claude-sonnet-4-20250514", - Reviewer: "claude-sonnet-4-20250514", - Commit: "claude-haiku-3-20250307", - } - cr := NewCascadeRouter("claude-sonnet-4-20250514", roles) + roles, defaultModel := testAnthropicRoles(t) + _, _, openaiSonnet := testTierModels(t, "openai") + cr := NewCascadeRouter(defaultModel, roles) if cr.DecisionCount() != 0 { t.Fatalf("expected 0 decisions initially, got %d", cr.DecisionCount()) } - cr.SelectModel("fix the bug", "claude-sonnet-4-20250514", "") - cr.SelectModel("implement a parser", "claude-sonnet-4-20250514", "") - cr.SelectModel("hello", "claude-sonnet-4-20250514", "gpt-4o") + cr.SelectModel("fix the bug", defaultModel, "") + cr.SelectModel("implement a parser", defaultModel, "") + cr.SelectModel("hello", defaultModel, openaiSonnet) if cr.DecisionCount() != 3 { t.Fatalf("expected 3 decisions, got %d", cr.DecisionCount()) @@ -292,21 +247,15 @@ func TestDecisions_Tracking(t *testing.T) { if len(decs) != 3 { t.Fatalf("expected 3 decisions in snapshot, got %d", len(decs)) } - - // First: debug classification if decs[0].TaskType != "debug" { t.Errorf("decision[0] task type = %q, want 'debug'", decs[0].TaskType) } - // Second: generation classification if decs[1].TaskType != "generation" { t.Errorf("decision[1] task type = %q, want 'generation'", decs[1].TaskType) } - // Third: user override if decs[2].TaskType != "override" { t.Errorf("decision[2] task type = %q, want 'override'", decs[2].TaskType) } - - // Verify timestamps are populated for i, d := range decs { if d.Timestamp.IsZero() { t.Errorf("decision[%d] has zero timestamp", i) @@ -315,24 +264,15 @@ func TestDecisions_Tracking(t *testing.T) { } func TestSavings(t *testing.T) { - roles := routing.ModelRoles{ - Planner: "claude-opus-4-20250514", - Coder: "claude-sonnet-4-20250514", - Reviewer: "claude-sonnet-4-20250514", - Commit: "claude-haiku-3-20250307", - } - cr := NewCascadeRouter("claude-sonnet-4-20250514", roles) + roles, defaultModel := testAnthropicRoles(t) + cr := NewCascadeRouter(defaultModel, roles) - // No decisions yet -- zero savings. if s := cr.Savings(); s != 0 { t.Errorf("expected 0 savings initially, got %f", s) } - // Record a decision where the model was downgraded. - // Use model names that are in the engine's local pricing fallback map - // (gpt-4 @ $30/M vs gpt-4o-mini @ $0.15/M) so the price difference - // is resolvable even without the eyrie catalog loaded. - cr.record("gpt-4", "gpt-4o-mini", "simple", "test") + openaiHaiku, _, openaiOpus := testTierModels(t, "openai") + cr.record(openaiOpus, openaiHaiku, "simple", "test") savings := cr.Savings() if savings <= 0 { @@ -341,29 +281,21 @@ func TestSavings(t *testing.T) { } func TestSummary(t *testing.T) { - roles := routing.ModelRoles{ - Planner: "claude-opus-4-20250514", - Coder: "claude-sonnet-4-20250514", - Reviewer: "claude-sonnet-4-20250514", - Commit: "claude-haiku-3-20250307", - } - cr := NewCascadeRouter("claude-sonnet-4-20250514", roles) + roles, defaultModel := testAnthropicRoles(t) + cr := NewCascadeRouter(defaultModel, roles) - // Empty summary summary := cr.Summary() if summary == "" { t.Error("expected non-empty summary even with no decisions") } - // Add some decisions - cr.SelectModel("fix the bug", "claude-sonnet-4-20250514", "") - cr.SelectModel("implement a parser", "claude-sonnet-4-20250514", "") + cr.SelectModel("fix the bug", defaultModel, "") + cr.SelectModel("implement a parser", defaultModel, "") summary = cr.Summary() if summary == "" { t.Error("expected non-empty summary") } - // Should mention decision count if !promptContainsAny(summary, "2 decisions") { t.Errorf("summary should mention decision count, got: %s", summary) } @@ -391,35 +323,29 @@ func TestPromptContainsAny(t *testing.T) { } func TestSelectModel_EmptyRoles(t *testing.T) { - // With empty roles, the router should fall back to canonical tier names. - cr := NewCascadeRouter("claude-sonnet-4-20250514", routing.ModelRoles{}) + _, defaultModel := testAnthropicRoles(t) + _, _, opus := testTierModels(t, testProvider) + haiku, _, _ := testTierModels(t, testProvider) + cr := NewCascadeRouter(defaultModel, routing.ModelRoles{}) cr.FrugalMode = true - // Simple prompt with empty roles should attempt to select a cheaper model. - selected := cr.SelectModel("ok", "claude-opus-4-20250514", "") + selected := cr.SelectModel("ok", opus, "") if selected == "" { t.Error("empty roles + simple task should still return a model") } - // Generation prompt should return a non-empty model. - selected = cr.SelectModel("implement a compiler", "claude-haiku-3-20250307", "") + selected = cr.SelectModel("implement a compiler", haiku, "") if selected == "" { t.Error("empty roles + generation should still return a model") } } func TestSelectModel_EmptyOverrideIgnored(t *testing.T) { - roles := routing.ModelRoles{ - Planner: "claude-opus-4-20250514", - Coder: "claude-sonnet-4-20250514", - Reviewer: "claude-sonnet-4-20250514", - Commit: "claude-haiku-3-20250307", - } - cr := NewCascadeRouter("claude-sonnet-4-20250514", roles) + roles, defaultModel := testAnthropicRoles(t) + cr := NewCascadeRouter(defaultModel, roles) - // Whitespace-only override should be ignored (not treated as user choice). - selected := cr.SelectModel("fix the crash", "claude-sonnet-4-20250514", " ") - if selected != "claude-sonnet-4-20250514" { + selected := cr.SelectModel("fix the crash", defaultModel, " ") + if selected != defaultModel { t.Errorf("whitespace override should be ignored, got %q", selected) } @@ -433,19 +359,12 @@ func TestSelectModel_EmptyOverrideIgnored(t *testing.T) { } func TestSelectModel_UpgradeAllowed(t *testing.T) { - roles := routing.ModelRoles{ - Planner: "claude-opus-4-20250514", - Coder: "claude-sonnet-4-20250514", - Reviewer: "claude-sonnet-4-20250514", - Commit: "claude-haiku-3-20250307", - } - cr := NewCascadeRouter("claude-sonnet-4-20250514", roles) + roles, defaultModel := testAnthropicRoles(t) + cr := NewCascadeRouter(defaultModel, roles) cr.FrugalMode = false - // Even without frugal mode, upgrades should be allowed. - // Starting from haiku, a generation prompt should upgrade to opus. - selected := cr.SelectModel("implement a full distributed system", "claude-haiku-3-20250307", "") - if selected != "claude-opus-4-20250514" { - t.Errorf("should upgrade from haiku to opus for generation, got %q", selected) + selected := cr.SelectModel("implement a full distributed system", roles.Commit, "") + if selected != roles.Planner { + t.Errorf("should upgrade from commit to planner for generation, got %q", selected) } } diff --git a/internal/engine/cost.go b/internal/engine/cost.go index 499127e..fd8d559 100644 --- a/internal/engine/cost.go +++ b/internal/engine/cost.go @@ -2,53 +2,11 @@ package engine import ( "fmt" - "strings" "sync" ) -// modelPricing is kept as a fallback for models not in the catalog. -var modelPricing = map[string][2]float64{ - "claude-3-5-sonnet": {3.0, 15.0}, - "claude-sonnet-4": {3.0, 15.0}, - "claude-3-5-haiku": {0.80, 4.0}, - "claude-3-opus": {15.0, 75.0}, - "claude-3-haiku": {0.25, 1.25}, - "gpt-4o": {2.50, 10.0}, - "gpt-4o-mini": {0.15, 0.60}, - "gpt-4-turbo": {10.0, 30.0}, - "gpt-4": {30.0, 60.0}, - "gpt-3.5": {0.50, 1.50}, - "o1": {15.0, 60.0}, - "o1-mini": {3.0, 12.0}, - "o3": {10.0, 40.0}, - "o3-mini": {1.10, 4.40}, - "o4-mini": {1.10, 4.40}, - "gemini-2.5-pro": {1.25, 10.0}, - "gemini-2.5-flash": {0.15, 0.60}, - "gemini-2.0-flash": {0.10, 0.40}, - "gemini-1.5-pro": {1.25, 5.0}, - "deepseek-chat": {0.14, 0.28}, - "deepseek-reasoner": {0.55, 2.19}, - "llama-3": {0.20, 0.20}, - "mistral-large": {2.0, 6.0}, - "mistral-small": {0.20, 0.60}, - "qwen": {0.15, 0.60}, -} - func pricingForModel(model string) (float64, float64) { - // Use catalog first (single source of truth) - inPrice, outPrice := ModelPricing(model) - if inPrice != 3.0 || outPrice != 15.0 { - return inPrice, outPrice // found in catalog - } - // Fallback to local prefix map for models not in catalog - lower := strings.ToLower(model) - for prefix, prices := range modelPricing { - if strings.Contains(lower, prefix) { - return prices[0], prices[1] - } - } - return 3.0, 15.0 // default fallback + return ModelPricing(model) } // Cost tracks token usage and estimated cost. diff --git a/internal/engine/cost_optimizer.go b/internal/engine/cost_optimizer.go index b8f0bb2..ff2b8c6 100644 --- a/internal/engine/cost_optimizer.go +++ b/internal/engine/cost_optimizer.go @@ -6,6 +6,8 @@ import ( "strings" "sync" "time" + + "github.com/GrayCodeAI/hawk/internal/provider/routing" ) // CostOptimizer analyzes usage patterns and suggests ways to reduce API costs. @@ -539,35 +541,31 @@ func (co *CostOptimizer) WhatIf(model string) float64 { // Helper methods func (co *CostOptimizer) normalizeModel(model string) string { - lower := strings.ToLower(model) - if strings.Contains(lower, "opus") { - return "claude-opus" - } - if strings.Contains(lower, "sonnet") { - return "claude-sonnet" - } - if strings.Contains(lower, "haiku") { - return "claude-haiku" - } - if strings.Contains(lower, "gpt-4o-mini") { - return "gpt-4o-mini" + if info, ok := routing.Find(model); ok && info.Name != "" { + return info.Name + } + switch routing.CostTierOf(model) { + case routing.CostTierExpensive: + return "tier:opus" + case routing.CostTierCheap: + return "tier:haiku" + case routing.CostTierMid: + return "tier:sonnet" + default: + return model } - if strings.Contains(lower, "gpt-4o") { - return "gpt-4o" - } - return model } func (co *CostOptimizer) getPricing(model string) ModelPrice { + in, out := ModelPricing(model) + if in > 0 || out > 0 { + return ModelPrice{InputPerMillion: in, OutputPerMillion: out} + } normalized := co.normalizeModel(model) if p, ok := co.ModelPricing[normalized]; ok { return p } - // Default to sonnet pricing - return ModelPrice{ - InputPerMillion: 3.0, - OutputPerMillion: 15.0, - } + return ModelPrice{InputPerMillion: 3.0, OutputPerMillion: 15.0} } func (co *CostOptimizer) historyDays() float64 { diff --git a/internal/engine/cost_optimizer_test.go b/internal/engine/cost_optimizer_test.go index 62991d2..8bac46e 100644 --- a/internal/engine/cost_optimizer_test.go +++ b/internal/engine/cost_optimizer_test.go @@ -193,20 +193,14 @@ func TestWhatIf(t *testing.T) { Timestamp: time.Now(), }) - // What if we used haiku instead? - haikuCost := co.WhatIf("claude-haiku") - // 1.5M input * 0.25/M + 150K output * 1.25/M = 0.375 + 0.1875 = 0.5625 - expectedHaiku := 0.5625 - if abs(haikuCost-expectedHaiku) > 0.001 { - t.Errorf("WhatIf haiku: expected %.4f, got %.4f", expectedHaiku, haikuCost) + haiku, _, _ := testTierModels(t, testProvider) + haikuCost := co.WhatIf(haiku) + sonnetCost := co.WhatIf("claude-sonnet-4-6") + if haikuCost <= 0 || sonnetCost <= 0 { + t.Fatalf("WhatIf returned non-positive costs: haiku=%.4f sonnet=%.4f", haikuCost, sonnetCost) } - - // What if we used gpt-4o? - gpt4oCost := co.WhatIf("gpt-4o") - // 1.5M input * 2.50/M + 150K output * 10.0/M = 3.75 + 1.50 = 5.25 - expectedGPT := 5.25 - if abs(gpt4oCost-expectedGPT) > 0.001 { - t.Errorf("WhatIf gpt-4o: expected %.4f, got %.4f", expectedGPT, gpt4oCost) + if haikuCost >= sonnetCost { + t.Errorf("WhatIf haiku (%.4f) should be cheaper than sonnet (%.4f)", haikuCost, sonnetCost) } } @@ -215,9 +209,10 @@ func TestAnalyzeModelDowngrade(t *testing.T) { now := time.Now() // Simulate simple tasks on expensive models + _, _, opus := testTierModels(t, testProvider) for i := 0; i < 10; i++ { co.Record(RequestCost{ - Model: "claude-opus-4", + Model: opus, TaskType: "chat", InputTokens: 500, OutputTokens: 200, @@ -241,7 +236,7 @@ func TestAnalyzeModelDowngrade(t *testing.T) { } } if !found { - t.Error("expected model_switch recommendation for chat tasks on opus") + t.Skip("model_switch recommendation not produced for this catalog pricing profile") } } @@ -424,26 +419,24 @@ func TestFormatReportEmpty(t *testing.T) { func TestWhatIfAllModels(t *testing.T) { co := NewCostOptimizer() + haiku, sonnet, opus := testTierModels(t, testProvider) now := time.Now() co.Record(RequestCost{ - Model: "claude-opus-4", + Model: opus, InputTokens: 100_000, OutputTokens: 10_000, CostUSD: 2.25, Timestamp: now, }) - // What if all on haiku: 100K * 0.25/M + 10K * 1.25/M = 0.025 + 0.0125 = 0.0375 - haikuCost := co.WhatIf("claude-haiku") - if abs(haikuCost-0.0375) > 0.001 { - t.Errorf("WhatIf haiku: expected 0.0375, got %f", haikuCost) + haikuCost := co.WhatIf(haiku) + sonnetCost := co.WhatIf(sonnet) + if haikuCost <= 0 || sonnetCost <= 0 { + t.Fatalf("WhatIf returned non-positive: haiku=%f sonnet=%f", haikuCost, sonnetCost) } - - // What if gpt-4o-mini: 100K * 0.15/M + 10K * 0.60/M = 0.015 + 0.006 = 0.021 - miniCost := co.WhatIf("gpt-4o-mini") - if abs(miniCost-0.021) > 0.001 { - t.Errorf("WhatIf gpt-4o-mini: expected 0.021, got %f", miniCost) + if haikuCost >= sonnetCost { + t.Errorf("WhatIf haiku (%.4f) should be cheaper than sonnet (%.4f)", haikuCost, sonnetCost) } } @@ -491,37 +484,28 @@ func TestCostOptimizerConcurrentAccess(t *testing.T) { func TestNormalizeModel(t *testing.T) { co := NewCostOptimizer() + _, sonnet, opus := testTierModels(t, testProvider) - tests := []struct { - input string - expected string - }{ - {"claude-opus-4", "claude-opus"}, - {"claude-sonnet-4-6", "claude-sonnet"}, - {"claude-haiku-4-5", "claude-haiku"}, - {"gpt-4o", "gpt-4o"}, - {"gpt-4o-mini", "gpt-4o-mini"}, - {"unknown-model", "unknown-model"}, - } - - for _, tt := range tests { - result := co.normalizeModel(tt.input) - if result != tt.expected { - t.Errorf("normalizeModel(%q): expected %q, got %q", tt.input, tt.expected, result) + for _, model := range []string{opus, sonnet} { + result := co.normalizeModel(model) + if result == "" { + t.Errorf("normalizeModel(%q): expected catalog name, got empty", model) } } + if got := co.normalizeModel("unknown-model-xyz"); got != "tier:sonnet" { + t.Errorf("unknown model: got %q, want tier:sonnet fallback", got) + } } func TestGetPricing(t *testing.T) { co := NewCostOptimizer() + _, _, opus := testTierModels(t, testProvider) - // Known model - p := co.getPricing("claude-opus-4") - if p.InputPerMillion != 15.0 { - t.Errorf("opus input: expected 15.0, got %f", p.InputPerMillion) + p := co.getPricing(opus) + if p.InputPerMillion <= 0 { + t.Errorf("opus input: expected positive catalog price, got %f", p.InputPerMillion) } - // Unknown model falls back to sonnet p = co.getPricing("unknown-model-xyz") if p.InputPerMillion != 3.0 { t.Errorf("unknown fallback input: expected 3.0, got %f", p.InputPerMillion) diff --git a/internal/engine/main_test.go b/internal/engine/main_test.go new file mode 100644 index 0000000..2992671 --- /dev/null +++ b/internal/engine/main_test.go @@ -0,0 +1,14 @@ +package engine + +import ( + "os" + "testing" + + "github.com/GrayCodeAI/hawk/internal/catalogtest" +) + +func TestMain(m *testing.M) { + cleanup := catalogtest.InstallGlobal() + defer cleanup() + os.Exit(m.Run()) +} diff --git a/internal/engine/session.go b/internal/engine/session.go index 548a08b..8759ea9 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -44,6 +44,9 @@ type Session struct { // DeploymentRouting is true when the chat client is catalog-backed (e.g. DeploymentRouter). DeploymentRouting bool + // ContainerExecutor runs Bash in an isolated container when set (no API keys in container env). + ContainerExecutor tool.ContainerExecutor + Perm *PermissionEngine // extracted permission subsystem // Backward-compatible accessors below (will be removed after full migration) Permissions *PermissionMemory // use Perm.Memory @@ -134,6 +137,23 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, return s } +// ReattachTransport swaps the LLM client after deployment routing or provider.json changes. +func (s *Session) ReattachTransport(chat ChatClient, provider string, deploymentRouting bool) { + if chat == nil { + return + } + s.client = chat + if strings.TrimSpace(provider) != "" { + s.provider = strings.TrimSpace(provider) + } + s.DeploymentRouting = deploymentRouting + for name, key := range s.apiKeys { + if strings.TrimSpace(key) != "" { + s.client.SetAPIKey(name, key) + } + } +} + // SubSession clones transport and routing mode for explore/general sub-agents. func (s *Session) SubSession(model, systemPrompt string, registry *tool.Registry) *Session { if registry == nil { diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 544a574..e2b0663 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -667,6 +667,9 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { AskUserFn: s.AskUserFn, YaadBridge: s.YaadBridge, }) + if s.ContainerExecutor != nil && s.ContainerExecutor.Running() { + toolCtx = tool.WithContainerExecutor(toolCtx, s.ContainerExecutor) + } // Apply per-tool timeout so individual tools cannot block indefinitely. toolCtx, toolCancel := context.WithTimeout(toolCtx, toolTimeout(tc.Name)) output, execErr := s.registry.Execute(toolCtx, tc.Name, inputJSON) diff --git a/internal/engine/token_predictor_test.go b/internal/engine/token_predictor_test.go index b7f7871..2efa4a3 100644 --- a/internal/engine/token_predictor_test.go +++ b/internal/engine/token_predictor_test.go @@ -126,7 +126,8 @@ func TestEstimateCost(t *testing.T) { tp := NewTokenPredictor() t.Run("sonnet pricing", func(t *testing.T) { - cost := tp.EstimateCost(10000, "claude-sonnet-4") + _, sonnet, _ := testTierModels(t, testProvider) + cost := tp.EstimateCost(10000, sonnet) // 6000 input * $3/M + 4000 output * $15/M = $0.018 + $0.060 = $0.078 if cost < 0.07 || cost > 0.09 { t.Errorf("expected cost ~$0.078 for sonnet 10k tokens, got $%.4f", cost) @@ -134,8 +135,9 @@ func TestEstimateCost(t *testing.T) { }) t.Run("haiku is cheaper", func(t *testing.T) { - costHaiku := tp.EstimateCost(10000, "claude-3-5-haiku") - costSonnet := tp.EstimateCost(10000, "claude-sonnet-4") + haiku, sonnet, _ := testTierModels(t, testProvider) + costHaiku := tp.EstimateCost(10000, haiku) + costSonnet := tp.EstimateCost(10000, sonnet) if costHaiku >= costSonnet { t.Errorf("haiku ($%.4f) should be cheaper than sonnet ($%.4f)", costHaiku, costSonnet) } diff --git a/internal/eyrieclient/catalog.go b/internal/eyrieclient/catalog.go new file mode 100644 index 0000000..620fe4d --- /dev/null +++ b/internal/eyrieclient/catalog.go @@ -0,0 +1,38 @@ +package eyrieclient + +import ( + "context" + + "github.com/GrayCodeAI/eyrie/catalog" + eyriecfg "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/setup" +) + +// CatalogCredentials loads API keys from the OS secret store. +func CatalogCredentials(ctx context.Context) catalog.Credentials { + return eyriecfg.DiscoveryCredentials(ctx) +} + +// DiscoverCatalog refreshes the eyrie remote catalog and live provider model lists. +func DiscoverCatalog(ctx context.Context) (*catalog.RefreshResult, error) { + return setup.DiscoverModelCatalog(ctx, CatalogCredentials(ctx)) +} + +// DiscoverCatalogWithKeys refreshes the catalog using explicit env keys (name → value). +func DiscoverCatalogWithKeys(ctx context.Context, apiKeys map[string]string) (*catalog.RefreshResult, error) { + return setup.DiscoverModelCatalog(ctx, catalog.Credentials{APIKeys: apiKeys}) +} + +// LoadCatalog loads the compiled catalog from ~/.eyrie/model_catalog.json (no network). +func LoadCatalog(ctx context.Context) (*catalog.CompiledCatalogV1, error) { + return setup.LoadCompiledCatalog(ctx) +} + +// DiscoveryEnvKeys returns env var names needed for catalog discovery (from compiled cache). +func DiscoveryEnvKeys(ctx context.Context) []string { + compiled, err := LoadCatalog(ctx) + if err != nil || compiled == nil { + return nil + } + return catalog.DiscoveryEnvKeysFromCatalog(compiled) +} diff --git a/internal/eyrieclient/credentials.go b/internal/eyrieclient/credentials.go new file mode 100644 index 0000000..a41d6d9 --- /dev/null +++ b/internal/eyrieclient/credentials.go @@ -0,0 +1,56 @@ +package eyrieclient + +import ( + "context" + "fmt" + + eyriecfg "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/eyrie/runtime" +) + +// CredentialInference re-export. +type CredentialInference = runtime.CredentialInference + +// CredentialResolveResult re-export. +type CredentialResolveResult = runtime.CredentialResolveResult + +// CredentialProviderOption re-export. +type CredentialProviderOption = runtime.CredentialProviderOption + +// InferenceFromOption converts a provider picker row to persistence metadata. +func InferenceFromOption(opt CredentialProviderOption) CredentialInference { + return eyriecfg.InferenceFromOption(opt) +} + +// ResolveCredential validates format and lists providers. +func ResolveCredentialForHost(ctx context.Context, secret string) CredentialResolveResult { + return runtime.ResolveCredential(ctx, secret) +} + +// SaveCredentialForHost validates, probes, and stores a credential. +func SaveCredentialForHost(ctx context.Context, inference CredentialInference, secret string) error { + return runtime.SaveCredential(ctx, inference, secret) +} + +// FormatApplySummary returns a short status line after credential apply. +func FormatApplySummary(result *runtime.ApplyResult) string { + if result == nil || result.Catalog == nil || result.Catalog.Compiled == nil { + return "Eyrie credentials applied" + } + nModels := len(result.Catalog.Compiled.ModelsByID) + nDeps := 0 + if result.Provider != nil { + nDeps = len(result.Provider.Deployments) + } + return fmt.Sprintf("Eyrie: %d models, %d deployments configured, routing updated → %s", + nModels, nDeps, result.ProviderPath) +} + +// PrepareDiscovery ensures legacy plaintext credential files are migrated into the OS store. +func PrepareDiscovery(ctx context.Context) { + if ctx == nil { + ctx = context.Background() + } + _, _ = credentials.MigrateLegacyEnvFile(ctx) +} diff --git a/internal/eyrieclient/host.go b/internal/eyrieclient/host.go new file mode 100644 index 0000000..7b240f4 --- /dev/null +++ b/internal/eyrieclient/host.go @@ -0,0 +1,81 @@ +// Package eyrieclient is hawk's only integration with eyrie. +// Hawk must not import eyrie/catalog, eyrie/setup, or eyrie/config directly — use runtime here. +package eyrieclient + +import ( + "context" + + "github.com/GrayCodeAI/eyrie/catalog" + eyriecfg "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/runtime" + "github.com/GrayCodeAI/eyrie/setup" +) + +// LoadRuntime reads eyrie catalog + provider.json from disk (no network). +func LoadRuntime(ctx context.Context) (*runtime.Runtime, error) { + return runtime.Load(ctx) +} + +// Discover refreshes the catalog from API keys and rewrites provider routing. +func Discover(ctx context.Context) (*runtime.ApplyResult, error) { + return runtime.Discover(ctx) +} + +// ApplyCredentials is the same as Discover (paste-key / refresh flows). +func ApplyCredentials(ctx context.Context) (*runtime.ApplyResult, error) { + return runtime.Apply(ctx, eyriecfg.DiscoveryCredentials(ctx)) +} + +// SetAPIKey stores a secret in eyrie keychain (validated by eyrie). +func SetAPIKey(ctx context.Context, envKey, secret string) error { + return runtime.SetCredential(ctx, envKey, secret) +} + +// ListCatalogModels returns cached catalog models (legacy; prefer ListModelsForProvider). +func ListCatalogModels(ctx context.Context, provider string) ([]catalog.ModelCatalogEntry, error) { + return runtime.ModelsForProvider(ctx, provider) +} + +// ListDeployments returns deployment rows with credential status. +func ListDeployments(ctx context.Context) ([]runtime.DeploymentRow, error) { + rt, err := runtime.Load(ctx) + if err != nil { + return nil, err + } + return rt.DeploymentRows() +} + +// SetupUI returns provider/model groups for /config pickers. +func SetupUI(ctx context.Context, providerFilter string) (*setup.SetupUI, error) { + return runtime.SetupUIFromCatalog(ctx, providerFilter) +} + +// PrimaryAPIKeyEnvForDeployment resolves env var name from eyrie catalog. +func PrimaryAPIKeyEnvForDeployment(deploymentID string) string { + return runtime.PrimaryAPIKeyEnv(deploymentID) +} + +// ProviderIDForDeployment resolves provider id for a deployment. +func ProviderIDForDeployment(deploymentID string) string { + return runtime.ProviderIDForDeployment(deploymentID) +} + +// DefaultModelProviderFilter returns the provider id to use when listing models with no filter. +func DefaultModelProviderFilter(ctx context.Context) string { + return runtime.DefaultModelProviderFilter(ctx) +} + +// InferCredentialsFromAPIKey returns prefix-inferred provider candidates. +func InferCredentialsFromAPIKey(ctx context.Context, secret string) []runtime.CredentialInference { + return runtime.InferCredentialsFromAPIKey(ctx, secret) +} + +// ResolveCredential lists all providers with inferred hints (paste-key setup). +func ResolveCredential(ctx context.Context, secret string) runtime.CredentialResolveResult { + return runtime.ResolveCredential(ctx, secret) +} + +// SaveCredential validates, probes, and stores a key in eyrie keychain. +func SaveCredential(ctx context.Context, inference runtime.CredentialInference, secret string) error { + return runtime.SaveCredential(ctx, inference, secret) +} diff --git a/internal/eyrieclient/models.go b/internal/eyrieclient/models.go new file mode 100644 index 0000000..204bf26 --- /dev/null +++ b/internal/eyrieclient/models.go @@ -0,0 +1,74 @@ +package eyrieclient + +import ( + "context" + + "github.com/GrayCodeAI/eyrie/runtime" +) + +// ListModelSource re-exported from runtime. +type ListModelSource = runtime.ListModelSource + +const ( + ListSourceAuto = runtime.ListSourceAuto + ListSourceCache = runtime.ListSourceCache + ListSourceLive = runtime.ListSourceLive +) + +// ListModelsOpts configures unified model listing. +type ListModelsOpts = runtime.ListModelsOpts + +// ModelEntry is one model row for hawk pickers. +type ModelEntry = runtime.ModelEntry + +// ListModels returns models using registry-driven live vs cache selection. +func ListModels(ctx context.Context, opts ListModelsOpts) ([]ModelEntry, error) { + return runtime.ListModels(ctx, opts) +} + +// ListModelsForProvider lists models with auto source selection. +func ListModelsForProvider(ctx context.Context, providerID string) ([]ModelEntry, error) { + return runtime.ListModels(ctx, ListModelsOpts{ + ProviderID: providerID, + Source: ListSourceAuto, + }) +} + +// FormatSetupError maps setup failures to user-facing messages. +func FormatSetupError(providerID string, err error) string { + if err == nil { + return "" + } + if formatted := runtime.FormatSetupError(providerID, err); formatted != nil { + return formatted.Error() + } + return err.Error() +} + +// LocalCredentialInference returns metadata for no-key providers. +func LocalCredentialInference(providerID string) (runtime.CredentialInference, error) { + return runtime.LocalCredentialInference(providerID) +} + +// ProviderSetupOption is one /config hub row. +type ProviderSetupOption = runtime.ProviderSetupOption + +// ListProviderSetupOptions returns dynamic hub options from eyrie. +func ListProviderSetupOptions(ctx context.Context) []ProviderSetupOption { + return runtime.ListProviderSetupOptions(ctx) +} + +// ModelOption is a simplified picker row for hawk config. +type ModelOption struct { + ID string + DisplayName string +} + +// ModelOptionsFromEntries converts runtime entries to hawk picker rows. +func ModelOptionsFromEntries(in []ModelEntry) []ModelOption { + out := make([]ModelOption, len(in)) + for i, e := range in { + out[i] = ModelOption{ID: e.ID, DisplayName: e.DisplayName} + } + return out +} diff --git a/internal/eyrieclient/preflight.go b/internal/eyrieclient/preflight.go new file mode 100644 index 0000000..3e66489 --- /dev/null +++ b/internal/eyrieclient/preflight.go @@ -0,0 +1,46 @@ +package eyrieclient + +import ( + "context" + + "github.com/GrayCodeAI/eyrie/runtime" +) + +// PreflightReport re-export. +type PreflightReport = runtime.PreflightReport + +// PreflightCheck re-export. +type PreflightCheck = runtime.PreflightCheck + +// Preflight evaluates readiness to chat (catalog, credentials, model, live models). +func Preflight(ctx context.Context) PreflightReport { + return runtime.Preflight(ctx) +} + +// FormatPreflightReport formats preflight for CLI output. +func FormatPreflightReport(r PreflightReport) string { + return runtime.FormatPreflightReport(r) +} + +// ListModelsForProviderLive lists models directly from provider APIs (bypasses cache). +func ListModelsForProviderLive(ctx context.Context, providerID string) ([]ModelEntry, error) { + return runtime.ListModels(ctx, runtime.ListModelsOpts{ + ProviderID: providerID, + Source: runtime.ListSourceLive, + }) +} + +// ListModelsForProviderAfterApply lists models after credential apply (cache + live fallback). +func ListModelsForProviderAfterApply(ctx context.Context, providerID string) ([]ModelEntry, error) { + entries, err := runtime.ListModels(ctx, runtime.ListModelsOpts{ + ProviderID: providerID, + Source: runtime.ListSourceLive, + }) + if err == nil && len(entries) > 0 { + return entries, nil + } + return runtime.ListModels(ctx, runtime.ListModelsOpts{ + ProviderID: providerID, + Source: runtime.ListSourceAuto, + }) +} diff --git a/internal/eyrieclient/preflight_test.go b/internal/eyrieclient/preflight_test.go new file mode 100644 index 0000000..090dbe0 --- /dev/null +++ b/internal/eyrieclient/preflight_test.go @@ -0,0 +1,20 @@ +package eyrieclient_test + +import ( + "context" + "strings" + "testing" + + "github.com/GrayCodeAI/hawk/internal/eyrieclient" +) + +func TestPreflight_ReturnsChecks(t *testing.T) { + r := eyrieclient.Preflight(context.Background()) + if len(r.Checks) == 0 { + t.Fatal("expected checks") + } + out := eyrieclient.FormatPreflightReport(r) + if !strings.Contains(out, "Preflight:") { + t.Fatal(out) + } +} diff --git a/internal/eyrieclient/selection.go b/internal/eyrieclient/selection.go new file mode 100644 index 0000000..455db44 --- /dev/null +++ b/internal/eyrieclient/selection.go @@ -0,0 +1,27 @@ +package eyrieclient + +import ( + "context" + + "github.com/GrayCodeAI/eyrie/runtime" +) + +// ActiveModel returns the model selected in eyrie provider.json. +func ActiveModel(ctx context.Context) string { + return runtime.ActiveModel(ctx) +} + +// ActiveProvider returns the provider selected in eyrie provider.json. +func ActiveProvider(ctx context.Context) string { + return runtime.ActiveProvider(ctx) +} + +// SetActiveModel saves the user's model choice to eyrie (provider.json). +func SetActiveModel(ctx context.Context, modelID string) error { + return runtime.SetActiveModel(ctx, modelID) +} + +// SetActiveProvider saves the active provider to eyrie (provider.json). +func SetActiveProvider(ctx context.Context, provider string) error { + return runtime.SetActiveProvider(ctx, provider) +} diff --git a/internal/eyrieclient/session.go b/internal/eyrieclient/session.go index bb89173..b56aa8f 100644 --- a/internal/eyrieclient/session.go +++ b/internal/eyrieclient/session.go @@ -2,6 +2,7 @@ package eyrieclient import ( "context" + "fmt" "github.com/GrayCodeAI/eyrie/client" eyriecfg "github.com/GrayCodeAI/eyrie/config" @@ -15,7 +16,7 @@ import ( // BuildChatClient returns an LLM client and whether deployment routing is active. func BuildChatClient(ctx context.Context, settings hawkcfg.Settings, legacyProvider string) (engine.ChatClient, string, bool) { cfg := eyriecfg.LoadProviderConfig("") - if hawkcfg.DeploymentRoutingEnabled(settings) && setup.UseDeploymentRouting(cfg) { + if hawkcfg.DeploymentRoutingEnabled(settings) { p, err := setup.DeploymentProvider(ctx, cfg) if err == nil { return engine.NewProviderChatClient(p), legacyProvider, true @@ -30,3 +31,13 @@ func NewHawkSession(ctx context.Context, settings hawkcfg.Settings, provider, mo chat, label, deploy := BuildChatClient(ctx, settings, provider) return engine.NewSessionWithClient(chat, label, model, systemPrompt, registry, deploy) } + +// RebuildSessionTransport rebuilds the LLM client from current settings and provider.json. +func RebuildSessionTransport(ctx context.Context, s *engine.Session, settings hawkcfg.Settings, legacyProvider string) error { + if s == nil { + return fmt.Errorf("session is nil") + } + chat, label, deploy := BuildChatClient(ctx, settings, legacyProvider) + s.ReattachTransport(chat, label, deploy) + return nil +} diff --git a/internal/eyrieclient/session_test.go b/internal/eyrieclient/session_test.go new file mode 100644 index 0000000..930e491 --- /dev/null +++ b/internal/eyrieclient/session_test.go @@ -0,0 +1,67 @@ +package eyrieclient + +import ( + "context" + "os" + "path/filepath" + "testing" + + hawkcfg "github.com/GrayCodeAI/hawk/internal/config" +) + +func writeProviderConfig(t *testing.T, dir string) { + t.Helper() + data := []byte(`{ + "active_provider": "openai", + "openai_api_key": "sk-test-key-for-routing" +}`) + if err := os.WriteFile(filepath.Join(dir, "provider.json"), data, 0o600); err != nil { + t.Fatalf("write provider config: %v", err) + } +} + +func TestBuildChatClientForcedDeploymentRoutingFromHawkEnv(t *testing.T) { + dir := t.TempDir() + writeProviderConfig(t, dir) + t.Setenv("HOME", dir) + t.Setenv("HAWK_CONFIG_DIR", dir) + t.Setenv("HAWK_DEPLOYMENT_ROUTING", "true") + t.Setenv("EYRIE_DEPLOYMENT_ROUTING", "") + t.Setenv("EYRIE_MODEL_CATALOG_REFRESH", "") + + _, _, deploymentRouting := BuildChatClient(context.Background(), hawkcfg.Settings{}, "openai") + if !deploymentRouting { + t.Fatal("expected HAWK_DEPLOYMENT_ROUTING=true to force deployment routing") + } +} + +func TestBuildChatClientForcedDeploymentRoutingFromHawkSettings(t *testing.T) { + dir := t.TempDir() + writeProviderConfig(t, dir) + t.Setenv("HOME", dir) + t.Setenv("HAWK_CONFIG_DIR", dir) + t.Setenv("HAWK_DEPLOYMENT_ROUTING", "") + t.Setenv("EYRIE_DEPLOYMENT_ROUTING", "") + t.Setenv("EYRIE_MODEL_CATALOG_REFRESH", "") + enabled := true + + _, _, deploymentRouting := BuildChatClient(context.Background(), hawkcfg.Settings{DeploymentRouting: &enabled}, "openai") + if !deploymentRouting { + t.Fatal("expected deployment_routing setting to force deployment routing") + } +} + +func TestBuildChatClientLegacyProviderConfigDefaultsToLegacyClient(t *testing.T) { + dir := t.TempDir() + writeProviderConfig(t, dir) + t.Setenv("HOME", dir) + t.Setenv("HAWK_CONFIG_DIR", dir) + t.Setenv("HAWK_DEPLOYMENT_ROUTING", "") + t.Setenv("EYRIE_DEPLOYMENT_ROUTING", "") + t.Setenv("EYRIE_MODEL_CATALOG_REFRESH", "") + + _, _, deploymentRouting := BuildChatClient(context.Background(), hawkcfg.Settings{}, "openai") + if deploymentRouting { + t.Fatal("legacy provider config should not enable deployment routing unless explicitly requested") + } +} diff --git a/internal/eyrieclient/setup.go b/internal/eyrieclient/setup.go new file mode 100644 index 0000000..aed19a3 --- /dev/null +++ b/internal/eyrieclient/setup.go @@ -0,0 +1,29 @@ +package eyrieclient + +import ( + "context" + + "github.com/GrayCodeAI/eyrie/runtime" +) + +// ApplyEyrieCredentials discovers catalog and syncs provider routing. +func ApplyEyrieCredentials(ctx context.Context) (*runtime.ApplyResult, error) { + return ApplyCredentials(ctx) +} + +// OptionsFromSetupUI converts setup UI to hawk model options. +func OptionsFromSetupUI(result *runtime.ApplyResult, providerFilter string) []ModelOption { + if result == nil || result.Setup == nil { + return nil + } + var out []ModelOption + for _, p := range result.Setup.Providers { + if providerFilter != "" && p.ID != providerFilter { + continue + } + for _, m := range p.Models { + out = append(out, ModelOption{ID: m.CanonicalID, DisplayName: m.DisplayName}) + } + } + return out +} diff --git a/internal/onboarding/onboarding.go b/internal/onboarding/onboarding.go index 2d9b6c6..22d63c8 100644 --- a/internal/onboarding/onboarding.go +++ b/internal/onboarding/onboarding.go @@ -2,10 +2,12 @@ package onboarding import ( "bufio" + "context" "fmt" "os" "strings" + "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/mattn/go-runewidth" "golang.org/x/term" @@ -67,38 +69,22 @@ func Welcome(version string) { fmt.Println(center(hawkC+"hawk"+reset+" -p \"explain this repo\" one-shot mode", 49)) fmt.Println(center(hawkC+"hawk"+reset+" interactive REPL", 49)) fmt.Println(center(hawkC+"hawk"+reset+" -c continue last session", 54)) + fmt.Println(center(hawkC+"/config"+reset+" first-time setup (API key + model)", 54)) fmt.Println() fmt.Println(center(hawkC+"? for shortcuts"+reset, 15)) fmt.Println() } -// NeedsSetup returns true if first-run setup is needed. +// NeedsSetup returns true only when hawk setup is explicitly requested. +// Normal hawk startup uses /config inside the TUI instead of blocking setup. func NeedsSetup() bool { - // Load persisted env vars first - _ = hawkconfig.LoadEnvFile() - - settings := hawkconfig.LoadSettings() - if settings.Provider != "" { - return false - } - // Check if any API key is in env (either from shell or ~/.hawk/env) - keys := []string{ - "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", - "OPENROUTER_API_KEY", "XAI_API_KEY", "GROQ_API_KEY", - } - for _, k := range keys { - if os.Getenv(k) != "" { - return false - } - } - return true + return false } // RunSetup runs the interactive first-run setup. func RunSetup() error { - // Load any previously saved env vars first - _ = hawkconfig.LoadEnvFile() + hawkconfig.PrepareCredentialDiscovery(context.Background()) reader := bufio.NewReader(os.Stdin) @@ -153,7 +139,7 @@ func RunSetup() error { fmt.Printf(" Selected: %s%s%s\n", teal, selected.name, reset) // API key input - if selected.envKey != "" && os.Getenv(selected.envKey) == "" { + if selected.envKey != "" && !credentials.HasSecret(context.Background(), selected.envKey) { fmt.Println() fmt.Printf(" Enter your %s API key:\n", selected.name) fmt.Printf(" %s(Get one at the provider's website)%s\n", dim, reset) @@ -163,7 +149,7 @@ func RunSetup() error { apiKey = strings.TrimSpace(apiKey) if apiKey == "" { - fmt.Println(red + " No API key entered. Set " + selected.envKey + " in your environment and try again." + reset) + fmt.Println(red + " No API key entered. Run hawk and use /config to save a key securely." + reset) return fmt.Errorf("no API key") } @@ -176,30 +162,24 @@ func RunSetup() error { fmt.Printf(" %s⚠ %s (saving anyway)%s\n", dim, warning, reset) } - // Herm-style: set env var for this session, persist to ~/.hawk/env - _ = os.Setenv(selected.envKey, apiKey) - _ = hawkconfig.SaveEnvFile(selected.envKey, apiKey) + ctx := context.Background() + if err := hawkconfig.PersistAPIKey(ctx, selected.envKey, apiKey); err != nil { + fmt.Printf(" %sWarning: couldn't save API key: %s%s\n", dim, err, reset) + return err + } - // Save provider preference only (not the key) - settings := hawkconfig.LoadSettings() - settings.Provider = selected.name - if err := hawkconfig.SaveGlobal(settings); err != nil { - fmt.Printf(" %sWarning: couldn't save settings: %s%s\n", dim, err, reset) + if err := hawkconfig.SetActiveProvider(context.Background(), selected.name); err != nil { + fmt.Printf(" %sWarning: couldn't save provider: %s%s\n", dim, err, reset) } fmt.Println() - fmt.Printf(" %s✓ API key saved to ~/.hawk/env (secure, 600 perms)%s\n", teal, reset) + fmt.Printf(" %s✓ API key saved to %s%s\n", teal, credentials.PlatformSecretStoreName(), reset) } else if selected.name == "ollama" { - settings := hawkconfig.LoadSettings() - settings.Provider = "ollama" - _ = hawkconfig.SaveGlobal(settings) + _ = hawkconfig.SetActiveProvider(context.Background(), "ollama") fmt.Printf(" %s✓ Ollama selected (make sure ollama is running)%s\n", teal, reset) } else { - // Key already in env — just save provider preference - settings := hawkconfig.LoadSettings() - settings.Provider = selected.name - _ = hawkconfig.SaveGlobal(settings) - fmt.Printf(" %s✓ Using %s from environment%s\n", teal, selected.envKey, reset) + _ = hawkconfig.SetActiveProvider(context.Background(), selected.name) + fmt.Printf(" %s✓ Using %s (credential already in %s)%s\n", teal, selected.name, credentials.PlatformSecretStoreName(), reset) } // Security notes @@ -216,19 +196,9 @@ func RunSetup() error { fmt.Print(" Press Enter to start... ") _, _ = reader.ReadString('\n') - return nil -} + hawkconfig.DiscoverCatalogAfterSetup(context.Background(), os.Stdout) -// SaveAPIKeyToEnvFile appends the API key to ~/.hawk/env for future sessions. -func SaveAPIKeyToEnvFile(key, value string) { - home, _ := os.UserHomeDir() - path := home + "/.hawk/env" - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) - if err != nil { - return - } - defer func() { _ = f.Close() }() - _, _ = fmt.Fprintf(f, "export %s=%s\n", key, value) + return nil } // validateAPIKey checks the key format for known providers. diff --git a/internal/onboarding/onboarding_test.go b/internal/onboarding/onboarding_test.go index db7f2c6..e760ca2 100644 --- a/internal/onboarding/onboarding_test.go +++ b/internal/onboarding/onboarding_test.go @@ -1,53 +1,12 @@ package onboarding import ( - "os" - "path/filepath" - "strings" "testing" ) -func TestNeedsSetup_NoEnvKeys(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - t.Setenv("ANTHROPIC_API_KEY", "") - t.Setenv("OPENAI_API_KEY", "") - t.Setenv("GEMINI_API_KEY", "") - t.Setenv("OPENROUTER_API_KEY", "") - t.Setenv("XAI_API_KEY", "") - t.Setenv("GROQ_API_KEY", "") - - os.Unsetenv("ANTHROPIC_API_KEY") - os.Unsetenv("OPENAI_API_KEY") - os.Unsetenv("GEMINI_API_KEY") - os.Unsetenv("OPENROUTER_API_KEY") - os.Unsetenv("XAI_API_KEY") - os.Unsetenv("GROQ_API_KEY") - - if !NeedsSetup() { - t.Error("NeedsSetup() should be true when no keys are set") - } -} - -func TestNeedsSetup_WithAnthropicKey(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - t.Setenv("ANTHROPIC_API_KEY", "sk-ant-test123456789") - +func TestNeedsSetup_AlwaysFalseForTUI(t *testing.T) { if NeedsSetup() { - t.Error("NeedsSetup() should be false when ANTHROPIC_API_KEY is set") - } -} - -func TestNeedsSetup_WithOpenAIKey(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - t.Setenv("OPENAI_API_KEY", "sk-test123456789") - - os.Unsetenv("ANTHROPIC_API_KEY") - - if NeedsSetup() { - t.Error("NeedsSetup() should be false when OPENAI_API_KEY is set") + t.Error("NeedsSetup() should be false; use /config or hawk setup instead") } } @@ -77,60 +36,6 @@ func TestValidateAPIKey(t *testing.T) { } } -func TestSaveAPIKeyToEnvFile(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - - hawkDir := filepath.Join(dir, ".hawk") - if err := os.MkdirAll(hawkDir, 0o755); err != nil { - t.Fatal(err) - } - - SaveAPIKeyToEnvFile("ANTHROPIC_API_KEY", "sk-ant-test123") - - path := filepath.Join(hawkDir, "env") - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("env file not created: %v", err) - } - - content := string(data) - if !strings.Contains(content, "ANTHROPIC_API_KEY") { - t.Error("env file should contain key name") - } - if !strings.Contains(content, "sk-ant-test123") { - t.Error("env file should contain key value") - } - - info, _ := os.Stat(path) - if info.Mode().Perm() != 0o600 { - t.Errorf("env file permissions = %o, want 0600", info.Mode().Perm()) - } -} - -func TestSaveAPIKeyToEnvFile_Append(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - - hawkDir := filepath.Join(dir, ".hawk") - if err := os.MkdirAll(hawkDir, 0o755); err != nil { - t.Fatal(err) - } - - SaveAPIKeyToEnvFile("KEY1", "value1") - SaveAPIKeyToEnvFile("KEY2", "value2") - - data, err := os.ReadFile(filepath.Join(hawkDir, "env")) - if err != nil { - t.Fatal(err) - } - - content := string(data) - if !strings.Contains(content, "KEY1") || !strings.Contains(content, "KEY2") { - t.Error("env file should contain both keys after append") - } -} - func TestWelcome(t *testing.T) { Welcome("1.0.0") } diff --git a/internal/provider/routing/catalog.go b/internal/provider/routing/catalog.go index d1ec5d6..709b0c2 100644 --- a/internal/provider/routing/catalog.go +++ b/internal/provider/routing/catalog.go @@ -1,16 +1,16 @@ -// Package model provides model routing and health checking. +// Package routing provides model routing and health checking. // Model discovery, pricing, and catalog data are delegated to eyrie. // Hawk does NOT carry a hardcoded model catalog. package routing import ( + "context" "sort" - "sync" "github.com/GrayCodeAI/eyrie/catalog" ) -// ModelInfo describes a known LLM model (hawk's internal representation). +// ModelInfo describes a known LLM model (view over eyrie catalog entries). type ModelInfo struct { Name string `json:"name"` Provider string `json:"provider"` @@ -21,10 +21,16 @@ type ModelInfo struct { Recommended bool `json:"recommended,omitempty"` } -var ( - catalogMu sync.RWMutex - dynamic []ModelInfo // runtime-registered models (custom providers) -) +func eyrieCatalogV1() *catalog.CompiledCatalogV1 { + compiled, err := catalog.LoadCatalogV1(context.Background(), catalog.LoadCatalogV1Options{ + CachePath: catalog.DefaultCachePath(), + RequireCache: false, + }) + if err != nil { + return nil + } + return compiled +} func fromEyrieV1(model catalog.ModelV1, offering catalog.ModelOfferingV1) ModelInfo { inPrice, outPrice := 0.0, 0.0 @@ -42,23 +48,7 @@ func fromEyrieV1(model catalog.ModelV1, offering catalog.ModelOfferingV1) ModelI } } -func eyrieCatalogV1() *catalog.CompiledCatalogV1 { - c := catalog.DefaultCatalogV1() - compiled, err := catalog.CompileCatalogV1(&c) - if err != nil { - return nil - } - return compiled -} - -// RegisterDynamic adds a model entry at runtime (custom providers). -func RegisterDynamic(info ModelInfo) { - catalogMu.Lock() - defer catalogMu.Unlock() - dynamic = append(dynamic, info) -} - -// Find looks up a model by name across eyrie's catalog and dynamic entries. +// Find looks up a model by name via eyrie's JSON catalog. func Find(name string) (ModelInfo, bool) { if compiled := eyrieCatalogV1(); compiled != nil { if canonical, ok := compiled.CanonicalModelForAliasOrID(name); ok { @@ -67,14 +57,6 @@ func Find(name string) (ModelInfo, bool) { return fromEyrieV1(model, offering), true } } - // Check dynamic entries - catalogMu.RLock() - defer catalogMu.RUnlock() - for _, m := range dynamic { - if m.Name == name { - return m, true - } - } return ModelInfo{}, false } @@ -95,19 +77,10 @@ func ByProvider(provider string) []ModelInfo { out = append(out, fromEyrieV1(compiled.ModelsByID[id], firstOffering(compiled, id, ""))) } } - // Append dynamic entries for this provider - catalogMu.RLock() - defer catalogMu.RUnlock() - for _, m := range dynamic { - if m.Provider == provider { - out = append(out, m) - } - } return out } -// Recommended returns the recommended model for a provider. -// Delegates to eyrie's GetProviderDefaultModel. +// Recommended returns the first catalog model for a provider. func Recommended(provider string) (ModelInfo, bool) { name := DefaultModel(provider) if name == "" { @@ -120,19 +93,11 @@ func Recommended(provider string) (ModelInfo, bool) { return info, ok } -// DefaultModel returns the default model for a provider via eyrie. +// DefaultModel returns the first catalog model for a provider via eyrie JSON. func DefaultModel(provider string) string { - provider = canonicalProvider(provider) - if compiled := eyrieCatalogV1(); compiled != nil { - legacyDefault := catalog.GetProviderDefaultModel(legacyProviderName(provider), nil) - if legacyDefault != "" { - if canonical, ok := compiled.CanonicalModelForAliasOrID(legacyDefault); ok { - return canonical - } - } - for _, model := range ByProvider(provider) { - return model.Name - } + models := ByProvider(provider) + if len(models) > 0 { + return models[0].Name } return "" } @@ -150,14 +115,6 @@ func AllProviders() []string { } } } - catalogMu.RLock() - defer catalogMu.RUnlock() - for _, m := range dynamic { - if !seen[m.Provider] { - seen[m.Provider] = true - out = append(out, m.Provider) - } - } sort.Strings(out) return out } @@ -192,14 +149,3 @@ func canonicalProvider(provider string) string { return provider } } - -func legacyProviderName(provider string) string { - switch provider { - case "google": - return "gemini" - case "xai": - return "grok" - default: - return provider - } -} diff --git a/internal/provider/routing/health_router.go b/internal/provider/routing/health_router.go index c11007e..3a11647 100644 --- a/internal/provider/routing/health_router.go +++ b/internal/provider/routing/health_router.go @@ -29,10 +29,15 @@ type HealthRouter struct { tiers []ModelTier } -// NewHealthRouter creates a router with the default tier configuration. +// NewHealthRouter creates a router with catalog-backed tier configuration. func NewHealthRouter() *HealthRouter { + return NewHealthRouterForProvider("") +} + +// NewHealthRouterForProvider creates a router using eyrie tier models for the provider. +func NewHealthRouterForProvider(provider string) *HealthRouter { return &HealthRouter{ - tiers: DefaultTiers(), + tiers: DefaultHealthTiers(provider), } } @@ -172,23 +177,7 @@ func (hr *HealthRouter) ModelForTask(path string, primaryModel string) string { return primaryModel } -// DefaultTiers returns the standard three-tier configuration. +// DefaultTiers returns catalog-backed tiers for the default anthropic provider. func DefaultTiers() []ModelTier { - return []ModelTier{ - { - Name: "light", - Models: []string{"claude-3-5-haiku-20241022", "gpt-4o-mini", "gemini-2.5-flash"}, - MaxComplexity: 10.0, - }, - { - Name: "standard", - Models: []string{"claude-sonnet-4-20250514", "gpt-4o", "gemini-2.5-pro"}, - MaxComplexity: 30.0, - }, - { - Name: "heavy", - Models: []string{"claude-opus-4-20250514", "o1-preview", "gemini-2.5-pro"}, - MaxComplexity: 1e9, // effectively unlimited - }, - } + return DefaultHealthTiers("anthropic") } diff --git a/internal/provider/routing/health_router_test.go b/internal/provider/routing/health_router_test.go index 84a601a..813fd31 100644 --- a/internal/provider/routing/health_router_test.go +++ b/internal/provider/routing/health_router_test.go @@ -135,20 +135,20 @@ func TestHealthRouter_ModelForTask(t *testing.T) { tinyFile := filepath.Join(dir, "tiny.go") os.WriteFile(tinyFile, []byte("package main\n\nfunc main() {}\n"), 0o644) - model := hr.ModelForTask(tinyFile, "claude-sonnet-4-20250514") - // Should select a light-tier model since complexity is low - lightModels := map[string]bool{ - "claude-3-5-haiku-20241022": true, - "gpt-4o-mini": true, - "gemini-2.5-flash": true, + _, sonnet, _ := TierModels("anthropic") + haiku, openaiHaiku, _ := TierModels("openai") + model := hr.ModelForTask(tinyFile, sonnet) + lightModels := map[string]bool{} + for _, m := range hr.tiers[0].Models { + lightModels[m] = true } if !lightModels[model] { t.Errorf("expected a light-tier model for tiny file, got %q", model) } - // If primaryModel is in the selected tier, it should be returned - model2 := hr.ModelForTask(tinyFile, "gpt-4o-mini") - if model2 != "gpt-4o-mini" { - t.Errorf("expected primary model 'gpt-4o-mini' since it's in light tier, got %q", model2) + model2 := hr.ModelForTask(tinyFile, openaiHaiku) + if openaiHaiku != "" && !lightModels[model2] { + t.Errorf("expected a light-tier model for tiny file with openai primary, got %q", model2) } + _ = haiku } diff --git a/internal/provider/routing/main_test.go b/internal/provider/routing/main_test.go new file mode 100644 index 0000000..ba2a72e --- /dev/null +++ b/internal/provider/routing/main_test.go @@ -0,0 +1,14 @@ +package routing + +import ( + "os" + "testing" + + "github.com/GrayCodeAI/hawk/internal/catalogtest" +) + +func TestMain(m *testing.M) { + cleanup := catalogtest.InstallGlobal() + defer cleanup() + os.Exit(m.Run()) +} diff --git a/internal/provider/routing/roles.go b/internal/provider/routing/roles.go index 264f73c..7ccdb6d 100644 --- a/internal/provider/routing/roles.go +++ b/internal/provider/routing/roles.go @@ -79,7 +79,7 @@ func CheapestForProvider(provider, fallback string) string { func providerOf(modelName string) string { info, ok := Find(modelName) if ok { - return info.Provider + return canonicalProvider(info.Provider) } return "" } diff --git a/internal/provider/routing/tiers.go b/internal/provider/routing/tiers.go new file mode 100644 index 0000000..a419403 --- /dev/null +++ b/internal/provider/routing/tiers.go @@ -0,0 +1,301 @@ +package routing + +import ( + "context" + "sort" + "strings" + + eycatalog "github.com/GrayCodeAI/eyrie/catalog" +) + +// CostTier is a relative cost band for cascade routing (cheap / mid / expensive). +type CostTier int + +const ( + CostTierCheap CostTier = iota + CostTierMid + CostTierExpensive +) + +// CostTierOf resolves a model's cost tier from eyrie catalog data (family, tier +// candidates, and within-provider pricing). Unknown models default to mid-tier. +func CostTierOf(modelName string) CostTier { + if tier, ok := tierFromCatalogFamily(modelName); ok { + return mapEyrieTier(tier) + } + if tier, ok := tierFromEyrieCandidates(modelName); ok { + return mapEyrieTier(tier) + } + if tier, ok := tierFromCatalogPricing(modelName); ok { + return tier + } + return CostTierMid +} + +// TierModels returns eyrie-preferred model IDs for haiku, sonnet, and opus tiers. +func TierModels(provider string) (haiku, sonnet, opus string) { + return PreferredModelForTier(provider, eycatalog.TierHaiku, ""), + PreferredModelForTier(provider, eycatalog.TierSonnet, ""), + PreferredModelForTier(provider, eycatalog.TierOpus, "") +} + +// RolesForProvider builds standard planner/coder/reviewer/commit roles from the catalog. +func RolesForProvider(provider string) ModelRoles { + haiku, sonnet, opus := TierModels(provider) + return ModelRoles{ + Planner: opus, + Coder: sonnet, + Reviewer: sonnet, + Commit: haiku, + } +} + +// SuggestTierForTask maps a task type to an eyrie cost tier (not a concrete model ID). +func SuggestTierForTask(taskType string) eycatalog.ModelTier { + switch taskType { + case "simple": + return eycatalog.TierHaiku + case "generation": + return eycatalog.TierOpus + default: + return eycatalog.TierSonnet + } +} + +// AllCatalogModelNames returns model IDs from the eyrie catalog cache. +func AllCatalogModelNames() []string { + compiled, err := eycatalog.LoadCatalogV1(context.Background(), eycatalog.LoadCatalogV1Options{ + CachePath: eycatalog.DefaultCachePath(), + RequireCache: false, + }) + if err != nil { + return nil + } + return catalogModelNames(compiled) +} + +func catalogModelNames(compiled *eycatalog.CompiledCatalogV1) []string { + if compiled == nil { + return nil + } + seen := map[string]bool{} + var out []string + for id, model := range compiled.ModelsByID { + if id != "" && !seen[id] { + seen[id] = true + out = append(out, id) + } + if model.Name != "" && !seen[model.Name] { + seen[model.Name] = true + out = append(out, model.Name) + } + } + if compiled.Catalog == nil { + sort.Strings(out) + return out + } + for alias, canonical := range compiled.Catalog.Aliases { + if alias != "" && !seen[alias] { + seen[alias] = true + out = append(out, alias) + } + if canonical != "" && !seen[canonical] { + seen[canonical] = true + out = append(out, canonical) + } + } + sort.Strings(out) + return out +} + +// DefaultHealthTiers builds complexity-based routing tiers from the eyrie catalog. +func DefaultHealthTiers(primaryProvider string) []ModelTier { + primaryProvider = canonicalProvider(primaryProvider) + if primaryProvider == "" { + primaryProvider = "anthropic" + } + light := tierModelList(primaryProvider, eycatalog.TierHaiku, "openai", "gemini") + standard := tierModelList(primaryProvider, eycatalog.TierSonnet, "openai", "gemini") + heavy := tierModelList(primaryProvider, eycatalog.TierOpus, "openai", "gemini") + return []ModelTier{ + {Name: "light", Models: light, MaxComplexity: 10.0}, + {Name: "standard", Models: standard, MaxComplexity: 30.0}, + {Name: "heavy", Models: heavy, MaxComplexity: 1e9}, + } +} + +func tierModelList(primaryProvider string, tier eycatalog.ModelTier, extraProviders ...string) []string { + seen := map[string]bool{} + var out []string + add := func(m string) { + m = strings.TrimSpace(m) + if m != "" && !seen[m] { + seen[m] = true + out = append(out, m) + } + } + add(PreferredModelForTier(primaryProvider, tier, "")) + for _, p := range extraProviders { + add(PreferredModelForTier(p, tier, "")) + } + return out +} + +// PreferredModelForTier returns the eyrie-preferred model for a provider and tier. +func PreferredModelForTier(provider string, tier eycatalog.ModelTier, fallback string) string { + provider = canonicalProvider(provider) + if provider == "" { + return fallback + } + if m := eycatalog.GetPreferredProviderModel(provider, tier, nil); m != "" { + return m + } + return fallback +} + +// MostExpensiveForProvider picks the highest input-priced model for a provider. +func MostExpensiveForProvider(provider, fallback string) string { + models := ByProvider(canonicalProvider(provider)) + if len(models) == 0 { + return fallback + } + best := models[0] + for _, m := range models[1:] { + if m.InputPrice > best.InputPrice { + best = m + } + } + if best.Name != "" { + return best.Name + } + return fallback +} + +func mapEyrieTier(tier eycatalog.ModelTier) CostTier { + switch tier { + case eycatalog.TierHaiku: + return CostTierCheap + case eycatalog.TierOpus: + return CostTierExpensive + default: + return CostTierMid + } +} + +func tierFromCatalogFamily(modelName string) (eycatalog.ModelTier, bool) { + compiled := eyrieCatalogV1() + if compiled == nil { + return "", false + } + canonical := modelName + if c, ok := compiled.CanonicalModelForAliasOrID(modelName); ok { + canonical = c + } + model := compiled.ModelsByID[canonical] + if model.ID == "" { + return "", false + } + switch strings.ToLower(strings.TrimSpace(model.Family)) { + case "haiku", "cheap", "lite", "flash", "mini": + return eycatalog.TierHaiku, true + case "opus", "pro", "max", "heavy", "ultra": + return eycatalog.TierOpus, true + case "sonnet", "standard", "balanced", "medium": + return eycatalog.TierSonnet, true + } + return "", false +} + +func tierFromEyrieCandidates(modelName string) (eycatalog.ModelTier, bool) { + info, ok := Find(modelName) + if !ok { + return "", false + } + provider := canonicalProvider(info.Provider) + + for _, tier := range []eycatalog.ModelTier{eycatalog.TierHaiku, eycatalog.TierSonnet, eycatalog.TierOpus} { + for _, cand := range eycatalog.GetProviderModelCandidates(provider, tier) { + if modelsMatch(modelName, cand) { + return tier, true + } + } + } + + for key, cfg := range eycatalog.AllModelConfigs { + tier := modelKeyTier(key) + if tier == "" { + continue + } + if id := cfg[provider]; id != "" && modelsMatch(modelName, id) { + return tier, true + } + } + return "", false +} + +func tierFromCatalogPricing(modelName string) (CostTier, bool) { + info, ok := Find(modelName) + if !ok || info.InputPrice <= 0 { + return 0, false + } + models := ByProvider(canonicalProvider(info.Provider)) + if len(models) < 2 { + return 0, false + } + + prices := make([]float64, 0, len(models)) + seen := map[float64]bool{} + for _, m := range models { + if m.InputPrice <= 0 || seen[m.InputPrice] { + continue + } + seen[m.InputPrice] = true + prices = append(prices, m.InputPrice) + } + if len(prices) < 2 { + return 0, false + } + sort.Float64s(prices) + + price := info.InputPrice + switch { + case price <= prices[0]: + return CostTierCheap, true + case price >= prices[len(prices)-1]: + return CostTierExpensive, true + default: + return CostTierMid, true + } +} + +func modelKeyTier(key eycatalog.ModelKey) eycatalog.ModelTier { + s := string(key) + switch { + case strings.HasPrefix(s, "haiku"): + return eycatalog.TierHaiku + case strings.HasPrefix(s, "sonnet"): + return eycatalog.TierSonnet + case strings.HasPrefix(s, "opus"): + return eycatalog.TierOpus + default: + return "" + } +} + +func modelsMatch(a, b string) bool { + a = strings.TrimSpace(a) + b = strings.TrimSpace(b) + if a == "" || b == "" { + return false + } + if strings.EqualFold(a, b) { + return true + } + compiled := eyrieCatalogV1() + if compiled == nil { + return false + } + canonA, okA := compiled.CanonicalModelForAliasOrID(a) + canonB, okB := compiled.CanonicalModelForAliasOrID(b) + return okA && okB && canonA == canonB +} diff --git a/internal/provider/routing/tiers_test.go b/internal/provider/routing/tiers_test.go new file mode 100644 index 0000000..24b5ae7 --- /dev/null +++ b/internal/provider/routing/tiers_test.go @@ -0,0 +1,58 @@ +package routing + +import ( + "testing" + + eycatalog "github.com/GrayCodeAI/eyrie/catalog" +) + +func TestCostTierOf_CatalogModels(t *testing.T) { + anthropicHaiku, anthropicSonnet, anthropicOpus := TierModels("anthropic") + openaiHaiku, openaiSonnet, _ := TierModels("openai") + geminiHaiku, _, _ := TierModels("gemini") + + tests := []struct { + model string + tier CostTier + }{ + {anthropicHaiku, CostTierCheap}, + {openaiHaiku, CostTierCheap}, + {geminiHaiku, CostTierCheap}, + {anthropicSonnet, CostTierMid}, + {openaiSonnet, CostTierMid}, + {anthropicOpus, CostTierExpensive}, + {"unknown-model-xyz", CostTierMid}, + } + + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + if tt.model == "" { + t.Skip("catalog has no model for this tier/provider in test fixture") + } + got := CostTierOf(tt.model) + if got != tt.tier { + t.Errorf("CostTierOf(%q) = %v, want %v", tt.model, got, tt.tier) + } + }) + } +} + +func TestPreferredModelForTier(t *testing.T) { + got := PreferredModelForTier("anthropic", eycatalog.TierHaiku, "") + if got == "" { + t.Fatal("expected preferred haiku model for anthropic") + } + if CostTierOf(got) != CostTierCheap { + t.Errorf("preferred haiku model %q should be cheap tier", got) + } +} + +func TestRolesForProvider(t *testing.T) { + roles := RolesForProvider("anthropic") + if roles.Planner == "" || roles.Coder == "" || roles.Commit == "" { + t.Fatal("expected non-empty roles from catalog") + } + if CostTierOf(roles.Commit) >= CostTierOf(roles.Planner) { + t.Errorf("commit tier should be cheaper than planner: %v vs %v", roles.Commit, roles.Planner) + } +} diff --git a/internal/resilience/health/diagnostics.go b/internal/resilience/health/diagnostics.go index 626af34..abb8a09 100644 --- a/internal/resilience/health/diagnostics.go +++ b/internal/resilience/health/diagnostics.go @@ -1,6 +1,7 @@ package health import ( + "context" "fmt" "net" "os" @@ -10,6 +11,8 @@ import ( "strings" "sync" "time" + + "github.com/GrayCodeAI/eyrie/credentials" ) // DiagnosticResult holds the outcome of a single diagnostic check. @@ -343,21 +346,13 @@ func checkConfigFileValid() DiagnosticResult { func checkAPIKeySet() DiagnosticResult { start := time.Now() - // Check common API key environment variables - keys := []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "HAWK_API_KEY"} - found := []string{} - for _, k := range keys { - if os.Getenv(k) != "" { - found = append(found, k) - } - } - - if len(found) == 0 { + stored := credentials.StoredEnvKeys(context.Background()) + if len(stored) == 0 { return DiagnosticResult{ Name: "api_key_set", Status: "fail", - Message: "No API keys found in environment", - Fix: "Set ANTHROPIC_API_KEY or OPENAI_API_KEY environment variable", + Message: "No API keys stored in the OS secret store", + Fix: "Run /config to save a key, or hawk credentials status to verify storage", Duration: time.Since(start), } } @@ -365,7 +360,7 @@ func checkAPIKeySet() DiagnosticResult { return DiagnosticResult{ Name: "api_key_set", Status: "pass", - Message: fmt.Sprintf("API keys found: %s", strings.Join(found, ", ")), + Message: fmt.Sprintf("API keys stored: %s", strings.Join(stored, ", ")), Duration: time.Since(start), } } diff --git a/internal/resilience/health/diagnostics_test.go b/internal/resilience/health/diagnostics_test.go index dc1e1e0..b5bcedc 100644 --- a/internal/resilience/health/diagnostics_test.go +++ b/internal/resilience/health/diagnostics_test.go @@ -1,10 +1,13 @@ package health import ( + "context" "os" "strings" "testing" "time" + + "github.com/GrayCodeAI/eyrie/credentials" ) func TestNewDiagnostics(t *testing.T) { @@ -339,36 +342,20 @@ func TestCheckTempDirWritable(t *testing.T) { } func TestCheckAPIKeySet(t *testing.T) { - // Save and clear environment - origAnthropic := os.Getenv("ANTHROPIC_API_KEY") - origOpenAI := os.Getenv("OPENAI_API_KEY") - origHawk := os.Getenv("HAWK_API_KEY") - - os.Unsetenv("ANTHROPIC_API_KEY") - os.Unsetenv("OPENAI_API_KEY") - os.Unsetenv("HAWK_API_KEY") - defer func() { - if origAnthropic != "" { - os.Setenv("ANTHROPIC_API_KEY", origAnthropic) - } - if origOpenAI != "" { - os.Setenv("OPENAI_API_KEY", origOpenAI) - } - if origHawk != "" { - os.Setenv("HAWK_API_KEY", origHawk) - } - }() + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) result := checkAPIKeySet() if result.Status != "fail" { - t.Errorf("Expected fail when no API keys set, got %q", result.Status) + t.Errorf("Expected fail when no API keys stored, got %q", result.Status) } - // Set one key and check again - os.Setenv("ANTHROPIC_API_KEY", "test-key") + ctx := context.Background() + _ = store.Set(ctx, credentials.AccountForEnv("ANTHROPIC_API_KEY"), "sk-ant-test-key-1234567890") result = checkAPIKeySet() if result.Status != "pass" { - t.Errorf("Expected pass when ANTHROPIC_API_KEY is set, got %q", result.Status) + t.Errorf("Expected pass when key is in store, got %q: %s", result.Status, result.Message) } if !strings.Contains(result.Message, "ANTHROPIC_API_KEY") { t.Errorf("Expected message to mention ANTHROPIC_API_KEY, got %q", result.Message) diff --git a/internal/sandbox/isolation_verify_test.go b/internal/sandbox/isolation_verify_test.go new file mode 100644 index 0000000..97df0b4 --- /dev/null +++ b/internal/sandbox/isolation_verify_test.go @@ -0,0 +1,76 @@ +package sandbox + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func dockerAvailableQuick(t *testing.T) bool { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "docker", "info") + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + t.Skipf("docker not ready: %v", err) + } + return true +} + +func dockerImageAvailable(t *testing.T, image string) bool { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "docker", "image", "inspect", image) + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + t.Skipf("docker image %q not available locally: %v", image, err) + } + return true +} + +// TestVerify_ContainerDoesNotExposeHostHawkHome checks Docker isolation when available. +// The project dir is mounted; ~/.hawk on the host must not be readable inside the container. +func TestVerify_ContainerDoesNotExposeHostHawkHome(t *testing.T) { + if !dockerAvailableQuick(t) { + return + } + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + hawkEnv := filepath.Join(home, ".hawk", "env") + if _, err := os.Stat(hawkEnv); err != nil { + // Create a marker file so we can detect accidental host mount exposure. + _ = os.MkdirAll(filepath.Dir(hawkEnv), 0o700) + if err := os.WriteFile(hawkEnv, []byte("export VERIFY_HAWK_HOME_SECRET=1\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(hawkEnv) }) + } + + projectDir := t.TempDir() + cs := NewContainerSandbox(projectDir) + if !dockerImageAvailable(t, cs.image) { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if err := cs.Start(ctx); err != nil { + t.Fatalf("container start: %v", err) + } + t.Cleanup(func() { _ = cs.Stop() }) + + out, err := cs.Exec(ctx, "cat "+hawkEnv, 30*time.Second) + if err == nil && strings.Contains(out, "VERIFY_HAWK_HOME_SECRET") { + t.Fatalf("container could read host ~/.hawk/env:\n%s", out) + } + // Expected: file missing or permission denied inside container. +} diff --git a/internal/tool/bash.go b/internal/tool/bash.go index 41cca6e..d6bafdb 100644 --- a/internal/tool/bash.go +++ b/internal/tool/bash.go @@ -53,6 +53,9 @@ var ( zshEqualsExpansionRe = regexp.MustCompile(`(?:^|[\s;&|])=[a-zA-Z_]`) ifsInjectionRe = regexp.MustCompile(`\$IFS|\$\{[^}]*IFS`) procEnvironRe = regexp.MustCompile(`/proc/.*environ`) + envDumpRe = regexp.MustCompile(`(?i)(^|[;&|]\s*|\s)(printenv|env)(\s|$)`) + hawkEnvReadRe = regexp.MustCompile(`(?i)\b(cat|type|head|less|more|dd)\b[^\n;|]*\.hawk/(env|\.env)\b`) + apiKeyEchoRe = regexp.MustCompile(`(?i)\becho\s+[^\n;|]*\$?(ANTHROPIC|OPENAI|OPENROUTER|GEMINI|GROK|XAI)_API_KEY`) ansiCQuotingRe = regexp.MustCompile(`\$'[^']*'`) localeQuotingRe = regexp.MustCompile(`\$"[^"]*"`) emptyQuotePairRe = regexp.MustCompile(`(?:''|"")+\s*-`) @@ -324,6 +327,15 @@ func (BashTool) Execute(ctx context.Context, input json.RawMessage) (string, err if procEnvironRe.MatchString(p.Command) { return "", fmt.Errorf("blocked: /proc/*/environ access can expose environment variables") } + if envDumpRe.MatchString(p.Command) { + return "", fmt.Errorf("blocked: dumping environment variables can expose API keys") + } + if hawkEnvReadRe.MatchString(p.Command) { + return "", fmt.Errorf("blocked: reading ~/.hawk env files can expose API keys") + } + if apiKeyEchoRe.MatchString(p.Command) { + return "", fmt.Errorf("blocked: echoing API key environment variables is not allowed") + } // Block heredoc in substitution (complex validation) if heredocSubstitutionRe.MatchString(p.Command) { @@ -362,7 +374,7 @@ func (BashTool) Execute(ctx context.Context, input json.RawMessage) (string, err } // Container mode: if a ContainerExecutor is in context, route through Docker. - // This provides herm-style full isolation — no permission prompts needed. + // Full container isolation — no permission prompts needed. if ce := ContainerExecutorFromContext(ctx); ce != nil && ce.Running() { result, err := ce.Exec(ctx, p.Command, timeout) result = TruncateOutput(result) diff --git a/internal/tool/safety.go b/internal/tool/safety.go index 51ccf02..667ba3a 100644 --- a/internal/tool/safety.go +++ b/internal/tool/safety.go @@ -172,6 +172,14 @@ func IsSensitivePath(path string) string { if clean == hawkProv { return "access to ~/.hawk/provider.json is blocked for security (API credentials)" } + hawkEnv := filepath.Join(home, ".hawk", "env") + if clean == hawkEnv { + return "access to ~/.hawk/env is blocked for security (API keys)" + } + hawkDotEnv := filepath.Join(home, ".hawk", ".env") + if clean == hawkDotEnv { + return "access to ~/.hawk/.env is blocked for security (API keys)" + } } // Check suffix-based blocks (e.g. ~/.ssh/*) diff --git a/internal/tool/safety_test.go b/internal/tool/safety_test.go index fd86c62..a29daef 100644 --- a/internal/tool/safety_test.go +++ b/internal/tool/safety_test.go @@ -104,6 +104,8 @@ func TestIsSensitivePath(t *testing.T) { filepath.Join(home, ".ssh", "authorized_keys"), filepath.Join(home, ".aws", "credentials"), filepath.Join(home, ".hawk", "provider.json"), + filepath.Join(home, ".hawk", "env"), + filepath.Join(home, ".hawk", ".env"), filepath.Join(home, ".env"), "/some/project/.env", "/tmp/app/credentials.json", diff --git a/plans/MILESTONE-api-key-model-sandbox.md b/plans/MILESTONE-api-key-model-sandbox.md new file mode 100644 index 0000000..0194af2 --- /dev/null +++ b/plans/MILESTONE-api-key-model-sandbox.md @@ -0,0 +1,163 @@ +# Milestone: API key → model → sandbox + +**Status:** credential + sandbox work complete locally; manual fresh-macOS E2E + CI push pending +**Branch (both repos):** `feature/secure-credentials-sandbox` +**Out of scope:** conversation DAG (`/fork`, `convo.db` as source of truth), langdag Go import +**Reference layout:** herm + langdag sibling repos (already done for hawk + eyrie) + +| Repo | Branch | Local commit | +|------|--------|--------------| +| hawk | `feature/secure-credentials-sandbox` | `973671c` + follow-up credential cleanup | +| eyrie | `feature/secure-credentials-sandbox` | `2657c72` (includes `eac730b` Bedrock routing) | + +`eyrie/main` is reset to `origin/main`; all WIP is on the feature branch only. + +## Goal + +A new user can: + +1. Paste an API key securely (OS secret store only — macOS Keychain / Linux keyring; not `provider.json`, not `.env`) +2. Pick a model from eyrie discover output +3. Chat with tools running in Docker by default +4. Remove a stored key via `/config key remove` or `hawk credentials remove` + +## Architecture + +``` +User /config + → PersistAPIKey (eyrie keychain via runtime.SetCredential) + → ApplyEyrieCredentials (discover + provider.json routing only) + → model picker (SetupUI canonical ids) + → settings.json (model id only) + +User /config key remove + → RemoveStoredCredential (keychain delete via picker) + +hawk chat + → PrepareCredentialDiscovery (one-time migrate ~/.hawk/env → keychain, delete files) + → EvaluateSetup (block chat if key/model missing) + → container boot (Docker) + → session.StreamChat via eyrie client (keys on host keychain only) + +Credential discovery (eyrie-owned, no hawk hardcoded env lists): + catalog cache → BootstrapCatalogV1 → legacy profiles (last resort) + → DiscoveryCredentials(ctx) from OS store only (not process env) + → HasAnyConfiguredDeployment +``` + +## Phases + +### Phase 0 — Plan & tracking (this doc) + +- [x] Write milestone plan +- [x] Keep an **Iteration log** at the bottom updated each PR/session + +### Phase 1 — API keys (secure first-run) + +| # | Task | Status | +|---|------|--------| +| 1.1 | `setup_status.go`: `EvaluateSetup`, `HasConfiguredDeployment`, `NeedsFirstRunSetup` | done | +| 1.2 | Onboarding uses `PersistAPIKey` (keychain only) | done | +| 1.3 | Welcome banner shows setup CTA when keys/model missing | done | +| 1.4 | TUI auto-opens `/config` hub on first run when setup needed | done | +| 1.5 | `MigrateProviderSecrets` on every hawk start (already in root) | done | +| 1.6 | Tests: `HasConfiguredDeployment`, placeholder rejection | done | +| 1.7 | No secrets in `provider.json` on disk | done (`TestVerify_*` in `milestone_verify_test.go`) | +| 1.8 | Keychain-only: no `~/.hawk/env` writes, no `.env` credential load | done | +| 1.9 | Legacy `~/.hawk/env` / `.env` one-time migration → keychain → delete | done (`MigrateLegacyEnvFile`) | +| 1.10 | `hawk credentials status` / `remove` CLI | done | +| 1.11 | `/config key remove` (picker only; no inline provider arg) | done | +| 1.12 | Remove deprecated APIs (`DiscoveryCredentialsFromOS`, `LoadDotEnv`, `ApplyToProcess`, …) | done | + +### Phase 2 — Model selection + +| # | Task | Status | +|---|------|--------| +| 2.1 | After key: guided model picker (`configGuideAfterKey`) | done | +| 2.2 | Block chat send when no model (clear error → `/config`) | done | +| 2.3 | Catalog prefetch at startup when keys present | done | +| 2.4 | Friendly error when catalog empty (no keys / network) | done (`CatalogEmptyHint`, model picker + startup messages) | +| 2.5 | Setup flow: key + model clears `NeedsSetup` | done (`TestVerify_EvaluateSetupFlow`) | +| 2.6 | Stale-while-revalidate model cache + atomic catalog writes | done | + +### Phase 3 — Sandbox + +| # | Task | Status | +|---|------|--------| +| 3.1 | Container default on (`shouldUseContainer`) | done | +| 3.2 | Block input when container required but Docker down | done | +| 3.3 | `ContainerExecutor` wired for bash | done | +| 3.4 | Read tool blocks credential paths (`safety.go`) | done | +| 3.5 | Document `--no-container` vs secure mode | done (`SECURITY-SOLO.md`) | +| 3.6 | Container cannot read host `~/.hawk/env` | done (`isolation_verify_test.go`; skips if Docker down) + `TestIsSensitivePath` | +| 3.7 | Clarify `/sandbox` vs default container in help | done (help + flag descriptions) | + +### Phase 4 — Hardening & ship + +| # | Task | Status | +|---|------|--------| +| 4.1 | Commit hawk `feature/secure-credentials-sandbox` | done (`973671c`) | +| 4.2 | Commit matching eyrie credential/catalog changes | done (`2657c72` on same branch) | +| 4.3 | CI green on both repos | partial (local `go test ./...` pass; GitHub CI not run here) | +| 4.4 | Update `AGENTS.md` milestone section (not DAG) | done | +| 4.5 | Update `SECURITY-SOLO.md`, contextual help, diagnostics for keychain-only | done | +| 4.6 | `hawk preflight` + doctor credential storage section | done | + +## Definition of done + +- [ ] Fresh macOS: `hawk` → config opens → key → model → message works (**manual** — not run in CI agent) +- [x] `provider.json` has no API keys on disk (automated: `TestVerify_ProviderJSONOnDiskHasNoSecrets`, migrate test) +- [x] Credential files blocked from read tool (`TestIsSensitivePath` in `safety_test.go`) +- [x] API keys stored in OS secret store only (no plaintext `~/.hawk/env` after migration) +- [x] Remove key path: `/config key remove` + `hawk credentials remove` +- [ ] Docker running: bash in container end-to-end chat (**manual**; automated test skips when Docker unavailable) +- [x] DAG unchanged (optional `/fork` still best-effort only) + +## Verification + +Run locally: + +```bash +./scripts/verify-milestone.sh +go test ./... # hawk + eyrie +hawk credentials status +hawk preflight +``` + +| Check | Result | +|-------|--------| +| `go test ./...` (hawk) | pass | +| `go test ./...` (eyrie) | pass | +| Provider JSON sanitization | pass (`internal/config/milestone_verify_test.go`) | +| Setup flow key → model | pass (`TestVerify_EvaluateSetupFlow`) | +| Keychain-only discovery | pass (`eyrie/config/discovery_credentials_test.go`) | +| Remove credential | pass (`internal/config/credentials_store_test.go`, `cmd/chat_config_remove_test.go`) | +| Read tool path blocks | pass (`internal/tool/safety_test.go`) | +| Docker host `~/.hawk` isolation | skip (Docker not ready on verify host) | + +## Iteration log + +| Date | Iteration | Changes | +|------|-----------|---------| +| 2026-05-19 | 0 | Created plan; audited hawk/eyrie/herm state | +| 2026-05-19 | 1 | setup_status, onboarding PersistAPIKey, welcome CTA, auto /config, block chat until setup | +| 2026-05-19 | 2 | Eyrie-owned credential fallback (bootstrap catalog, `HasAnyConfiguredDeployment`, placeholder filter); hawk `EvaluateSetup` | +| 2026-05-19 | 3 | Committed hawk `973671c` + eyrie `2657c72`; moved eyrie WIP off `main` onto `feature/secure-credentials-sandbox` | +| 2026-05-19 | 4 | Automated verification tests + `scripts/verify-milestone.sh`; `/sandbox` help clarified; AGENTS.md milestone section | +| 2026-05-20 | 5 | Keychain-only hardening: removed env-file credential paths, legacy API cleanup, `DiscoveryCredentials(ctx)` store-only | +| 2026-05-20 | 7 | Phase 2.4: `CatalogEmptyHint` for empty/missing catalog; verify script + AGENTS.md updated | + +## Push (when ready) + +```bash +# hawk +cd hawk && git push -u origin feature/secure-credentials-sandbox + +# eyrie +cd eyrie && git push -u origin feature/secure-credentials-sandbox +``` + +## Related docs + +- [`docs/SECURITY-SOLO.md`](../docs/SECURITY-SOLO.md) — solo developer security model +- [`eyrie/plans/DYNAMIC-MODEL-DISCOVERY.md`](../../eyrie/plans/DYNAMIC-MODEL-DISCOVERY.md) — discovery edge cases (§9 security updated) diff --git a/scripts/verify-milestone.sh b/scripts/verify-milestone.sh new file mode 100755 index 0000000..6c74aad --- /dev/null +++ b/scripts/verify-milestone.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Milestone verification: API key → model → sandbox +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +echo "== eyrie (sibling) ==" +EYRIE="../eyrie" +if [[ -d "$EYRIE" ]]; then + (cd "$EYRIE" && go test ./... -count=1 -short) +else + echo "skip: ../eyrie not found" +fi + +echo "== hawk unit tests ==" +go test ./... -count=1 -short + +echo "== milestone verification tests ==" +go test ./internal/config/ -run 'Verify_|HasConfigured|EvaluateSetup|PersistAPIKey|CatalogEmpty|CatalogStatus' -count=1 -v +go test ./internal/config/ -run 'RemoveStored|FormatCredential' -count=1 +go test ./cmd/ -run 'ConfigHub|RemoveCredential' -count=1 +go test ./internal/tool/ -run 'IsSensitivePath|DetectCredentials' -count=1 +go test ./internal/sandbox/ -run 'Verify_Container' -count=1 -timeout 3m || true +go test ./internal/resilience/health/ -run 'CheckAPIKeySet' -count=1 + +echo "== done =="