mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3518bfc64e | ||
|
|
fee94fc949 | ||
|
|
29e47d879d | ||
|
|
a3a19b26b6 |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.4.0"}
|
||||
{".":"0.3.0"}
|
||||
+4
-60
@@ -6,60 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||
tagged. Until then, source builds report the version `dev`.
|
||||
|
||||
## [0.4.0](https://github.com/Gitlawb/zero/compare/v0.3.0...v0.4.0) (2026-07-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **aimlapi:** AI/ML API provider with guided onboarding (top-up + key issuance) ([#655](https://github.com/Gitlawb/zero/issues/655)) ([6b9c2f0](https://github.com/Gitlawb/zero/commit/6b9c2f0c083e02bcd3aad68fb7af0501a9e7cd61))
|
||||
* **cli:** show ZERO wordmark on --version ([#673](https://github.com/Gitlawb/zero/issues/673)) ([9acb411](https://github.com/Gitlawb/zero/commit/9acb4113cc3337b3f361a16278e9cf11ca105e34))
|
||||
* **cli:** wire MCP serve WorkspaceRoot and --add-dir scope ([#694](https://github.com/Gitlawb/zero/issues/694)) ([75a78e7](https://github.com/Gitlawb/zero/commit/75a78e715bf23154f69c8c79cb58eb4b535b2a2a))
|
||||
* **npm:** ship the native binary as platform optionalDependencies ([#626](https://github.com/Gitlawb/zero/issues/626)) ([5e1405d](https://github.com/Gitlawb/zero/commit/5e1405d0b7abff5b3ccb3cfdb66d64d6d3322922))
|
||||
* **perf:** emit prompt-prefix hash fingerprint per turn ([#704](https://github.com/Gitlawb/zero/issues/704)) ([1c5c6e7](https://github.com/Gitlawb/zero/commit/1c5c6e78a8a0e228bdf53d7be90934fbce9d98c3))
|
||||
* **providers:** add AI/ML API preset (rebased onto main) ([#621](https://github.com/Gitlawb/zero/issues/621)) ([d66a9dd](https://github.com/Gitlawb/zero/commit/d66a9dda69c32aa59f4bea903cfefe00d4b7adef))
|
||||
* **providers:** refresh MiniMax model coverage ([#665](https://github.com/Gitlawb/zero/issues/665)) ([fa3052a](https://github.com/Gitlawb/zero/commit/fa3052a1422a4ad30a3a6295829564f42dee31a8))
|
||||
* **skills:** discover shared ~/.agents/skills with multi-root skill loading ([#696](https://github.com/Gitlawb/zero/issues/696)) ([7d57999](https://github.com/Gitlawb/zero/commit/7d579996b43741a2e57e3e38fcdd8484fcfc34e9))
|
||||
* **tui:** Ctrl+X leader chords and emacs menu navigation ([#699](https://github.com/Gitlawb/zero/issues/699)) ([7f669f4](https://github.com/Gitlawb/zero/commit/7f669f455021be51319ee3b8298cd48a17f745c7))
|
||||
* **tui:** press up to edit queued messages ([#656](https://github.com/Gitlawb/zero/issues/656)) ([4c986d3](https://github.com/Gitlawb/zero/commit/4c986d327b5ed26338d295c23bea5839681db8d0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **acp:** make truncateHint rune-safe ([#614](https://github.com/Gitlawb/zero/issues/614)) ([ddc4927](https://github.com/Gitlawb/zero/commit/ddc4927aac5544bf4dd2c46615aeda5a81c96576))
|
||||
* **agent,tui:** resolve git branch detection when starting Zero in subdirectories ([#613](https://github.com/Gitlawb/zero/issues/613)) ([0184581](https://github.com/Gitlawb/zero/commit/0184581ec234ea414e0a47bc33e8b0f4ddfb497b))
|
||||
* **agent:** raise default and deep-mode turn budgets ([#650](https://github.com/Gitlawb/zero/issues/650)) ([635c93a](https://github.com/Gitlawb/zero/commit/635c93af51ebc20f3e0917917e55a79edfe27c35))
|
||||
* **cli:** prevent consuming positional arguments as flag values ([#619](https://github.com/Gitlawb/zero/issues/619)) ([5b4f48d](https://github.com/Gitlawb/zero/commit/5b4f48d2dcb66402c13bde0c3cfe9c9371da19fb))
|
||||
* **config:** enforce MCP trust boundary so project config cannot override user disable ([#609](https://github.com/Gitlawb/zero/issues/609)) ([4d8c31c](https://github.com/Gitlawb/zero/commit/4d8c31cf16a3080344e3be7039408fcae71c075d)), closes [#512](https://github.com/Gitlawb/zero/issues/512)
|
||||
* **config:** surface unknown/typo'd config fields instead of silently dropping them ([#645](https://github.com/Gitlawb/zero/issues/645)) ([893b7b4](https://github.com/Gitlawb/zero/commit/893b7b424cc203a2fcf92327a4e25c84286a90e0))
|
||||
* **cron:** prevent cron job Mutate from clobbering concurrent updates ([#630](https://github.com/Gitlawb/zero/issues/630)) ([e4bd703](https://github.com/Gitlawb/zero/commit/e4bd703cfb28dab2dfa3c2ddba46237e1bb2e164))
|
||||
* **daemon:** handle os.ErrPermission as collision during O_EXCL lock creation ([#616](https://github.com/Gitlawb/zero/issues/616)) ([8ea5384](https://github.com/Gitlawb/zero/commit/8ea53841a5d54c37a153779ae86bea010659433c))
|
||||
* **exec:** stop false INCOMPLETE downgrades on conversational final messages ([#608](https://github.com/Gitlawb/zero/issues/608)) ([b6117af](https://github.com/Gitlawb/zero/commit/b6117af86d6bc87a4ee66910e99d76cb16b03fed))
|
||||
* harden MCP credential boundaries ([#597](https://github.com/Gitlawb/zero/issues/597)) ([fdddb05](https://github.com/Gitlawb/zero/commit/fdddb05ba84b1600ae6c3a20028bf83afe474c44))
|
||||
* **hooks:** fail closed on launch failures for beforeTool hooks ([#629](https://github.com/Gitlawb/zero/issues/629)) ([dc06fe7](https://github.com/Gitlawb/zero/commit/dc06fe72caf45f72d2cba1e8a835c0f5b405c1e8))
|
||||
* **hooks:** run sessionEnd hooks after Esc/Ctrl+C interrupts ([#606](https://github.com/Gitlawb/zero/issues/606)) ([824ecdb](https://github.com/Gitlawb/zero/commit/824ecdbcf9c467c35ef4e2666770fdadcb5bf402))
|
||||
* **keyring:** pass generic password via stdin on macOS ([#574](https://github.com/Gitlawb/zero/issues/574)) ([91ea6de](https://github.com/Gitlawb/zero/commit/91ea6ded7503538834a84d090f78670a363c62d3))
|
||||
* **lock:** prevent POSIX lock file overwrite and leak on Windows/Unix ([#628](https://github.com/Gitlawb/zero/issues/628)) ([da41c3a](https://github.com/Gitlawb/zero/commit/da41c3a75b782d6e0836fe13346321e40a90fbb4))
|
||||
* **openai:** omit prompt_cache_key for openai-compatible providers ([#636](https://github.com/Gitlawb/zero/issues/636)) ([1af5882](https://github.com/Gitlawb/zero/commit/1af58828eb3c22567599c000736c913a290959d2)), closes [#624](https://github.com/Gitlawb/zero/issues/624)
|
||||
* **plugins:** resolve relative executable paths against plugin root ([#627](https://github.com/Gitlawb/zero/issues/627)) ([2efe6d5](https://github.com/Gitlawb/zero/commit/2efe6d539e29374b3ef39c2290bdea81f33a228b))
|
||||
* **sandbox:** remove windowsWriteRestricted flag to fix DenyRead bypass ([#612](https://github.com/Gitlawb/zero/issues/612)) ([3d96ac7](https://github.com/Gitlawb/zero/commit/3d96ac7e55c760a97f28c0e6ceaf1ec3b4ab717a))
|
||||
* **sandbox:** scrub dynamic credential env vars ([#682](https://github.com/Gitlawb/zero/issues/682)) ([9043bae](https://github.com/Gitlawb/zero/commit/9043baedcff7776c7373645b57563cac06b31847))
|
||||
* **sandbox:** scrub sensitive credentials from sandbox environment ([#660](https://github.com/Gitlawb/zero/issues/660)) ([6fc1220](https://github.com/Gitlawb/zero/commit/6fc1220f6ac66fb3ae67b637cbbed7068d2213c0))
|
||||
* **sandbox:** unblock git fetch/commit/add under the write-restricted sandbox ([#654](https://github.com/Gitlawb/zero/issues/654)) ([5c4815a](https://github.com/Gitlawb/zero/commit/5c4815a66ed07d9cf90b825adfd936d3ac07639d))
|
||||
* **sandbox:** use WRITE_RESTRICTED token when no DenyRead paths are configured ([#658](https://github.com/Gitlawb/zero/issues/658)) ([a5d2e32](https://github.com/Gitlawb/zero/commit/a5d2e327c8681671aa8a9e5378801215b747edcf))
|
||||
* **securefile,credstore:** call Sync on temp file before close and rename ([#631](https://github.com/Gitlawb/zero/issues/631)) ([212734a](https://github.com/Gitlawb/zero/commit/212734adf3b5f982e22385161205aa1afe4634fe))
|
||||
* **securefile:** reclaim stale lock files to prevent permanent DOS ([#615](https://github.com/Gitlawb/zero/issues/615)) ([8536cc8](https://github.com/Gitlawb/zero/commit/8536cc87f7a885f9e436d6ef28f5f325201623dc))
|
||||
* **swarm:** wait for job.Runs directly in scheduler skip test ([#667](https://github.com/Gitlawb/zero/issues/667)) ([1bb6b57](https://github.com/Gitlawb/zero/commit/1bb6b5745af90321d1e657a12a1976cded5dd1bd))
|
||||
* **tools:** classify silent wrapped Windows command failures as sandbox denials ([#659](https://github.com/Gitlawb/zero/issues/659)) ([8bd9742](https://github.com/Gitlawb/zero/commit/8bd9742fa95c41b93ea4e718628aed0ff3ae9dd0))
|
||||
* **tools:** preserve SysProcAttr during PTY fallback ([#618](https://github.com/Gitlawb/zero/issues/618)) ([f78b36c](https://github.com/Gitlawb/zero/commit/f78b36c770daa4577a9f99265b18a354454e36eb))
|
||||
* **tui:** resolve pending askUser callbacks to prevent runner hangs ([#620](https://github.com/Gitlawb/zero/issues/620)) ([aa73a76](https://github.com/Gitlawb/zero/commit/aa73a76f1bd1b6fe97bac2fbff2d61b7474139f2))
|
||||
* **tui:** stop the composer cursor blinking while typing or unfocused ([#672](https://github.com/Gitlawb/zero/issues/672)) ([2b42cd5](https://github.com/Gitlawb/zero/commit/2b42cd567b96d6f7e0818594e53ff31cce1e42e9))
|
||||
* **windows:** resolve absolute path for taskkill to prevent hijacking ([#617](https://github.com/Gitlawb/zero/issues/617)) ([2db00ee](https://github.com/Gitlawb/zero/commit/2db00ee3d57db97e0fbe23cb8628e8bcb47f6f09))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **tools:** add explicit effect metadata for safe concurrency ([#705](https://github.com/Gitlawb/zero/issues/705)) ([8ef8576](https://github.com/Gitlawb/zero/commit/8ef8576df7d0775a5b815bc0776a794cadb75c34))
|
||||
|
||||
## [0.3.0](https://github.com/Gitlawb/zero/compare/v0.2.0...v0.3.0) (2026-07-09)
|
||||
|
||||
|
||||
@@ -188,12 +134,10 @@ tagged. Until then, source builds report the version `dev`.
|
||||
GitHub issue/PR templates.
|
||||
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
|
||||
and applies on select (Esc reverts).
|
||||
- Twelve built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
|
||||
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`,
|
||||
`solarized-light`, `dune`, and `neon` — selectable via `/theme <name>`, `--theme <name>`, or
|
||||
`ZERO_THEME`. Every palette is contrast-audited to WCAG AA, and the new presets are additionally
|
||||
audited after xterm-256 downsampling; see [docs/THEMES.md](docs/THEMES.md). The built-in light
|
||||
theme was reworked for legibility.
|
||||
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
|
||||
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
|
||||
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
|
||||
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
|
||||
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
|
||||
`ZERO_THEME` env var existed).
|
||||
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
|
||||
|
||||
@@ -191,8 +191,6 @@ Useful controls:
|
||||
|---|---|
|
||||
| `Enter` | send the prompt |
|
||||
| `/` | open slash-command suggestions |
|
||||
| `Ctrl+X` then letter | common slash commands (e.g. `m` → `/model`; `Ctrl+X` `?` for full list) |
|
||||
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
|
||||
| `Shift+Tab` | cycle permission mode |
|
||||
| `Ctrl+B` | show/hide the sidebar |
|
||||
| `Ctrl+C` | cancel or exit |
|
||||
@@ -341,7 +339,7 @@ manifest.
|
||||
| Control | Effect |
|
||||
|---|---|
|
||||
| `NO_COLOR=<anything>` | disables color output |
|
||||
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `neon`, `solarized-light`, `dune`) |
|
||||
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
|
||||
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
|
||||
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
|
||||
| `ZERO_NO_FADE=1` | disables streaming fade animation |
|
||||
@@ -388,7 +386,6 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
|
||||
- [Install](docs/INSTALL.md)
|
||||
- [Update flow](docs/UPDATE.md)
|
||||
- [Themes](docs/THEMES.md)
|
||||
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md)
|
||||
- [Specialists](docs/SPECIALISTS.md)
|
||||
- [GitHub Action](docs/GITHUB_ACTION.md)
|
||||
|
||||
+1
-2
@@ -234,7 +234,7 @@ zero update 检查更新版本
|
||||
| 控制 | 效果 |
|
||||
|---|---|
|
||||
| `NO_COLOR=<任意值>` | 禁用颜色输出 |
|
||||
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`neon`、`solarized-light`、`dune`) |
|
||||
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`solarized-light`) |
|
||||
| `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) |
|
||||
| `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) |
|
||||
| `ZERO_NO_FADE=1` | 禁用流式淡入动画 |
|
||||
@@ -261,7 +261,6 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
|
||||
- [安装](docs/INSTALL.md)
|
||||
- [更新流程](docs/UPDATE.md)
|
||||
- [主题](docs/THEMES.md)
|
||||
- [Stream-JSON 协议](docs/STREAM_JSON_PROTOCOL.md)
|
||||
- [专家](docs/SPECIALISTS.md)
|
||||
- [GitHub Action](docs/GITHUB_ACTION.md)
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# TUI Themes
|
||||
|
||||
Zero's TUI ships a set of built-in color themes. Pick one with `--theme <name>`,
|
||||
the `ZERO_THEME` environment variable, or the `/theme <name>` command while
|
||||
running. `auto` (the default) follows the terminal's detected background.
|
||||
|
||||
Run `/theme` with no argument to open a picker: move through the list to
|
||||
live-preview each theme, press Enter to apply the highlighted one, or Esc to
|
||||
cancel and restore the previously active theme. Run `/theme list` to print
|
||||
the active theme and the registered names without opening the picker.
|
||||
|
||||
## Dune (`dune`)
|
||||
|
||||
A warm sand-and-cream palette: sand/cream surface, charcoal ink, and a soft
|
||||
amber accent.
|
||||
|
||||
## Neon (`neon`)
|
||||
|
||||
A neon-on-black palette: pitch-black surface, bright green ink, and a cyan accent.
|
||||
|
||||
## Adding a theme
|
||||
|
||||
Every theme is a `palette` (a table of color hex tokens) plus one entry in
|
||||
`themeRegistry`, both in `internal/tui/theme_palettes.go`. `buildTheme` in
|
||||
`internal/tui/theme.go` turns a `palette` into the resolved `lipgloss.Style`
|
||||
set every renderer reads from the active `zeroTheme`. Adding a theme means
|
||||
adding a new `palette{...}` literal, a `themeRegistry` entry, and test
|
||||
coverage for the new palette (see below).
|
||||
|
||||
Registry-wide tests in `internal/tui/theme_select_test.go` assert the basic
|
||||
WCAG AA text tokens, the gray-ramp order, the diff word-span pairs, and the
|
||||
selected-row band for every entry. The rendered-surface invariants beyond
|
||||
those (permission surfaces, selected-row secondary text, diff gutters, and
|
||||
the xterm-256 downsampling checks) are asserted per palette, not against the
|
||||
whole registry: `TestExtendedThemeContrastInvariants` and
|
||||
`TestExtendedThemeANSI256Contrast` enumerate the palettes they cover. A new
|
||||
theme must be added to those tests (or given equivalent palette-specific
|
||||
assertions), or CI can stay green while its permission, selected-row, and
|
||||
diff surfaces ship unreadable.
|
||||
+1
-25
@@ -185,11 +185,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result = Result{Messages: copyMessages(messages)}
|
||||
dispatchSessionStart(ctx, options)
|
||||
defer func() {
|
||||
// Use a context detached from ctx's cancellation: on Esc/Ctrl+C the run
|
||||
// context is already canceled by the time this defer runs, and a canceled
|
||||
// parent would make Hooks.Dispatch's context.WithTimeout derive an
|
||||
// already-expired context, so the sessionEnd hook would never actually run.
|
||||
dispatchSessionEnd(context.WithoutCancel(ctx), options, result, err)
|
||||
dispatchSessionEnd(ctx, options, result, err)
|
||||
}()
|
||||
for turn := 0; turn < maxTurns; turn++ {
|
||||
result.Turns = turn + 1
|
||||
@@ -217,26 +213,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
toolPartitionSpan.End()
|
||||
|
||||
// Prompt-prefix fingerprint: hash the seven cacheable sub-components
|
||||
// of this turn's request (system prompt sections, project context,
|
||||
// skills, partitioned tool list, tool schemas) and emit one trace
|
||||
// event. Stable across turns => cacheable. Drift in any sub-hash
|
||||
// names the sub-component that broke the cache for this turn. The
|
||||
// computation is pure and cheap (seven SHA-256s of small strings)
|
||||
// and is a no-op when tracing is off.
|
||||
if options.Trace != nil {
|
||||
fp := ComputePrefixFingerprint(options, exposed)
|
||||
options.Trace.EmitPrefixHash(trace.PrefixHash{
|
||||
BaseInstructionsHash: fp.BaseInstructionsHash,
|
||||
ConfirmationPolicyHash: fp.ConfirmationPolicyHash,
|
||||
ProjectContextHash: fp.ProjectContextHash,
|
||||
SkillsHash: fp.SkillsHash,
|
||||
ToolsHash: fp.ToolsHash,
|
||||
SchemaHash: fp.SchemaHash,
|
||||
CompletePrefixHash: fp.CompletePrefixHash,
|
||||
})
|
||||
}
|
||||
|
||||
// PROACTIVE compaction: if the history is approaching the model's
|
||||
// context window, summarize the oldest middle before building the
|
||||
// request. A no-op when ContextWindow == 0 (compaction disabled).
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -94,87 +93,6 @@ func TestRunDispatchesSessionLifecycleHooks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// cancelingProvider simulates an Esc/Ctrl+C abort: it cancels the run's own
|
||||
// context mid-stream (as the TUI's interrupt handler would) and returns no
|
||||
// events, so Run observes ctx.Err() and returns early with an already-canceled
|
||||
// context in scope for the deferred sessionEnd dispatch.
|
||||
type cancelingProvider struct {
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (provider *cancelingProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
provider.cancel()
|
||||
ch := make(chan zeroruntime.StreamEvent)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// TestRunDispatchesSessionEndHookAfterContextCancellation is a regression test
|
||||
// for a bug where an interrupted run (Esc/Ctrl+C) canceled ctx before the
|
||||
// deferred sessionEnd dispatch ran, so Hooks.Dispatch's context.WithTimeout(ctx, ...)
|
||||
// derived an already-canceled context and the hook command never actually launched.
|
||||
func TestRunDispatchesSessionEndHookAfterContextCancellation(t *testing.T) {
|
||||
goBinary, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
// The test binary runs on the build machine, so its build-time GOROOT
|
||||
// still identifies the toolchain that produced it.
|
||||
goRoot := runtime.GOROOT() //nolint:staticcheck // Safe for this non-portable test binary.
|
||||
goBinary = filepath.Join(goRoot, "bin", "go")
|
||||
if runtime.GOOS == "windows" {
|
||||
goBinary += ".exe"
|
||||
}
|
||||
if _, statErr := os.Stat(goBinary); statErr != nil {
|
||||
t.Skipf("go binary unavailable on PATH or in GOROOT: %v", statErr)
|
||||
}
|
||||
}
|
||||
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuditStore: %v", err)
|
||||
}
|
||||
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
|
||||
Config: hooks.Config{
|
||||
Enabled: true,
|
||||
Hooks: []hooks.Definition{
|
||||
{ID: "zero.session-end", Event: hooks.EventSessionEnd, Command: goBinary, Args: []string{"version"}, Enabled: true},
|
||||
},
|
||||
},
|
||||
Audit: audit,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
provider := &cancelingProvider{cancel: cancel}
|
||||
|
||||
_, err = Run(ctx, "hi", provider, Options{
|
||||
SessionID: "session-cancel",
|
||||
Cwd: t.TempDir(),
|
||||
ProviderName: "test-provider",
|
||||
Model: "test-model",
|
||||
Hooks: dispatcher,
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run err = %v, want context.Canceled", err)
|
||||
}
|
||||
|
||||
events, err := audit.ReadEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents: %v", err)
|
||||
}
|
||||
var completedStatus hooks.AuditStatus
|
||||
found := false
|
||||
for _, event := range events {
|
||||
if event.Type == "hook_execution_completed" && event.Event == hooks.EventSessionEnd {
|
||||
completedStatus = event.Status
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no sessionEnd hook_execution_completed audit event, events=%#v", events)
|
||||
}
|
||||
if completedStatus != hooks.AuditCompleted {
|
||||
t.Fatalf("sessionEnd hook status = %q, want %q (hook must actually run despite ctx cancellation)", completedStatus, hooks.AuditCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingWebSearchTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// promptSubstrings are the seven cacheable sub-components of the prompt a run
|
||||
// sends to a model. Each is the literal content the sub-component would emit
|
||||
// into the system prompt or tool list, hashed so a downstream observer can
|
||||
// detect drift turn-over-turn. The substrings are produced by
|
||||
// buildPromptSubstrings and consumed by ComputePrefixFingerprint.
|
||||
type promptSubstrings struct {
|
||||
baseInstructions string
|
||||
confirmationPolicy string
|
||||
projectContext string
|
||||
skills string
|
||||
tools string
|
||||
schema string
|
||||
}
|
||||
|
||||
// buildPromptSubstrings assembles the seven cacheable sub-components of the
|
||||
// prompt without joining them. The system prompt sections are produced by the
|
||||
// same private builders that buildSystemPrompt uses, so the substrings are
|
||||
// byte-identical to what would have appeared in the joined prompt. The tools
|
||||
// and schema substrings are derived from the partitioned tool list (caller-
|
||||
// provided) so this helper does not need to re-run the partition.
|
||||
//
|
||||
// This is the seam ComputePrefixFingerprint reads from. It exists as a
|
||||
// separate function (rather than returning both prompt and substrings from
|
||||
// buildSystemPrompt) so existing callers of buildSystemPrompt are unaffected
|
||||
// and the substrings helper is independently testable.
|
||||
func buildPromptSubstrings(options Options, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
||||
core := strings.TrimSpace(options.SystemPrompt)
|
||||
if core == "" {
|
||||
core = strings.TrimSpace(coreSystemPrompt)
|
||||
}
|
||||
if core == "" {
|
||||
core = fallbackSystemPrompt
|
||||
}
|
||||
|
||||
// The confirmation policy is appended unconditionally in buildSystemPrompt;
|
||||
// the substring here is the same constant.
|
||||
policy := strings.TrimSpace(confirmationPolicy)
|
||||
|
||||
// projectContext is the joined output of workspaceContext, which itself
|
||||
// walks the AGENTS.md / ZERO.md / .zero/AGENTS.md chain. Hashing the
|
||||
// joined string captures every guideline file's contribution and the
|
||||
// ordering between them.
|
||||
project := workspaceContext(options.Cwd)
|
||||
|
||||
// skills is the joined output of skillsContext, with each skill on its own
|
||||
// line. The substring is the literal text the model would see.
|
||||
skills := skillsContext(options)
|
||||
|
||||
// tools and schema are derived from the partitioned tool list. The list
|
||||
// passed in is already in stable order (partitionToolsCached is the
|
||||
// canonical stable partitioner) so concatenating in slice order is
|
||||
// sufficient — we sort names again defensively in case a future caller
|
||||
// hands us a list from a different partitioner.
|
||||
toolsSubstr, schemaSubstr := toolSubstrings(exposed)
|
||||
|
||||
return promptSubstrings{
|
||||
baseInstructions: core,
|
||||
confirmationPolicy: policy,
|
||||
projectContext: project,
|
||||
skills: skills,
|
||||
tools: toolsSubstr,
|
||||
schema: schemaSubstr,
|
||||
}
|
||||
}
|
||||
|
||||
// toolSubstrings extracts a stable, name-sorted digest of the partitioned tool
|
||||
// list. The tools substring is "name1\nname2\n..." (sorted) so a reordering of
|
||||
// the partition produces a different hash. The schema substring is the
|
||||
// concatenation of each tool's JSON schema in name order, so a schema change
|
||||
// is detected independently of the tool ordering.
|
||||
func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSubstr string) {
|
||||
if len(exposed) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
names := make([]string, 0, len(exposed))
|
||||
byName := make(map[string]zeroruntime.ToolDefinition, len(exposed))
|
||||
for _, def := range exposed {
|
||||
names = append(names, def.Name)
|
||||
byName[def.Name] = def
|
||||
}
|
||||
sort.Strings(names)
|
||||
var toolsSB, schemaSB strings.Builder
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
toolsSB.WriteByte('\n')
|
||||
}
|
||||
toolsSB.WriteString(name)
|
||||
def := byName[name]
|
||||
// Parameters is rendered to a canonical JSON string per tool. The
|
||||
// schema-render cache in internal/agent (used by partitionToolsCached)
|
||||
// guarantees the same render is byte-identical across turns for the
|
||||
// same tool, so this substring is a stable hash input.
|
||||
schemaSB.WriteString(name)
|
||||
schemaSB.WriteByte('\n')
|
||||
schemaSB.WriteString(canonicalSchemaString(def.Parameters))
|
||||
schemaSB.WriteByte('\n')
|
||||
}
|
||||
return toolsSB.String(), schemaSB.String()
|
||||
}
|
||||
|
||||
// canonicalSchemaString renders a tool's parameter schema to a stable string.
|
||||
// tools.ToolDef.Parameters is a map[string]any in the common case; Go's
|
||||
// fmt.Sprintf("%v", m) iterates a map in random order, which would produce
|
||||
// a different hash for the same Parameters value across calls and defeat
|
||||
// the fingerprint. encoding/json marshals maps with keys sorted
|
||||
// alphabetically, so json.Marshal is the primary stable render. The
|
||||
// fallback for the rare non-JSON-compatible value (functions, channels,
|
||||
// NaN/Inf floats, cyclic references) is a stable key-sorted stringifier
|
||||
// that walks the value with sorted keys for maps and a leading
|
||||
// "__non_json:" prefix so a future schema change is visible in the trace
|
||||
// (a SchemaHash collision from the same value) rather than a silent hash
|
||||
// drift.
|
||||
//
|
||||
// Note: SchemaHash is a stability signal, not a wire-identity signal. The
|
||||
// bytes json.Marshal produces for a Go map[string]any may differ from
|
||||
// the bytes the provider actually sends on the wire (provider encoders
|
||||
// use their own JSON conventions; an Anthropic schema may serialize with
|
||||
// different whitespace, key order, or number formatting). A consumer
|
||||
// must NOT assume SchemaHash matches the provider's wire schema — only
|
||||
// that two turns with the same Go-side Parameters produce the same
|
||||
// SchemaHash. This is sufficient for the trace's purpose (drift
|
||||
// detection) but not for a content-based equality check.
|
||||
func canonicalSchemaString(params map[string]any) string {
|
||||
if len(params) == 0 {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return "__non_json:" + stableStringify(params)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// stableStringify renders v to a deterministic string. Maps have their keys
|
||||
// sorted alphabetically; slices are walked in order; primitives use their
|
||||
// natural Go format. The function is recursive but bounded by the size of
|
||||
// the input, so a cyclic reference is not reachable here (the json.Marshal
|
||||
// fallback is hit when json.Marshal itself returns an error, which for the
|
||||
// common case is a non-JSON-compatible value, not a cycle — cycles are
|
||||
// rare in tool schemas and acceptable to mis-render in the fallback path
|
||||
// since the trace's contract is "the hash is stable for the same input,"
|
||||
// not "the fallback is lossless"). Used only when json.Marshal fails.
|
||||
func stableStringify(v any) string {
|
||||
var sb strings.Builder
|
||||
writeStable(&sb, v)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func writeStable(sb *strings.Builder, v any) {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
sb.WriteString("null")
|
||||
case bool:
|
||||
if x {
|
||||
sb.WriteString("true")
|
||||
} else {
|
||||
sb.WriteString("false")
|
||||
}
|
||||
case string:
|
||||
sb.WriteByte('"')
|
||||
sb.WriteString(x)
|
||||
sb.WriteByte('"')
|
||||
case float64:
|
||||
// Use %g for compact, deterministic float rendering. NaN and Inf are
|
||||
// not JSON-encodable (which is why we are in the fallback path) and
|
||||
// render as "NaN" / "+Inf" / "-Inf" — distinct, stable strings.
|
||||
fmt.Fprintf(sb, "%g", x)
|
||||
case float32:
|
||||
fmt.Fprintf(sb, "%g", float64(x))
|
||||
case int:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case int64:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case int32:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case uint:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case uint64:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case uint32:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case map[string]any:
|
||||
sb.WriteByte('{')
|
||||
keys := make([]string, 0, len(x))
|
||||
for k := range x {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
sb.WriteByte('"')
|
||||
sb.WriteString(k)
|
||||
sb.WriteString(`":`)
|
||||
writeStable(sb, x[k])
|
||||
}
|
||||
sb.WriteByte('}')
|
||||
case []any:
|
||||
sb.WriteByte('[')
|
||||
for i, item := range x {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
writeStable(sb, item)
|
||||
}
|
||||
sb.WriteByte(']')
|
||||
default:
|
||||
// Last-resort: include the type so distinct values produce distinct
|
||||
// strings even if their default format collides. Without the type
|
||||
// tag, fmt.Sprintf("%v", x) for two different types could produce
|
||||
// the same bytes (rare, but the type prefix makes it impossible).
|
||||
fmt.Fprintf(sb, "<%T:%v>", x, x)
|
||||
}
|
||||
}
|
||||
|
||||
// ComputePrefixFingerprint returns a trace.PrefixHash (a 7-field fingerprint
|
||||
// of the prompt prefix) for one turn of a run. The seven sub-hashes are
|
||||
// independent SHA-256s of the corresponding sub-component; the complete-prefix
|
||||
// hash is a SHA-256 of the canonical concatenation of the other six, so any
|
||||
// sub-component drift is observable both individually and in aggregate.
|
||||
//
|
||||
// I/O side effects: buildPromptSubstrings calls workspaceContext, which runs
|
||||
// git (gitBranchForPrompt, FindProjectGitRoot) and reads the AGENTS.md /
|
||||
// ZERO.md / .zero/AGENTS.md chain plus the repo map. The same workspace-
|
||||
// context work is performed separately by buildSystemPrompt a few lines
|
||||
// later in the request-build path, so on every traced turn the file and
|
||||
// git reads happen twice. The duplication is intentional for this PR (a
|
||||
// follow-up will pass substrings through) and is a known perf cost only on
|
||||
// the opt-in trace path.
|
||||
//
|
||||
// A stable CompletePrefixHash across turns means the four captured
|
||||
// sub-components (baseInstructions, confirmationPolicy, projectContext,
|
||||
// skills) are byte-identical. It does NOT rule out drift in the seven
|
||||
// uncaptured sections of buildSystemPrompt (modelPromptAddendum,
|
||||
// sessionRuntimeContext, approvedCommandPrefixContext, workspaceSeedContext,
|
||||
// userGuidelines, specialistDelegationContext, responseStyleContext). For
|
||||
// default Options the four captured substrings are the full prompt; for
|
||||
// non-default Options the seven uncaptured sections may contribute, and a
|
||||
// consumer correlating CompletePrefix stability with cached_input_tokens
|
||||
// must cross-check the model_switches counter (modelPromptAddendum changes
|
||||
// on a model switch) to disambiguate "no drift" from "drift in an
|
||||
// uncaptured section."
|
||||
|
||||
func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint {
|
||||
subs := buildPromptSubstrings(options, exposed)
|
||||
base := sha256hex(subs.baseInstructions)
|
||||
policy := sha256hex(subs.confirmationPolicy)
|
||||
project := sha256hex(subs.projectContext)
|
||||
skills := sha256hex(subs.skills)
|
||||
toolsH := sha256hex(subs.tools)
|
||||
schema := sha256hex(subs.schema)
|
||||
complete := sha256hex(strings.Join([]string{
|
||||
base, policy, project, skills, toolsH, schema,
|
||||
}, "|"))
|
||||
return prefixFingerprint{
|
||||
BaseInstructionsHash: base,
|
||||
ConfirmationPolicyHash: policy,
|
||||
ProjectContextHash: project,
|
||||
SkillsHash: skills,
|
||||
ToolsHash: toolsH,
|
||||
SchemaHash: schema,
|
||||
CompletePrefixHash: complete,
|
||||
}
|
||||
}
|
||||
|
||||
// prefixFingerprint is the agent-side shape of a prompt-prefix fingerprint. It
|
||||
// is converted to a trace.PrefixHash at the loop boundary (see EmitPrefixHash
|
||||
// in loop.go) so the trace package does not need to import this one. The field
|
||||
// names match the trace.PrefixHash JSON tags 1:1.
|
||||
type prefixFingerprint struct {
|
||||
BaseInstructionsHash string
|
||||
ConfirmationPolicyHash string
|
||||
ProjectContextHash string
|
||||
SkillsHash string
|
||||
ToolsHash string
|
||||
SchemaHash string
|
||||
CompletePrefixHash string
|
||||
}
|
||||
|
||||
// sha256hex returns the hex-encoded SHA-256 of s. Empty input produces the
|
||||
// hash of the empty string, which is a constant; callers that want to
|
||||
// distinguish "absent" from "empty" should check s before calling.
|
||||
func sha256hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// TestComputePrefixFingerprintStableAcrossCalls asserts the headline property
|
||||
// the trace is meant to expose: two ComputePrefixFingerprint calls over the
|
||||
// same Options and the same exposed tool list produce byte-identical hashes.
|
||||
// This is the regression catch for "did anyone introduce non-determinism into
|
||||
// the prompt prefix."
|
||||
func TestComputePrefixFingerprintStableAcrossCalls(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
exposed := []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Parameters: map[string]any{"type": "object"}},
|
||||
{Name: "grep", Parameters: map[string]any{"type": "object"}},
|
||||
}
|
||||
first := ComputePrefixFingerprint(opts, exposed)
|
||||
second := ComputePrefixFingerprint(opts, exposed)
|
||||
if first != second {
|
||||
t.Fatalf("fingerprint must be stable across calls with the same input:\n first=%+v\n second=%+v", first, second)
|
||||
}
|
||||
if first.CompletePrefixHash == "" {
|
||||
t.Fatalf("CompletePrefixHash must be non-empty for a non-trivial prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintToolsHashIsNameSetSensitive asserts the
|
||||
// name-set sensitivity of the ToolsHash. The test name used to read as
|
||||
// "reorder changes hash" but the assertion is the opposite: the hash is
|
||||
// name-set sensitive, not name-order sensitive (toolSubstrings sorts
|
||||
// names before hashing). The partitioner is expected to produce a stable
|
||||
// order, so a name-order change is not a hash signal — a name-set change
|
||||
// is. The test asserts both: permutation of the same tools produces the
|
||||
// same hash, and adding a tool produces a different hash.
|
||||
func TestComputePrefixFingerprintToolsHashIsNameSetSensitive(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
a := []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
}
|
||||
b := []zeroruntime.ToolDefinition{
|
||||
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
}
|
||||
// toolSubstrings sorts names internally, so the ToolsHash is identical
|
||||
// for a permutation of the same tools. We assert that here to document
|
||||
// the contract: name-order independence, name-set sensitivity.
|
||||
fpA := ComputePrefixFingerprint(opts, a)
|
||||
fpB := ComputePrefixFingerprint(opts, b)
|
||||
if fpA.ToolsHash != fpB.ToolsHash {
|
||||
t.Fatalf("ToolsHash must be name-set sensitive, not name-order sensitive: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
|
||||
}
|
||||
// A different tool set must produce a different ToolsHash.
|
||||
c := append([]zeroruntime.ToolDefinition{}, a...)
|
||||
c = append(c, zeroruntime.ToolDefinition{Name: "bash", Parameters: map[string]any{"schema": "schema-bash"}})
|
||||
fpC := ComputePrefixFingerprint(opts, c)
|
||||
if fpA.ToolsHash == fpC.ToolsHash {
|
||||
t.Fatalf("ToolsHash must change when the tool set changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintSchemaChangeChangesSchemaHash asserts that a
|
||||
// change to a tool's Parameters schema is visible in the SchemaHash. A schema
|
||||
// edit that should be cache-stable (e.g. field reorder, doc tweak) will move
|
||||
// the hash and surface in the trace; a schema edit that legitimately
|
||||
// invalidates the cache also moves the hash, which is the right behavior.
|
||||
func TestComputePrefixFingerprintSchemaChangeChangesSchemaHash(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
a := []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Parameters: map[string]any{"version": "v1"}},
|
||||
}
|
||||
b := []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Parameters: map[string]any{"version": "v2"}},
|
||||
}
|
||||
fpA := ComputePrefixFingerprint(opts, a)
|
||||
fpB := ComputePrefixFingerprint(opts, b)
|
||||
if fpA.SchemaHash == fpB.SchemaHash {
|
||||
t.Fatalf("SchemaHash must change when tool Parameters change: a=%s b=%s", fpA.SchemaHash, fpB.SchemaHash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
|
||||
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
|
||||
// model addendum change) must move the base-instructions hash, which moves
|
||||
// the complete-prefix hash, which is what the trace is for.
|
||||
func TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash(t *testing.T) {
|
||||
optsA := Options{Cwd: t.TempDir(), SystemPrompt: "core v1"}
|
||||
optsB := Options{Cwd: t.TempDir(), SystemPrompt: "core v2"}
|
||||
exposed := []zeroruntime.ToolDefinition{{Name: "noop", Parameters: map[string]any{}}}
|
||||
fpA := ComputePrefixFingerprint(optsA, exposed)
|
||||
fpB := ComputePrefixFingerprint(optsB, exposed)
|
||||
if fpA.BaseInstructionsHash == fpB.BaseInstructionsHash {
|
||||
t.Fatalf("BaseInstructionsHash must change when SystemPrompt changes: a=%s b=%s", fpA.BaseInstructionsHash, fpB.BaseInstructionsHash)
|
||||
}
|
||||
if fpA.CompletePrefixHash == fpB.CompletePrefixHash {
|
||||
t.Fatalf("CompletePrefixHash must change when any sub-hash changes: a=%s b=%s", fpA.CompletePrefixHash, fpB.CompletePrefixHash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes asserts the
|
||||
// canonical-join property: CompletePrefixHash must depend on every sub-hash.
|
||||
// This is the regression catch for "did anyone reorder or drop a sub-hash
|
||||
// from the canonical join without updating CompletePrefixHash."
|
||||
func TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
exposed := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"k": "v"}}}
|
||||
fp := ComputePrefixFingerprint(opts, exposed)
|
||||
// Manually compute what the canonical join should be.
|
||||
expected := sha256hex(strings.Join([]string{
|
||||
fp.BaseInstructionsHash,
|
||||
fp.ConfirmationPolicyHash,
|
||||
fp.ProjectContextHash,
|
||||
fp.SkillsHash,
|
||||
fp.ToolsHash,
|
||||
fp.SchemaHash,
|
||||
}, "|"))
|
||||
if fp.CompletePrefixHash != expected {
|
||||
t.Fatalf("CompletePrefixHash must equal sha256hex of the canonical join of the other six:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPromptSubstringsDefaultOptions asserts the invariants of the
|
||||
// substrings helper for default Options: which substrings are non-empty,
|
||||
// which are empty, and that the substring-to-hash round-trip is lossless.
|
||||
func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
|
||||
opts := Options{
|
||||
Cwd: t.TempDir(),
|
||||
SystemPrompt: "test core",
|
||||
}
|
||||
subs := buildPromptSubstrings(opts, nil)
|
||||
// Invariants the trace depends on (default Options):
|
||||
// 1. baseInstructions substring equals the core system prompt bytes.
|
||||
// 2. confirmationPolicy substring is non-empty (the embedded policy
|
||||
// is always present, post TrimSpace).
|
||||
// 3. skills, tools, and schema substrings are empty (no skills or
|
||||
// tools configured for default Options).
|
||||
if subs.baseInstructions != "test core" {
|
||||
t.Fatalf("baseInstructions substring must equal the core system prompt: got %q want %q", subs.baseInstructions, "test core")
|
||||
}
|
||||
if subs.confirmationPolicy == "" {
|
||||
t.Fatalf("confirmationPolicy substring must be non-empty (the embedded policy is always present)")
|
||||
}
|
||||
if subs.skills != "" {
|
||||
t.Fatalf("skills substring must be empty for default Options: got %q", subs.skills)
|
||||
}
|
||||
// Round-trip: the corresponding fingerprint hashes must equal
|
||||
// sha256hex of the substrings, with no truncation or reformatting
|
||||
// between the two layers.
|
||||
fp := ComputePrefixFingerprint(opts, nil)
|
||||
if fp.BaseInstructionsHash != sha256hex(subs.baseInstructions) {
|
||||
t.Fatalf("BaseInstructionsHash in fingerprint must equal sha256hex of the substring")
|
||||
}
|
||||
if fp.ConfirmationPolicyHash != sha256hex(subs.confirmationPolicy) {
|
||||
t.Fatalf("ConfirmationPolicyHash in fingerprint must equal sha256hex of the substring")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalSchemaStringStableForMapParameters asserts the headline
|
||||
// property the trace needs: the same map[string]any produces the same
|
||||
// canonical string across calls, even though Go's fmt.Sprintf("%v", m)
|
||||
// iterates a map in random order. json.Marshal sorts map keys
|
||||
// alphabetically, which is the fix.
|
||||
func TestCanonicalSchemaStringStableForMapParameters(t *testing.T) {
|
||||
params := map[string]any{
|
||||
"description": "read a file",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []any{"path"},
|
||||
"type": "object",
|
||||
}
|
||||
first := canonicalSchemaString(params)
|
||||
second := canonicalSchemaString(params)
|
||||
if first != second {
|
||||
t.Fatalf("canonicalSchemaString must be stable across calls for the same Parameters:\n first=%s\n second=%s", first, second)
|
||||
}
|
||||
if first == "" {
|
||||
t.Fatal("canonicalSchemaString must produce a non-empty string for a non-empty map")
|
||||
}
|
||||
// Two maps with the same key/value pairs (different declaration
|
||||
// order in source) must produce identical canonical strings. This
|
||||
// is the property that defeats Go's map iteration randomization.
|
||||
reordered := map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"path"},
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string"},
|
||||
},
|
||||
"description": "read a file",
|
||||
}
|
||||
if canonicalSchemaString(params) != canonicalSchemaString(reordered) {
|
||||
t.Fatal("canonicalSchemaString must produce identical output for maps with the same key/value pairs in different declaration orders")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalSchemaStringFallbackStableForNonJSONValue exercises the
|
||||
// non-JSON fallback in canonicalSchemaString: when Parameters contains a
|
||||
// value json.Marshal cannot encode (here, a channel), the function must
|
||||
// fall back to a key-sorted stringification that is stable across calls
|
||||
// and across map-iteration order. The previous fallback used
|
||||
// fmt.Sprintf("%v", params), which iterates the map in random order and
|
||||
// produced a different hash for the same value across calls — defeating
|
||||
// the fingerprint. The current fallback is stable by construction.
|
||||
func TestCanonicalSchemaStringFallbackStableForNonJSONValue(t *testing.T) {
|
||||
ch := make(chan int) // channels are not JSON-encodable
|
||||
params := map[string]any{
|
||||
"description": "a tool with a non-JSON value",
|
||||
"channel": ch,
|
||||
"z_first": 1,
|
||||
"a_second": 2,
|
||||
}
|
||||
first := canonicalSchemaString(params)
|
||||
second := canonicalSchemaString(params)
|
||||
if first == "" {
|
||||
t.Fatal("canonicalSchemaString must produce a non-empty string for the fallback path")
|
||||
}
|
||||
if first != second {
|
||||
t.Fatalf("canonicalSchemaString fallback must be stable across calls:\n first=%s\n second=%s", first, second)
|
||||
}
|
||||
// A re-ordered map (same key/value pairs, different declaration order)
|
||||
// must produce the same fallback string. This is the property the
|
||||
// key-sorted stringifier provides that fmt.Sprintf("%v", m) does not.
|
||||
reordered := map[string]any{
|
||||
"a_second": 2,
|
||||
"z_first": 1,
|
||||
"channel": ch,
|
||||
"description": "a tool with a non-JSON value",
|
||||
}
|
||||
if canonicalSchemaString(params) != canonicalSchemaString(reordered) {
|
||||
t.Fatalf("canonicalSchemaString fallback must produce identical output for maps with the same key/value pairs in different declaration orders:\n first=%s\n second=%s", first, canonicalSchemaString(reordered))
|
||||
}
|
||||
// The fallback must include the "__non_json:" prefix so a consumer
|
||||
// can tell the bytes are not a JSON-marshaled schema (a hash collision
|
||||
// between a json.Marshal result and a stableStringify result is
|
||||
// possible in theory; the prefix makes the source observable).
|
||||
if !strings.HasPrefix(first, "__non_json:") {
|
||||
t.Fatalf("canonicalSchemaString fallback must start with the __non_json: prefix, got: %s", first)
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PaymentMethod selects how a partner-checkout top-up is paid for.
|
||||
type PaymentMethod string
|
||||
|
||||
const (
|
||||
PaymentMethodCard PaymentMethod = "card"
|
||||
PaymentMethodCrypto PaymentMethod = "crypto"
|
||||
)
|
||||
|
||||
// SessionStatus is the lifecycle state of a partner-checkout session, as reported
|
||||
// by the aimlapi.com backend while polling for payment.
|
||||
type SessionStatus string
|
||||
|
||||
const (
|
||||
SessionStatusPendingAuth SessionStatus = "pending_auth"
|
||||
SessionStatusPendingPayment SessionStatus = "pending_payment"
|
||||
SessionStatusPaid SessionStatus = "paid"
|
||||
SessionStatusExchanging SessionStatus = "exchanging"
|
||||
SessionStatusExchanged SessionStatus = "exchanged"
|
||||
SessionStatusCancelled SessionStatus = "cancelled"
|
||||
SessionStatusExpired SessionStatus = "expired"
|
||||
SessionStatusFailed SessionStatus = "failed"
|
||||
)
|
||||
|
||||
// AuthResult is the bearer token minted by a passwordless sign-in / sign-up.
|
||||
type AuthResult struct {
|
||||
Token string `json:"token"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
// PartnerCheckoutSession is a partner-attributed top-up session; its SessionToken
|
||||
// addresses it on later pay/exchange/poll calls.
|
||||
type PartnerCheckoutSession struct {
|
||||
ID string `json:"id"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
PartnerID string `json:"partnerId"`
|
||||
PartnerName *string `json:"partnerName"`
|
||||
UserID *int64 `json:"userId"`
|
||||
AmountUSDMinor *int64 `json:"amountUsdMinor"`
|
||||
Status SessionStatus `json:"status"`
|
||||
IssuedKeyID *string `json:"issuedKeyId"`
|
||||
ReturnURL *string `json:"returnUrl"`
|
||||
}
|
||||
|
||||
// PaymentSession is the hosted payment-provider checkout; PayURL is the page the
|
||||
// user opens to pay.
|
||||
type PaymentSession struct {
|
||||
ProviderSessionID string `json:"providerSessionId"`
|
||||
PayURL string `json:"payUrl"`
|
||||
}
|
||||
|
||||
// PayResult pairs the hosted checkout with the updated partner-checkout session
|
||||
// returned when a top-up payment is initiated.
|
||||
type PayResult struct {
|
||||
Checkout PaymentSession `json:"checkout"`
|
||||
PartnerCheckout PartnerCheckoutSession `json:"partnerCheckout"`
|
||||
}
|
||||
|
||||
// ExchangeResult holds the API key minted when a paid session is exchanged.
|
||||
type ExchangeResult struct {
|
||||
APIKey string `json:"apiKey"`
|
||||
APIKeyID string `json:"apiKeyId"`
|
||||
}
|
||||
|
||||
// TopUpByKeyRequest funds a partner-checkout session with the API key that owns
|
||||
// the account (Path A / env key), instead of an email session.
|
||||
type TopUpByKeyRequest struct {
|
||||
SessionToken string
|
||||
AmountUSDMinor int
|
||||
PaymentSessionID string // idempotency: same id → same checkout, never a second charge
|
||||
AutoTopUp bool // enroll the account in automatic top-up at checkout (card, saved off-session)
|
||||
SuccessURL string
|
||||
CancelURL string
|
||||
}
|
||||
|
||||
// TopUpByKeyResult is the hosted checkout returned by TopUpByKey.
|
||||
type TopUpByKeyResult struct {
|
||||
Checkout PaymentSession `json:"checkout"`
|
||||
}
|
||||
|
||||
// APIError is a non-2xx HTTP response from the aimlapi.com API. Status carries the
|
||||
// code so callers can branch (e.g. 401 = bad key, 5xx = retryable).
|
||||
type APIError struct {
|
||||
Message string
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
// Error renders the method, endpoint, status, and (when present) the response body.
|
||||
func (e APIError) Error() string {
|
||||
if strings.TrimSpace(e.Body) != "" {
|
||||
return fmt.Sprintf("%s: HTTP %d: %s", e.Message, e.Status, strings.TrimSpace(e.Body))
|
||||
}
|
||||
return fmt.Sprintf("%s: HTTP %d", e.Message, e.Status)
|
||||
}
|
||||
|
||||
// Client talks to the aimlapi.com auth, app, and inference APIs for the onboarding
|
||||
// and partner-checkout top-up flows.
|
||||
type Client struct {
|
||||
endpoints Endpoints
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(endpoints Endpoints, httpClient *http.Client) *Client {
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 60 * time.Second}
|
||||
}
|
||||
return &Client{endpoints: endpoints, httpClient: httpClient}
|
||||
}
|
||||
|
||||
// CreateSession opens a partner-checkout session attributed to partnerID; the
|
||||
// returned SessionToken addresses it on the pay/exchange/poll calls.
|
||||
func (c *Client) CreateSession(ctx context.Context, partnerID string, partnerName string, returnURL string) (PartnerCheckoutSession, error) {
|
||||
body := map[string]any{"partnerId": partnerID}
|
||||
if strings.TrimSpace(partnerName) != "" {
|
||||
body["partnerName"] = strings.TrimSpace(partnerName)
|
||||
}
|
||||
if strings.TrimSpace(returnURL) != "" {
|
||||
body["returnUrl"] = strings.TrimSpace(returnURL)
|
||||
}
|
||||
var result PartnerCheckoutSession
|
||||
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions", "", body, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// GetSession fetches the current state of a partner-checkout session; used to poll
|
||||
// for payment completion.
|
||||
func (c *Client) GetSession(ctx context.Context, sessionToken string) (PartnerCheckoutSession, error) {
|
||||
var result PartnerCheckoutSession
|
||||
err := c.request(ctx, http.MethodGet, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken), "", nil, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Pay initiates payment for a session and returns the hosted checkout URL.
|
||||
// autoTopUp enrolls the account in automatic top-up at checkout time.
|
||||
func (c *Client) Pay(ctx context.Context, bearer string, sessionToken string, amountUSDMinor int, paymentSessionID string, method PaymentMethod, successURL string, cancelURL string, autoTopUp bool) (PayResult, error) {
|
||||
body := map[string]any{
|
||||
"amountUsdMinor": amountUSDMinor,
|
||||
"paymentSessionId": paymentSessionID,
|
||||
"method": method,
|
||||
}
|
||||
if strings.TrimSpace(successURL) != "" {
|
||||
body["successUrl"] = strings.TrimSpace(successURL)
|
||||
}
|
||||
if strings.TrimSpace(cancelURL) != "" {
|
||||
body["cancelUrl"] = strings.TrimSpace(cancelURL)
|
||||
}
|
||||
// Only sent when enabled: enrolls the account in automatic top-up. The backend
|
||||
// honours this field at checkout time.
|
||||
if autoTopUp {
|
||||
body["autoTopUp"] = true
|
||||
}
|
||||
var result PayResult
|
||||
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken)+"/pay", bearer, body, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Exchange converts a paid session into a freshly minted API key (new-account flow).
|
||||
func (c *Client) Exchange(ctx context.Context, bearer string, sessionToken string) (ExchangeResult, error) {
|
||||
var result ExchangeResult
|
||||
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken)+"/exchange", bearer, nil, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// TopUpByKey funds a partner-checkout session using the raw API key that owns the
|
||||
// account (Path A / env key), returning the hosted checkout. It binds the session
|
||||
// to the key's account server-side, so no email session is needed and no key is
|
||||
// exchanged. req.PaymentSessionID makes a retry idempotent (same id → same
|
||||
// checkout, never a second charge).
|
||||
func (c *Client) TopUpByKey(ctx context.Context, apiKey string, req TopUpByKeyRequest) (TopUpByKeyResult, error) {
|
||||
body := map[string]any{
|
||||
"sessionToken": req.SessionToken,
|
||||
"amountUsdMinor": req.AmountUSDMinor,
|
||||
"paymentSessionId": req.PaymentSessionID,
|
||||
}
|
||||
if req.AutoTopUp {
|
||||
body["autoTopUp"] = true
|
||||
}
|
||||
if strings.TrimSpace(req.SuccessURL) != "" {
|
||||
body["successUrl"] = strings.TrimSpace(req.SuccessURL)
|
||||
}
|
||||
if strings.TrimSpace(req.CancelURL) != "" {
|
||||
body["cancelUrl"] = strings.TrimSpace(req.CancelURL)
|
||||
}
|
||||
var result TopUpByKeyResult
|
||||
err := c.request(ctx, http.MethodPost, c.billingV2URL("/billing/topup"), apiKey, body, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// billingV2URL builds a v2 billing API URL from the inference base. The top-up
|
||||
// endpoint lives next to the balance endpoint on the API gateway, one version up
|
||||
// (".../v1" → ".../v2/billing/..."), so it re-anchors the version while keeping any
|
||||
// host/prefix an override supplied.
|
||||
func (c *Client) billingV2URL(path string) string {
|
||||
base := strings.TrimRight(strings.TrimSpace(c.endpoints.InferenceBaseURL), "/")
|
||||
base = strings.TrimSuffix(base, "/v1")
|
||||
return strings.TrimRight(base, "/") + "/v2" + path
|
||||
}
|
||||
|
||||
func (c *Client) request(ctx context.Context, method string, endpoint string, bearer string, body any, out any) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if strings.TrimSpace(bearer) != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(bearer))
|
||||
}
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("network request to %s failed: %w", endpoint, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
text, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return APIError{Message: method + " " + endpoint, Status: response.StatusCode, Body: string(text)}
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if len(bytes.TrimSpace(text)) == 0 {
|
||||
return APIError{Message: method + " " + endpoint + " returned empty body", Status: response.StatusCode}
|
||||
}
|
||||
if err := json.Unmarshal(text, out); err != nil {
|
||||
return APIError{Message: method + " " + endpoint + " returned non-JSON body", Status: response.StatusCode, Body: string(text)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func urlPathEscape(value string) string {
|
||||
return url.PathEscape(value)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Additional client methods backing the interactive TUI onboarding flow. They
|
||||
// target the endpoints shipped in the aimlapi backend:
|
||||
// - GET {inference}/billing/balance (Path A: validate key + balance)
|
||||
// - PATCH{auth}/v1/auth/account (Path B: does the account exist?)
|
||||
// - POST {auth}/v1/auth/sign-in/code(/verify) (Path B existing: passwordless login)
|
||||
// - POST {auth}/v1/auth/account/passwordless (Path B new: email-only signup)
|
||||
// - POST {app}/v1/keys (mint a key once we hold a session)
|
||||
|
||||
// BalanceResult is the wallet balance returned by GetBalance, with the backend's
|
||||
// low-balance flag and threshold.
|
||||
type BalanceResult struct {
|
||||
Balance float64 `json:"balance"`
|
||||
LowBalance bool `json:"lowBalance"`
|
||||
LowBalanceThreshold float64 `json:"lowBalanceThreshold"`
|
||||
}
|
||||
|
||||
// CheckResult reports whether an email already has an account, driving the
|
||||
// sign-in vs sign-up branch of the onboarding flow.
|
||||
type CheckResult struct {
|
||||
// "sign-in" (account exists) or "sign-up" (no account yet).
|
||||
Action string `json:"action"`
|
||||
Provider *string `json:"provider"`
|
||||
}
|
||||
|
||||
// CreatedKey is an API key minted by CreateKey.
|
||||
type CreatedKey struct {
|
||||
Key string `json:"key"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (c *Client) authURL(path string) string {
|
||||
return strings.TrimRight(c.endpoints.AuthBaseURL, "/") + path
|
||||
}
|
||||
|
||||
func (c *Client) appURL(path string) string {
|
||||
return strings.TrimRight(c.endpoints.AppBaseURL, "/") + path
|
||||
}
|
||||
|
||||
func (c *Client) inferenceURL(path string) string {
|
||||
return strings.TrimRight(c.endpoints.InferenceBaseURL, "/") + path
|
||||
}
|
||||
|
||||
// GetBalance doubles as key validation: a 401 means the key is invalid, a 2xx
|
||||
// returns the wallet balance. Auth is the raw API key itself.
|
||||
func (c *Client) GetBalance(ctx context.Context, apiKey string) (BalanceResult, error) {
|
||||
var result BalanceResult
|
||||
err := c.request(ctx, http.MethodGet, c.inferenceURL("/billing/balance"), apiKey, nil, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CheckAccount reports whether an email already has an aimlapi.com account
|
||||
// (Action "sign-in") or not ("sign-up").
|
||||
func (c *Client) CheckAccount(ctx context.Context, email string) (CheckResult, error) {
|
||||
var result CheckResult
|
||||
err := c.request(ctx, http.MethodPatch, c.authURL("/v1/auth/account"), "", map[string]any{"email": email}, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SendSignInCode emails a 6-digit sign-in code to an existing account.
|
||||
func (c *Client) SendSignInCode(ctx context.Context, email string) error {
|
||||
return c.request(ctx, http.MethodPost, c.authURL("/v1/auth/sign-in/code"), "", map[string]any{"email": email}, nil)
|
||||
}
|
||||
|
||||
// VerifySignInCode exchanges an emailed code for a session bearer token.
|
||||
func (c *Client) VerifySignInCode(ctx context.Context, email string, code string) (AuthResult, error) {
|
||||
var result AuthResult
|
||||
err := c.request(ctx, http.MethodPost, c.authURL("/v1/auth/sign-in/code/verify"), "", map[string]any{"email": email, "code": code}, &result)
|
||||
if err == nil && strings.TrimSpace(result.Token) == "" {
|
||||
err = fmt.Errorf("aimlapi.com did not return an auth token")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CreatePasswordlessAccount registers a new email-only account and returns its
|
||||
// session bearer token.
|
||||
func (c *Client) CreatePasswordlessAccount(ctx context.Context, email string) (AuthResult, error) {
|
||||
var result AuthResult
|
||||
err := c.request(ctx, http.MethodPost, c.authURL("/v1/auth/account/passwordless"), "", map[string]any{"email": email}, &result)
|
||||
if err == nil && strings.TrimSpace(result.Token) == "" {
|
||||
err = fmt.Errorf("aimlapi.com did not return an auth token")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CreateKey mints a fresh API key for a session-holding user.
|
||||
func (c *Client) CreateKey(ctx context.Context, bearer string, name string) (CreatedKey, error) {
|
||||
body := map[string]any{}
|
||||
if strings.TrimSpace(name) != "" {
|
||||
body["name"] = strings.TrimSpace(name)
|
||||
}
|
||||
var result CreatedKey
|
||||
err := c.request(ctx, http.MethodPost, c.appURL("/v1/keys"), bearer, body, &result)
|
||||
if err == nil && strings.TrimSpace(result.Key) == "" {
|
||||
err = fmt.Errorf("aimlapi.com did not return an API key")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateKeySendsAuthenticatedRequestAndDecodesKey(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/keys" {
|
||||
t.Fatalf("request = %s %s, want POST /v1/keys", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer session-token" {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["name"] != "Zero CLI" {
|
||||
t.Fatalf("name = %q", body["name"])
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"key":"sk-issued","id":"key-id"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
|
||||
key, err := client.CreateKey(context.Background(), " session-token ", " Zero CLI ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key.Key != "sk-issued" || key.ID != "key-id" {
|
||||
t.Fatalf("key = %#v", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateKeyRejectsEmptyKeyResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"key-id"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
|
||||
if _, err := client.CreateKey(context.Background(), "session-token", "Zero"); err == nil {
|
||||
t.Fatal("CreateKey accepted a response without an API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMethodsRejectEmptyTokenResponses(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"token":""}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{AuthBaseURL: server.URL}, server.Client())
|
||||
tests := []struct {
|
||||
name string
|
||||
call func() error
|
||||
}{
|
||||
{
|
||||
name: "verify sign-in code",
|
||||
call: func() error {
|
||||
_, err := client.VerifySignInCode(context.Background(), "[email protected]", "123456")
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create passwordless account",
|
||||
call: func() error {
|
||||
_, err := client.CreatePasswordlessAccount(context.Background(), "[email protected]")
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := test.call(); err == nil {
|
||||
t.Fatal("accepted a response without an auth token")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReturnsTypedAPIError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "invalid key", http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{InferenceBaseURL: server.URL}, server.Client())
|
||||
_, err := client.GetBalance(context.Background(), "bad-key")
|
||||
var apiErr APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error = %T %v, want APIError", err, err)
|
||||
}
|
||||
if apiErr.Status != http.StatusUnauthorized || apiErr.Body != "invalid key\n" {
|
||||
t.Fatalf("APIError = %#v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedResponseRejectsEmptySuccessBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{InferenceBaseURL: server.URL}, server.Client())
|
||||
if _, err := client.GetBalance(context.Background(), "key"); err == nil {
|
||||
t.Fatal("GetBalance accepted an empty successful response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodylessOperationAcceptsEmptySuccessBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{AuthBaseURL: server.URL}, server.Client())
|
||||
if err := client.SendSignInCode(context.Background(), "[email protected]"); err != nil {
|
||||
t.Fatalf("SendSignInCode() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAmountUSD(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "default", want: DefaultAmountUSDMinor},
|
||||
{name: "minimum", value: "20", want: 2000},
|
||||
{name: "cents", value: "25.49", want: 2549},
|
||||
{name: "below minimum", value: "19.99", wantErr: true},
|
||||
{name: "above maximum", value: "10001", wantErr: true},
|
||||
{name: "not a number", value: "twenty", wantErr: true},
|
||||
{name: "nan", value: "NaN", wantErr: true},
|
||||
{name: "infinity", value: "Inf", wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := ParseAmountUSD(test.value)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("ParseAmountUSD(%q) error = %v", test.value, err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("ParseAmountUSD(%q) = %d, want %d", test.value, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Endpoints struct {
|
||||
AuthBaseURL string
|
||||
AppBaseURL string
|
||||
InferenceBaseURL string
|
||||
PayBaseURL string
|
||||
// VerificationBaseURL is the aimlapi web-app frontend base. The browser is sent
|
||||
// back here after the partner checkout completes (see BuildPartnerReturnURL), so
|
||||
// it must point at the same environment's front as the one the CLI is talking to
|
||||
// to land the user there. Empty falls back to DefaultReturnURL.
|
||||
VerificationBaseURL string
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultPartnerID = "part_62yQoGYDq4Yqnrj2R1iGrDNJ"
|
||||
DefaultPartnerName = "Gitlawb"
|
||||
PartnerHeaderName = "X-AIMLAPI-Partner-ID"
|
||||
// DefaultReturnURL is the https fallback the browser is sent to after the
|
||||
// checkout / OAuth consent finishes when no frontend base is known. It must be
|
||||
// a real web page the browser can open — NOT a custom scheme like
|
||||
// zero://aimlapi/complete, which fails with "the scheme does not have a
|
||||
// registered handler" because the CLI registers no OS protocol handler. The
|
||||
// CLI learns of success by polling; this URL is purely the browser's landing.
|
||||
DefaultReturnURL = "https://aimlapi.com/app"
|
||||
DefaultModel = "anthropic/claude-sonnet-5"
|
||||
|
||||
MinAmountUSDMinor = 2000
|
||||
MaxAmountUSDMinor = 1000000
|
||||
DefaultAmountUSDMinor = 2500
|
||||
)
|
||||
|
||||
func ResolveEndpoints() Endpoints {
|
||||
return Endpoints{
|
||||
AuthBaseURL: envOrDefault("AIMLAPI_AUTH_URL", "https://auth.aimlapi.com"),
|
||||
AppBaseURL: envOrDefault("AIMLAPI_APP_URL", "https://app.aimlapi.com"),
|
||||
InferenceBaseURL: envOrDefault("AIMLAPI_INFERENCE_URL", "https://api.aimlapi.com/v1"),
|
||||
PayBaseURL: envOrDefault("AIMLAPI_PAY_URL", "https://pay.aimlapi.com"),
|
||||
VerificationBaseURL: envOrDefault("AIMLAPI_VERIFICATION_BASE_URL", "https://aimlapi.com/app"),
|
||||
}
|
||||
}
|
||||
|
||||
// ResolvePartnerID applies the same explicit-option, environment, and default
|
||||
// precedence everywhere partner attribution is used. Keeping checkout creation
|
||||
// and saved inference profiles on one resolver prevents them from attributing
|
||||
// the same user to different partners.
|
||||
func ResolvePartnerID(explicit string) string {
|
||||
if value := strings.TrimSpace(explicit); value != "" {
|
||||
return value
|
||||
}
|
||||
return envOrDefault("AIMLAPI_PARTNER_ID", DefaultPartnerID)
|
||||
}
|
||||
|
||||
// WithResolvedPartnerHeader returns a copy with the effective partner ID set.
|
||||
// Header matching is case-insensitive so an existing spelling is replaced rather
|
||||
// than duplicated. Callers remain responsible for applying catalog attribution
|
||||
// only to the canonical AIMLAPI endpoint.
|
||||
func WithResolvedPartnerHeader(headers map[string]string) map[string]string {
|
||||
resolved := make(map[string]string, len(headers)+1)
|
||||
for key, value := range headers {
|
||||
if strings.EqualFold(strings.TrimSpace(key), PartnerHeaderName) {
|
||||
continue
|
||||
}
|
||||
resolved[key] = value
|
||||
}
|
||||
resolved[PartnerHeaderName] = ResolvePartnerID("")
|
||||
return resolved
|
||||
}
|
||||
|
||||
func BuildPartnerCheckoutReturnURLs(appBaseURL string, sessionToken string) (successURL string, cancelURL string) {
|
||||
base := strings.TrimRight(strings.TrimSpace(appBaseURL), "/")
|
||||
if base == "" {
|
||||
// With no base there is nothing to anchor an absolute URL to. Return empty
|
||||
// so the caller omits the return URLs entirely (client.Pay skips empty ones,
|
||||
// leaving the backend's generic checkout) instead of emitting a broken
|
||||
// relative "/checkout?...".
|
||||
return "", ""
|
||||
}
|
||||
token := url.QueryEscape(sessionToken)
|
||||
return base + "/checkout?checkout=success&partnerCheckout=1&sessionToken=" + token,
|
||||
base + "/checkout?checkout=cancel&partnerCheckout=1&sessionToken=" + token
|
||||
}
|
||||
|
||||
// BuildPartnerReturnURL returns the https page the browser is sent back to once
|
||||
// the partner checkout or OAuth consent completes — the aimlapi web app the user
|
||||
// opened the flow from. frontendBaseURL is normally endpoints.VerificationBaseURL
|
||||
// (the front that hosts the login/consent page), so the return follows the same
|
||||
// environment. AIMLAPI_RETURN_URL overrides it outright; an empty base falls back
|
||||
// to DefaultReturnURL. It is deliberately NOT a custom scheme (zero://…): no
|
||||
// browser can hand a custom scheme off without an OS-registered handler, which a
|
||||
// CLI does not install.
|
||||
func BuildPartnerReturnURL(frontendBaseURL string) string {
|
||||
if override := strings.TrimSpace(os.Getenv("AIMLAPI_RETURN_URL")); override != "" {
|
||||
return override
|
||||
}
|
||||
if base := strings.TrimRight(strings.TrimSpace(frontendBaseURL), "/"); base != "" {
|
||||
return base
|
||||
}
|
||||
return DefaultReturnURL
|
||||
}
|
||||
|
||||
func envOrDefault(name string, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveEndpointsDefaults(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_AUTH_URL", "")
|
||||
t.Setenv("AIMLAPI_APP_URL", "")
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", "")
|
||||
t.Setenv("AIMLAPI_PAY_URL", "")
|
||||
|
||||
endpoints := ResolveEndpoints()
|
||||
if endpoints.AuthBaseURL != "https://auth.aimlapi.com" ||
|
||||
endpoints.AppBaseURL != "https://app.aimlapi.com" ||
|
||||
endpoints.InferenceBaseURL != "https://api.aimlapi.com/v1" ||
|
||||
endpoints.PayBaseURL != "https://pay.aimlapi.com" {
|
||||
t.Fatalf("unexpected endpoints: %#v", endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedPartnerHeaderUsesEnvironmentOverrideWithoutDuplicates(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_PARTNER_ID", "part_override")
|
||||
|
||||
headers := WithResolvedPartnerHeader(map[string]string{
|
||||
"x-aimlapi-partner-id": "part_catalog",
|
||||
"X-AIMLAPI-Integration-Repo": "Gitlawb/zero",
|
||||
})
|
||||
|
||||
if got := headers[PartnerHeaderName]; got != "part_override" {
|
||||
t.Fatalf("partner header = %q, want part_override", got)
|
||||
}
|
||||
if _, ok := headers["x-aimlapi-partner-id"]; ok {
|
||||
t.Fatalf("case-insensitive partner header was duplicated: %#v", headers)
|
||||
}
|
||||
if got := headers["X-AIMLAPI-Integration-Repo"]; got != "Gitlawb/zero" {
|
||||
t.Fatalf("unrelated header changed: %#v", headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPartnerReturnURL(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_RETURN_URL", "")
|
||||
// The frontend base (the web app the flow was opened from) is returned as-is,
|
||||
// trailing slash trimmed — an https page the browser can actually land on.
|
||||
if got := BuildPartnerReturnURL("https://aimlapi.com/app/"); got != "https://aimlapi.com/app" {
|
||||
t.Fatalf("return URL = %q, want the frontend base", got)
|
||||
}
|
||||
// An empty base falls back to the https default, never a custom scheme.
|
||||
if got := BuildPartnerReturnURL(""); got != DefaultReturnURL {
|
||||
t.Fatalf("empty base return URL = %q, want %q", got, DefaultReturnURL)
|
||||
}
|
||||
|
||||
t.Setenv("AIMLAPI_RETURN_URL", "https://example.test/done")
|
||||
if got := BuildPartnerReturnURL("https://aimlapi.com/app"); got != "https://example.test/done" {
|
||||
t.Fatalf("env override return URL = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
// UI strings shared by the first-run and provider-wizard onboarding surfaces.
|
||||
|
||||
const (
|
||||
// Path A — existing API key. Pick-path prompt + option labels.
|
||||
MsgAPIKeyInputPrompt = "Enter your aimlapi.com key."
|
||||
MsgAPIKeyInvalid = "API key is invalid. Please make sure you enter a valid aimlapi.com key."
|
||||
MsgPickPathPrompt = "Do you have aimlapi.com key?"
|
||||
MsgPickPathHaveKey = "I already have aimlapi.com key"
|
||||
MsgPickPathHaveHint = "Proceed to paste the key"
|
||||
MsgPickPathNewUser = "I am a new user"
|
||||
MsgPickPathNewHint = "One click set up"
|
||||
// Path B — email.
|
||||
MsgEnterEmail = "Enter your email.\nTo access aimlapi.com dashboard."
|
||||
MsgEmailInvalid = "Email format is incorrect."
|
||||
MsgAccountActionInvalid = "aimlapi.com returned an unsupported account action. Please try again."
|
||||
// %s = email
|
||||
MsgCodeSent = "We sent a 6-digit code to %s.\nEnter it below to continue."
|
||||
MsgCodeIncorrect = "Code you've entered is incorrect."
|
||||
|
||||
// Balance check.
|
||||
MsgLowBalance = "Your aimlapi.com balance is running low.\nIt is recommended to top up your balance."
|
||||
MsgEverythingRuns = "Everything is ready."
|
||||
// Low-balance choices (spec wording).
|
||||
MsgLowBalanceTopUp = "Sure, let's do that"
|
||||
MsgLowBalanceSkip = "I'll skip topping up the balance for now"
|
||||
|
||||
// Top-up workflow.
|
||||
MsgTopUpPrompt = "Add credits.\nEnter an amount (min $20)."
|
||||
MsgAmountRequired = "Please enter a top-up amount."
|
||||
// %s = checkout URL
|
||||
MsgTopUpBrowserFallback = "If the browser did not open automatically please use this link to top up your account: %s"
|
||||
MsgTopUpFailed = "Top up failed. Please try again."
|
||||
MsgTopUpSuccess = "Top-up successful."
|
||||
|
||||
// Success — key delivered.
|
||||
// %s = amount in dollars (e.g. "25")
|
||||
MsgTopUpAddedFmt = "$%s has been added to your balance."
|
||||
// %s = email
|
||||
MsgSuccessMagicLink = "We've emailed you a magic link to %s. Use it to access your aimlapi.com account and review your usage and balance."
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidEmailRejectsIncompleteDomains(t *testing.T) {
|
||||
tests := []struct {
|
||||
value string
|
||||
valid bool
|
||||
}{
|
||||
{value: "[email protected]", valid: true},
|
||||
{value: "[email protected]", valid: true},
|
||||
{value: "123@123.", valid: false},
|
||||
{value: "123@123", valid: false},
|
||||
{value: "[email protected]", valid: false},
|
||||
{value: "[email protected]", valid: false},
|
||||
{value: "[email protected]", valid: false},
|
||||
{value: "[email protected]", valid: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.value, func(t *testing.T) {
|
||||
if got := ValidEmail(test.value); got != test.valid {
|
||||
t.Fatalf("ValidEmail(%q) = %v, want %v", test.value, got, test.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StreamTopUpOptions drives a session-based partner-checkout top-up for the
|
||||
// interactive (TUI) onboarding. The caller already holds a user session —
|
||||
// obtained from an email-code sign-in (Path B existing) or a
|
||||
// passwordless new account (Path B sign-up) — so this skips authentication and
|
||||
// runs create-session → pay → wait-for-payment, optionally exchanging the paid
|
||||
// session into a fresh key (new accounts) versus keeping the caller's key.
|
||||
type StreamTopUpOptions struct {
|
||||
SessionToken string // user session bearer (required)
|
||||
AmountUSD string // dollars; parsed via ParseAmountUSD (min $20)
|
||||
// InferenceBaseURL pins key-bound billing and the returned provider profile to
|
||||
// the endpoint that validated the credential. Empty uses ResolveEndpoints.
|
||||
InferenceBaseURL string
|
||||
Method PaymentMethod
|
||||
AutoTopUp bool // enroll the account in automatic top-up at checkout time
|
||||
Exchange bool // exchange the paid session into a new key (new accounts)
|
||||
Model string
|
||||
PartnerID string
|
||||
PartnerName string
|
||||
NoOpen bool
|
||||
|
||||
// ResumeSessionToken, when set, resumes that partner-checkout session instead
|
||||
// of opening a new one, so a retry after an ambiguous failure (a lost Pay/
|
||||
// Exchange response) can never double-charge or strand an already-paid session.
|
||||
ResumeSessionToken string
|
||||
// PaymentSessionID is generated once per amount/auto-top-up intent and reused
|
||||
// on retry so an ambiguous Pay response cannot create a second checkout.
|
||||
PaymentSessionID string
|
||||
|
||||
HTTPClient *http.Client
|
||||
OpenBrowser func(string) error
|
||||
OnStatus func(Status, string)
|
||||
// OnSession reports the partner-checkout session token the moment it exists (a
|
||||
// fresh CreateSession, or the resumed one), so the caller can retain it and
|
||||
// resume on a later failure. It fires with "" when the session is dead and the
|
||||
// retained token should be dropped (a fresh run must start over).
|
||||
OnSession func(sessionToken string)
|
||||
}
|
||||
|
||||
// topupPhase is where StreamTopUp enters its create-checkout → poll → exchange
|
||||
// pipeline. A fresh run starts at phasePay; a resume skips ahead to whatever the
|
||||
// existing session's status makes safe.
|
||||
type topupPhase int
|
||||
|
||||
const (
|
||||
phasePay topupPhase = iota // (re-)open the Stripe checkout, then poll + exchange
|
||||
phasePoll // checkout already open; poll for payment, never re-pay
|
||||
phaseExchange // already paid; exchange only (recovers a lost key)
|
||||
phaseWaitExchange // an exchange claim is in flight; poll, never call Exchange twice
|
||||
)
|
||||
|
||||
// StreamTopUp performs the browser-checkout top-up and reports progress via
|
||||
// OnStatus.
|
||||
func StreamTopUp(ctx context.Context, options StreamTopUpOptions) (ProvisionedKey, error) {
|
||||
endpoints := ResolveEndpoints()
|
||||
if baseURL := strings.TrimSpace(options.InferenceBaseURL); baseURL != "" {
|
||||
endpoints.InferenceBaseURL = baseURL
|
||||
}
|
||||
client := NewClient(endpoints, options.HTTPClient)
|
||||
partnerID := ResolvePartnerID(options.PartnerID)
|
||||
partnerName := firstNonEmpty(options.PartnerName, DefaultPartnerName)
|
||||
method := options.Method
|
||||
if method != PaymentMethodCrypto {
|
||||
method = PaymentMethodCard
|
||||
}
|
||||
amount, err := ParseAmountUSD(options.AmountUSD)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
if strings.TrimSpace(options.SessionToken) == "" {
|
||||
return ProvisionedKey{}, fmt.Errorf("a session is required to top up")
|
||||
}
|
||||
if strings.TrimSpace(options.PaymentSessionID) == "" {
|
||||
return ProvisionedKey{}, fmt.Errorf("a payment session id is required to top up")
|
||||
}
|
||||
|
||||
status(options.OnStatus, StatusCreatingSession, "")
|
||||
sessionToken, phase, err := resolveTopupSession(ctx, client, options, partnerID, partnerName, endpoints)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
// Retain the live token so an ambiguous failure below can resume this session
|
||||
// rather than open a second checkout on the next attempt.
|
||||
reportSession(options.OnSession, sessionToken)
|
||||
|
||||
if phase <= phasePay {
|
||||
successURL, cancelURL := BuildPartnerCheckoutReturnURLs(endpoints.PayBaseURL, sessionToken)
|
||||
status(options.OnStatus, StatusOpeningCheckout, "")
|
||||
pay, err := client.Pay(ctx, options.SessionToken, sessionToken, amount, options.PaymentSessionID, method, successURL, cancelURL, options.AutoTopUp)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
checkoutURL, err := validatedCheckoutURL(pay.Checkout.PayURL)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
announceCheckout(options.OnStatus, options.OpenBrowser, options.NoOpen, checkoutURL)
|
||||
}
|
||||
|
||||
paidToken := sessionToken
|
||||
if phase <= phasePoll {
|
||||
status(options.OnStatus, StatusWaitingPayment, "")
|
||||
paid, err := pollUntilPaid(ctx, client, sessionToken, options.OnSession)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
paidToken = paid.SessionToken
|
||||
if paid.Status == SessionStatusExchanging {
|
||||
// Another/lost request claimed exchange while we were polling payment.
|
||||
// Carry that fact into the provisioning branch so it follows the claim
|
||||
// instead of issuing a second single-flight Exchange request.
|
||||
phase = phaseWaitExchange
|
||||
}
|
||||
}
|
||||
|
||||
result := ProvisionedKey{
|
||||
BaseURL: endpoints.InferenceBaseURL,
|
||||
Model: firstNonEmpty(options.Model, DefaultModel),
|
||||
}
|
||||
if options.Exchange {
|
||||
status(options.OnStatus, StatusProvisioningKey, "")
|
||||
if phase == phaseWaitExchange {
|
||||
return ProvisionedKey{}, pollUntilExchangeSettled(ctx, client, sessionToken, options.OnSession)
|
||||
}
|
||||
exchange, err := client.Exchange(ctx, options.SessionToken, paidToken)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
if strings.TrimSpace(exchange.APIKey) == "" {
|
||||
return ProvisionedKey{}, fmt.Errorf("aimlapi.com did not return an API key")
|
||||
}
|
||||
result.APIKey = exchange.APIKey
|
||||
result.APIKeyID = exchange.APIKeyID
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resolveTopupSession returns the partner-checkout session token to drive and the
|
||||
// phase to enter. A fresh run opens a new session at phasePay. A resume inspects
|
||||
// the existing session and enters the earliest phase that cannot re-charge:
|
||||
// - pending_auth → phasePay: payment never started, so (re-)Pay is safe.
|
||||
// - pending_payment → phasePoll: a checkout is already open; poll, never re-Pay.
|
||||
// - paid → phaseExchange: exchange the paid session once.
|
||||
// - exchanging → phaseWaitExchange: another/lost request owns the atomic
|
||||
// exchange claim; poll it, never issue a second Exchange request.
|
||||
// - exchanged → terminal: the one-shot raw key is gone; point at dashboard
|
||||
// recovery and keep the token so a re-run repeats that guidance, not a charge.
|
||||
// - cancelled/expired/failed → terminal: drop the dead token so a re-run is fresh.
|
||||
func resolveTopupSession(ctx context.Context, client *Client, options StreamTopUpOptions, partnerID, partnerName string, endpoints Endpoints) (string, topupPhase, error) {
|
||||
resume := strings.TrimSpace(options.ResumeSessionToken)
|
||||
if resume == "" {
|
||||
session, err := client.CreateSession(ctx, partnerID, partnerName, BuildPartnerReturnURL(endpoints.VerificationBaseURL))
|
||||
if err != nil {
|
||||
return "", phasePay, err
|
||||
}
|
||||
return session.SessionToken, phasePay, nil
|
||||
}
|
||||
session, err := client.GetSession(ctx, resume)
|
||||
if err != nil {
|
||||
if isTerminalSessionAPIError(err) {
|
||||
reportSession(options.OnSession, "")
|
||||
}
|
||||
return "", phasePay, err
|
||||
}
|
||||
switch session.Status {
|
||||
case SessionStatusPendingAuth:
|
||||
return resume, phasePay, nil
|
||||
case SessionStatusPendingPayment:
|
||||
return resume, phasePoll, nil
|
||||
case SessionStatusPaid:
|
||||
return resume, phaseExchange, nil
|
||||
case SessionStatusExchanging:
|
||||
return resume, phaseWaitExchange, nil
|
||||
case SessionStatusExchanged:
|
||||
return "", phasePay, fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
|
||||
default:
|
||||
reportSession(options.OnSession, "") // drop the dead token → next run starts fresh
|
||||
return "", phasePay, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func reportSession(callback func(string), sessionToken string) {
|
||||
if callback != nil {
|
||||
callback(sessionToken)
|
||||
}
|
||||
}
|
||||
|
||||
// announceCheckout opens the hosted checkout in the browser (unless suppressed)
|
||||
// and reports the URL either way, so the TUI can always show the direct link.
|
||||
func announceCheckout(onStatus func(Status, string), open func(string) error, noOpen bool, checkoutURL string) {
|
||||
if noOpen || open == nil {
|
||||
status(onStatus, StatusOpeningCheckout, checkoutURL)
|
||||
return
|
||||
}
|
||||
if err := open(checkoutURL); err != nil {
|
||||
status(onStatus, StatusOpeningCheckout, "Open manually: "+checkoutURL)
|
||||
return
|
||||
}
|
||||
status(onStatus, StatusOpeningCheckout, checkoutURL)
|
||||
}
|
||||
|
||||
func validatedCheckoutURL(value string) (string, error) {
|
||||
checkoutURL := strings.TrimSpace(value)
|
||||
parsed, err := url.Parse(checkoutURL)
|
||||
if err != nil || !parsed.IsAbs() || !strings.EqualFold(parsed.Scheme, "https") || parsed.Hostname() == "" || parsed.User != nil {
|
||||
return "", fmt.Errorf("payment provider did not return a valid HTTPS checkout URL")
|
||||
}
|
||||
return checkoutURL, nil
|
||||
}
|
||||
|
||||
// StreamTopUpByKeyOptions drives a top-up funded with the API key that already
|
||||
// owns the account (Path A pasted key, or AIMLAPI_API_KEY). Unlike StreamTopUp it
|
||||
// needs no email session and never exchanges a key — the caller already holds one,
|
||||
// so it only funds the wallet: create-session → pay-by-key → wait-for-payment.
|
||||
type StreamTopUpByKeyOptions struct {
|
||||
APIKey string // raw key that owns the account (required)
|
||||
AmountUSD string // dollars; parsed via ParseAmountUSD (min $20)
|
||||
InferenceBaseURL string // validated key endpoint; empty uses ResolveEndpoints
|
||||
AutoTopUp bool // enroll the account in automatic top-up at checkout time
|
||||
PartnerID string
|
||||
PartnerName string
|
||||
NoOpen bool
|
||||
|
||||
// ResumeSessionToken resumes an existing partner-checkout session instead of
|
||||
// opening a new one. PaymentSessionID is the idempotency handle carried across
|
||||
// retries so a re-issued pay returns the same checkout, never a second charge.
|
||||
ResumeSessionToken string
|
||||
PaymentSessionID string // required; generate once via NewPaymentSessionID, reuse on retry
|
||||
|
||||
HTTPClient *http.Client
|
||||
OpenBrowser func(string) error
|
||||
OnStatus func(Status, string)
|
||||
OnSession func(sessionToken string)
|
||||
}
|
||||
|
||||
// StreamTopUpByKey funds the account behind APIKey via the hosted checkout and
|
||||
// reports progress through OnStatus. It returns an empty ProvisionedKey: the
|
||||
// pasted/env key is unchanged, only the balance grows.
|
||||
func StreamTopUpByKey(ctx context.Context, options StreamTopUpByKeyOptions) (ProvisionedKey, error) {
|
||||
endpoints := ResolveEndpoints()
|
||||
if baseURL := strings.TrimSpace(options.InferenceBaseURL); baseURL != "" {
|
||||
endpoints.InferenceBaseURL = baseURL
|
||||
}
|
||||
client := NewClient(endpoints, options.HTTPClient)
|
||||
partnerID := ResolvePartnerID(options.PartnerID)
|
||||
partnerName := firstNonEmpty(options.PartnerName, DefaultPartnerName)
|
||||
amount, err := ParseAmountUSD(options.AmountUSD)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
if strings.TrimSpace(options.APIKey) == "" {
|
||||
return ProvisionedKey{}, fmt.Errorf("an API key is required to top up")
|
||||
}
|
||||
if strings.TrimSpace(options.PaymentSessionID) == "" {
|
||||
return ProvisionedKey{}, fmt.Errorf("a payment session id is required to top up")
|
||||
}
|
||||
|
||||
status(options.OnStatus, StatusCreatingSession, "")
|
||||
sessionToken, phase, err := resolveByKeySession(ctx, client, options, partnerID, partnerName, endpoints)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
reportSession(options.OnSession, sessionToken)
|
||||
|
||||
if phase <= phasePay {
|
||||
successURL, cancelURL := BuildPartnerCheckoutReturnURLs(endpoints.PayBaseURL, sessionToken)
|
||||
status(options.OnStatus, StatusOpeningCheckout, "")
|
||||
pay, err := client.TopUpByKey(ctx, options.APIKey, TopUpByKeyRequest{
|
||||
SessionToken: sessionToken,
|
||||
AmountUSDMinor: amount,
|
||||
PaymentSessionID: options.PaymentSessionID,
|
||||
AutoTopUp: options.AutoTopUp,
|
||||
SuccessURL: successURL,
|
||||
CancelURL: cancelURL,
|
||||
})
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
checkoutURL, err := validatedCheckoutURL(pay.Checkout.PayURL)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
announceCheckout(options.OnStatus, options.OpenBrowser, options.NoOpen, checkoutURL)
|
||||
}
|
||||
|
||||
if phase <= phasePoll {
|
||||
status(options.OnStatus, StatusWaitingPayment, "")
|
||||
if _, err := pollUntilPaid(ctx, client, sessionToken, options.OnSession); err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
}
|
||||
// No exchange: the account is funded and the caller keeps its existing key.
|
||||
return ProvisionedKey{}, nil
|
||||
}
|
||||
|
||||
// resolveByKeySession mirrors resolveTopupSession for the by-key flow. Because
|
||||
// by-key never exchanges, a paid/exchanging/exchanged session all mean "already
|
||||
// funded, done" (phaseExchange skips straight past pay + poll to the empty result).
|
||||
func resolveByKeySession(ctx context.Context, client *Client, options StreamTopUpByKeyOptions, partnerID, partnerName string, endpoints Endpoints) (string, topupPhase, error) {
|
||||
resume := strings.TrimSpace(options.ResumeSessionToken)
|
||||
if resume == "" {
|
||||
session, err := client.CreateSession(ctx, partnerID, partnerName, BuildPartnerReturnURL(endpoints.VerificationBaseURL))
|
||||
if err != nil {
|
||||
return "", phasePay, err
|
||||
}
|
||||
return session.SessionToken, phasePay, nil
|
||||
}
|
||||
session, err := client.GetSession(ctx, resume)
|
||||
if err != nil {
|
||||
if isTerminalSessionAPIError(err) {
|
||||
reportSession(options.OnSession, "")
|
||||
}
|
||||
return "", phasePay, err
|
||||
}
|
||||
switch session.Status {
|
||||
case SessionStatusPendingAuth:
|
||||
return resume, phasePay, nil
|
||||
case SessionStatusPendingPayment:
|
||||
return resume, phasePoll, nil
|
||||
case SessionStatusPaid, SessionStatusExchanging, SessionStatusExchanged:
|
||||
return resume, phaseExchange, nil
|
||||
default:
|
||||
reportSession(options.OnSession, "") // drop the dead token → next run starts fresh
|
||||
return "", phasePay, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
|
||||
}
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStreamTopUpByKeyFundsWithoutExchange(t *testing.T) {
|
||||
var topupCalls, exchangeCalls int
|
||||
var topupBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPendingAuth})
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/v2/billing/topup"):
|
||||
topupCalls++
|
||||
_ = json.NewDecoder(r.Body).Decode(&topupBody)
|
||||
_ = json.NewEncoder(w).Encode(TopUpByKeyResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPaid})
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/exchange"):
|
||||
exchangeCalls++
|
||||
t.Errorf("by-key must never exchange a key")
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", server.URL+"/v1")
|
||||
|
||||
var opened string
|
||||
result, err := StreamTopUpByKey(context.Background(), StreamTopUpByKeyOptions{
|
||||
APIKey: "sk-user",
|
||||
AmountUSD: "20",
|
||||
AutoTopUp: true,
|
||||
PaymentSessionID: "pay-1",
|
||||
OpenBrowser: func(u string) error { opened = u; return nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTopUpByKey = %v", err)
|
||||
}
|
||||
if result.APIKey != "" {
|
||||
t.Fatalf("by-key must not mint a key, got %q", result.APIKey)
|
||||
}
|
||||
if topupCalls != 1 || exchangeCalls != 0 {
|
||||
t.Fatalf("topup=%d exchange=%d, want 1/0", topupCalls, exchangeCalls)
|
||||
}
|
||||
if opened != "https://pay/checkout" {
|
||||
t.Fatalf("opened checkout = %q", opened)
|
||||
}
|
||||
if topupBody["autoTopUp"] != true || topupBody["paymentSessionId"] != "pay-1" {
|
||||
t.Fatalf("topup body missing autoTopUp/paymentSessionId: %#v", topupBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatedCheckoutURLRequiresAbsoluteHTTPS(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "https", value: " https://pay.example.test/checkout?id=1 ", want: "https://pay.example.test/checkout?id=1"},
|
||||
{name: "empty", wantErr: true},
|
||||
{name: "relative", value: "/checkout", wantErr: true},
|
||||
{name: "http", value: "http://pay.example.test/checkout", wantErr: true},
|
||||
{name: "file", value: "file:///tmp/checkout", wantErr: true},
|
||||
{name: "custom protocol", value: "wallet://checkout/123", wantErr: true},
|
||||
{name: "userinfo", value: "https://[email protected]/checkout", wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := validatedCheckoutURL(test.value)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("validatedCheckoutURL(%q) error = %v", test.value, err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("validatedCheckoutURL(%q) = %q, want %q", test.value, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpByKeyPinsValidatedInferenceEndpoint(t *testing.T) {
|
||||
validatedCalls := 0
|
||||
validated := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPendingAuth})
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/v2/billing/topup"):
|
||||
validatedCalls++
|
||||
_ = json.NewEncoder(w).Encode(TopUpByKeyResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPaid})
|
||||
default:
|
||||
t.Errorf("unexpected validated-endpoint request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer validated.Close()
|
||||
override := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("validated key leaked to process override: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer override.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", validated.URL)
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", override.URL)
|
||||
|
||||
_, err := StreamTopUpByKey(context.Background(), StreamTopUpByKeyOptions{
|
||||
APIKey: "production-key",
|
||||
AmountUSD: "20",
|
||||
InferenceBaseURL: validated.URL,
|
||||
PaymentSessionID: "payment-1",
|
||||
OpenBrowser: func(string) error { return nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTopUpByKey() error = %v", err)
|
||||
}
|
||||
if validatedCalls != 1 {
|
||||
t.Fatalf("validated endpoint top-up calls = %d, want 1", validatedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpEmailRetrySendsStablePaymentSessionID(t *testing.T) {
|
||||
getCalls := 0
|
||||
var paymentSessionID string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
|
||||
status := SessionStatusPendingAuth
|
||||
if getCalls > 0 {
|
||||
status = SessionStatusPaid
|
||||
}
|
||||
getCalls++
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_retry", Status: status})
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/pay"):
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
paymentSessionID, _ = body["paymentSessionId"].(string)
|
||||
_ = json.NewEncoder(w).Encode(PayResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pcs_retry",
|
||||
PaymentSessionID: "payment-stable",
|
||||
AmountUSD: "20",
|
||||
NoOpen: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTopUp() error = %v", err)
|
||||
}
|
||||
if paymentSessionID != "payment-stable" {
|
||||
t.Fatalf("paymentSessionId = %q, want stable retry id", paymentSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// resumeServer is a partner-checkout backend fake that fails the test if the
|
||||
// re-charging endpoints (create-session / pay) are ever hit, and serves a
|
||||
// scripted GetSession status sequence so a resume can be driven deterministically.
|
||||
type resumeServer struct {
|
||||
t *testing.T
|
||||
statuses []string // GetSession status per call, last value repeats
|
||||
getCalls int
|
||||
payCalls int
|
||||
createCall int
|
||||
exchanged int
|
||||
}
|
||||
|
||||
func (rs *resumeServer) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
|
||||
rs.createCall++
|
||||
rs.t.Errorf("resume must not create a new session")
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/pay"):
|
||||
rs.payCalls++
|
||||
rs.t.Errorf("resume must not re-pay (double charge)")
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/exchange"):
|
||||
rs.exchanged++
|
||||
_ = json.NewEncoder(w).Encode(ExchangeResult{APIKey: "sk-resumed", APIKeyID: "key_1"})
|
||||
case r.Method == http.MethodGet:
|
||||
status := rs.statuses[len(rs.statuses)-1]
|
||||
if rs.getCalls < len(rs.statuses) {
|
||||
status = rs.statuses[rs.getCalls]
|
||||
}
|
||||
rs.getCalls++
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pc_tok", Status: SessionStatus(status)})
|
||||
default:
|
||||
rs.t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreamTopUpResumePaidExchangesWithoutRecharge(t *testing.T) {
|
||||
rs := &resumeServer{t: t, statuses: []string{"paid"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
var seen string
|
||||
result, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
OnSession: func(token string) { seen = token },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTopUp resume = %v", err)
|
||||
}
|
||||
if result.APIKey != "sk-resumed" {
|
||||
t.Fatalf("APIKey = %q, want recovered key", result.APIKey)
|
||||
}
|
||||
if rs.exchanged != 1 {
|
||||
t.Fatalf("exchange calls = %d, want 1", rs.exchanged)
|
||||
}
|
||||
if seen != "pc_tok" {
|
||||
t.Fatalf("OnSession token = %q, want retained pc_tok", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpResumePendingPaymentPollsNeverRepays(t *testing.T) {
|
||||
// First GetSession (resume resolution) sees the still-open checkout; the next
|
||||
// (poll) sees it settle. Pay must never be called — the fake fails the test if
|
||||
// it is.
|
||||
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "paid"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
result, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTopUp resume = %v", err)
|
||||
}
|
||||
if result.APIKey != "sk-resumed" {
|
||||
t.Fatalf("APIKey = %q, want recovered key", result.APIKey)
|
||||
}
|
||||
if rs.payCalls != 0 {
|
||||
t.Fatalf("pay calls = %d, want 0", rs.payCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpPollingDeadSessionClearsRetainedToken(t *testing.T) {
|
||||
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "expired"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
seen := []string{}
|
||||
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
OnSession: func(token string) { seen = append(seen, token) },
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "payment expired") {
|
||||
t.Fatalf("err = %v, want expired payment error", err)
|
||||
}
|
||||
if len(seen) == 0 || seen[len(seen)-1] != "" {
|
||||
t.Fatalf("OnSession events = %#v, want terminal clear", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollUntilPaidTerminal4xxClearsRetainedToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "session rejected", http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
seen := "retained"
|
||||
_, err := pollUntilPaid(context.Background(), NewClient(Endpoints{AppBaseURL: server.URL}, server.Client()), "pc_missing", func(token string) {
|
||||
seen = token
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("pollUntilPaid() error = nil, want terminal 4xx")
|
||||
}
|
||||
if seen != "" {
|
||||
t.Fatalf("retained session = %q, want cleared", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollUntilExchangeSettledClearsDeadSession(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
statusCode int
|
||||
status SessionStatus
|
||||
}{
|
||||
{name: "terminal API rejection", statusCode: http.StatusNotFound},
|
||||
{name: "expired exchange claim", status: SessionStatusExpired},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if test.statusCode != 0 {
|
||||
http.Error(w, "session rejected", test.statusCode)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_dead", Status: test.status})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
seen := "retained"
|
||||
err := pollUntilExchangeSettled(context.Background(), NewClient(Endpoints{AppBaseURL: server.URL}, server.Client()), "pcs_dead", func(token string) {
|
||||
seen = token
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("pollUntilExchangeSettled() error = nil")
|
||||
}
|
||||
if seen != "" {
|
||||
t.Fatalf("retained session = %q, want cleared", seen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeHelpersClearOnlyRejectedSessions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
wantClear bool
|
||||
}{
|
||||
{name: "terminal 4xx", status: http.StatusNotFound, wantClear: true},
|
||||
{name: "retryable 5xx", status: http.StatusServiceUnavailable, wantClear: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "session lookup failed", test.status)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
|
||||
|
||||
for _, flow := range []struct {
|
||||
name string
|
||||
resolve func(func(string)) error
|
||||
}{
|
||||
{
|
||||
name: "session bearer",
|
||||
resolve: func(onSession func(string)) error {
|
||||
_, _, err := resolveTopupSession(context.Background(), client, StreamTopUpOptions{
|
||||
ResumeSessionToken: "pc_rejected", OnSession: onSession,
|
||||
}, DefaultPartnerID, DefaultPartnerName, Endpoints{})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "api key",
|
||||
resolve: func(onSession func(string)) error {
|
||||
_, _, err := resolveByKeySession(context.Background(), client, StreamTopUpByKeyOptions{
|
||||
ResumeSessionToken: "pc_rejected", OnSession: onSession,
|
||||
}, DefaultPartnerID, DefaultPartnerName, Endpoints{})
|
||||
return err
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(flow.name, func(t *testing.T) {
|
||||
seen := "retained"
|
||||
if err := flow.resolve(func(token string) { seen = token }); err == nil {
|
||||
t.Fatal("resolve error = nil")
|
||||
}
|
||||
if cleared := seen == ""; cleared != test.wantClear {
|
||||
t.Fatalf("session cleared = %v, want %v", cleared, test.wantClear)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpResumeExchangedReturnsRecoveryError(t *testing.T) {
|
||||
rs := &resumeServer{t: t, statuses: []string{"exchanged"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
|
||||
t.Fatalf("err = %v, want an already-exchanged recovery error", err)
|
||||
}
|
||||
if rs.exchanged != 0 {
|
||||
t.Fatalf("exchange calls = %d, want 0 (no second key)", rs.exchanged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpResumeExchangingPollsWithoutSecondExchange(t *testing.T) {
|
||||
rs := &resumeServer{t: t, statuses: []string{"exchanging", "exchanged"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
|
||||
t.Fatalf("err = %v, want dashboard recovery guidance", err)
|
||||
}
|
||||
if rs.exchanged != 0 {
|
||||
t.Fatalf("exchange calls = %d, want 0 while an exchange claim is in flight", rs.exchanged)
|
||||
}
|
||||
if rs.createCall != 0 || rs.payCalls != 0 {
|
||||
t.Fatalf("resume created/paid a new checkout: create=%d pay=%d", rs.createCall, rs.payCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpPollObservesExchangingWithoutSecondExchange(t *testing.T) {
|
||||
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "exchanging", "exchanged"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
|
||||
t.Fatalf("err = %v, want dashboard recovery guidance", err)
|
||||
}
|
||||
if rs.exchanged != 0 {
|
||||
t.Fatalf("exchange calls = %d, want 0 after poll observed an existing claim", rs.exchanged)
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status is a progress phase reported by StreamTopUp through its OnStatus callback.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusCreatingSession Status = "creating-session"
|
||||
StatusOpeningCheckout Status = "opening-checkout"
|
||||
StatusWaitingPayment Status = "waiting-payment"
|
||||
StatusProvisioningKey Status = "provisioning-key"
|
||||
)
|
||||
|
||||
// ProvisionedKey is the outcome of a top-up: the (optionally minted) API key plus
|
||||
// the inference base URL and model to write into the provider profile.
|
||||
type ProvisionedKey struct {
|
||||
APIKey string
|
||||
APIKeyID string
|
||||
BaseURL string
|
||||
Model string
|
||||
}
|
||||
|
||||
const (
|
||||
pollInterval = 3 * time.Second
|
||||
pollTimeout = 20 * time.Minute
|
||||
)
|
||||
|
||||
// ParseAmountUSD parses a dollar string into USD minor units (cents), enforcing the
|
||||
// min/max top-up bounds. An empty value yields the default amount.
|
||||
func ParseAmountUSD(value string) (int, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return DefaultAmountUSDMinor, nil
|
||||
}
|
||||
dollars, err := strconv.ParseFloat(value, 64)
|
||||
// NaN/±Inf parse without error and slip past the min/max comparisons below
|
||||
// (every ordered comparison with NaN is false), so reject them explicitly
|
||||
// before the cents conversion turns them into a garbage minor-unit amount.
|
||||
if err != nil || math.IsNaN(dollars) || math.IsInf(dollars, 0) || dollars <= 0 {
|
||||
return 0, fmt.Errorf("invalid amount %q; pass a positive USD amount", value)
|
||||
}
|
||||
minor := int(dollars*100 + 0.5)
|
||||
if minor < MinAmountUSDMinor {
|
||||
return 0, fmt.Errorf("minimum top-up is $%d", MinAmountUSDMinor/100)
|
||||
}
|
||||
if minor > MaxAmountUSDMinor {
|
||||
return 0, fmt.Errorf("maximum top-up is $%d", MaxAmountUSDMinor/100)
|
||||
}
|
||||
return minor, nil
|
||||
}
|
||||
|
||||
// NewPaymentSessionID returns a random UUIDv4-shaped idempotency id for a top-up.
|
||||
// The caller generates it once per top-up intent and reuses it on retry so the
|
||||
// backend (and Stripe) coalesce retries onto the same checkout instead of a
|
||||
// second charge.
|
||||
func NewPaymentSessionID() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||||
}
|
||||
|
||||
func pollUntilPaid(ctx context.Context, client *Client, sessionToken string, onSession func(string)) (PartnerCheckoutSession, error) {
|
||||
deadline := time.Now().Add(pollTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
session, err := client.GetSession(ctx, sessionToken)
|
||||
if err != nil {
|
||||
// Context cancellation/deadline is terminal (also covers an in-flight
|
||||
// request cancelled mid-poll, whose transport error wraps ctx.Err()).
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return PartnerCheckoutSession{}, err
|
||||
}
|
||||
// A 4xx API response is terminal. Everything else — a 5xx or a transient
|
||||
// transport error (non-APIError, e.g. a dropped connection during the
|
||||
// wait) — is retried until the poll deadline.
|
||||
if isTerminalSessionAPIError(err) {
|
||||
reportSession(onSession, "")
|
||||
return PartnerCheckoutSession{}, err
|
||||
}
|
||||
if err := sleepContext(ctx, pollInterval); err != nil {
|
||||
return PartnerCheckoutSession{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch session.Status {
|
||||
case SessionStatusPaid, SessionStatusExchanging:
|
||||
return session, nil
|
||||
case SessionStatusExchanged:
|
||||
return PartnerCheckoutSession{}, fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
|
||||
case SessionStatusCancelled, SessionStatusExpired, SessionStatusFailed:
|
||||
// The checkout cannot recover from these states. Drop the retained token
|
||||
// (and the TUI's paired by-key idempotency ID) now so the very next retry
|
||||
// creates a fresh payment intent.
|
||||
reportSession(onSession, "")
|
||||
return PartnerCheckoutSession{}, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
|
||||
default:
|
||||
if err := sleepContext(ctx, pollInterval); err != nil {
|
||||
return PartnerCheckoutSession{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return PartnerCheckoutSession{}, fmt.Errorf("timed out waiting for payment; re-run once the payment clears")
|
||||
}
|
||||
|
||||
func isTerminalSessionAPIError(err error) bool {
|
||||
var apiErr APIError
|
||||
return errors.As(err, &apiErr) && apiErr.Status >= http.StatusBadRequest && apiErr.Status < http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// pollUntilExchangeSettled follows an exchange claim that was already in flight
|
||||
// when a retry resumed the session. Exchange is single-flight and the raw key is
|
||||
// returned only to the winning request, so issuing Exchange again is never safe.
|
||||
// Once the claim completes, direct the user to the dashboard recovery path.
|
||||
func pollUntilExchangeSettled(ctx context.Context, client *Client, sessionToken string, onSession func(string)) error {
|
||||
deadline := time.Now().Add(pollTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
session, err := client.GetSession(ctx, sessionToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
if isTerminalSessionAPIError(err) {
|
||||
reportSession(onSession, "")
|
||||
return err
|
||||
}
|
||||
if err := sleepContext(ctx, pollInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch session.Status {
|
||||
case SessionStatusExchanged:
|
||||
return fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
|
||||
case SessionStatusExchanging:
|
||||
if err := sleepContext(ctx, pollInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
case SessionStatusCancelled, SessionStatusExpired, SessionStatusFailed:
|
||||
reportSession(onSession, "")
|
||||
return fmt.Errorf("key provisioning %s; rotate the key from the aimlapi.com dashboard", session.Status)
|
||||
default:
|
||||
return fmt.Errorf("key provisioning returned to %s; re-run the top-up", session.Status)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("timed out waiting for key provisioning; retry to check the same session")
|
||||
}
|
||||
|
||||
func sleepContext(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func status(callback func(Status, string), value Status, detail string) {
|
||||
if callback != nil {
|
||||
callback(value, detail)
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidEmail performs the lightweight validation used before starting the
|
||||
// passwordless onboarding request.
|
||||
func ValidEmail(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
address, err := mail.ParseAddress(value)
|
||||
if err != nil || address.Address != value {
|
||||
return false
|
||||
}
|
||||
at := strings.LastIndexByte(value, '@')
|
||||
if at <= 0 || at == len(value)-1 {
|
||||
return false
|
||||
}
|
||||
domain := value[at+1:]
|
||||
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") || strings.Contains(domain, "..") {
|
||||
return false
|
||||
}
|
||||
dot := strings.LastIndexByte(domain, '.')
|
||||
if dot <= 0 || dot == len(domain)-1 {
|
||||
return false
|
||||
}
|
||||
tld := domain[dot+1:]
|
||||
if len(tld) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, char := range tld {
|
||||
if char < 'A' || char > 'Z' && char < 'a' || char > 'z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
+5
-6
@@ -766,12 +766,11 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
}
|
||||
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
||||
Store: sandboxStore,
|
||||
Backend: sandboxBackend,
|
||||
Scope: scope,
|
||||
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
||||
Store: sandboxStore,
|
||||
Backend: sandboxBackend,
|
||||
Scope: scope,
|
||||
})
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
fileTracker := tools.NewFileTracker()
|
||||
|
||||
@@ -348,83 +348,6 @@ func TestRunProvidersAddWritesCatalogProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersAddAimlapiWritesDefaultHeaders(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "add", "aimlapi"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if len(cfg.Providers) != 1 {
|
||||
t.Fatalf("providers = %#v, want one provider", cfg.Providers)
|
||||
}
|
||||
profile := cfg.Providers[0]
|
||||
if profile.Name != "aimlapi" ||
|
||||
profile.CatalogID != "aimlapi" ||
|
||||
profile.BaseURL != "https://api.aimlapi.com/v1" ||
|
||||
profile.Model != "anthropic/claude-sonnet-5" ||
|
||||
profile.APIKeyEnv != "AIMLAPI_API_KEY" {
|
||||
t.Fatalf("unexpected provider profile: %#v", profile)
|
||||
}
|
||||
if profile.CustomHeaders["X-AIMLAPI-Partner-ID"] != "part_62yQoGYDq4Yqnrj2R1iGrDNJ" ||
|
||||
profile.CustomHeaders["X-AIMLAPI-Integration-Repo"] != "Gitlawb/zero" {
|
||||
t.Fatalf("missing aimlapi.com default headers: %#v", profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersAddAimlapiMixedCaseHeaderOverride(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
|
||||
// A differently-cased override must replace the catalog header in place, not
|
||||
// leave both spellings behind to race when request construction canonicalizes.
|
||||
exitCode := runWithDeps([]string{
|
||||
"providers", "add", "aimlapi",
|
||||
"--header", "x-aimlapi-partner-id=part_override",
|
||||
}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
profile := readFileConfig(t, configPath).Providers[0]
|
||||
if _, ok := profile.CustomHeaders["x-aimlapi-partner-id"]; ok {
|
||||
t.Fatalf("lowercase override left a duplicate key: %#v", profile.CustomHeaders)
|
||||
}
|
||||
if profile.CustomHeaders["X-AIMLAPI-Partner-ID"] != "part_override" {
|
||||
t.Fatalf("override did not win on the canonical key: %#v", profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersAddAimlapiOverrideDropsCatalogHeaders(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
|
||||
exitCode := runWithDeps([]string{
|
||||
"providers", "add", "aimlapi",
|
||||
"--base-url", "https://staging.example/v1",
|
||||
"--header", "X-Environment=staging",
|
||||
}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
profile := readFileConfig(t, configPath).Providers[0]
|
||||
if _, ok := profile.CustomHeaders["X-AIMLAPI-Partner-ID"]; ok {
|
||||
t.Fatalf("partner attribution leaked to overridden endpoint: %#v", profile.CustomHeaders)
|
||||
}
|
||||
if _, ok := profile.CustomHeaders["X-AIMLAPI-Integration-Repo"]; ok {
|
||||
t.Fatalf("integration attribution leaked to overridden endpoint: %#v", profile.CustomHeaders)
|
||||
}
|
||||
if profile.CustomHeaders["X-Environment"] != "staging" {
|
||||
t.Fatalf("explicit user header was dropped: %#v", profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersAddWritesCustomHeaders(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
+5
-19
@@ -853,28 +853,14 @@ func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig
|
||||
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox)
|
||||
backend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||
return sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Store: store,
|
||||
Backend: backend,
|
||||
Scope: scope,
|
||||
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Store: store,
|
||||
Backend: backend,
|
||||
Scope: scope,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func providerSensitiveEnvKeys(resolved config.ResolvedConfig) []string {
|
||||
keys := make([]string, 0, len(resolved.Providers)+1)
|
||||
for _, profile := range resolved.Providers {
|
||||
if key := strings.TrimSpace(profile.APIKeyEnv); key != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
if key := strings.TrimSpace(resolved.Provider.APIKeyEnv); key != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
|
||||
// the default policy.
|
||||
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
|
||||
|
||||
@@ -17,6 +17,7 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
defaultModel string
|
||||
}{
|
||||
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
|
||||
{id: "aimlapi", name: "AI/ML API", transport: "openai-compatible", defaultModel: "openai/gpt-5-chat"},
|
||||
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
|
||||
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
|
||||
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
|
||||
@@ -47,6 +48,21 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCatalogAIMLAPIRequiresAPIKey(t *testing.T) {
|
||||
output := runProviderCatalogOnboarding(t)
|
||||
block := providerCatalogOnboardingBlock(t, output, "aimlapi")
|
||||
|
||||
for _, want := range []string{
|
||||
"requiresAuth=true",
|
||||
"authEnvVars=AIMLAPI_API_KEY",
|
||||
"setup: zero providers setup aimlapi --set-active",
|
||||
} {
|
||||
if !strings.Contains(block, want) {
|
||||
t.Fatalf("expected AIMLAPI catalog block to contain %q, got:\n%s", want, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCatalogLongCatRequiresAPIKey(t *testing.T) {
|
||||
output := runProviderCatalogOnboarding(t)
|
||||
block := providerCatalogOnboardingBlock(t, output, "longcat")
|
||||
|
||||
@@ -3,10 +3,8 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/aimlapi"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
@@ -426,39 +424,22 @@ func providerProfileForAdd(options providerAddOptions) (config.ProviderProfile,
|
||||
if apiKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 {
|
||||
apiKeyEnv = descriptor.AuthEnvVars[0]
|
||||
}
|
||||
baseURL := firstNonEmptyCLI(options.baseURL, descriptor.DefaultBaseURL)
|
||||
var catalogHeaders map[string]string
|
||||
if sameProviderSetupBaseURL(baseURL, descriptor.DefaultBaseURL) {
|
||||
catalogHeaders = descriptor.CustomHeaders
|
||||
if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") {
|
||||
catalogHeaders = aimlapi.WithResolvedPartnerHeader(catalogHeaders)
|
||||
}
|
||||
}
|
||||
profile := config.ProviderProfile{
|
||||
Name: name,
|
||||
ProviderKind: providerKindForDescriptor(descriptor),
|
||||
CatalogID: descriptor.ID,
|
||||
BaseURL: baseURL,
|
||||
BaseURL: firstNonEmptyCLI(options.baseURL, descriptor.DefaultBaseURL),
|
||||
APIKeyEnv: apiKeyEnv,
|
||||
APIFormat: firstAPIFormat(descriptor),
|
||||
AuthHeader: strings.TrimSpace(options.authHeader),
|
||||
AuthScheme: normalizeAuthScheme(options.authScheme),
|
||||
AuthHeaderValue: strings.TrimSpace(options.authHeaderValue),
|
||||
// Catalog attribution belongs only to the catalog endpoint. Explicit
|
||||
// user headers still apply to an override/staging/proxy endpoint.
|
||||
CustomHeaders: mergeProviderHeaders(catalogHeaders, options.customHeaders),
|
||||
Model: firstNonEmptyCLI(options.model, descriptor.DefaultModel),
|
||||
CustomHeaders: copyProviderHeaders(options.customHeaders),
|
||||
Model: firstNonEmptyCLI(options.model, descriptor.DefaultModel),
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func sameProviderSetupBaseURL(left string, right string) bool {
|
||||
return strings.EqualFold(
|
||||
strings.TrimRight(strings.TrimSpace(left), "/"),
|
||||
strings.TrimRight(strings.TrimSpace(right), "/"),
|
||||
)
|
||||
}
|
||||
|
||||
func selectProviderForCheck(resolved config.ResolvedConfig, name string) (config.ProviderProfile, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
@@ -563,43 +544,3 @@ func copyProviderHeaders(headers map[string]string) map[string]string {
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func mergeProviderHeaders(base map[string]string, overrides map[string]string) map[string]string {
|
||||
merged := copyProviderHeaders(base)
|
||||
if len(overrides) == 0 {
|
||||
return merged
|
||||
}
|
||||
if merged == nil {
|
||||
merged = map[string]string{}
|
||||
}
|
||||
for key, value := range overrides {
|
||||
// HTTP header names are case-insensitive and request construction
|
||||
// canonicalizes them (http.Header.Set), so an override that differs from a
|
||||
// base key only in case must replace that key's value in place — otherwise
|
||||
// both survive as distinct map entries and race non-deterministically at
|
||||
// build time. Keep the existing spelling (the catalog's, which the resolver
|
||||
// re-merges verbatim); only a genuinely new header is added as typed.
|
||||
if existing, ok := headerKeyFold(merged, key); ok {
|
||||
merged[existing] = value
|
||||
continue
|
||||
}
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// headerKeyFold returns the key in headers whose canonical HTTP form matches key,
|
||||
// so a case-insensitive override lands on the existing entry instead of adding a
|
||||
// colliding duplicate.
|
||||
func headerKeyFold(headers map[string]string, key string) (string, bool) {
|
||||
if _, ok := headers[key]; ok {
|
||||
return key, true
|
||||
}
|
||||
canonical := http.CanonicalHeaderKey(key)
|
||||
for existing := range headers {
|
||||
if http.CanonicalHeaderKey(existing) == canonical {
|
||||
return existing, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
func TestProviderSensitiveEnvKeys(t *testing.T) {
|
||||
resolved := config.ResolvedConfig{
|
||||
Providers: []config.ProviderProfile{
|
||||
{Name: "custom", APIKeyEnv: " COMPANY_LLM_SECRET "},
|
||||
{Name: "catalog", APIKeyEnv: "OPENAI_API_KEY"},
|
||||
{Name: "inline"},
|
||||
},
|
||||
Provider: config.ProviderProfile{Name: "active", APIKeyEnv: "ACTIVE_PROVIDER_SECRET"},
|
||||
}
|
||||
want := []string{"COMPANY_LLM_SECRET", "OPENAI_API_KEY", "ACTIVE_PROVIDER_SECRET"}
|
||||
if got := providerSensitiveEnvKeys(resolved); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("providerSensitiveEnvKeys() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -200,54 +200,6 @@ func TestSaveSetupProviderStoresPastedAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSetupProviderAimlapiEnvReferenceAndOverrideHeaders(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
result, err := saveSetupProvider(appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
}, tui.SetupSelection{
|
||||
CatalogID: "aimlapi",
|
||||
BaseURL: "https://staging.example/v1",
|
||||
Model: "anthropic/claude-sonnet-5",
|
||||
}, setupSaveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("saveSetupProvider() error = %v", err)
|
||||
}
|
||||
if result.Provider.APIKey != "" || result.Provider.APIKeyEnv != "AIMLAPI_API_KEY" {
|
||||
t.Fatalf("runtime profile did not retain AIMLAPI_API_KEY reference: %+v", result.Provider)
|
||||
}
|
||||
if len(result.Provider.CustomHeaders) != 0 {
|
||||
t.Fatalf("catalog headers leaked to first-run override: %#v", result.Provider.CustomHeaders)
|
||||
}
|
||||
persisted := readFileConfig(t, configPath).Providers[0]
|
||||
if persisted.APIKey != "" || persisted.APIKeyEnv != "AIMLAPI_API_KEY" || persisted.APIKeyStored {
|
||||
t.Fatalf("persisted credential source changed: %+v", persisted)
|
||||
}
|
||||
if len(persisted.CustomHeaders) != 0 {
|
||||
t.Fatalf("persisted override contains catalog headers: %#v", persisted.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSetupProviderAimlapiUsesResolvedPartnerOverride(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_PARTNER_ID", "part_override")
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
result, err := saveSetupProvider(appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
}, tui.SetupSelection{
|
||||
CatalogID: "aimlapi",
|
||||
BaseURL: "https://api.aimlapi.com/v1",
|
||||
Model: "anthropic/claude-sonnet-5",
|
||||
}, setupSaveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("saveSetupProvider() error = %v", err)
|
||||
}
|
||||
if got := result.Provider.CustomHeaders["X-AIMLAPI-Partner-ID"]; got != "part_override" {
|
||||
t.Fatalf("runtime partner header = %q, want part_override", got)
|
||||
}
|
||||
if got := readFileConfig(t, configPath).Providers[0].CustomHeaders["X-AIMLAPI-Partner-ID"]; got != "part_override" {
|
||||
t.Fatalf("persisted partner header = %q, want part_override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSetupProviderStoresCustomEndpointSelection(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func mergeMCPConfig(dst *MCPConfig, src MCPConfig, canReenable bool) {
|
||||
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
|
||||
if len(src.Servers) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -14,14 +14,10 @@ func mergeMCPConfig(dst *MCPConfig, src MCPConfig, canReenable bool) {
|
||||
dst.Servers = map[string]MCPServerConfig{}
|
||||
}
|
||||
for name, server := range src.Servers {
|
||||
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server, canReenable)
|
||||
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server)
|
||||
}
|
||||
}
|
||||
|
||||
// mergeProjectMCPConfig merges a project-scoped (lower-trust) MCP config into
|
||||
// dst. It never re-enables a server the user explicitly disabled or disabled a
|
||||
// server the user explicitly enabled: it merges with canReenable=false so the
|
||||
// trust-boundary guard in mergeMCPServer keeps the user's decision authoritative.
|
||||
func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
|
||||
if len(src.Servers) == 0 {
|
||||
return nil
|
||||
@@ -31,7 +27,7 @@ func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
|
||||
}
|
||||
for name, server := range src.Servers {
|
||||
base := dst.Servers[name]
|
||||
candidate := mergeMCPServer(base, server, false)
|
||||
candidate := mergeMCPServer(base, server)
|
||||
if projectMCPServerTargetChanges(base, server) && hasInheritedMCPCredentialMaterial(server, candidate) {
|
||||
return fmt.Errorf("project MCP server %q cannot override target while inheriting user credentials; set headers/env/oauth explicitly or use a new server name", name)
|
||||
}
|
||||
@@ -41,11 +37,7 @@ func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeMCPServer merges a later config layer's MCP server into the base. The
|
||||
// canReenable flag marks the CLI-override scope, the only layer that is allowed
|
||||
// to lift a sticky user-level disable/enable decision (see the disabled-handling
|
||||
// block).
|
||||
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig, canReenable bool) MCPServerConfig {
|
||||
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig {
|
||||
if strings.TrimSpace(next.Type) != "" {
|
||||
base.Type = next.Type
|
||||
}
|
||||
@@ -70,26 +62,8 @@ func mergeMCPServer(base MCPServerConfig, next MCPServerConfig, canReenable bool
|
||||
if next.OAuth != nil {
|
||||
base.OAuth = next.OAuth
|
||||
}
|
||||
// Capture the higher-trust scope's prior decision before folding in
|
||||
// next.disabledSet. The trust boundary must be evaluated against the state
|
||||
// the higher-trust layer actually left behind, not against next's own flag.
|
||||
baseDisabledSet := base.disabledSet
|
||||
baseDisabled := base.Disabled
|
||||
if next.disabledSet {
|
||||
base.disabledSet = true
|
||||
}
|
||||
if next.disabledSet || next.Disabled {
|
||||
// Trust boundary: once a higher-trust scope has explicitly set the
|
||||
// disabled flag, a lower-trust layer (project config) cannot override
|
||||
// that decision in either direction — the user's choice (whether to
|
||||
// enable or disable a server) wins over the repo. This blocks a repo
|
||||
// from re-enabling a server the user disabled, and from disabling a
|
||||
// server the user explicitly enabled. The only layer allowed to
|
||||
// override an explicit higher-scope decision is the CLI override scope
|
||||
// (canReenable=true).
|
||||
if canReenable || !(baseDisabledSet && baseDisabled != next.Disabled) {
|
||||
base.Disabled = next.Disabled
|
||||
}
|
||||
base.Disabled = next.Disabled
|
||||
}
|
||||
if next.configured {
|
||||
base.configured = true
|
||||
@@ -101,7 +75,7 @@ func mergeMCPServer(base MCPServerConfig, next MCPServerConfig, canReenable bool
|
||||
}
|
||||
|
||||
func projectMCPServerTargetChanges(base MCPServerConfig, next MCPServerConfig) bool {
|
||||
if strings.TrimSpace(next.Type) != "" && mcpServerTransportKind(base) != mcpServerTransportKind(mergeMCPServer(base, next, true)) {
|
||||
if strings.TrimSpace(next.Type) != "" && mcpServerTransportKind(base) != mcpServerTransportKind(mergeMCPServer(base, next)) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(next.URL) != "" && strings.TrimSpace(next.URL) != strings.TrimSpace(base.URL) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/aimlapi"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
@@ -171,10 +170,7 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
|
||||
if err != nil {
|
||||
return MCPConfig{}, err
|
||||
}
|
||||
// User config is higher-trust than project config: it may re-enable a
|
||||
// server the user disabled (a user-level disable is sticky, but the user
|
||||
// scope itself may lift it).
|
||||
mergeMCPConfig(&cfg.MCP, fileConfig.MCP, true)
|
||||
mergeMCPConfig(&cfg.MCP, fileConfig.MCP)
|
||||
}
|
||||
// Drop the project layer when the workspace is untrusted, so a cloned repo's
|
||||
// ./.zero/config.json cannot register (and spawn) MCP servers. Fail-closed:
|
||||
@@ -188,7 +184,7 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
|
||||
return MCPConfig{}, err
|
||||
}
|
||||
}
|
||||
mergeMCPConfig(&cfg.MCP, options.Overrides.MCP, true)
|
||||
mergeMCPConfig(&cfg.MCP, options.Overrides.MCP)
|
||||
return cfg.MCP, nil
|
||||
}
|
||||
|
||||
@@ -215,7 +211,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
for _, provider := range src.Providers {
|
||||
mergeProvider(dst, provider)
|
||||
}
|
||||
mergeMCPConfig(&dst.MCP, src.MCP, true)
|
||||
mergeMCPConfig(&dst.MCP, src.MCP)
|
||||
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
||||
dst.Sandbox.Network = network
|
||||
}
|
||||
@@ -713,7 +709,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
if hasProviderFields(overrides.Provider) {
|
||||
mergeProvider(cfg, overrides.Provider)
|
||||
}
|
||||
mergeMCPConfig(&cfg.MCP, overrides.MCP, true)
|
||||
mergeMCPConfig(&cfg.MCP, overrides.MCP)
|
||||
}
|
||||
|
||||
func mergeLocalControlConfig(dst *LocalControlConfig, src LocalControlConfig) {
|
||||
@@ -1043,45 +1039,6 @@ func applyCatalogDescriptor(profile *ProviderProfile, descriptor providercatalog
|
||||
if profile.APIKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 && (!explicitBaseURL || sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)) {
|
||||
profile.APIKeyEnv = descriptor.AuthEnvVars[0]
|
||||
}
|
||||
canonicalCatalogEndpoint := !explicitBaseURL || sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)
|
||||
if len(descriptor.CustomHeaders) > 0 && canonicalCatalogEndpoint {
|
||||
catalogHeaders := descriptor.CustomHeaders
|
||||
if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") {
|
||||
catalogHeaders = aimlapi.WithResolvedPartnerHeader(catalogHeaders)
|
||||
}
|
||||
merged := copyStringMap(catalogHeaders)
|
||||
for key, value := range profile.CustomHeaders {
|
||||
// Header names are case-insensitive. Preserve the catalog spelling while
|
||||
// replacing its value so request construction cannot see two colliding
|
||||
// map entries whose eventual winner depends on iteration order.
|
||||
existingKey := ""
|
||||
for candidate := range merged {
|
||||
if strings.EqualFold(candidate, key) {
|
||||
existingKey = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if existingKey != "" {
|
||||
merged[existingKey] = value
|
||||
continue
|
||||
}
|
||||
merged[key] = value
|
||||
}
|
||||
profile.CustomHeaders = merged
|
||||
} else if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") && !canonicalCatalogEndpoint {
|
||||
// AIMLAPI attribution is owned by the catalog endpoint. A profile can retain
|
||||
// those generated headers after its base URL is edited; strip their names
|
||||
// before sending requests to an arbitrary staging/proxy host while preserving
|
||||
// unrelated headers explicitly supplied by the user.
|
||||
for profileKey := range profile.CustomHeaders {
|
||||
for catalogKey := range descriptor.CustomHeaders {
|
||||
if strings.EqualFold(profileKey, catalogKey) {
|
||||
delete(profile.CustomHeaders, profileKey)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sameBaseURL(left string, right string) bool {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
)
|
||||
|
||||
func TestResolveAppliesLayerPrecedence(t *testing.T) {
|
||||
@@ -555,7 +554,7 @@ func TestResolveReplacesMCPServerOverlayCollections(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPServerLayersCannotReenableUserDisabled(t *testing.T) {
|
||||
func TestResolveMCPServerLayersCanClearAndReenable(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
@@ -589,11 +588,9 @@ func TestResolveMCPServerLayersCannotReenableUserDisabled(t *testing.T) {
|
||||
}
|
||||
|
||||
docs := resolved.MCP.Servers["docs"]
|
||||
// A user-level disable is sticky: project config must not re-enable it.
|
||||
if !docs.Disabled {
|
||||
t.Fatal("docs.Disabled = false, want user-level disable to remain sticky")
|
||||
if docs.Disabled {
|
||||
t.Fatalf("docs.Disabled = true, want project layer to re-enable")
|
||||
}
|
||||
// Non-disable fields from the project layer still merge normally.
|
||||
if len(docs.Args) != 0 {
|
||||
t.Fatalf("docs.Args = %#v, want project layer to clear inherited args", docs.Args)
|
||||
}
|
||||
@@ -602,125 +599,6 @@ func TestResolveMCPServerLayersCannotReenableUserDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMCPStickyDisableReenable(t *testing.T) {
|
||||
disabled := MCPServerConfig{Disabled: true, disabledSet: true}
|
||||
|
||||
// A lower-trust (project) layer must not lift a sticky user disable.
|
||||
project := MCPServerConfig{Disabled: false, disabledSet: true}
|
||||
if got := mergeMCPServer(disabled, project, false); !got.Disabled {
|
||||
t.Fatal("project layer re-enabled a sticky user disable")
|
||||
}
|
||||
|
||||
// The user scope (explicit mcp enable command / CLI override) may re-enable.
|
||||
override := MCPServerConfig{Disabled: false, disabledSet: true}
|
||||
if got := mergeMCPServer(disabled, override, true); got.Disabled {
|
||||
t.Fatal("user-scope override must be able to re-enable")
|
||||
}
|
||||
|
||||
// A user-scope disable stacked on a project attempt still sticks.
|
||||
if got := mergeMCPServer(disabled, project, false); !got.Disabled {
|
||||
t.Fatal("sticky disable must survive a project re-enable attempt")
|
||||
}
|
||||
|
||||
// Baseline: a project layer may disable a default-enabled server when no
|
||||
// higher-trust scope has explicitly set disabled (the trust boundary must
|
||||
// not block legitimate project disables). Regression guard for the
|
||||
// disabledSet-before-check ordering bug.
|
||||
defaultEnabled := MCPServerConfig{Disabled: false, disabledSet: false}
|
||||
projectDisable := MCPServerConfig{Disabled: true, disabledSet: true}
|
||||
if got := mergeMCPServer(defaultEnabled, projectDisable, false); !got.Disabled {
|
||||
t.Fatal("project layer should be able to disable a default-enabled server")
|
||||
}
|
||||
|
||||
// And an empty project layer must not flip an unconfigured server to disabled.
|
||||
if got := mergeMCPServer(MCPServerConfig{}, MCPServerConfig{}, false); got.Disabled {
|
||||
t.Fatal("an empty project layer must not disable an unconfigured server")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveMCPCannotReenableUserDisabled exercises the trust boundary at the
|
||||
// actual ResolveMCP entry point: a project config (project scope) must not lift
|
||||
// a disable the user set in their higher-trust user config.
|
||||
func TestResolveMCPCannotReenableUserDisabled(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
// Project config tries to re-enable it.
|
||||
projectPath := writeConfig(t, `{
|
||||
"mcpServers": {
|
||||
"docs": {
|
||||
"disabled": false
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
resolved, err := ResolveMCP(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP() error = %v", err)
|
||||
}
|
||||
|
||||
docs, ok := resolved.Servers["docs"]
|
||||
if !ok {
|
||||
t.Fatal("docs server missing from resolved config")
|
||||
}
|
||||
if !docs.Disabled {
|
||||
t.Fatalf("docs.Disabled = false, want user-level disable to remain sticky across project re-enable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveMCPUserLiftsProjectDisabled asserts the reverse direction is open:
|
||||
// a user config (higher-trust scope) re-enabling a server the project config
|
||||
// disabled must be permitted, since user > project in the trust hierarchy.
|
||||
func TestResolveMCPUserLiftsProjectDisabled(t *testing.T) {
|
||||
// Project config disables the server.
|
||||
projectPath := writeConfig(t, `{
|
||||
"mcpServers": {
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
}`)
|
||||
// User config re-enables it.
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"disabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
resolved, err := ResolveMCP(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP() error = %v", err)
|
||||
}
|
||||
|
||||
docs, ok := resolved.Servers["docs"]
|
||||
if !ok {
|
||||
t.Fatal("docs server missing from resolved config")
|
||||
}
|
||||
if docs.Disabled {
|
||||
t.Fatalf("docs.Disabled = true, want user scope to lift the project-level disable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsDuplicateMCPRootAliases(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"mcpServers": {
|
||||
@@ -1496,71 +1374,6 @@ func TestResolveProviderProfileExtendedJSONAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCatalogDescriptorFoldsCustomHeadersCaseInsensitively(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_PARTNER_ID", "part_env")
|
||||
descriptor, err := providercatalog.Require("aimlapi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := ProviderProfile{
|
||||
BaseURL: descriptor.DefaultBaseURL,
|
||||
CustomHeaders: map[string]string{
|
||||
"x-aimlapi-partner-id": "part_override",
|
||||
},
|
||||
}
|
||||
|
||||
applyCatalogDescriptor(&profile, descriptor, true)
|
||||
|
||||
if got := profile.CustomHeaders["X-AIMLAPI-Partner-ID"]; got != "part_override" {
|
||||
t.Fatalf("canonical header value = %q, want override", got)
|
||||
}
|
||||
if _, duplicate := profile.CustomHeaders["x-aimlapi-partner-id"]; duplicate {
|
||||
t.Fatalf("case-colliding header survived merge: %#v", profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCatalogDescriptorUsesAimlapiPartnerEnvironmentOverride(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_PARTNER_ID", "part_env")
|
||||
descriptor, err := providercatalog.Require("aimlapi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := ProviderProfile{BaseURL: descriptor.DefaultBaseURL}
|
||||
|
||||
applyCatalogDescriptor(&profile, descriptor, true)
|
||||
|
||||
if got := profile.CustomHeaders["X-AIMLAPI-Partner-ID"]; got != "part_env" {
|
||||
t.Fatalf("partner header = %q, want part_env", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCatalogDescriptorStripsAimlapiAttributionFromRetargetedProfile(t *testing.T) {
|
||||
descriptor, err := providercatalog.Require("aimlapi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := ProviderProfile{
|
||||
BaseURL: "https://proxy.example.test/v1",
|
||||
CustomHeaders: map[string]string{
|
||||
"x-aimlapi-partner-id": "persisted-partner",
|
||||
"X-AIMLAPI-Integration-Repo": "Gitlawb/zero",
|
||||
"X-AIMLAPI-Integration-Version": "zero",
|
||||
"X-Environment": "staging",
|
||||
},
|
||||
}
|
||||
|
||||
applyCatalogDescriptor(&profile, descriptor, true)
|
||||
|
||||
for key := range profile.CustomHeaders {
|
||||
if strings.HasPrefix(strings.ToLower(key), "x-aimlapi-") {
|
||||
t.Fatalf("catalog attribution survived retargeting: %#v", profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
if profile.CustomHeaders["X-Environment"] != "staging" {
|
||||
t.Fatalf("user header was removed: %#v", profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProviderProfileParseThinkTagsFalseAlias(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "custom",
|
||||
|
||||
@@ -34,7 +34,7 @@ const (
|
||||
|
||||
var ErrUnknownProvider = errors.New("unknown provider")
|
||||
|
||||
// AIMLAPIID is the provider catalog identifier for the aimlapi.com preset.
|
||||
// AIMLAPIID is the provider catalog identifier for the AI/ML API preset.
|
||||
const AIMLAPIID = "aimlapi"
|
||||
|
||||
type Descriptor struct {
|
||||
@@ -44,7 +44,6 @@ type Descriptor struct {
|
||||
DefaultBaseURL string
|
||||
DefaultModel string
|
||||
AuthEnvVars []string
|
||||
CustomHeaders map[string]string
|
||||
RequiresAuth bool
|
||||
UsesAmbientAuth bool
|
||||
Public bool
|
||||
@@ -71,9 +70,9 @@ type Descriptor struct {
|
||||
// headless / SSH use) in addition to the browser flow.
|
||||
OAuthDeviceFlow bool
|
||||
|
||||
// Recommended marks a provider surfaced at the top and badged in
|
||||
// catalog-ordered lists and pickers. The recommended descriptors are the first
|
||||
// entries in the catalog, in order.
|
||||
// Recommended marks the provider that should be surfaced first and badged
|
||||
// (★ … (recommended)) in every catalog-ordered list and picker. At most one
|
||||
// descriptor should set this.
|
||||
Recommended bool
|
||||
}
|
||||
|
||||
@@ -104,11 +103,6 @@ var descriptors = []Descriptor{
|
||||
// minimax, qwen, google, nvidia, tencent, z-ai). Flat /v1/chat/completions
|
||||
// with a Bearer ogw_live_… key; listed first and badged in every picker.
|
||||
recommended(openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://opengateway.gitlawb.com/v1", "mimo-v2.5-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway")),
|
||||
// aimlapi.com — OpenAI-compatible aggregating gateway, also badged as
|
||||
// recommended (second, right after the OpenGateway default). The built-in TUI
|
||||
// onboarding can register/top up an account and save the issued key; the partner
|
||||
// headers attribute usage for the rebate.
|
||||
recommended(aimlapi()),
|
||||
openAI("openai", "OpenAI", "https://api.openai.com/v1", "gpt-4.1", []string{"OPENAI_API_KEY"}),
|
||||
anthropic("anthropic", "Anthropic", "https://api.anthropic.com", "claude-sonnet-4.5", []string{"ANTHROPIC_API_KEY"}),
|
||||
google("google", "Google", "https://generativelanguage.googleapis.com", "gemini-2.5-pro", []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"}, "gemini"),
|
||||
@@ -116,6 +110,7 @@ var descriptors = []Descriptor{
|
||||
localOpenAI("ollama", "Ollama Local", "http://localhost:11434/v1", "llama3.1", "ollama local"),
|
||||
localOpenAI("lmstudio", "LM Studio", "http://localhost:1234/v1", "local-model", "lm-studio", "lm studio"),
|
||||
oauthProvider(openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}), true, false),
|
||||
openAICompat(AIMLAPIID, "AI/ML API", "https://api.aimlapi.com/v1", "openai/gpt-5-chat", []string{"AIMLAPI_API_KEY"}, "aiml api", "ai/ml api"),
|
||||
// Hugging Face Inference Providers — OpenAI-compatible router at
|
||||
// https://router.huggingface.co/v1 exposes hundreds of OSS models. OAuth
|
||||
// requires a one-time app registration at huggingface.co/settings/applications/new
|
||||
@@ -307,21 +302,8 @@ func google(id string, name string, baseURL string, model string, env []string,
|
||||
}
|
||||
}
|
||||
|
||||
func aimlapi() Descriptor {
|
||||
descriptor := openAICompat(AIMLAPIID, "aimlapi.com", "https://api.aimlapi.com/v1", "anthropic/claude-sonnet-5", []string{"AIMLAPI_API_KEY"}, "aimlapi", "aiml api", "ai/ml api", "ai ml api")
|
||||
descriptor.CustomHeaders = map[string]string{
|
||||
"X-AIMLAPI-Partner-ID": "part_62yQoGYDq4Yqnrj2R1iGrDNJ",
|
||||
"X-AIMLAPI-Integration-Repo": "Gitlawb/zero",
|
||||
"X-AIMLAPI-Integration-Version": "zero",
|
||||
}
|
||||
// Onboarding is key-based only: the TUI sub-flow takes an existing key or an
|
||||
// email top-up (internal/aimlapi Paths A/B). Not flagged
|
||||
// OAuth-capable, so it does not appear in the wizard's "Sign in with OAuth" list.
|
||||
return descriptor
|
||||
}
|
||||
|
||||
// recommended marks a descriptor as a recommended default so list/picker
|
||||
// surfaces sort it to the top and render a provider-specific badge.
|
||||
// recommended marks a descriptor as the recommended default so list/picker
|
||||
// surfaces sort it first and render the ★ … (recommended) badge.
|
||||
func recommended(descriptor Descriptor) Descriptor {
|
||||
descriptor.Recommended = true
|
||||
return descriptor
|
||||
@@ -402,19 +384,5 @@ func cloneDescriptor(descriptor Descriptor) Descriptor {
|
||||
descriptor.AuthEnvVars = append([]string{}, descriptor.AuthEnvVars...)
|
||||
descriptor.SupportedAPIFormats = append([]APIFormat{}, descriptor.SupportedAPIFormats...)
|
||||
descriptor.Aliases = append([]string{}, descriptor.Aliases...)
|
||||
if descriptor.CustomHeaders != nil {
|
||||
descriptor.CustomHeaders = copyStringMap(descriptor.CustomHeaders)
|
||||
}
|
||||
return descriptor
|
||||
}
|
||||
|
||||
func copyStringMap(values map[string]string) map[string]string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
copied := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copied[key] = value
|
||||
}
|
||||
return copied
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
var expectedCatalogIDs = []string{
|
||||
"gitlawb-opengateway",
|
||||
"aimlapi",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
@@ -17,6 +16,7 @@ var expectedCatalogIDs = []string{
|
||||
"ollama",
|
||||
"lmstudio",
|
||||
"openrouter",
|
||||
"aimlapi",
|
||||
"huggingface",
|
||||
"chatgpt",
|
||||
"groq",
|
||||
@@ -69,31 +69,25 @@ func TestAllHasStableUniqueIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendedProvidersAreTopOfCatalog(t *testing.T) {
|
||||
func TestRecommendedProviderIsFirstAndUnique(t *testing.T) {
|
||||
descriptors := All()
|
||||
// The recommended providers are badged and pinned to the top of the catalog,
|
||||
// in this order (OpenGateway remains the default; aimlapi.com is also badged).
|
||||
wantTop := []string{"gitlawb-opengateway", "aimlapi"}
|
||||
if len(descriptors) < len(wantTop) {
|
||||
t.Fatalf("All() returned %d descriptors, want at least %d", len(descriptors), len(wantTop))
|
||||
if len(descriptors) == 0 {
|
||||
t.Fatal("All() returned no descriptors")
|
||||
}
|
||||
for index, id := range wantTop {
|
||||
if descriptors[index].ID != id {
|
||||
t.Fatalf("descriptors[%d] = %q, want %q", index, descriptors[index].ID, id)
|
||||
}
|
||||
if !descriptors[index].Recommended {
|
||||
t.Fatalf("descriptors[%d] (%q) should be recommended", index, id)
|
||||
}
|
||||
if !descriptors[0].Recommended {
|
||||
t.Fatalf("All()[0] = %q, want it to be the recommended provider", descriptors[0].ID)
|
||||
}
|
||||
if descriptors[0].ID != "gitlawb-opengateway" {
|
||||
t.Fatalf("recommended provider = %q, want %q", descriptors[0].ID, "gitlawb-opengateway")
|
||||
}
|
||||
// Exactly those are recommended, and they are contiguous at the top.
|
||||
recommended := 0
|
||||
for _, descriptor := range descriptors {
|
||||
if descriptor.Recommended {
|
||||
recommended++
|
||||
}
|
||||
}
|
||||
if recommended != len(wantTop) {
|
||||
t.Fatalf("recommended descriptor count = %d, want %d", recommended, len(wantTop))
|
||||
if recommended != 1 {
|
||||
t.Fatalf("recommended descriptor count = %d, want exactly 1", recommended)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,14 +109,14 @@ func TestAIMLAPIDescriptor(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Require(aimlapi) error = %v", err)
|
||||
}
|
||||
if descriptor.Name != "aimlapi.com" {
|
||||
t.Fatalf("Name = %q, want aimlapi.com", descriptor.Name)
|
||||
if descriptor.Name != "AI/ML API" {
|
||||
t.Fatalf("Name = %q, want AI/ML API", descriptor.Name)
|
||||
}
|
||||
if descriptor.DefaultBaseURL != "https://api.aimlapi.com/v1" {
|
||||
t.Fatalf("DefaultBaseURL = %q, want aimlapi.com endpoint", descriptor.DefaultBaseURL)
|
||||
t.Fatalf("DefaultBaseURL = %q, want AI/ML API endpoint", descriptor.DefaultBaseURL)
|
||||
}
|
||||
if descriptor.DefaultModel != "anthropic/claude-sonnet-5" {
|
||||
t.Fatalf("DefaultModel = %q, want anthropic/claude-sonnet-5", descriptor.DefaultModel)
|
||||
if descriptor.DefaultModel != "openai/gpt-5-chat" {
|
||||
t.Fatalf("DefaultModel = %q, want openai/gpt-5-chat", descriptor.DefaultModel)
|
||||
}
|
||||
if descriptor.Transport != TransportOpenAICompatible {
|
||||
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
|
||||
@@ -322,7 +316,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "aimlapi", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
|
||||
@@ -61,17 +61,6 @@ var zaiCuratedModels = []Model{
|
||||
}
|
||||
|
||||
var curatedModels = map[string][]Model{
|
||||
// aimlapi.com aggregates every upstream provider behind one OpenAI-compatible
|
||||
// endpoint, so the curated defaults are a cross-provider "best of" flagship
|
||||
// list. The live /v1/models discovery (when a key is present) replaces this;
|
||||
// these are the offline/pre-key fallback and the picker's default order.
|
||||
"aimlapi": {
|
||||
{ID: "anthropic/claude-sonnet-5", Description: "catalog default (Anthropic Claude Sonnet 5)"},
|
||||
{ID: "google/gemini-3.5-flash", Description: "Google Gemini Flash 3.5"},
|
||||
{ID: "openai/gpt-5.5-2026-04-23", Description: "OpenAI GPT-5.5"},
|
||||
{ID: "qwen/qwen-3.7-max", Description: "Qwen 3.7 Max"},
|
||||
{ID: "deepseek/deepseek-v4-pro", Description: "DeepSeek V4"},
|
||||
},
|
||||
"ollama-cloud": {
|
||||
{ID: "qwen3-coder:480b", Description: "catalog default"},
|
||||
{ID: "gpt-oss:120b", Description: "agentic coding model"},
|
||||
|
||||
@@ -33,11 +33,6 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
want: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"},
|
||||
notWant: []string{"gpt-5", "gpt-4.1", "openai/gpt-4.1"},
|
||||
},
|
||||
{
|
||||
provider: "aimlapi",
|
||||
want: []string{"anthropic/claude-sonnet-5", "google/gemini-3.5-flash", "openai/gpt-5.5-2026-04-23", "qwen/qwen-3.7-max", "deepseek/deepseek-v4-pro"},
|
||||
notWant: []string{"gpt-4o", "openai/gpt-4o", "anthropic/claude-opus-4.8", "x-ai/grok-4-5"},
|
||||
},
|
||||
{
|
||||
provider: "mistral",
|
||||
want: []string{"mistral-large-latest", "codestral-latest"},
|
||||
|
||||
+18
-23
@@ -14,22 +14,18 @@ type EngineOptions struct {
|
||||
Store *GrantStore
|
||||
Backend Backend
|
||||
Scope *Scope
|
||||
// SensitiveEnvKeys adds config-derived credential variable names to the
|
||||
// catalog and namespace secrets scrubbed from sandboxed commands.
|
||||
SensitiveEnvKeys []string
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
workspaceRoot string
|
||||
policy Policy
|
||||
store *GrantStore
|
||||
backend Backend
|
||||
scope *Scope
|
||||
sensitiveEnvKeys []string
|
||||
sessionGrants *memoryGrantSet
|
||||
sessionProfiles *permissionProfileGrantSet
|
||||
turnProfiles *permissionProfileGrantSet
|
||||
commandPrefixes *commandPrefixGrantSet
|
||||
workspaceRoot string
|
||||
policy Policy
|
||||
store *GrantStore
|
||||
backend Backend
|
||||
scope *Scope
|
||||
sessionGrants *memoryGrantSet
|
||||
sessionProfiles *permissionProfileGrantSet
|
||||
turnProfiles *permissionProfileGrantSet
|
||||
commandPrefixes *commandPrefixGrantSet
|
||||
}
|
||||
|
||||
func NewEngine(options EngineOptions) *Engine {
|
||||
@@ -53,16 +49,15 @@ func NewEngine(options EngineOptions) *Engine {
|
||||
scope = newScopeBestEffort(workspaceRoot)
|
||||
}
|
||||
return &Engine{
|
||||
workspaceRoot: workspaceRoot,
|
||||
policy: policy,
|
||||
store: options.Store,
|
||||
backend: options.Backend,
|
||||
scope: scope,
|
||||
sensitiveEnvKeys: normalizeSensitiveEnvKeys(options.SensitiveEnvKeys),
|
||||
sessionGrants: newMemoryGrantSet(),
|
||||
sessionProfiles: newPermissionProfileGrantSet(),
|
||||
turnProfiles: newPermissionProfileGrantSet(),
|
||||
commandPrefixes: newCommandPrefixGrantSet(),
|
||||
workspaceRoot: workspaceRoot,
|
||||
policy: policy,
|
||||
store: options.Store,
|
||||
backend: options.Backend,
|
||||
scope: scope,
|
||||
sessionGrants: newMemoryGrantSet(),
|
||||
sessionProfiles: newPermissionProfileGrantSet(),
|
||||
turnProfiles: newPermissionProfileGrantSet(),
|
||||
commandPrefixes: newCommandPrefixGrantSet(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,9 +29,6 @@ type CommandSpec struct {
|
||||
Args []string
|
||||
Dir string
|
||||
Env []string
|
||||
// sensitiveEnvKeys is populated by Engine from config-derived credential
|
||||
// variable names and carried through SandboxManager to the platform plan.
|
||||
sensitiveEnvKeys []string
|
||||
}
|
||||
|
||||
type CommandPlan struct {
|
||||
@@ -125,7 +122,6 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
|
||||
return CommandPlan{}, errors.New("sandbox command name is required")
|
||||
}
|
||||
spec.Dir = commandDir
|
||||
spec.sensitiveEnvKeys = engine.sensitiveEnvKeys
|
||||
|
||||
backend := engine.backend
|
||||
if backend.Name == "" {
|
||||
@@ -211,7 +207,7 @@ func linuxSandboxHelperCommandPlan(execRequest SandboxExecutionRequest, policy P
|
||||
if err != nil {
|
||||
return CommandPlan{}, err
|
||||
}
|
||||
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendLinuxBwrap, "", spec.sensitiveEnvKeys)
|
||||
env := sandboxEnvironmentForCommand(spec.Env, policy, BackendLinuxBwrap, "")
|
||||
planDir := spec.Dir
|
||||
if helper.Dir != "" {
|
||||
planDir = helper.Dir
|
||||
@@ -257,27 +253,10 @@ func directCommandPlan(spec CommandSpec, backend Backend, policy Policy, workspa
|
||||
Name: spec.Name,
|
||||
Args: cloneStrings(spec.Args),
|
||||
Dir: spec.Dir,
|
||||
Env: directCommandEnv(spec),
|
||||
Env: cloneStrings(spec.Env),
|
||||
}
|
||||
}
|
||||
|
||||
// directCommandEnv scrubs sensitive credentials from the environment for a
|
||||
// direct (unwrapped) command plan: the platform sandbox backend is
|
||||
// unavailable, disabled, or not required, so this is the actual environment
|
||||
// the child process inherits. Without scrubbing here, config-derived
|
||||
// apiKeyEnv and dynamic OAuth client-secret variables would leak into
|
||||
// commands that fall back to this path (e.g. EnforcementDegraded).
|
||||
func directCommandEnv(spec CommandSpec) []string {
|
||||
env := cloneStrings(spec.Env)
|
||||
if spec.Env == nil {
|
||||
// Match the wrapped-plan behavior: an unset spec.Env means "inherit the
|
||||
// caller's environment," so scrub a snapshot of it rather than passing
|
||||
// nil through to exec.Cmd, which would skip scrubbing entirely.
|
||||
env = os.Environ()
|
||||
}
|
||||
return scrubSensitiveEnv(env, spec.sensitiveEnvKeys...)
|
||||
}
|
||||
|
||||
func (engine *Engine) resolveCommandDir(dir string, policy Policy) (string, string, error) {
|
||||
workspaceRoot := strings.TrimSpace(engine.workspaceRoot)
|
||||
if workspaceRoot == "" {
|
||||
@@ -339,7 +318,7 @@ func seatbeltCommandPlanWithProfile(spec CommandSpec, workspaceRoot string, prof
|
||||
if envBackend == "" {
|
||||
envBackend = BackendMacOSSeatbelt
|
||||
}
|
||||
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, envBackend, "", spec.sensitiveEnvKeys)
|
||||
env := sandboxEnvironmentForCommand(spec.Env, policy, envBackend, "")
|
||||
plan := CommandPlan{
|
||||
Backend: backend,
|
||||
TargetBackend: backend.TargetBackend(),
|
||||
@@ -398,10 +377,6 @@ func sandboxEnvironment(policy Policy, backend BackendName, workspaceRoot string
|
||||
}
|
||||
|
||||
func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend BackendName, workspaceRoot string) []string {
|
||||
return sandboxEnvironmentForCommandWithSensitiveEnv(specEnv, policy, backend, workspaceRoot, nil)
|
||||
}
|
||||
|
||||
func sandboxEnvironmentForCommandWithSensitiveEnv(specEnv []string, policy Policy, backend BackendName, workspaceRoot string, sensitiveEnvKeys []string) []string {
|
||||
env := cloneStrings(specEnv)
|
||||
if specEnv == nil {
|
||||
// Preserve the caller environment for sandboxed commands. The sandbox
|
||||
@@ -409,7 +384,7 @@ func sandboxEnvironmentForCommandWithSensitiveEnv(specEnv []string, policy Polic
|
||||
// command env values still replace inherited values below.
|
||||
env = os.Environ()
|
||||
}
|
||||
env = scrubSensitiveEnv(env, sensitiveEnvKeys...)
|
||||
env = scrubSensitiveEnv(env)
|
||||
pathValue := envListValue(env, "PATH", defaultPath())
|
||||
if runtime.GOOS == "darwin" {
|
||||
// Preserve standard user tool locations so a bare `python3`/`node`
|
||||
@@ -992,7 +967,7 @@ func regexpQuoteMeta(value string) string {
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
|
||||
func scrubSensitiveEnv(env []string, additionalKeys ...string) []string {
|
||||
func scrubSensitiveEnv(env []string) []string {
|
||||
// Secrets not covered by the provider catalog: cloud/VCS credentials and
|
||||
// providers Zero talks to through generic OpenAI-compatible endpoints.
|
||||
sensitiveKeys := []string{
|
||||
@@ -1022,7 +997,6 @@ func scrubSensitiveEnv(env []string, additionalKeys ...string) []string {
|
||||
sensitiveKeys = append(sensitiveKeys, key)
|
||||
}
|
||||
}
|
||||
sensitiveKeys = append(sensitiveKeys, normalizeSensitiveEnvKeys(additionalKeys)...)
|
||||
|
||||
out := make([]string, 0, len(env))
|
||||
for _, kv := range env {
|
||||
@@ -1031,7 +1005,7 @@ func scrubSensitiveEnv(env []string, additionalKeys ...string) []string {
|
||||
out = append(out, kv)
|
||||
continue
|
||||
}
|
||||
drop := isDynamicSensitiveEnvKey(key)
|
||||
drop := false
|
||||
for _, sensitive := range sensitiveKeys {
|
||||
if strings.EqualFold(key, sensitive) {
|
||||
drop = true
|
||||
@@ -1044,37 +1018,3 @@ func scrubSensitiveEnv(env []string, additionalKeys ...string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeSensitiveEnvKeys(keys []string) []string {
|
||||
out := make([]string, 0, len(keys))
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
// A misconfigured value like "COMPANY_LLM_SECRET=..." would never
|
||||
// match a real env key during scrubbing; keep only the name part.
|
||||
if name, _, found := strings.Cut(key, "="); found {
|
||||
key = strings.TrimSpace(name)
|
||||
}
|
||||
folded := strings.ToUpper(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[folded]; ok {
|
||||
continue
|
||||
}
|
||||
seen[folded] = struct{}{}
|
||||
out = append(out, key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isDynamicSensitiveEnvKey(key string) bool {
|
||||
const (
|
||||
prefix = "ZERO_OAUTH_"
|
||||
suffix = "_CLIENT_SECRET"
|
||||
)
|
||||
key = strings.ToUpper(strings.TrimSpace(key))
|
||||
return strings.HasPrefix(key, prefix) &&
|
||||
strings.HasSuffix(key, suffix) &&
|
||||
len(key) > len(prefix)+len(suffix)
|
||||
}
|
||||
@@ -122,47 +122,6 @@ func TestBuildCommandPlanDegradesUnavailableFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCommandPlanDegradedFallbackScrubsInheritedEnv covers the
|
||||
// EnforcementDegraded fallback path: when the native backend is
|
||||
// unavailable, BuildCommandPlan falls back to a direct (unwrapped) plan
|
||||
// whose spec.Env is nil, so exec.Cmd would otherwise inherit the caller's
|
||||
// environment — including configured and dynamically named credentials —
|
||||
// unscrubbed.
|
||||
func TestBuildCommandPlanDegradedFallbackScrubsInheritedEnv(t *testing.T) {
|
||||
t.Setenv("COMPANY_LLM_SECRET", "custom-secret")
|
||||
t.Setenv("ZERO_OAUTH_ACME_CLIENT_SECRET", "oauth-secret")
|
||||
t.Setenv("SAFE_VAR", "hello")
|
||||
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{Name: BackendUnavailable, Message: "native sandbox unavailable"},
|
||||
SensitiveEnvKeys: []string{"COMPANY_LLM_SECRET"},
|
||||
})
|
||||
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{
|
||||
Name: "/bin/sh",
|
||||
Args: []string{"-c", "pwd"},
|
||||
Dir: root,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
if plan.Wrapped || plan.EnforcementLevel != EnforcementDegraded {
|
||||
t.Fatalf("plan = %#v, want degraded direct plan", plan)
|
||||
}
|
||||
for _, entry := range plan.Env {
|
||||
key, _, _ := strings.Cut(entry, "=")
|
||||
if strings.EqualFold(key, "COMPANY_LLM_SECRET") || strings.EqualFold(key, "ZERO_OAUTH_ACME_CLIENT_SECRET") {
|
||||
t.Fatalf("degraded plan.Env retained sensitive key %q: %v", key, plan.Env)
|
||||
}
|
||||
}
|
||||
if got := envListValue(plan.Env, "SAFE_VAR", ""); got != "hello" {
|
||||
t.Fatalf("SAFE_VAR = %q, want hello", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommandPlanRejectsOutsideDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{
|
||||
@@ -694,19 +653,14 @@ func TestScrubSensitiveEnv(t *testing.T) {
|
||||
"XAI_API_KEY=xai-12345",
|
||||
"HUGGINGFACE_API_KEY=hf_12345",
|
||||
"GOOGLE_APPLICATION_CREDENTIALS=/home/user/sa-key.json",
|
||||
"COMPANY_LLM_SECRET=custom-secret",
|
||||
"ZERO_OAUTH_MY_SVC_CLIENT_SECRET=oauth-secret",
|
||||
"zero_oauth_second_client_secret=case-insensitive-secret",
|
||||
"ZERO_OAUTH_CLIENT_SECRET=not-a-provider-secret",
|
||||
"AWS_PROFILE=staging",
|
||||
"SAFE_VAR=hello",
|
||||
}
|
||||
scrubbed := scrubSensitiveEnv(inputEnv, " COMPANY_LLM_SECRET ", "company_llm_secret", "GITHUB_TOKEN=ghp_pasted-assignment", "=", "")
|
||||
scrubbed := scrubSensitiveEnv(inputEnv)
|
||||
expected := map[string]string{
|
||||
"PATH": "/usr/bin",
|
||||
"SAFE_VAR": "hello",
|
||||
"AWS_PROFILE": "staging",
|
||||
"ZERO_OAUTH_CLIENT_SECRET": "not-a-provider-secret",
|
||||
"PATH": "/usr/bin",
|
||||
"SAFE_VAR": "hello",
|
||||
"AWS_PROFILE": "staging",
|
||||
}
|
||||
actual := make(map[string]string, len(scrubbed))
|
||||
for _, entry := range scrubbed {
|
||||
@@ -720,34 +674,3 @@ func TestScrubSensitiveEnv(t *testing.T) {
|
||||
t.Errorf("scrubSensitiveEnv() = %v, want %v", actual, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineScrubsConfiguredSensitiveEnvKeys(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox"},
|
||||
SensitiveEnvKeys: []string{"COMPANY_LLM_SECRET"},
|
||||
})
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{
|
||||
Name: "true",
|
||||
Env: []string{
|
||||
"PATH=/usr/bin",
|
||||
"COMPANY_LLM_SECRET=custom-secret",
|
||||
"ZERO_OAUTH_CUSTOM_CLIENT_SECRET=oauth-secret",
|
||||
"SAFE_VAR=hello",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
for _, entry := range plan.Env {
|
||||
key, _, _ := strings.Cut(entry, "=")
|
||||
if strings.EqualFold(key, "COMPANY_LLM_SECRET") || strings.EqualFold(key, "ZERO_OAUTH_CUSTOM_CLIENT_SECRET") {
|
||||
t.Fatalf("plan.Env retained sensitive key %q: %v", key, plan.Env)
|
||||
}
|
||||
}
|
||||
if got := envListValue(plan.Env, "SAFE_VAR", ""); got != "hello" {
|
||||
t.Fatalf("SAFE_VAR = %q, want hello", got)
|
||||
}
|
||||
}
|
||||
@@ -326,7 +326,7 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
|
||||
if err != nil {
|
||||
return CommandPlan{}, err
|
||||
}
|
||||
childEnv := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendWindowsRestrictedToken, execRequest.WorkspaceRoot, spec.sensitiveEnvKeys)
|
||||
childEnv := sandboxEnvironmentForCommand(spec.Env, policy, BackendWindowsRestrictedToken, execRequest.WorkspaceRoot)
|
||||
// The unelevated enforcement tier maps to the runner's unelevated level: same
|
||||
// restricted token, but the runner applies the workspace ACLs itself instead
|
||||
// of requiring the elevated setup marker.
|
||||
|
||||
@@ -94,29 +94,6 @@ func WriteNDJSON(w io.Writer, t *TurnTrace) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Prefix fingerprints are emitted after counters in insertion (turn)
|
||||
// order. The order is the order EmitPrefixHash was called, which is the
|
||||
// order the agent loop computed each turn's fingerprint, which is the
|
||||
// order a downstream consumer needs to correlate a prefix_hash event
|
||||
// with the cached_input_tokens counter for that turn. Sorting by
|
||||
// complete_prefix hash would destroy that correlation, so we do not
|
||||
// sort. The slice is already a deep copy from Finish (see
|
||||
// Recorder.Finish) so it is safe to range over without copying.
|
||||
for _, p := range t.PrefixHashes {
|
||||
if err := enc.Encode(map[string]any{
|
||||
"type": "prefix_hash",
|
||||
"base_instructions": p.BaseInstructionsHash,
|
||||
"confirmation_policy": p.ConfirmationPolicyHash,
|
||||
"project_context": p.ProjectContextHash,
|
||||
"skills": p.SkillsHash,
|
||||
"tools": p.ToolsHash,
|
||||
"schema": p.SchemaHash,
|
||||
"complete_prefix": p.CompletePrefixHash,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+2
-30
@@ -97,25 +97,6 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) {
|
||||
}
|
||||
name, _ := obj["name"].(string)
|
||||
t.Counters = append(t.Counters, Counter{Name: name, Value: parseInt64(obj["value"])})
|
||||
case "prefix_hash":
|
||||
if !sawTraceHeader {
|
||||
return nil, errors.New("parse trace: not a valid NDJSON trace (no type:trace header)")
|
||||
}
|
||||
// Round-trip the seven prefix-hash fields. Missing fields are
|
||||
// accepted as empty strings (a partially-written trace from a
|
||||
// crashed emitter must not fatal a parse). The decoder tolerates
|
||||
// any shape the encoder produced and the seven keys are the
|
||||
// contract — adding a new field is non-breaking; renaming one
|
||||
// requires a schema version bump.
|
||||
t.PrefixHashes = append(t.PrefixHashes, PrefixHash{
|
||||
BaseInstructionsHash: stringField(obj, "base_instructions"),
|
||||
ConfirmationPolicyHash: stringField(obj, "confirmation_policy"),
|
||||
ProjectContextHash: stringField(obj, "project_context"),
|
||||
SkillsHash: stringField(obj, "skills"),
|
||||
ToolsHash: stringField(obj, "tools"),
|
||||
SchemaHash: stringField(obj, "schema"),
|
||||
CompletePrefixHash: stringField(obj, "complete_prefix"),
|
||||
})
|
||||
default:
|
||||
// Unknown event type: tolerate (forward-compat) but only after a
|
||||
// header has been seen.
|
||||
@@ -136,21 +117,12 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) {
|
||||
if !sawTraceHeader {
|
||||
return nil, errors.New("parse trace: non-empty input had no type:trace header")
|
||||
}
|
||||
if len(t.Spans) == 0 && len(t.Counters) == 0 && len(t.PrefixHashes) == 0 {
|
||||
return nil, errors.New("parse trace: header present but no spans, counters, or prefix hashes recovered (corrupt or truncated)")
|
||||
if len(t.Spans) == 0 && len(t.Counters) == 0 {
|
||||
return nil, errors.New("parse trace: header present but no spans or counters recovered (corrupt or truncated)")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// stringField returns obj[key] as a string, or "" if the key is missing or
|
||||
// the value is not a string. JSON-marshaled trace events always emit
|
||||
// string fields as JSON strings, so the type assertion is the right
|
||||
// narrowing; a missing key produces an empty hash, which is what the
|
||||
// encoder would have produced for the absent value.
|
||||
func stringField(obj map[string]any, key string) string {
|
||||
s, _ := obj[key].(string)
|
||||
return s
|
||||
}
|
||||
func parseTime(v any) time.Time {
|
||||
s, _ := v.(string)
|
||||
if s == "" {
|
||||
|
||||
@@ -172,22 +172,6 @@ func (r *Recorder) StampFirstUsefulAction() {
|
||||
r.tr.FirstUsefulActionAt = time.Now()
|
||||
}
|
||||
|
||||
// EmitPrefixHash records one prompt-prefix fingerprint on the trace. Multiple
|
||||
// calls are allowed within a run (one per turn, typically) and accumulate in
|
||||
// order. The first call after Finish is a no-op; later calls are also no-ops
|
||||
// because the trace has been sealed.
|
||||
func (r *Recorder) EmitPrefixHash(p PrefixHash) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.finished {
|
||||
return
|
||||
}
|
||||
r.tr.PrefixHashes = append(r.tr.PrefixHashes, p)
|
||||
}
|
||||
|
||||
// Finish stamps CompletedAt, derives each span's parent (by interval
|
||||
// containment) and exclusive time, and returns a snapshot of the trace. Calling
|
||||
// Finish more than once returns the same snapshot.
|
||||
@@ -206,7 +190,6 @@ func (r *Recorder) Finish() *TurnTrace {
|
||||
// Copy the slices so callers cannot mutate the recorder's state.
|
||||
snap.Spans = append([]Span(nil), r.tr.Spans...)
|
||||
snap.Counters = append([]Counter(nil), r.tr.Counters...)
|
||||
snap.PrefixHashes = append([]PrefixHash(nil), r.tr.PrefixHashes...)
|
||||
return &snap
|
||||
}
|
||||
|
||||
|
||||
+10
-47
@@ -82,52 +82,16 @@ type Counter struct {
|
||||
// TurnTrace is the finished record for one agent.Run. It is the value
|
||||
// emitters serialize; it is not mutated after Finish returns a snapshot.
|
||||
type TurnTrace struct {
|
||||
SessionID string `json:"session_id"`
|
||||
RunID string `json:"run_id"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FirstVisibleEventAt time.Time `json:"first_visible_event_at,omitempty"`
|
||||
FirstUsefulActionAt time.Time `json:"first_useful_action_at,omitempty"`
|
||||
FirstTokenAt time.Time `json:"first_token_at,omitempty"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Spans []Span `json:"spans"`
|
||||
Counters []Counter `json:"counters"`
|
||||
PrefixHashes []PrefixHash `json:"prefix_hashes,omitempty"`
|
||||
}
|
||||
|
||||
// PrefixHash is one fingerprint of the prompt prefix emitted by an agent run.
|
||||
// The seven fields decompose the cacheable part of the request so a downstream
|
||||
// observer can detect which sub-component drifted turn-over-turn:
|
||||
// base_instructions (the embedded core system prompt), confirmation_policy,
|
||||
// project_context (AGENTS.md / ZERO.md chain), skills, tools, schema (tool
|
||||
// JSON schemas), and complete_prefix (SHA-256 of the canonical concatenation
|
||||
// of the other six). All hashes are hex-encoded SHA-256.
|
||||
//
|
||||
// Scope: the fingerprint covers 4 of 11 sections of buildSystemPrompt. The
|
||||
// seven sections NOT covered — modelPromptAddendum, sessionRuntimeContext,
|
||||
// approvedCommandPrefixContext, workspaceSeedContext, userGuidelines,
|
||||
// specialistDelegationContext, responseStyleContext — fire only for
|
||||
// non-default Options. For default Options (the common case), the four
|
||||
// captured substrings are the full prompt and the fingerprint is accurate.
|
||||
//
|
||||
// A run with a stable CompletePrefix across turns means the four captured
|
||||
// sub-components are byte-identical. A run where CompletePrefix changes
|
||||
// names the captured sub-component that drifted, but does NOT rule out
|
||||
// drift in the seven uncaptured sections. modelPromptAddendum in particular
|
||||
// changes on a model switch (a model_switches counter the trace already
|
||||
// emits), so a consumer correlating CompletePrefix stability with
|
||||
// cached_input_tokens must cross-check the model_switches counter to
|
||||
// disambiguate "no drift" from "drift in an uncaptured section." The fields
|
||||
// are emitted as a "prefix_hash" event in the NDJSON trace (see
|
||||
// WriteNDJSON).
|
||||
type PrefixHash struct {
|
||||
BaseInstructionsHash string `json:"base_instructions"`
|
||||
ConfirmationPolicyHash string `json:"confirmation_policy"`
|
||||
ProjectContextHash string `json:"project_context"`
|
||||
SkillsHash string `json:"skills"`
|
||||
ToolsHash string `json:"tools"`
|
||||
SchemaHash string `json:"schema"`
|
||||
CompletePrefixHash string `json:"complete_prefix"`
|
||||
SessionID string `json:"session_id"`
|
||||
RunID string `json:"run_id"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FirstVisibleEventAt time.Time `json:"first_visible_event_at,omitempty"`
|
||||
FirstUsefulActionAt time.Time `json:"first_useful_action_at,omitempty"`
|
||||
FirstTokenAt time.Time `json:"first_token_at,omitempty"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Spans []Span `json:"spans"`
|
||||
Counters []Counter `json:"counters"`
|
||||
}
|
||||
|
||||
// WallDuration is the total traced wall time of the run.
|
||||
@@ -265,6 +229,5 @@ func OptionalEventKeys() []string {
|
||||
"counter:" + CounterAcceptanceChecks,
|
||||
"counter:" + CounterPollingTurn,
|
||||
"counter:" + CounterModelSwitches,
|
||||
"event:prefix_hash",
|
||||
}
|
||||
}
|
||||
@@ -370,27 +370,6 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
|
||||
r.Counter(CounterModelRequests, 3)
|
||||
r.Counter(CounterToolCalls, 7)
|
||||
r.StampFirstToken()
|
||||
// Two prefix_hash events so the round-trip test covers the third
|
||||
// event type. Insertion order is preserved by the parser (no sort
|
||||
// is applied on read or write).
|
||||
r.EmitPrefixHash(PrefixHash{
|
||||
BaseInstructionsHash: "b1",
|
||||
ConfirmationPolicyHash: "c1",
|
||||
ProjectContextHash: "p1",
|
||||
SkillsHash: "s1",
|
||||
ToolsHash: "t1",
|
||||
SchemaHash: "x1",
|
||||
CompletePrefixHash: "complete1",
|
||||
})
|
||||
r.EmitPrefixHash(PrefixHash{
|
||||
BaseInstructionsHash: "b2",
|
||||
ConfirmationPolicyHash: "c2",
|
||||
ProjectContextHash: "p2",
|
||||
SkillsHash: "s2",
|
||||
ToolsHash: "t2",
|
||||
SchemaHash: "x2",
|
||||
CompletePrefixHash: "complete2",
|
||||
})
|
||||
original := r.Finish()
|
||||
|
||||
var buf bytes.Buffer
|
||||
@@ -419,20 +398,6 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
|
||||
if parsed.FirstTokenAt.IsZero() {
|
||||
t.Fatal("first_token_at lost in round-trip")
|
||||
}
|
||||
// prefix_hash round-trip: two events, in insertion order, with all
|
||||
// seven sub-hash fields preserved exactly.
|
||||
if len(parsed.PrefixHashes) != 2 {
|
||||
t.Fatalf("expected 2 prefix_hash events after round-trip, got %d", len(parsed.PrefixHashes))
|
||||
}
|
||||
if parsed.PrefixHashes[0].CompletePrefixHash != "complete1" || parsed.PrefixHashes[1].CompletePrefixHash != "complete2" {
|
||||
t.Fatalf("prefix_hash insertion order lost: got %+v want [complete1, complete2]", parsed.PrefixHashes)
|
||||
}
|
||||
if parsed.PrefixHashes[0].BaseInstructionsHash != "b1" || parsed.PrefixHashes[0].SchemaHash != "x1" {
|
||||
t.Fatalf("prefix_hash sub-hashes lost on first event: got %+v", parsed.PrefixHashes[0])
|
||||
}
|
||||
if parsed.PrefixHashes[1].BaseInstructionsHash != "b2" || parsed.PrefixHashes[1].SchemaHash != "x2" {
|
||||
t.Fatalf("prefix_hash sub-hashes lost on second event: got %+v", parsed.PrefixHashes[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNDJSONRejectsNonTrace(t *testing.T) {
|
||||
@@ -446,12 +411,10 @@ func TestReadNDJSONRejectsNonTrace(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReadNDJSONRejectsHeaderOnly(t *testing.T) {
|
||||
// A header with no recoverable spans, counters, or prefix hashes is
|
||||
// corrupt/truncated, not empty. prefix_hash is now a third valid
|
||||
// event type, so a header with only prefix_hash events is accepted.
|
||||
// A header with no recoverable spans/counters is corrupt/truncated, not empty.
|
||||
header := `{"type":"trace","name":"run","session_id":"s","run_id":"r"}` + "\n"
|
||||
if _, err := ReadNDJSON(strings.NewReader(header)); err == nil {
|
||||
t.Fatal("expected error for a trace header with no spans, counters, or prefix hashes")
|
||||
t.Fatal("expected error for a trace header with no spans or counters")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -63,14 +63,6 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
|
||||
// do it once here, before any early return, so both the bracketed-paste
|
||||
// and right-click-paste paths are covered uniformly.
|
||||
m = m.disarmCancelConfirmation()
|
||||
// Same immediate-input transition as the keypress hook: text entering the
|
||||
// composer must land the caret in its solid-while-typing state right away
|
||||
// and refresh the typing timestamp. Without this, a paste right after the
|
||||
// blink phase hid the caret leaves the newly populated composer caret-less
|
||||
// until the next 530ms tick, and the stale timestamp makes that tick
|
||||
// toggle instead of going solid.
|
||||
m.lastCharTime = m.now()
|
||||
m.composerCursorVisible = true
|
||||
if content == "" {
|
||||
// Empty text clipboard — the user may have pasted a screenshot.
|
||||
// Probe the OS clipboard for image content asynchronously.
|
||||
|
||||
@@ -39,8 +39,6 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
{"Shift+Enter / Alt+Enter", "insert a newline (multi-line compose)"},
|
||||
{"Esc (\u00d72)", "cancel the run / dismiss a popup / clear the input"},
|
||||
{"Ctrl+C", "cancel the run, then quit"},
|
||||
{"Ctrl+X then letter", "common slash commands (m=/model, p=/provider, r=/resume, \u2026)"},
|
||||
{"Ctrl+X ?", "list every Ctrl+X slash shortcut"},
|
||||
{"?", "show this help (on an empty input)"},
|
||||
},
|
||||
},
|
||||
@@ -49,7 +47,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
bindings: []keybinding{
|
||||
{labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"},
|
||||
{"Shift+Tab", "cycle permission mode (auto \u2194 ask)"},
|
||||
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel (when no menu is open)"},
|
||||
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -57,7 +55,6 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
bindings: []keybinding{
|
||||
{"PgUp / PgDn", "scroll the transcript by a page"},
|
||||
{"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"},
|
||||
{"Ctrl+P / Ctrl+N", "previous / next item in menus (emacs-style)"},
|
||||
{labelOr(m.keyBindings.toggleDetailed, "Ctrl+O"), "toggle the detailed (full-screen) transcript"},
|
||||
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "hide / show the right context sidebar"},
|
||||
{labelOr(m.keyBindings.toggleMouse, "Ctrl+E"), "release the mouse to drag-select & copy text"},
|
||||
|
||||
@@ -79,7 +79,6 @@ func TestHelpOverlayViewRendersGroupsAndKeys(t *testing.T) {
|
||||
"Ctrl+T", "cycle reasoning effort",
|
||||
"Shift+Tab", "Ctrl+P", "Ctrl+O",
|
||||
"drill into its sub-session",
|
||||
"Ctrl+X then letter", "/model",
|
||||
keybindingHelpFooter,
|
||||
} {
|
||||
if !strings.Contains(view, want) {
|
||||
@@ -225,9 +224,7 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
|
||||
m.height = 40
|
||||
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
|
||||
m.keyBindings.toggleMouse = parseBinding("ctrl+m")
|
||||
// Avoid Ctrl+N: idle Ctrl+N is reserved as an emacs menu no-op and never
|
||||
// reaches configurable global bindings.
|
||||
m.keyBindings.toggleSidebar = parseBinding("ctrl+y")
|
||||
m.keyBindings.toggleSidebar = parseBinding("ctrl+n")
|
||||
if !m.sidebarToggleAllowed() {
|
||||
t.Fatal("sidebar toggle should be allowed")
|
||||
}
|
||||
@@ -248,10 +245,10 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
|
||||
}
|
||||
|
||||
initialSidebar := next.sidebarHidden
|
||||
updated, _ = next.Update(testKeyCtrl('y'))
|
||||
updated, _ = next.Update(testKeyCtrl('n'))
|
||||
next = updated.(model)
|
||||
if next.sidebarHidden == initialSidebar {
|
||||
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still fire with a non-empty composer")
|
||||
t.Fatal("remapped Ctrl+N toggleSidebar binding should still fire with a non-empty composer")
|
||||
}
|
||||
if next.composerValue() != "hello" {
|
||||
t.Fatalf("remapped toggleSidebar binding should not fall through to composer input, got %q", next.composerValue())
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// leaderTimeout is how long Ctrl+X waits for a follow-up key before the chord
|
||||
// cancels itself. Short enough to not feel sticky; long enough to find the
|
||||
// second key without racing.
|
||||
const leaderTimeout = 2 * time.Second
|
||||
|
||||
// leaderCommandByKey maps the second key of a Ctrl+X chord to a builtin slash
|
||||
// command. Case-sensitive so m/M and p/P stay distinct. Only bare commands (no
|
||||
// args) — same as typing the slash and pressing Enter with an empty argument.
|
||||
var leaderCommandByKey = map[rune]string{
|
||||
'm': "/model",
|
||||
'p': "/provider",
|
||||
'P': "/plan",
|
||||
'M': "/stt-model",
|
||||
'v': "/voice",
|
||||
'c': "/clear",
|
||||
'C': "/context",
|
||||
's': "/stop",
|
||||
'i': "/image",
|
||||
'r': "/resume",
|
||||
'u': "/rewind",
|
||||
't': "/tools",
|
||||
'R': "/retry",
|
||||
}
|
||||
|
||||
// leaderExpiredMsg clears leader-pending when the follow-up window elapses.
|
||||
// seq must match m.leaderSeq or the tick is stale (a later Ctrl+X re-armed).
|
||||
type leaderExpiredMsg struct {
|
||||
seq int
|
||||
}
|
||||
|
||||
func (m model) canArmLeader() bool {
|
||||
return m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active && !m.helpOverlay && !m.leaderHelpOverlay
|
||||
}
|
||||
|
||||
func (m model) armLeader() (model, tea.Cmd) {
|
||||
m.leaderPending = true
|
||||
m.leaderSeq++
|
||||
seq := m.leaderSeq
|
||||
return m, tea.Tick(leaderTimeout, func(time.Time) tea.Msg {
|
||||
return leaderExpiredMsg{seq: seq}
|
||||
})
|
||||
}
|
||||
|
||||
func (m model) clearLeader() model {
|
||||
m.leaderPending = false
|
||||
// Bump seq so an in-flight timeout tick becomes a no-op.
|
||||
m.leaderSeq++
|
||||
return m
|
||||
}
|
||||
|
||||
// handleLeaderKey consumes the key while leader-pending is armed. Never inserts
|
||||
// into the composer; either runs a mapped slash command, opens the chord map
|
||||
// (?), or cancels the chord. Ctrl+C is handled before this is called so
|
||||
// exit/cancel still works.
|
||||
func (m model) handleLeaderKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Second Ctrl+X cancels (does not re-arm).
|
||||
if keyCtrl(msg, 'x') {
|
||||
return m.clearLeader(), nil
|
||||
}
|
||||
if keyIs(msg, tea.KeyEsc) {
|
||||
return m.clearLeader(), nil
|
||||
}
|
||||
// Ctrl+X ? opens the full leader-chord map (discoverability for every wired
|
||||
// slash shortcut). Not a letter binding, so handle before leaderSecondKey.
|
||||
if keyText(msg) == "?" && !keyAlt(msg) && !keyHasMod(msg, tea.ModCtrl) {
|
||||
m = m.clearLeader()
|
||||
m.leaderHelpOverlay = true
|
||||
return m, nil
|
||||
}
|
||||
key, ok := leaderSecondKey(msg)
|
||||
if !ok {
|
||||
return m.clearLeader(), nil
|
||||
}
|
||||
slash, mapped := leaderCommandByKey[key]
|
||||
if !mapped {
|
||||
return m.clearLeader(), nil
|
||||
}
|
||||
m = m.clearLeader()
|
||||
return m.executeSlash(slash)
|
||||
}
|
||||
|
||||
// leaderHelpBindings is the stable, display-ordered table of every wired
|
||||
// Ctrl+X chord. Keep in sync with leaderCommandByKey.
|
||||
func leaderHelpBindings() []keybinding {
|
||||
return []keybinding{
|
||||
{"Ctrl+X m", "open /model"},
|
||||
{"Ctrl+X p", "open /provider"},
|
||||
{"Ctrl+X P", "run /plan"},
|
||||
{"Ctrl+X M", "open /stt-model"},
|
||||
{"Ctrl+X v", "toggle /voice"},
|
||||
{"Ctrl+X c", "run /clear"},
|
||||
{"Ctrl+X C", "run /context"},
|
||||
{"Ctrl+X s", "run /stop"},
|
||||
{"Ctrl+X i", "run /image"},
|
||||
{"Ctrl+X r", "open /resume"},
|
||||
{"Ctrl+X u", "run /rewind"},
|
||||
{"Ctrl+X t", "run /tools"},
|
||||
{"Ctrl+X R", "run /retry"},
|
||||
{"Ctrl+X ?", "show this list"},
|
||||
}
|
||||
}
|
||||
|
||||
const leaderHelpFooter = "? or Esc to close \u00b7 letter after Ctrl+X runs the command"
|
||||
|
||||
// renderLeaderHelpLines builds the body of the Ctrl+X ? overlay — same shape as
|
||||
// renderKeybindingHelpLines (group title + key rows + footer) so the two modals
|
||||
// share layout and column alignment.
|
||||
func renderLeaderHelpLines(innerWidth int) []string {
|
||||
groups := []keybindingGroup{{
|
||||
title: "Slash commands",
|
||||
bindings: leaderHelpBindings(),
|
||||
}}
|
||||
keyColumn := keybindingKeyColumnWidth(groups)
|
||||
lines := make([]string, 0, 32)
|
||||
for index, group := range groups {
|
||||
if index > 0 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
lines = append(lines, zeroTheme.accent.Render(group.title))
|
||||
for _, binding := range group.bindings {
|
||||
lines = append(lines, formatKeybindingLine(binding, keyColumn, innerWidth))
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, zeroTheme.faint.Render(leaderHelpFooter))
|
||||
return lines
|
||||
}
|
||||
|
||||
// renderLeaderHelpOverlay frames the Ctrl+X ? chord map exactly like the
|
||||
// general ? keyboard-shortcut overlay: same width helper, border style
|
||||
// (zeroTheme.line), panel fill, and centered block.
|
||||
func (m model) renderLeaderHelpOverlay(width int) string {
|
||||
overlayWidth := keybindingHelpOverlayWidth(width)
|
||||
lines := renderLeaderHelpLines(overlayWidth - 4)
|
||||
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
|
||||
return centerRenderedBlock(block, width)
|
||||
}
|
||||
|
||||
// leaderSecondKey extracts the letter for a leader follow-up. Prefers printable
|
||||
// text ("m" / "M"); falls back to code+Shift so test harnesses and terminals
|
||||
// that report only ModShift still work.
|
||||
func leaderSecondKey(msg tea.KeyMsg) (rune, bool) {
|
||||
if keyHasMod(msg, tea.ModCtrl) || keyAlt(msg) {
|
||||
return 0, false
|
||||
}
|
||||
text := keyText(msg)
|
||||
if runes := []rune(text); len(runes) == 1 && unicode.IsLetter(runes[0]) {
|
||||
return runes[0], true
|
||||
}
|
||||
code := keyCode(msg)
|
||||
if code >= 'a' && code <= 'z' {
|
||||
if keyShift(msg) {
|
||||
return code - ('a' - 'A'), true
|
||||
}
|
||||
return code, true
|
||||
}
|
||||
if code >= 'A' && code <= 'Z' {
|
||||
return code, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
func TestLeaderArmsOnCtrlX(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
updated, cmd := m.Update(testKeyCtrl('x'))
|
||||
next := updated.(model)
|
||||
if !next.leaderPending {
|
||||
t.Fatal("Ctrl+X should arm leader-pending")
|
||||
}
|
||||
if next.composerValue() != "" {
|
||||
t.Fatalf("Ctrl+X must not type into the composer, got %q", next.composerValue())
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("arming leader should schedule a timeout tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderModelOpensPicker(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ModelName: "gpt-4o",
|
||||
ProviderName: "openai",
|
||||
})
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKeyText("m"))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("mapped second key should clear leader-pending")
|
||||
}
|
||||
if next.picker == nil || next.picker.kind != pickerModel {
|
||||
t.Fatalf("Ctrl+X m should open the model picker, picker=%#v", next.picker)
|
||||
}
|
||||
if next.composerValue() != "" {
|
||||
t.Fatalf("leader chord must not leave /model in the composer, got %q", next.composerValue())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderPreservesComposerDraft(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ModelName: "gpt-4o",
|
||||
ProviderName: "openai",
|
||||
})
|
||||
m = typeRunes(t, m, "draft text")
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKeyText("m"))
|
||||
next := updated.(model)
|
||||
if got := next.composerValue(); got != "draft text" {
|
||||
t.Fatalf("leader /model must preserve composer draft, got %q", got)
|
||||
}
|
||||
if next.picker == nil || next.picker.kind != pickerModel {
|
||||
t.Fatal("expected model picker after Ctrl+X m")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderTimeoutClears(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
if !m.leaderPending {
|
||||
t.Fatal("expected leader pending after Ctrl+X")
|
||||
}
|
||||
seq := m.leaderSeq
|
||||
updated, _ = m.Update(leaderExpiredMsg{seq: seq})
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("matching timeout tick should clear leader-pending")
|
||||
}
|
||||
// Stale tick must not clear a re-armed leader.
|
||||
updated, _ = next.Update(testKeyCtrl('x'))
|
||||
next = updated.(model)
|
||||
updated, _ = next.Update(leaderExpiredMsg{seq: seq})
|
||||
next = updated.(model)
|
||||
if !next.leaderPending {
|
||||
t.Fatal("stale timeout must not clear a re-armed leader")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderEscCancels(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m = typeRunes(t, m, "keep me")
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKey(tea.KeyEsc))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("Esc should clear leader-pending")
|
||||
}
|
||||
if got := next.composerValue(); got != "keep me" {
|
||||
t.Fatalf("Esc cancel must not clear composer, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderSecondCtrlXCancels(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKeyCtrl('x'))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("second Ctrl+X should cancel, not re-arm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderUnknownKeyClearsWithoutCommand(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
before := len(m.transcript)
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKeyText("z"))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("unknown second key should clear leader-pending")
|
||||
}
|
||||
if next.picker != nil {
|
||||
t.Fatal("unknown second key must not open a picker")
|
||||
}
|
||||
if next.composerValue() != "" {
|
||||
t.Fatalf("unknown key must not type into composer, got %q", next.composerValue())
|
||||
}
|
||||
if len(next.transcript) != before {
|
||||
t.Fatalf("unknown key should not append transcript rows, before=%d after=%d", before, len(next.transcript))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderDoesNotInsertText(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKeyText("q"))
|
||||
next := updated.(model)
|
||||
if next.composerValue() != "" {
|
||||
t.Fatalf("follow-up key must never appear in composer, got %q", next.composerValue())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderIgnoredWhenPickerOpen(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ModelName: "gpt-4o",
|
||||
ProviderName: "openai",
|
||||
})
|
||||
// Open model picker the normal way.
|
||||
m.input.SetValue("/model")
|
||||
updated, _ := m.handleSubmit()
|
||||
m = updated.(model)
|
||||
if m.picker == nil {
|
||||
t.Fatal("setup: expected /model to open a picker")
|
||||
}
|
||||
updated, _ = m.Update(testKeyCtrl('x'))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("Ctrl+X must not arm leader while a picker is open")
|
||||
}
|
||||
// Printable 'm' should filter the picker, not dispatch another /model.
|
||||
updated, _ = next.Update(testKeyText("m"))
|
||||
next = updated.(model)
|
||||
if next.picker == nil {
|
||||
t.Fatal("picker should still be open")
|
||||
}
|
||||
if next.leaderPending {
|
||||
t.Fatal("picker keystrokes must not arm leader")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderToolsRunsSlash(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKeyText("t"))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("expected leader cleared after Ctrl+X t")
|
||||
}
|
||||
if !transcriptContains(next.transcript, "Tools") && !transcriptContains(next.transcript, "tool") {
|
||||
// toolsText content varies; at least something was appended.
|
||||
if len(next.transcript) == 0 {
|
||||
t.Fatal("Ctrl+X t should run /tools and append output")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderHelpOverlayListsChords(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
groups := m.buildKeybindingGroups()
|
||||
var found bool
|
||||
for _, g := range groups {
|
||||
for _, b := range g.bindings {
|
||||
if strings.Contains(b.keys, "Ctrl+X") && strings.Contains(b.desc, "/model") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("buildKeybindingGroups must document Ctrl+X slash-command chords")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderQuestionMarkOpensChordMap(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.width = 100
|
||||
m.height = 50
|
||||
m.altScreen = true
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
if !m.leaderPending {
|
||||
t.Fatal("setup: Ctrl+X should arm leader")
|
||||
}
|
||||
updated, _ = m.Update(testKeyText("?"))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("Ctrl+X ? should clear leader-pending")
|
||||
}
|
||||
if !next.leaderHelpOverlay {
|
||||
t.Fatal("Ctrl+X ? should open the leader chord map")
|
||||
}
|
||||
if next.composerValue() != "" {
|
||||
t.Fatalf("? must not type into composer, got %q", next.composerValue())
|
||||
}
|
||||
view := plainRender(t, next.View())
|
||||
for _, want := range []string{
|
||||
"Ctrl+X Shortcuts",
|
||||
"Slash commands",
|
||||
"Ctrl+X m", "open /model",
|
||||
"Ctrl+X p", "open /provider",
|
||||
"Ctrl+X R", "run /retry",
|
||||
"╭", "╰", "│", // same box border chrome as the ? keyboard-shortcut modal
|
||||
} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("leader help missing %q, got:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
|
||||
// Both overlays must use the same frame builder (titled box + panel fill).
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.width = 100
|
||||
help := m.renderKeybindingHelpOverlay(100)
|
||||
leader := m.renderLeaderHelpOverlay(100)
|
||||
for _, block := range []struct {
|
||||
name string
|
||||
s string
|
||||
}{
|
||||
{"help", help},
|
||||
{"leader", leader},
|
||||
} {
|
||||
plain := ansiPattern.ReplaceAllString(block.s, "")
|
||||
for _, want := range []string{"╭", "╰", "│", "╮", "╯"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Fatalf("%s overlay missing border %q:\n%s", block.name, want, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(ansiPattern.ReplaceAllString(help, ""), "Keyboard Shortcuts") {
|
||||
t.Fatal("help overlay should keep Keyboard Shortcuts title")
|
||||
}
|
||||
if !strings.Contains(ansiPattern.ReplaceAllString(leader, ""), "Ctrl+X Shortcuts") {
|
||||
t.Fatal("leader overlay should use Ctrl+X Shortcuts title in the border")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderHelpOverlayClosesOnEsc(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.leaderHelpOverlay = true
|
||||
updated, _ := m.Update(testKey(tea.KeyEsc))
|
||||
next := updated.(model)
|
||||
if next.leaderHelpOverlay {
|
||||
t.Fatal("Esc should close the leader help overlay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderHelpBindingsCoverEveryMapEntry(t *testing.T) {
|
||||
listed := map[string]bool{}
|
||||
for _, b := range leaderHelpBindings() {
|
||||
// "Ctrl+X m" → last field is the letter (skip the "?" meta row for map check).
|
||||
parts := strings.Fields(b.keys)
|
||||
if len(parts) != 2 || parts[0] != "Ctrl+X" {
|
||||
continue
|
||||
}
|
||||
letter := parts[1]
|
||||
if letter == "?" {
|
||||
continue
|
||||
}
|
||||
runes := []rune(letter)
|
||||
if len(runes) != 1 {
|
||||
t.Fatalf("unexpected key label %q", b.keys)
|
||||
}
|
||||
listed[string(runes[0])] = true
|
||||
if _, ok := leaderCommandByKey[runes[0]]; !ok {
|
||||
t.Fatalf("help lists %q but leaderCommandByKey has no entry", b.keys)
|
||||
}
|
||||
}
|
||||
for key := range leaderCommandByKey {
|
||||
if !listed[string(key)] {
|
||||
t.Fatalf("leaderCommandByKey has %q but help table omits it", string(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderSecondKeyShiftCapital(t *testing.T) {
|
||||
// Shift+p should map to /plan (capital P), not /provider.
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKeyShift('p'))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("Shift+p should resolve the leader chord")
|
||||
}
|
||||
if next.picker != nil {
|
||||
t.Fatal("/plan should not open a picker")
|
||||
}
|
||||
if !transcriptContains(next.transcript, "Plan") && !transcriptContains(next.transcript, "plan") {
|
||||
// planText wording may vary; require some transcript growth from /plan.
|
||||
if len(next.transcript) == 0 {
|
||||
t.Fatal("Ctrl+X P should run /plan")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderSecondKeyFromTextCapital(t *testing.T) {
|
||||
key, ok := leaderSecondKey(testKeyText("M"))
|
||||
if !ok || key != 'M' {
|
||||
t.Fatalf("leaderSecondKey(M) = %q,%v, want M,true", key, ok)
|
||||
}
|
||||
key, ok = leaderSecondKey(testKeyText("m"))
|
||||
if !ok || key != 'm' {
|
||||
t.Fatalf("leaderSecondKey(m) = %q,%v, want m,true", key, ok)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package tui
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
// moveModalSelection moves the highlight on an open selection surface by delta
|
||||
// (−1 previous / +1 next), matching arrow-key behavior for that surface.
|
||||
// handled is false when no list/selector modal owns the keys (caller may fall
|
||||
// through to plan toggle, composer, etc.).
|
||||
//
|
||||
// Surfaces: permission options, ask_user option lists, provider/MCP wizards and
|
||||
// managers, command pickers, slash/file autocomplete. Does not scroll the
|
||||
// transcript or drive composer history — those stay arrow-only.
|
||||
func (m model) moveModalSelection(delta int) (model, tea.Cmd, bool) {
|
||||
if delta == 0 {
|
||||
return m, nil, false
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(delta), nil, true
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
// moveAskUserCursor is a no-op in free-text mode (parity with arrows).
|
||||
return m.moveAskUserCursor(delta), nil, true
|
||||
}
|
||||
if m.providerWizard != nil {
|
||||
m.burstCount = 0
|
||||
next, cmd := m.handleProviderWizardKey(syntheticArrowKey(delta))
|
||||
return next, cmd, true
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
m.burstCount = 0
|
||||
next, cmd := m.handleMCPAddWizardKey(syntheticArrowKey(delta))
|
||||
return next, cmd, true
|
||||
}
|
||||
if m.mcpManager != nil {
|
||||
m.burstCount = 0
|
||||
next, cmd := m.handleMCPManagerKey(syntheticArrowKey(delta))
|
||||
return next, cmd, true
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil, true
|
||||
}
|
||||
m.pickerMoved(delta)
|
||||
return m, nil, true
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
m.moveSuggestion(delta)
|
||||
return m, nil, true
|
||||
}
|
||||
return m, nil, false
|
||||
}
|
||||
|
||||
// syntheticArrowKey builds a KeyUp (delta < 0) or KeyDown (delta > 0) message so
|
||||
// wizard/manager handlers that only match arrow keys can share one code path
|
||||
// with Ctrl+P / Ctrl+N.
|
||||
func syntheticArrowKey(delta int) tea.KeyPressMsg {
|
||||
code := tea.KeyDown
|
||||
if delta < 0 {
|
||||
code = tea.KeyUp
|
||||
}
|
||||
return tea.KeyPressMsg(tea.Key{Code: code})
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
func TestCtrlPNMovesModelPicker(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ModelName: "gpt-4o",
|
||||
ProviderName: "openai",
|
||||
Provider: &fakeProvider{},
|
||||
})
|
||||
m.input.SetValue("/model")
|
||||
updated, _ := m.handleSubmit()
|
||||
m = updated.(model)
|
||||
if m.picker == nil {
|
||||
t.Fatal("setup: expected model picker")
|
||||
}
|
||||
if len(m.picker.items) < 2 {
|
||||
t.Skip("model picker has fewer than 2 items; cannot assert movement")
|
||||
}
|
||||
m.picker.selected = 0
|
||||
updated, _ = m.Update(testKey(tea.KeyDown))
|
||||
viaArrow := updated.(model).picker.selected
|
||||
m.picker.selected = 0
|
||||
updated, _ = m.Update(testKeyCtrl('n'))
|
||||
m = updated.(model)
|
||||
if m.picker == nil {
|
||||
t.Fatal("picker should stay open")
|
||||
}
|
||||
if m.picker.selected != viaArrow {
|
||||
t.Fatalf("Ctrl+N selection = %d, KeyDown selection = %d", m.picker.selected, viaArrow)
|
||||
}
|
||||
updated, _ = m.Update(testKeyCtrl('p'))
|
||||
m = updated.(model)
|
||||
if m.picker.selected != 0 {
|
||||
t.Fatalf("Ctrl+P should return to 0, got %d", m.picker.selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlPNMovesSuggestions(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m = typeRunes(t, m, "/s")
|
||||
if !m.suggestionsActive() || len(m.suggestions) < 2 {
|
||||
t.Fatalf("setup: need multiple suggestions, got %d", len(m.suggestions))
|
||||
}
|
||||
updated, _ := m.Update(testKeyCtrl('n'))
|
||||
m = updated.(model)
|
||||
if m.suggestionIdx != 1 {
|
||||
t.Fatalf("Ctrl+N should select index 1, got %d", m.suggestionIdx)
|
||||
}
|
||||
if got := m.composerValue(); got != "/s" {
|
||||
t.Fatalf("Ctrl+N must not insert text, composer = %q", got)
|
||||
}
|
||||
updated, _ = m.Update(testKeyCtrl('p'))
|
||||
m = updated.(model)
|
||||
if m.suggestionIdx != 0 {
|
||||
t.Fatalf("Ctrl+P should return to index 0, got %d", m.suggestionIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlPNMovesPermissionOptions(t *testing.T) {
|
||||
m := pendingPermissionModel(t, func(agent.PermissionDecision) {})
|
||||
if m.pendingPermission == nil {
|
||||
t.Fatal("setup: expected permission prompt")
|
||||
}
|
||||
start := m.pendingPermission.cursor
|
||||
updated, _ := m.Update(testKeyCtrl('n'))
|
||||
m = updated.(model)
|
||||
if m.pendingPermission == nil {
|
||||
t.Fatal("permission prompt should stay open")
|
||||
}
|
||||
if m.pendingPermission.cursor == start {
|
||||
// Wrap or multi-option: try another N if single step no-op is impossible.
|
||||
if len(permissionOptions(m.pendingPermission.request)) > 1 {
|
||||
t.Fatalf("Ctrl+N should move permission cursor from %d", start)
|
||||
}
|
||||
}
|
||||
mid := m.pendingPermission.cursor
|
||||
updated, _ = m.Update(testKeyCtrl('p'))
|
||||
m = updated.(model)
|
||||
if m.pendingPermission.cursor != start && mid != start {
|
||||
if m.pendingPermission.cursor == mid {
|
||||
t.Fatalf("Ctrl+P should move permission cursor from %d", mid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlPTogglesPlanWhenNoModal(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.plan = planPanelState{
|
||||
steps: []planStep{{content: "step one", status: "pending"}},
|
||||
}
|
||||
if m.plan.isEmpty() {
|
||||
t.Fatal("setup: plan should be non-empty")
|
||||
}
|
||||
initial := m.plan.expanded
|
||||
updated, _ := m.Update(testKeyCtrl('p'))
|
||||
next := updated.(model)
|
||||
if next.plan.expanded == initial {
|
||||
t.Fatal("Ctrl+P with no modal should toggle plan.expanded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ModelName: "gpt-4o",
|
||||
ProviderName: "openai",
|
||||
Provider: &fakeProvider{},
|
||||
})
|
||||
m.plan = planPanelState{
|
||||
steps: []planStep{{content: "step one", status: "pending"}},
|
||||
expanded: true,
|
||||
}
|
||||
m.input.SetValue("/model")
|
||||
updated, _ := m.handleSubmit()
|
||||
m = updated.(model)
|
||||
if m.picker == nil {
|
||||
t.Fatal("setup: expected picker")
|
||||
}
|
||||
updated, _ = m.Update(testKeyCtrl('p'))
|
||||
next := updated.(model)
|
||||
if !next.plan.expanded {
|
||||
t.Fatal("Ctrl+P in picker must not collapse the plan panel")
|
||||
}
|
||||
if next.picker == nil {
|
||||
t.Fatal("picker should remain open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlNNoOpWithoutModal(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.plan = planPanelState{
|
||||
steps: []planStep{{content: "step one", status: "pending"}},
|
||||
expanded: false,
|
||||
}
|
||||
before := m.plan.expanded
|
||||
updated, _ := m.Update(testKeyCtrl('n'))
|
||||
next := updated.(model)
|
||||
if next.picker != nil || next.suggestionsActive() {
|
||||
t.Fatal("Ctrl+N idle must not open a modal")
|
||||
}
|
||||
if next.plan.expanded != before {
|
||||
t.Fatal("Ctrl+N idle must not toggle the plan panel")
|
||||
}
|
||||
if next.composerValue() != "" {
|
||||
t.Fatalf("Ctrl+N idle must not type into composer, got %q", next.composerValue())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlPNThemePickerPreview(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.input.SetValue("/theme")
|
||||
updated, _ := m.handleSubmit()
|
||||
m = updated.(model)
|
||||
if m.picker == nil || m.picker.kind != pickerTheme {
|
||||
t.Fatalf("setup: expected theme picker, got %#v", m.picker)
|
||||
}
|
||||
// Move with Ctrl+N — should not error; preview path is inside pickerMoved.
|
||||
start := m.picker.selected
|
||||
updated, _ = m.Update(testKeyCtrl('n'))
|
||||
m = updated.(model)
|
||||
if m.picker == nil {
|
||||
t.Fatal("theme picker should stay open")
|
||||
}
|
||||
if len(m.picker.items) > 1 && m.picker.selected == start {
|
||||
t.Fatalf("Ctrl+N should advance theme selection from %d", start)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveModalSelectionMatchesArrowsOnSuggestions(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m = typeRunes(t, m, "/")
|
||||
if len(m.suggestions) < 2 {
|
||||
t.Fatalf("need suggestions, got %d", len(m.suggestions))
|
||||
}
|
||||
m.suggestionIdx = 0
|
||||
viaArrow, _ := m.Update(testKey(tea.KeyDown))
|
||||
m.suggestionIdx = 0
|
||||
viaCtrl, _ := m.Update(testKeyCtrl('n'))
|
||||
if viaArrow.(model).suggestionIdx != viaCtrl.(model).suggestionIdx {
|
||||
t.Fatalf("arrow idx=%d ctrl idx=%d", viaArrow.(model).suggestionIdx, viaCtrl.(model).suggestionIdx)
|
||||
}
|
||||
}
|
||||
+131
-180
@@ -145,19 +145,13 @@ type model struct {
|
||||
// turnLatencySum / turnLatencyCount accumulate completed-run wall time so
|
||||
// /context can show a rolling average turn latency (the "is it slow?" signal).
|
||||
// Reset by /new.
|
||||
turnLatencySum time.Duration
|
||||
turnLatencyCount int
|
||||
turnTTFTSum time.Duration
|
||||
turnTTFTCount int
|
||||
transcript []transcriptRow
|
||||
transcriptDetailed bool
|
||||
helpOverlay bool // the `?` keyboard-shortcut overlay is open
|
||||
// leaderHelpOverlay is the Ctrl+X ? modal listing every leader slash chord.
|
||||
leaderHelpOverlay bool
|
||||
// leaderPending is true after Ctrl+X until a second key, Esc, or timeout
|
||||
// resolves the chord (see leader.go). leaderSeq invalidates a stale tick.
|
||||
leaderPending bool
|
||||
leaderSeq int
|
||||
turnLatencySum time.Duration
|
||||
turnLatencyCount int
|
||||
turnTTFTSum time.Duration
|
||||
turnTTFTCount int
|
||||
transcript []transcriptRow
|
||||
transcriptDetailed bool
|
||||
helpOverlay bool // the `?` keyboard-shortcut overlay is open
|
||||
transcriptBodyHeights *transcriptBodyHeightCache
|
||||
input textinput.Model
|
||||
composer composerState
|
||||
@@ -205,13 +199,8 @@ type model struct {
|
||||
// lastKeyTime tracks every keypress timestamp for burst calculation.
|
||||
lastKeyTime time.Time
|
||||
// burstCount counts consecutive keypresses within 100ms (paste mode).
|
||||
burstCount int
|
||||
// terminalFocused tracks whether the terminal window currently has focus, per
|
||||
// tea.FocusMsg/tea.BlurMsg. Defaults to true since many terminals/multiplexers
|
||||
// never send focus events at all, and defaulting to "unfocused" would wrongly
|
||||
// hide the cursor for those users.
|
||||
terminalFocused bool
|
||||
queuedMessage string
|
||||
burstCount int
|
||||
queuedMessage string
|
||||
// loops holds the session's active /loop definitions (see loop.go). activeLoopID
|
||||
// tags the in-flight run when it is a loop iteration (empty = a user turn), so the
|
||||
// completion seam knows whether to advance a loop. loopSeq invalidates a stale
|
||||
@@ -781,7 +770,6 @@ func newModel(ctx context.Context, options Options) model {
|
||||
userCommands: loadedUserCommands,
|
||||
loadSkills: options.LoadSkills,
|
||||
composerCursorVisible: true,
|
||||
terminalFocused: true,
|
||||
userConfigPath: options.UserConfigPath,
|
||||
doctorUserConfigPath: doctorUserConfigPath,
|
||||
projectConfigPath: options.ProjectConfigPath,
|
||||
@@ -893,11 +881,6 @@ const (
|
||||
// composerCursorBlinkInterval is the on/off period of the composer text cursor.
|
||||
const composerCursorBlinkInterval = 530 * time.Millisecond
|
||||
|
||||
// composerTypingIdleThreshold is how long a typing pause must last before the
|
||||
// cursor resumes blinking; comfortably above normal inter-keystroke gaps
|
||||
// (~150-300ms) so it won't flicker mid-sentence.
|
||||
const composerTypingIdleThreshold = 500 * time.Millisecond
|
||||
|
||||
// composerBlinkMsg toggles the composer cursor's visibility each tick. The custom
|
||||
// composer render draws its own cursor (not textinput's), so it drives its own
|
||||
// blink rather than relying on textinput.Blink.
|
||||
@@ -987,9 +970,6 @@ func (m model) noBlockingModal() bool {
|
||||
}
|
||||
|
||||
func (m model) quit() (tea.Model, tea.Cmd) {
|
||||
if m.providerWizard != nil {
|
||||
m.providerWizard.resetAimlapiOnboard()
|
||||
}
|
||||
m.stopPRWatcher()
|
||||
m.stopAllBackgroundTerminalSessions()
|
||||
m.shutdownLSPManager()
|
||||
@@ -1094,14 +1074,7 @@ func batchCommands(cmds ...tea.Cmd) tea.Cmd {
|
||||
func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case composerBlinkMsg:
|
||||
switch {
|
||||
case !m.terminalFocused:
|
||||
m.composerCursorVisible = false // hidden while unfocused
|
||||
case m.now().Sub(m.lastCharTime) < composerTypingIdleThreshold:
|
||||
m.composerCursorVisible = true // solid while actively typing
|
||||
default:
|
||||
m.composerCursorVisible = !m.composerCursorVisible // idle + focused: blink as before
|
||||
}
|
||||
m.composerCursorVisible = !m.composerCursorVisible
|
||||
return m, composerBlinkCmd()
|
||||
case tea.BackgroundColorMsg:
|
||||
// Terminal background-color reply (from Init's RequestBackgroundColor). In
|
||||
@@ -1145,11 +1118,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.cancelConfirmActive = false
|
||||
}
|
||||
return m, nil
|
||||
case leaderExpiredMsg:
|
||||
if msg.seq == m.leaderSeq {
|
||||
m.leaderPending = false
|
||||
}
|
||||
return m, nil
|
||||
case dragEdgeScrollTickMsg:
|
||||
if msg.seq != m.edgeScrollSeq || m.edgeScrollDelta == 0 || !m.transcriptSelection.active {
|
||||
return m, nil // stale, or the chain was stopped since this tick was scheduled
|
||||
@@ -1158,10 +1126,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, dragEdgeScrollTickCmd(m.edgeScrollSeq)
|
||||
case providerWizardOAuthMsg:
|
||||
return m.applyProviderWizardOAuth(msg)
|
||||
case aimlapiOnboardMsg:
|
||||
return m.applyAimlapiOnboard(msg)
|
||||
case aimlapiExistingBalanceMsg:
|
||||
return m.applyExistingAimlapiBalance(msg)
|
||||
case providerWizardDeviceCodeMsg:
|
||||
return m.applyProviderWizardDeviceCode(msg)
|
||||
case providerManagerCredsMsg:
|
||||
@@ -1239,11 +1203,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if !keyIs(msg, tea.KeyEnter) {
|
||||
m.lastCharTime = now
|
||||
}
|
||||
// Enter the solid-while-typing state right away: only composerBlinkMsg
|
||||
// evaluates the typing threshold, so if the blink phase had just hidden
|
||||
// the caret, the typed character would render caret-less for up to a
|
||||
// full tick before the timer catches up.
|
||||
m.composerCursorVisible = true
|
||||
if m.setup.visible {
|
||||
return m.handleSetupKey(msg)
|
||||
}
|
||||
@@ -1274,44 +1233,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.burstCount = 0
|
||||
return m, nil
|
||||
}
|
||||
// Ctrl+X ? leader-chord map: same dismiss keys as the general help overlay.
|
||||
if m.leaderHelpOverlay {
|
||||
if keyText(msg) == "?" || keyText(msg) == "q" || keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyEnter) || keyCtrl(msg, 'c') {
|
||||
m.leaderHelpOverlay = false
|
||||
}
|
||||
m.burstCount = 0
|
||||
return m, nil
|
||||
}
|
||||
if keyCtrl(msg, 'c') {
|
||||
if m.leaderPending {
|
||||
m = m.clearLeader()
|
||||
}
|
||||
return m.handleCtrlC()
|
||||
}
|
||||
if m.leaderPending {
|
||||
// Leader owns every keystroke until resolved; never type into the composer.
|
||||
return m.handleLeaderKey(msg)
|
||||
}
|
||||
if keyCtrl(msg, 'x') && m.canArmLeader() {
|
||||
return m.armLeader()
|
||||
}
|
||||
// Emacs Ctrl+P / Ctrl+N move selection in open menus. Runs before the
|
||||
// switch so menus win over global Ctrl+P (plan toggle). Idle Ctrl+P
|
||||
// falls through to that binding; idle Ctrl+N is a reserved no-op so it
|
||||
// never reaches remapped configurable bindings (e.g. toggleSidebar).
|
||||
if !m.transcriptDetailed && (keyCtrl(msg, 'p') || keyCtrl(msg, 'n')) {
|
||||
delta := 1
|
||||
if keyCtrl(msg, 'p') {
|
||||
delta = -1
|
||||
}
|
||||
if next, cmd, ok := m.moveModalSelection(delta); ok {
|
||||
return next, cmd
|
||||
}
|
||||
if keyCtrl(msg, 'n') {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case keyCtrl(msg, 'c'):
|
||||
return m.handleCtrlC()
|
||||
case m.keyMatch(m.keyBindings.toggleDetailed, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'o') }):
|
||||
return m.toggleDetailedTranscript(), nil
|
||||
case m.fileView.active && m.noBlockingModal() && m.composerValue() == "" && (keyText(msg) == "d" || keyText(msg) == "f"):
|
||||
@@ -1573,7 +1497,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case m.keyMatch(m.keyBindings.togglePlan, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'p') }):
|
||||
// Ctrl+P toggles the plan panel expansion (collapse/expand step list).
|
||||
// Modal selection is handled before this switch so menus win over toggle.
|
||||
if m.noBlockingModal() && !m.plan.isEmpty() {
|
||||
m.plan.expanded = !m.plan.expanded
|
||||
return m, nil
|
||||
@@ -1671,14 +1594,34 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
// Suggestions keep Shift+arrows for the composer path (unchanged); plain
|
||||
// ↑/↓ and Ctrl+P/N still move the palette via moveModalSelection.
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(-1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(-1), nil
|
||||
}
|
||||
if m.providerWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleProviderWizardKey(msg)
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPAddWizardKey(msg)
|
||||
}
|
||||
if m.mcpManager != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPManagerKey(msg)
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(-1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
break
|
||||
}
|
||||
if next, cmd, ok := m.moveModalSelection(-1); ok {
|
||||
return next, cmd
|
||||
}
|
||||
if m.composerValue() != "" {
|
||||
break // let the input handle multiline navigation
|
||||
}
|
||||
@@ -1690,12 +1633,34 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(1), nil
|
||||
}
|
||||
if m.providerWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleProviderWizardKey(msg)
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPAddWizardKey(msg)
|
||||
}
|
||||
if m.mcpManager != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPManagerKey(msg)
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
break
|
||||
}
|
||||
if next, cmd, ok := m.moveModalSelection(1); ok {
|
||||
return next, cmd
|
||||
}
|
||||
if m.composerValue() != "" {
|
||||
break // let the input handle multiline navigation
|
||||
}
|
||||
@@ -1706,8 +1671,34 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(-1), nil
|
||||
}
|
||||
if next, cmd, ok := m.moveModalSelection(1); ok {
|
||||
return next, cmd
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(1), nil
|
||||
}
|
||||
if m.providerWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleProviderWizardKey(msg)
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPAddWizardKey(msg)
|
||||
}
|
||||
if m.mcpManager != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPManagerKey(msg)
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
m.moveSuggestion(1)
|
||||
return m, nil
|
||||
}
|
||||
if next, ok := m.moveComposerVisualCursor(1); ok {
|
||||
return next, nil
|
||||
@@ -1726,8 +1717,34 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(1), nil
|
||||
}
|
||||
if next, cmd, ok := m.moveModalSelection(-1); ok {
|
||||
return next, cmd
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(-1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(-1), nil
|
||||
}
|
||||
if m.providerWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleProviderWizardKey(msg)
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPAddWizardKey(msg)
|
||||
}
|
||||
if m.mcpManager != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPManagerKey(msg)
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(-1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
m.moveSuggestion(-1)
|
||||
return m, nil
|
||||
}
|
||||
if next, ok := m.moveComposerVisualCursor(-1); ok {
|
||||
return next, nil
|
||||
@@ -1853,19 +1870,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.recomputeSuggestions()
|
||||
return m, cmd
|
||||
case tea.FocusMsg:
|
||||
m.terminalFocused = true
|
||||
// Sync the caret with focus immediately: leaving it to the next
|
||||
// composerBlinkMsg tick can keep it hidden for up to a tick after the
|
||||
// terminal regains focus (and, on blur below, leave it visible in an
|
||||
// unfocused terminal for the same window).
|
||||
m.composerCursorVisible = true
|
||||
if m.notifier != nil {
|
||||
m.notifier.SetFocused(true)
|
||||
}
|
||||
return m, nil
|
||||
case tea.BlurMsg:
|
||||
m.terminalFocused = false
|
||||
m.composerCursorVisible = false
|
||||
if m.notifier != nil {
|
||||
m.notifier.SetFocused(false)
|
||||
}
|
||||
@@ -1943,21 +1952,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// advancing even when no run is pending, so the tick loop stays alive while
|
||||
// sidebarHasAgents() holds (and stops the moment the agents/sidebar clear).
|
||||
if !m.pending && !m.compactInFlight && !m.doctorInFlight {
|
||||
// The tick also keeps advancing while the aimlapi.com onboarding sub-flow is
|
||||
// busy (its progress screen is spinner-only), even though no agent run is in
|
||||
// flight, so its shared MiniDot spinner keeps animating.
|
||||
//
|
||||
// Return the FPS-throttled tick that Update hands back — NOT m.spinner.Tick,
|
||||
// which fires immediately and busy-loops the frame at event-loop speed. That
|
||||
// makes the glyph spin far too fast (and burns CPU) on screens that sit here
|
||||
// for a while, e.g. the aimlapi checkout wait. The active-run path below
|
||||
// already does this; this keeps idle animation at the same cadence.
|
||||
if (m.sidebarHasAgents() || m.aimlapiOnboardAnimating()) && !m.reducedMotion {
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
if m.sidebarHasAgents() && !m.reducedMotion {
|
||||
m.spinner, _ = m.spinner.Update(msg)
|
||||
m.spinnerPhase++
|
||||
m.spinnerTicking = true
|
||||
return m, cmd
|
||||
return m, m.spinner.Tick
|
||||
}
|
||||
m.spinnerTicking = false
|
||||
return m, nil
|
||||
@@ -2520,9 +2519,9 @@ func (m model) View() tea.View {
|
||||
var content string
|
||||
if m.setup.visible {
|
||||
content = m.setupView(chatWidth(m.width))
|
||||
} else if m.helpOverlay || m.leaderHelpOverlay || !m.transcriptDetailed {
|
||||
// When helpOverlay / leaderHelpOverlay is active the panel is composited into
|
||||
// the normal transcript view as a true overlay (scrim + vertical centering), matching
|
||||
} else if m.helpOverlay || !m.transcriptDetailed {
|
||||
// When helpOverlay is active the help panel is composited into the normal
|
||||
// transcript view as a true overlay (scrim + vertical centering), matching
|
||||
// how the suggestion picker / provider wizard / pickers are drawn.
|
||||
content = m.transcriptView()
|
||||
} else {
|
||||
@@ -2542,11 +2541,7 @@ func (m model) View() tea.View {
|
||||
if m.altScreen {
|
||||
view.BackgroundColor = zeroTheme.bgPanel
|
||||
}
|
||||
// Always requested, independent of the notifier: the composer cursor's
|
||||
// focus/blink behavior (composerBlinkMsg above) needs tea.FocusMsg/BlurMsg
|
||||
// regardless of notification config. A standard, widely supported DEC
|
||||
// private mode (CSI ?1004h) that unsupported terminals silently ignore.
|
||||
view.ReportFocus = true
|
||||
view.ReportFocus = m.notifier != nil
|
||||
// Voice mode's Space-hold gesture needs key-release events (Kitty protocol).
|
||||
// Request them only while voice mode is on — the renderer re-sends the request
|
||||
// only when the value changes, so gating this costs nothing (§10).
|
||||
@@ -2618,10 +2613,6 @@ func (m model) transcriptView() string {
|
||||
if m.helpOverlay {
|
||||
helpOverlayContent = m.renderKeybindingHelpOverlay(width)
|
||||
}
|
||||
leaderHelpOverlayContent := ""
|
||||
if m.leaderHelpOverlay {
|
||||
leaderHelpOverlayContent = m.renderLeaderHelpOverlay(width)
|
||||
}
|
||||
|
||||
suggestionOverlay := m.suggestionOverlay(width)
|
||||
providerOverlay := m.providerWizardOverlay(width)
|
||||
@@ -2635,8 +2626,6 @@ func (m model) transcriptView() string {
|
||||
viewportOverlay = sttKeyOverlay
|
||||
case helpOverlayContent != "":
|
||||
viewportOverlay = helpOverlayContent
|
||||
case leaderHelpOverlayContent != "":
|
||||
viewportOverlay = leaderHelpOverlayContent
|
||||
case providerOverlay != "":
|
||||
viewportOverlay = providerOverlay
|
||||
case mcpAddOverlay != "":
|
||||
@@ -2781,11 +2770,6 @@ func (m model) footerView(width int) string {
|
||||
// full-screen transcript, or under any modal/overlay so it never competes for
|
||||
// attention. Width-tiered so a narrow terminal only shows the essential pointer.
|
||||
func (m model) composerIdleHint() string {
|
||||
// Leader-pending is always shown (even mid-type) so the user knows the next
|
||||
// key is a chord, not composer input.
|
||||
if m.leaderPending {
|
||||
return zeroTheme.faint.Render("Ctrl+X — await shortcut (m model · p provider · ? list · Esc cancel)")
|
||||
}
|
||||
// Managed (alt-screen) mode only: inline mode prints to native scrollback where
|
||||
// this footer row isn't a stable surface. Hidden while typing, during a run, in
|
||||
// the full-screen transcript, or under any modal/overlay.
|
||||
@@ -2804,9 +2788,9 @@ func (m model) composerIdleHint() string {
|
||||
case tierNarrow:
|
||||
hint = "? shortcuts"
|
||||
case tierMedium:
|
||||
hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar", sidebarKey)
|
||||
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s copy", sidebarKey, mouseKey)
|
||||
default:
|
||||
hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey)
|
||||
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey)
|
||||
}
|
||||
return zeroTheme.faint.Render(hint)
|
||||
}
|
||||
@@ -3491,7 +3475,7 @@ func (m model) composerLine(width int) string {
|
||||
}
|
||||
if argumentHint != "" {
|
||||
input.SetWidth(0)
|
||||
return fitStyledLine(commandArgumentHintComposerLine(input, argumentHint, m.composerCursorVisible), width)
|
||||
return fitStyledLine(commandArgumentHintComposerLine(input, argumentHint), width)
|
||||
}
|
||||
previews := validComposerPastePreviews(state, m.composerPastePreviews)
|
||||
displayState := composerDisplayStateForPastePreviews(state, previews)
|
||||
@@ -3767,23 +3751,16 @@ func composerCursorForVisualColumn(state composerState, segment composerVisualLi
|
||||
return segment.end
|
||||
}
|
||||
|
||||
func commandArgumentHintComposerLine(input textinput.Model, argumentHint string, cursorVisible bool) string {
|
||||
func commandArgumentHintComposerLine(input textinput.Model, argumentHint string) string {
|
||||
hintRunes := []rune(argumentHint)
|
||||
if len(hintRunes) == 0 {
|
||||
return input.View()
|
||||
}
|
||||
displayValue := strings.TrimRightFunc(input.Value(), unicode.IsSpace)
|
||||
// This alternate composer path must follow the same caret contract as
|
||||
// renderComposerInput: hidden while the terminal is unfocused and blinking
|
||||
// per composerCursorVisible, not a permanently painted cursor cell.
|
||||
cursor := zeroTheme.faint.Render(string(hintRunes[0]))
|
||||
if cursorVisible {
|
||||
cursor = composerCursor(cursor)
|
||||
}
|
||||
return zeroTheme.userPrompt.Render(input.Prompt) +
|
||||
zeroTheme.ink.Inline(true).Render(displayValue) +
|
||||
zeroTheme.faint.Render(" ") +
|
||||
cursor +
|
||||
composerCursor(zeroTheme.faint.Render(string(hintRunes[0]))) +
|
||||
zeroTheme.faint.Render(string(hintRunes[1:]))
|
||||
}
|
||||
|
||||
@@ -4092,12 +4069,6 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
m.chatScrollOffset = 0
|
||||
}
|
||||
|
||||
return m.dispatchCommand(command)
|
||||
}
|
||||
|
||||
// dispatchCommand runs a parsed slash/prompt command after submit/leader
|
||||
// preamble (history, composer clear, leave-prompt disarm) has already run.
|
||||
func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
switch command.kind {
|
||||
case commandEmpty:
|
||||
return m, nil
|
||||
@@ -4472,23 +4443,6 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
// executeSlash runs a builtin slash command as if the user typed it and pressed
|
||||
// Enter, without clearing the composer draft. Used by Ctrl+X leader chords so a
|
||||
// mid-type prompt is preserved while /model (etc.) still opens.
|
||||
func (m model) executeSlash(input string) (tea.Model, tea.Cmd) {
|
||||
command := parseCommand(input)
|
||||
if command.kind == commandEmpty || command.kind == commandPrompt {
|
||||
return m, nil
|
||||
}
|
||||
if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt {
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
}
|
||||
m.rememberInput(input)
|
||||
m.clearSuggestions()
|
||||
m.chatScrollOffset = 0
|
||||
return m.dispatchCommand(command)
|
||||
}
|
||||
|
||||
// launchPrompt starts a normal agent turn from text already accepted by the
|
||||
// composer. Queued prompts use this path too, so session and image behavior
|
||||
// stays identical to immediate submissions.
|
||||
@@ -4608,10 +4562,7 @@ func (m model) beginRun(cancel context.CancelFunc) model {
|
||||
// loop is already alive, when reduced motion is set, or when there is nothing to
|
||||
// animate, so an idle plain session schedules no timer.
|
||||
func (m *model) ensureSpinnerTick() tea.Cmd {
|
||||
if m.spinnerTicking || m.reducedMotion {
|
||||
return nil
|
||||
}
|
||||
if !m.sidebarHasAgents() && !m.aimlapiOnboardAnimating() {
|
||||
if m.spinnerTicking || m.reducedMotion || !m.sidebarHasAgents() {
|
||||
return nil
|
||||
}
|
||||
m.spinnerTicking = true
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
@@ -1965,35 +1964,6 @@ func TestCtrlCRequiresSecondPressToExit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmedCtrlCCancelsProviderAimlapiOnboarding(t *testing.T) {
|
||||
cancelled := false
|
||||
m := newModel(context.Background(), Options{ProviderName: "tokenrouter"})
|
||||
m.providerWizard = &providerWizardState{
|
||||
step: providerWizardStepAimlapi,
|
||||
aimlapi: &aimlapiOnboardState{
|
||||
topupCancel: func() { cancelled = true },
|
||||
},
|
||||
}
|
||||
|
||||
updated, _ := m.Update(testKeyCtrl('c'))
|
||||
next := updated.(model)
|
||||
if cancelled {
|
||||
t.Fatal("first Ctrl+C should only arm exit confirmation")
|
||||
}
|
||||
|
||||
updated, cmd := next.Update(testKeyCtrl('c'))
|
||||
next = updated.(model)
|
||||
if !cancelled {
|
||||
t.Fatal("confirmed Ctrl+C did not cancel AIMLAPI onboarding")
|
||||
}
|
||||
if next.providerWizard.aimlapi != nil {
|
||||
t.Fatal("confirmed Ctrl+C retained AIMLAPI onboarding state")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("confirmed Ctrl+C should return quit command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlCExitConfirmationDisarmsOnInterveningKey(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ProviderName: "tokenrouter"})
|
||||
|
||||
@@ -2677,172 +2647,6 @@ func TestModelNotifierFocusAndCompletion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerBlinkStaysSolidWhileTyping(t *testing.T) {
|
||||
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
|
||||
now := base
|
||||
m := model{
|
||||
now: func() time.Time { return now },
|
||||
terminalFocused: true,
|
||||
lastCharTime: base,
|
||||
composerCursorVisible: true,
|
||||
}
|
||||
|
||||
// Each iteration simulates a keystroke (refreshing lastCharTime) followed by
|
||||
// a blink tick within the typing-idle threshold: cursor must stay solid
|
||||
// rather than toggling off, however many ticks land.
|
||||
for i := 0; i < 3; i++ {
|
||||
now = now.Add(200 * time.Millisecond)
|
||||
m.lastCharTime = now
|
||||
updated, _ := m.Update(composerBlinkMsg{})
|
||||
m = updated.(model)
|
||||
if !m.composerCursorVisible {
|
||||
t.Fatalf("tick %d: expected cursor to stay visible while typing, got hidden", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerBlinkHiddenWhileUnfocused(t *testing.T) {
|
||||
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
|
||||
m := model{
|
||||
now: func() time.Time { return base },
|
||||
lastCharTime: base.Add(-time.Hour), // long idle, irrelevant while unfocused
|
||||
composerCursorVisible: true,
|
||||
}
|
||||
|
||||
updated, _ := m.Update(tea.BlurMsg{})
|
||||
m = updated.(model)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
updated, _ = m.Update(composerBlinkMsg{})
|
||||
m = updated.(model)
|
||||
if m.composerCursorVisible {
|
||||
t.Fatalf("tick %d: expected cursor to stay hidden while unfocused, got visible", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerBlinkResumesAfterRefocusAndIdle(t *testing.T) {
|
||||
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
|
||||
m := model{
|
||||
now: func() time.Time { return base },
|
||||
lastCharTime: base.Add(-time.Hour), // stale: well past the idle threshold
|
||||
composerCursorVisible: true,
|
||||
}
|
||||
|
||||
updated, _ := m.Update(tea.BlurMsg{})
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(tea.FocusMsg{})
|
||||
m = updated.(model)
|
||||
if !m.terminalFocused {
|
||||
t.Fatal("expected terminalFocused to be true after FocusMsg")
|
||||
}
|
||||
|
||||
updated, _ = m.Update(composerBlinkMsg{})
|
||||
m = updated.(model)
|
||||
first := m.composerCursorVisible
|
||||
updated, _ = m.Update(composerBlinkMsg{})
|
||||
m = updated.(model)
|
||||
second := m.composerCursorVisible
|
||||
if first == second {
|
||||
t.Fatalf("expected blink to toggle once idle+focused, got %v then %v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerBlinkTogglesWhenIdleAndFocused(t *testing.T) {
|
||||
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
|
||||
m := model{
|
||||
now: func() time.Time { return base },
|
||||
terminalFocused: true,
|
||||
lastCharTime: base.Add(-time.Hour),
|
||||
composerCursorVisible: true,
|
||||
}
|
||||
|
||||
updated, _ := m.Update(composerBlinkMsg{})
|
||||
m = updated.(model)
|
||||
if m.composerCursorVisible {
|
||||
t.Fatal("expected cursor to toggle off on first idle+focused tick")
|
||||
}
|
||||
updated, _ = m.Update(composerBlinkMsg{})
|
||||
m = updated.(model)
|
||||
if !m.composerCursorVisible {
|
||||
t.Fatal("expected cursor to toggle back on on second idle+focused tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerCursorShowsImmediatelyOnKeypress(t *testing.T) {
|
||||
// The blink phase may have just hidden the caret when the user starts
|
||||
// typing; the typed character must render with a caret immediately, not
|
||||
// after the next composerBlinkMsg tick evaluates the typing threshold.
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.terminalFocused = true
|
||||
m.composerCursorVisible = false
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'a', Text: "a"}))
|
||||
m = updated.(model)
|
||||
if !m.composerCursorVisible {
|
||||
t.Fatal("expected caret visible immediately after a keypress, before any blink tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerCursorSyncsImmediatelyOnFocusChange(t *testing.T) {
|
||||
// Focus transitions must apply the caret contract synchronously: a blur
|
||||
// with no blink tick yet must not leave a visible caret in an unfocused
|
||||
// terminal, and a refocus must not leave the caret hidden for a tick.
|
||||
m := model{composerCursorVisible: true, terminalFocused: true}
|
||||
|
||||
updated, _ := m.Update(tea.BlurMsg{})
|
||||
m = updated.(model)
|
||||
if m.composerCursorVisible {
|
||||
t.Fatal("expected caret hidden immediately after BlurMsg, before any blink tick")
|
||||
}
|
||||
|
||||
updated, _ = m.Update(tea.FocusMsg{})
|
||||
m = updated.(model)
|
||||
if !m.composerCursorVisible {
|
||||
t.Fatal("expected caret visible immediately after FocusMsg, before any blink tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerCursorShowsImmediatelyOnPaste(t *testing.T) {
|
||||
// A paste is the same immediate-input transition as a keypress: if the
|
||||
// blink phase had just hidden the caret, the freshly pasted composer must
|
||||
// render with a solid caret right away, and the typing timestamp must be
|
||||
// refreshed so the next blink tick holds solid instead of toggling off a
|
||||
// stale idle state.
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.terminalFocused = true
|
||||
m.composerCursorVisible = false
|
||||
m.lastCharTime = time.Now().Add(-time.Hour) // stale: well past the idle threshold
|
||||
|
||||
updated, _ := m.Update(tea.PasteMsg{Content: "pasted text"})
|
||||
m = updated.(model)
|
||||
if !m.composerCursorVisible {
|
||||
t.Fatal("expected caret visible immediately after a paste, before any blink tick")
|
||||
}
|
||||
if time.Since(m.lastCharTime) > time.Minute {
|
||||
t.Fatalf("expected the paste to refresh lastCharTime, still %v old", time.Since(m.lastCharTime))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandArgumentHintFollowsCursorVisibility(t *testing.T) {
|
||||
// The argument-hint composer line is an alternate render path that used to
|
||||
// paint its caret cell unconditionally, ignoring focus and blink state.
|
||||
input := textinput.New()
|
||||
input.SetValue("/rewind ")
|
||||
|
||||
visible := commandArgumentHintComposerLine(input, "hint", true)
|
||||
hidden := commandArgumentHintComposerLine(input, "hint", false)
|
||||
if visible == hidden {
|
||||
t.Fatal("expected the rendered hint line to differ between visible and hidden caret states")
|
||||
}
|
||||
if want := composerCursor(zeroTheme.faint.Render("h")); !strings.Contains(visible, want) {
|
||||
t.Fatalf("expected visible-caret render to contain the styled cursor cell, got %q", visible)
|
||||
}
|
||||
if got := hidden; strings.Contains(got, composerCursor(zeroTheme.faint.Render("h"))) {
|
||||
t.Fatalf("expected hidden-caret render to drop the styled cursor cell, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrimViewportLine(t *testing.T) {
|
||||
// Blank lines are left untouched (no scrim).
|
||||
if got := scrimViewportLine(" ", 10); got != " " {
|
||||
|
||||
+5
-142
@@ -3,7 +3,6 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -12,7 +11,6 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/aimlapi"
|
||||
"github.com/Gitlawb/zero/internal/browser"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
@@ -31,9 +29,6 @@ const (
|
||||
setupStageEndpoint
|
||||
setupStageName
|
||||
setupStageCredentials
|
||||
// aimlapi.com onboarding sub-flow, reached when the selected
|
||||
// provider is aimlapi. Delegated to the shared aimlapiOnboardState.
|
||||
setupStageAimlapi
|
||||
setupStageModel
|
||||
setupStageSafety
|
||||
setupStageReady
|
||||
@@ -67,25 +62,6 @@ type setupState struct {
|
||||
modelErr string
|
||||
modelSrc string
|
||||
modelGen uint64
|
||||
// aimlapi holds the shared aimlapi.com onboarding sub-flow while
|
||||
// setup is on setupStageAimlapi.
|
||||
aimlapi *aimlapiOnboardState
|
||||
}
|
||||
|
||||
// aimlapiTopupEvent is one live update from a running partner checkout, streamed
|
||||
// over a channel so the TUI can show progress (and the direct checkout link) as
|
||||
// it happens instead of blocking on a single result. Mirrors openclaude's
|
||||
// onStatus-driven progress screen.
|
||||
type aimlapiTopupEvent struct {
|
||||
detail string // for opening-checkout this is the direct checkout URL
|
||||
done bool
|
||||
result aimlapi.ProvisionedKey
|
||||
err error
|
||||
|
||||
// session carries the live partner-checkout session token (empty string means
|
||||
// "drop the retained token"); hasSession distinguishes it from a status update.
|
||||
session string
|
||||
hasSession bool
|
||||
}
|
||||
|
||||
// setupOAuthMsg carries the result of a first-run browser OAuth login.
|
||||
@@ -161,16 +137,6 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
// The aimlapi.com sub-flow owns all of its keys; Ctrl+C still quits.
|
||||
if m.setup.stage == setupStageAimlapi {
|
||||
if keyCtrl(msg, 'c') {
|
||||
if m.setup.aimlapi != nil {
|
||||
m.setup.aimlapi.cancelStream()
|
||||
}
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m.handleSetupAimlapiKey(msg)
|
||||
}
|
||||
if m.setupEndpointInputActive() {
|
||||
return m.handleSetupEndpointKey(msg)
|
||||
}
|
||||
@@ -217,7 +183,7 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m.advanceSetup()
|
||||
}
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyUp) || keyCtrl(msg, 'p'):
|
||||
case keyIs(msg, tea.KeyUp):
|
||||
if m.setup.stage == setupStageMethod {
|
||||
m.moveSetupMethod(-1)
|
||||
} else if m.setup.stage == setupStageProvider {
|
||||
@@ -226,7 +192,7 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.moveSetupModel(-1)
|
||||
}
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyDown) || keyCtrl(msg, 'n'):
|
||||
case keyIs(msg, tea.KeyDown):
|
||||
if m.setup.stage == setupStageMethod {
|
||||
m.moveSetupMethod(1)
|
||||
} else if m.setup.stage == setupStageProvider {
|
||||
@@ -303,10 +269,6 @@ func (m model) handleSetupPaste(msg tea.PasteMsg) (tea.Model, tea.Cmd) {
|
||||
m.appendSetupBaseURL([]rune(msg.Content))
|
||||
case m.setupNameInputActive():
|
||||
m.appendSetupName([]rune(msg.Content))
|
||||
case m.setup.stage == setupStageAimlapi:
|
||||
if m.setup.aimlapi != nil {
|
||||
m.setup.aimlapi.appendInput(msg.Content)
|
||||
}
|
||||
case m.setupCredentialInputActive():
|
||||
previousAPIKey := m.setup.apiKey.Value()
|
||||
var cmd tea.Cmd
|
||||
@@ -459,21 +421,10 @@ func setupContentTop(height int, contentLines int, hasError bool) int {
|
||||
func (m model) setupStages() []setupStage {
|
||||
stages := []setupStage{setupStageWelcome, setupStageMethod}
|
||||
if m.setup.oauthMode {
|
||||
// OAuth path skips endpoint/name/credentials because login provides the credential.
|
||||
// OAuth path skips endpoint/name/credentials (the login provides the credential).
|
||||
return append(stages, setupStageProvider, setupStageModel, setupStageSafety, setupStageReady)
|
||||
}
|
||||
stages = append(stages, setupStageProvider)
|
||||
if setupProviderIsAimlapi(m.setupProvider()) {
|
||||
// Preserve the catalog's normal env-credential path in first-run setup.
|
||||
// A ready AIMLAPI_API_KEY needs no guided identity flow: model discovery
|
||||
// resolves it at runtime and saveSetupProvider persists APIKeyEnv, not the
|
||||
// materialized secret. /provider owns the richer balance/top-up preflight.
|
||||
if setupAimlapiEnvKey() != "" {
|
||||
return append(stages, setupStageModel, setupStageSafety, setupStageReady)
|
||||
}
|
||||
stages = append(stages, setupStageAimlapi, setupStageModel, setupStageSafety, setupStageReady)
|
||||
return stages
|
||||
}
|
||||
if setupProviderNeedsEndpoint(m.setupProvider()) {
|
||||
stages = append(stages, setupStageEndpoint, setupStageName)
|
||||
}
|
||||
@@ -481,14 +432,6 @@ func (m model) setupStages() []setupStage {
|
||||
return stages
|
||||
}
|
||||
|
||||
func setupAimlapiEnvKey() string {
|
||||
return strings.TrimSpace(os.Getenv("AIMLAPI_API_KEY"))
|
||||
}
|
||||
|
||||
func setupProviderIsAimlapi(option SetupProviderOption) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(option.ID), "aimlapi")
|
||||
}
|
||||
|
||||
// setupReturnToMethod resets the OAuth selection when navigating back to the
|
||||
// connect-method chooser.
|
||||
func (m model) setupReturnToMethod() model {
|
||||
@@ -664,54 +607,6 @@ func (m model) applySetupOAuth(msg setupOAuthMsg) (tea.Model, tea.Cmd) {
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// handleSetupAimlapiKey delegates a key press to first-run setup's aimlapi.com
|
||||
// sub-flow and reacts to its outcome.
|
||||
func (m model) handleSetupAimlapiKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.setup.aimlapi == nil {
|
||||
return m, nil
|
||||
}
|
||||
cmd, outcome := m.setup.aimlapi.handleKey(msg)
|
||||
return m.resolveSetupAimlapi(cmd, outcome)
|
||||
}
|
||||
|
||||
// applySetupAimlapiOnboard routes an async aimlapi.com onboarding result into
|
||||
// first-run setup's sub-flow and reacts to its outcome.
|
||||
func (m model) applySetupAimlapiOnboard(msg aimlapiOnboardMsg) (tea.Model, tea.Cmd) {
|
||||
if m.setup.aimlapi == nil || m.setup.aimlapi != msg.state {
|
||||
return m, nil
|
||||
}
|
||||
cmd, outcome := m.setup.aimlapi.apply(msg)
|
||||
return m.resolveSetupAimlapi(cmd, outcome)
|
||||
}
|
||||
|
||||
// resolveSetupAimlapi lands the sub-flow outcome on first-run setup: a terminal
|
||||
// key moves the issued key into the setup and jumps to model selection; a cancel
|
||||
// backs out to the provider list.
|
||||
func (m model) resolveSetupAimlapi(cmd tea.Cmd, outcome aimlapiOutcome) (tea.Model, tea.Cmd) {
|
||||
switch outcome {
|
||||
case aimlapiDone:
|
||||
m.setup.apiKey.SetValue(m.setup.aimlapi.apiKey)
|
||||
m.setup.baseURL = m.setup.aimlapi.baseURL
|
||||
m.setup.aimlapi = nil
|
||||
m.setup.err = ""
|
||||
m.setup.stage = setupStageModel
|
||||
m.resetSetupModels()
|
||||
m.setup.modelErr = ""
|
||||
m.setup.modelGen++
|
||||
discoverCmd := m.setupModelDiscoveryCmd(m.setup.modelGen)
|
||||
m.setup.modelLoad = discoverCmd != nil
|
||||
return m, discoverCmd
|
||||
case aimlapiCancel:
|
||||
m.setup.aimlapi = nil
|
||||
m.setup.err = ""
|
||||
m.setup.stage = setupStageProvider
|
||||
return m, nil
|
||||
}
|
||||
// Keep the shared spinner tick alive whenever the sub-flow just entered a busy
|
||||
// or progress state, so its animated spinner advances during onboarding.
|
||||
return m, tea.Batch(cmd, m.ensureSpinnerTick())
|
||||
}
|
||||
|
||||
func (m model) nextSetupStage() setupStage {
|
||||
stages := m.setupStages()
|
||||
for index, stage := range stages {
|
||||
@@ -726,14 +621,6 @@ func (m model) nextSetupStage() setupStage {
|
||||
}
|
||||
|
||||
func (m model) previousSetupStage() setupStage {
|
||||
// The aimlapi.com connect flow is terminal: once it has issued a key, its
|
||||
// transient state is cleared. Going back from model selection or Safety can
|
||||
// otherwise strand the user on an empty intermediate screen. Return to the
|
||||
// provider list so choosing aimlapi.com again starts a fresh connect flow.
|
||||
if (m.setup.stage == setupStageModel || m.setup.stage == setupStageSafety) &&
|
||||
setupProviderIsAimlapi(m.setupProvider()) {
|
||||
return setupStageProvider
|
||||
}
|
||||
stages := m.setupStages()
|
||||
for index, stage := range stages {
|
||||
if stage == m.setup.stage {
|
||||
@@ -812,9 +699,6 @@ func (m model) advanceSetup() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.setup.stage = m.nextSetupStage()
|
||||
m.setup.err = ""
|
||||
if m.setup.stage == setupStageAimlapi && m.setup.aimlapi == nil {
|
||||
m.setup.aimlapi = newAimlapiOnboard(browser.OpenURL)
|
||||
}
|
||||
if m.setup.stage == setupStageModel {
|
||||
m.resetSetupModels()
|
||||
m.setup.modelErr = ""
|
||||
@@ -951,7 +835,7 @@ func (m model) setupModelDiscoveryCmd(gen uint64) tea.Cmd {
|
||||
apiKey = resolved
|
||||
}
|
||||
}
|
||||
profile := providerWizardDiscoveryProfile(provider, apiKey, m.setupBaseURL(option))
|
||||
profile := providerWizardDiscoveryProfile(provider, apiKey)
|
||||
secrets := append(append([]string{}, baseSecrets...), apiKey, profile.APIKey)
|
||||
models, err := discover(ctx, profile)
|
||||
return setupModelsDiscoveredMsg{providerID: providerID, gen: gen, redactionSecrets: secrets, models: models, err: err}
|
||||
@@ -1215,16 +1099,6 @@ func (m model) setupCredentialAPIKey(option SetupProviderOption) string {
|
||||
}
|
||||
|
||||
func (m model) setupBaseURL(option SetupProviderOption) string {
|
||||
// aimlapi.com never shows the endpoint field, but its onboarding resolves the
|
||||
// inference endpoint (honoring an AIMLAPI_INFERENCE_URL override) onto
|
||||
// m.setup.baseURL. Persist that so an override reaches the saved profile instead
|
||||
// of being replaced by the catalog default at save time.
|
||||
if providerWizardIsAimlapi(setupProviderDescriptor(option)) {
|
||||
if baseURL := strings.TrimSpace(m.setup.baseURL); baseURL != "" {
|
||||
return baseURL
|
||||
}
|
||||
return strings.TrimSpace(aimlapi.ResolveEndpoints().InferenceBaseURL)
|
||||
}
|
||||
if !setupProviderNeedsEndpoint(option) {
|
||||
return ""
|
||||
}
|
||||
@@ -1352,11 +1226,6 @@ func (m model) setupStageLines(width int, height int) []string {
|
||||
return m.setupNameLines(width)
|
||||
case setupStageCredentials:
|
||||
return m.setupCredentialLines(width)
|
||||
case setupStageAimlapi:
|
||||
if m.setup.aimlapi != nil {
|
||||
return m.setup.aimlapi.view(width, m.spinnerGlyph())
|
||||
}
|
||||
return nil
|
||||
case setupStageModel:
|
||||
return m.setupModelLines(width, height)
|
||||
case setupStageSafety:
|
||||
@@ -1737,11 +1606,7 @@ func setupProviderBlockWidth(terminalWidth int, providers []SetupProviderOption)
|
||||
available := maxInt(24, minInt(terminalWidth-8, 44))
|
||||
target := maxInt(lipgloss.Width(" 2/6"), lipgloss.Width(" Choose a provider"))
|
||||
for _, provider := range providers {
|
||||
label := displayValue(provider.Name, provider.ID)
|
||||
if provider.Recommended {
|
||||
label = "★ " + label + " (recommended)"
|
||||
}
|
||||
target = maxInt(target, 2+lipgloss.Width(label))
|
||||
target = maxInt(target, 2+lipgloss.Width(displayValue(provider.Name, provider.ID)))
|
||||
}
|
||||
target = maxInt(target, 32)
|
||||
return minInt(target, available)
|
||||
@@ -1829,8 +1694,6 @@ func (m model) setupFooter() string {
|
||||
return zeroTheme.faint.Render("paste key optional ") + zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" continue left back")
|
||||
}
|
||||
return zeroTheme.accent.Render("Space") + zeroTheme.faint.Render(" to continue")
|
||||
case setupStageAimlapi:
|
||||
return zeroTheme.faint.Render("type/↑↓ choose ") + zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" continue left back Esc cancel")
|
||||
case setupStageProvider:
|
||||
if m.setup.oauthMode && m.setupProviderDescriptor().OAuthDeviceFlow {
|
||||
return zeroTheme.faint.Render("↑/↓ choose ") + zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" sign in ") + zeroTheme.accent.Render("d") + zeroTheme.faint.Render(" device code q quit")
|
||||
|
||||
@@ -3,7 +3,6 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -11,7 +10,6 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/aimlapi"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
)
|
||||
@@ -51,611 +49,6 @@ func TestSetupMethodOptionsDropsOAuthWithoutOAuthProviders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiCheckoutLinkWrapsWithoutTruncation(t *testing.T) {
|
||||
link := "https://checkout.stripe.com/c/pay/cs_test_abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789#fidkdWxOYHwnPyd1blpxYHZxWjA0"
|
||||
lines := aimlapiLinkLines(link, 32)
|
||||
if len(lines) < 2 {
|
||||
t.Fatalf("expected long checkout link to wrap, got %d line(s): %#v", len(lines), lines)
|
||||
}
|
||||
plain := plainRender(t, strings.Join(lines, "\n"))
|
||||
for index, line := range strings.Split(plain, "\n") {
|
||||
if strings.Contains(line, "://") {
|
||||
t.Fatalf("line %d still exposes an auto-detectable URL scheme: %q", index, line)
|
||||
}
|
||||
}
|
||||
if strings.Contains(plain, "…") {
|
||||
t.Fatalf("wrapped checkout link should not be truncated:\n%s", plain)
|
||||
}
|
||||
var joined strings.Builder
|
||||
for _, line := range strings.Split(plain, "\n") {
|
||||
joined.WriteString(strings.TrimSpace(line))
|
||||
}
|
||||
if got := joined.String(); got != link {
|
||||
t.Fatalf("wrapped checkout link changed:\ngot %q\nwant %q", got, link)
|
||||
}
|
||||
for index, line := range strings.Split(plain, "\n") {
|
||||
if got := lipgloss.Width(line); got > 32 {
|
||||
t.Fatalf("line %d width = %d, want <= 32: %q", index, got, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiCheckoutLinkReplacesProgressSpinner(t *testing.T) {
|
||||
link := "https://checkout.stripe.com/c/pay/cs_test_abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepProgress,
|
||||
detail: link,
|
||||
}
|
||||
view := plainRender(t, strings.Join(state.viewProgress(40, "spin"), "\n"))
|
||||
assertContains(t, view, "Opening checkout in browser...")
|
||||
assertContains(t, view, "https:")
|
||||
assertContains(t, view, "//checkout.stripe.com")
|
||||
assertNotContains(t, view, "spin")
|
||||
}
|
||||
|
||||
func TestAimlapiEmailInputRejectsIncompleteDomain(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepEmailInput,
|
||||
email: "123@123.",
|
||||
}
|
||||
|
||||
cmd, outcome := state.handleKey(testKey(tea.KeyEnter))
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("invalid email should not start an account request")
|
||||
}
|
||||
if outcome != aimlapiContinue || state.busy {
|
||||
t.Fatalf("invalid email returned outcome=%v busy=%v, want continue/false", outcome, state.busy)
|
||||
}
|
||||
if state.errText != aimlapi.MsgEmailInvalid {
|
||||
t.Fatalf("error = %q, want %q", state.errText, aimlapi.MsgEmailInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiEverythingReadyBackReturnsToProviderList(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{
|
||||
Visible: true,
|
||||
Providers: []SetupProviderOption{
|
||||
{ID: "openai", Name: "OpenAI"},
|
||||
{ID: "aimlapi", Name: "aimlapi.com"},
|
||||
},
|
||||
}})
|
||||
m.setup.selected = 1
|
||||
m.setup.stage = setupStageAimlapi
|
||||
m.setup.aimlapi = &aimlapiOnboardState{
|
||||
step: aimlapiStepDone,
|
||||
successLines: []string{aimlapi.MsgEverythingRuns},
|
||||
}
|
||||
|
||||
updated, cmd := m.Update(testKey(tea.KeyLeft))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("back from Everything is ready should not start a command")
|
||||
}
|
||||
if next.setup.stage != setupStageProvider {
|
||||
t.Fatalf("stage = %v, want provider list", next.setup.stage)
|
||||
}
|
||||
if next.setup.aimlapi != nil {
|
||||
t.Fatal("completed AIMLAPI sub-flow should be cleared when returning to providers")
|
||||
}
|
||||
if got := next.setupProvider().ID; got != "aimlapi" {
|
||||
t.Fatalf("selected provider = %q, want aimlapi", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupAimlapiCtrlCCancelsInFlightCheckout(t *testing.T) {
|
||||
cancelled := false
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{Visible: true}})
|
||||
m.setup.stage = setupStageAimlapi
|
||||
m.setup.aimlapi = &aimlapiOnboardState{
|
||||
step: aimlapiStepProgress,
|
||||
topupCancel: func() { cancelled = true },
|
||||
}
|
||||
|
||||
_, cmd := m.Update(testKeyCtrl('c'))
|
||||
if !cancelled {
|
||||
t.Fatal("Ctrl+C did not cancel the in-flight AIMLAPI checkout")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("Ctrl+C should still quit after cancelling the checkout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupAimlapiEnvKeyUsesResolvedInferenceEndpoint(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_API_KEY", "env-secret")
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", "https://staging.example.test/v1")
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{
|
||||
Visible: true,
|
||||
Providers: []SetupProviderOption{{
|
||||
ID: "aimlapi", Name: "aimlapi.com", EnvVar: "AIMLAPI_API_KEY", RequiresAuth: true,
|
||||
}},
|
||||
}})
|
||||
|
||||
if got := m.setupBaseURL(m.setupProvider()); got != "https://staging.example.test/v1" {
|
||||
t.Fatalf("setup base URL = %q, want resolved override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiKeyInputUsesAutomaticVerificationHint(t *testing.T) {
|
||||
state := &aimlapiOnboardState{step: aimlapiStepKeyInput}
|
||||
view := plainRender(t, strings.Join(state.view(64, ""), "\n"))
|
||||
assertContains(t, view, "Your API key will be hidden and verified automatically.")
|
||||
assertNotContains(t, view, "Pasted keys are hidden")
|
||||
}
|
||||
|
||||
func TestAimlapiPastedKeyStartsBalanceValidation(t *testing.T) {
|
||||
state := &aimlapiOnboardState{step: aimlapiStepKeyInput, apiKey: " key_test "}
|
||||
|
||||
cmd, outcome := state.handleKey(testKey(tea.KeyEnter))
|
||||
|
||||
if cmd == nil {
|
||||
t.Fatal("pasted key should start balance validation")
|
||||
}
|
||||
if outcome != aimlapiContinue || !state.busy {
|
||||
t.Fatalf("validation returned outcome=%v busy=%v, want continue/true", outcome, state.busy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiOnboardingErrorsStripBodiesAndRedactActiveSecrets(t *testing.T) {
|
||||
const (
|
||||
apiKey = "sk-super-secret"
|
||||
code = "654321"
|
||||
bearer = "session-super-secret"
|
||||
body = "server echoed sk-super-secret 654321 session-super-secret"
|
||||
)
|
||||
newState := func(step aimlapiOnboardStep) *aimlapiOnboardState {
|
||||
return &aimlapiOnboardState{step: step, apiKey: apiKey, code: code, sessionToken: bearer, busy: true}
|
||||
}
|
||||
err := aimlapi.APIError{Message: "onboarding request failed", Status: http.StatusInternalServerError, Body: body}
|
||||
tests := []struct {
|
||||
name string
|
||||
state *aimlapiOnboardState
|
||||
apply func(*aimlapiOnboardState)
|
||||
}{
|
||||
{name: "key validation", state: newState(aimlapiStepKeyInput), apply: func(s *aimlapiOnboardState) { s.applyKeyValidation(aimlapiOnboardMsg{err: err}) }},
|
||||
{name: "account check", state: newState(aimlapiStepEmailInput), apply: func(s *aimlapiOnboardState) { s.applyCheck(aimlapiOnboardMsg{err: err}) }},
|
||||
{name: "code verification", state: newState(aimlapiStepCodeInput), apply: func(s *aimlapiOnboardState) { s.applyToken(aimlapiOnboardMsg{err: err}) }},
|
||||
{name: "key mint", state: newState(aimlapiStepEmailInput), apply: func(s *aimlapiOnboardState) { s.applyKey(aimlapiOnboardMsg{err: err}) }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
test.apply(test.state)
|
||||
if strings.Contains(test.state.errText, body) || strings.Contains(test.state.errText, apiKey) ||
|
||||
strings.Contains(test.state.errText, code) || strings.Contains(test.state.errText, bearer) {
|
||||
t.Fatalf("credential leaked in error: %q", test.state.errText)
|
||||
}
|
||||
if !strings.Contains(test.state.errText, "HTTP 500") {
|
||||
t.Fatalf("safe status missing from error: %q", test.state.errText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiPastedKeyRejectsUnauthorized(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepKeyInput,
|
||||
apiKey: "bad-key",
|
||||
busy: true,
|
||||
}
|
||||
|
||||
_, outcome := state.apply(aimlapiOnboardMsg{
|
||||
state: state,
|
||||
kind: aimlapiMsgKeyValidation,
|
||||
err: aimlapi.APIError{Status: http.StatusUnauthorized},
|
||||
})
|
||||
|
||||
if outcome != aimlapiContinue || state.busy {
|
||||
t.Fatalf("invalid key returned outcome=%v busy=%v, want continue/false", outcome, state.busy)
|
||||
}
|
||||
if state.step != aimlapiStepKeyInput || state.apiKey != "" {
|
||||
t.Fatalf("invalid key left step=%v key=%q, want key input with cleared key", state.step, state.apiKey)
|
||||
}
|
||||
if state.errText != aimlapi.MsgAPIKeyInvalid {
|
||||
t.Fatalf("error = %q, want %q", state.errText, aimlapi.MsgAPIKeyInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiVerifyCodeLabelsOnlyBadRequestAsIncorrect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantIncorrect bool
|
||||
}{
|
||||
{name: "invalid code", err: aimlapi.APIError{Status: http.StatusBadRequest}, wantIncorrect: true},
|
||||
{name: "throttled", err: aimlapi.APIError{Status: http.StatusTooManyRequests}},
|
||||
{name: "server failure", err: aimlapi.APIError{Status: http.StatusInternalServerError}},
|
||||
{name: "network failure", err: errors.New("connection reset")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
state := &aimlapiOnboardState{step: aimlapiStepCodeInput, code: "123456", busy: true}
|
||||
state.applyToken(aimlapiOnboardMsg{err: tt.err})
|
||||
if got := state.errText == aimlapi.MsgCodeIncorrect; got != tt.wantIncorrect {
|
||||
t.Fatalf("incorrect-code label = %v, error text %q", got, state.errText)
|
||||
}
|
||||
if state.step != aimlapiStepCodeInput || state.code != "123456" {
|
||||
t.Fatalf("verification retry lost its screen/code: step=%v code=%q", state.step, state.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiPastedKeyValidationCompletesForValidKey(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepKeyInput,
|
||||
apiKey: " key_test ",
|
||||
busy: true,
|
||||
}
|
||||
|
||||
_, outcome := state.apply(aimlapiOnboardMsg{
|
||||
state: state,
|
||||
kind: aimlapiMsgKeyValidation,
|
||||
})
|
||||
|
||||
if outcome != aimlapiContinue || state.busy {
|
||||
t.Fatalf("valid key returned outcome=%v busy=%v, want continue/false", outcome, state.busy)
|
||||
}
|
||||
if state.step != aimlapiStepDone || state.apiKey != "key_test" {
|
||||
t.Fatalf("valid key left step=%v key=%q, want done with trimmed key", state.step, state.apiKey)
|
||||
}
|
||||
if len(state.successLines) != 1 || state.successLines[0] != aimlapi.MsgEverythingRuns {
|
||||
t.Fatalf("success lines = %#v", state.successLines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiKeyBalanceLowBalanceOffersTopUp(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepProgress,
|
||||
apiKey: "key_minted",
|
||||
busy: true,
|
||||
}
|
||||
|
||||
_, outcome := state.apply(aimlapiOnboardMsg{
|
||||
state: state,
|
||||
kind: aimlapiMsgKeyBalance,
|
||||
balance: aimlapi.BalanceResult{LowBalance: true},
|
||||
})
|
||||
|
||||
if outcome != aimlapiContinue || state.busy {
|
||||
t.Fatalf("low balance returned outcome=%v busy=%v, want continue/false", outcome, state.busy)
|
||||
}
|
||||
if state.step != aimlapiStepLowBalance || state.lowCursor != 0 {
|
||||
t.Fatalf("low balance left step=%v cursor=%d, want low-balance prompt with cursor 0", state.step, state.lowCursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiTopUpFailureRetainsSessionForRetry(t *testing.T) {
|
||||
state := &aimlapiOnboardState{step: aimlapiStepProgress, paymentSessionID: "payment-live"}
|
||||
state.applyTopup(aimlapiOnboardMsg{
|
||||
topupOK: true,
|
||||
topup: aimlapiTopupEvent{session: "pcs_resume", hasSession: true},
|
||||
})
|
||||
state.applyTopup(aimlapiOnboardMsg{
|
||||
topupOK: true,
|
||||
topup: aimlapiTopupEvent{done: true, err: errors.New("lost response")},
|
||||
})
|
||||
if state.resumeSessionToken != "pcs_resume" {
|
||||
t.Fatalf("resume token = %q, want retained session", state.resumeSessionToken)
|
||||
}
|
||||
if state.paymentSessionID != "payment-live" {
|
||||
t.Fatalf("payment session id = %q, want retained live id", state.paymentSessionID)
|
||||
}
|
||||
if state.step != aimlapiStepAmountInput {
|
||||
t.Fatalf("step = %v, want retryable amount input", state.step)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiRejectsUnknownAccountAction(t *testing.T) {
|
||||
state := &aimlapiOnboardState{step: aimlapiStepProgress, email: "[email protected]", busy: true}
|
||||
cmd, outcome := state.applyCheck(aimlapiOnboardMsg{check: aimlapi.CheckResult{Action: "future-action"}})
|
||||
|
||||
if cmd != nil || outcome != aimlapiContinue {
|
||||
t.Fatalf("unknown action returned cmd=%v outcome=%v", cmd != nil, outcome)
|
||||
}
|
||||
if state.step != aimlapiStepEmailInput || state.newAccount || state.busy {
|
||||
t.Fatalf("unknown action state = step %v new=%v busy=%v", state.step, state.newAccount, state.busy)
|
||||
}
|
||||
if state.errText != aimlapi.MsgAccountActionInvalid {
|
||||
t.Fatalf("error = %q", state.errText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiChangedTopUpIntentDropsRetainedCheckout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
amount int
|
||||
autoTopUp bool
|
||||
wantDrop bool
|
||||
}{
|
||||
{name: "same intent", amount: 2500, autoTopUp: true, wantDrop: false},
|
||||
{name: "changed amount", amount: 5000, autoTopUp: true, wantDrop: true},
|
||||
{name: "changed auto top-up", amount: 2500, autoTopUp: false, wantDrop: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
resumeSessionToken: "pcs_old",
|
||||
paymentSessionID: "payment-old",
|
||||
paymentAmountUSDMinor: 2500,
|
||||
paymentAutoTopUp: true,
|
||||
autoTopUp: test.autoTopUp,
|
||||
}
|
||||
state.prepareTopUpIntent(test.amount)
|
||||
dropped := state.resumeSessionToken == "" && state.paymentSessionID == ""
|
||||
if dropped != test.wantDrop {
|
||||
t.Fatalf("checkout dropped = %v, want %v", dropped, test.wantDrop)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiTopUpSuccessShowsChargedCentAmount(t *testing.T) {
|
||||
state := &aimlapiOnboardState{amount: "20.999", paymentAmountUSDMinor: 2100}
|
||||
lines := state.topUpSuccessLines()
|
||||
if got := strings.Join(lines, "\n"); !strings.Contains(got, "$21.00 has been added") || strings.Contains(got, "20.999") {
|
||||
t.Fatalf("success lines do not show normalized charge: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiTerminalTopUpDropsByKeyIdempotencyID(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepProgress,
|
||||
resumeSessionToken: "pcs_old",
|
||||
paymentSessionID: "payment-old",
|
||||
}
|
||||
state.applyTopup(aimlapiOnboardMsg{
|
||||
topupOK: true,
|
||||
topup: aimlapiTopupEvent{
|
||||
done: true, err: errors.New("checkout expired"), session: "", hasSession: true,
|
||||
},
|
||||
})
|
||||
|
||||
if state.resumeSessionToken != "" || state.paymentSessionID != "" {
|
||||
t.Fatalf("terminal checkout retained session ids: resume=%q payment=%q", state.resumeSessionToken, state.paymentSessionID)
|
||||
}
|
||||
if state.step != aimlapiStepAmountInput {
|
||||
t.Fatalf("step = %v, want retryable amount input", state.step)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiOnboardingKeepsSharedTickAlive(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{Visible: true}})
|
||||
m.reducedMotion = false
|
||||
m.setup.stage = setupStageAimlapi
|
||||
m.setup.aimlapi = &aimlapiOnboardState{step: aimlapiStepProgress}
|
||||
|
||||
// No agent run is in flight, but the aimlapi onboarding sub-flow must keep the
|
||||
// shared tick loop alive so its spinner-only progress screen keeps re-rendering.
|
||||
updated, cmd := m.Update(m.spinner.Tick())
|
||||
next := updated.(model)
|
||||
if cmd == nil || !next.spinnerTicking {
|
||||
t.Fatal("aimlapi onboarding should keep the shared spinner tick loop alive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiAmountInputIgnoresLeft(t *testing.T) {
|
||||
for _, newAccount := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "existing account", true: "new account"}[newAccount], func(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepAmountInput,
|
||||
newAccount: newAccount,
|
||||
errText: "keep this error",
|
||||
}
|
||||
|
||||
cmd, outcome := state.handleKey(testKey(tea.KeyLeft))
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("left on the top-up amount screen should not start a command")
|
||||
}
|
||||
if outcome != aimlapiContinue {
|
||||
t.Fatalf("outcome = %v, want aimlapiContinue", outcome)
|
||||
}
|
||||
if state.step != aimlapiStepAmountInput {
|
||||
t.Fatalf("step = %v, want amount input", state.step)
|
||||
}
|
||||
if state.amountField != 0 || state.errText != "keep this error" {
|
||||
t.Fatalf("left changed amount-screen state: field=%d error=%q", state.amountField, state.errText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiAutoTopUpToggleUsesBothArrowKeys(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
key rune
|
||||
}{
|
||||
{name: "left", key: tea.KeyLeft},
|
||||
{name: "right", key: tea.KeyRight},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepAmountInput,
|
||||
amountField: 1,
|
||||
autoTopUp: true,
|
||||
}
|
||||
|
||||
cmd, outcome := state.handleKey(testKey(test.key))
|
||||
|
||||
if cmd != nil || outcome != aimlapiContinue {
|
||||
t.Fatalf("arrow returned cmd=%v outcome=%v, want nil/continue", cmd != nil, outcome)
|
||||
}
|
||||
if state.autoTopUp {
|
||||
t.Fatalf("%s did not toggle auto top-up", test.name)
|
||||
}
|
||||
if state.step != aimlapiStepAmountInput {
|
||||
t.Fatalf("step = %v, want amount input", state.step)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiAutoTopUpRendersOnOff(t *testing.T) {
|
||||
idle := aimlapiToggleLine("auto top up > ", true, false)
|
||||
view := plainRender(t, idle)
|
||||
assertContains(t, view, "on/off")
|
||||
assertNotContains(t, view, "yes/no")
|
||||
if !strings.Contains(idle, zeroTheme.ink.Render("on")) ||
|
||||
!strings.Contains(idle, zeroTheme.faint.Render("off")) {
|
||||
t.Fatalf("idle toggle should render selected on white and unselected off faint: %q", idle)
|
||||
}
|
||||
|
||||
focused := aimlapiToggleLine("auto top up > ", false, true)
|
||||
if !strings.Contains(focused, zeroTheme.accent.Render("auto top up > ")) ||
|
||||
!strings.Contains(focused, zeroTheme.faint.Render("on")) ||
|
||||
!strings.Contains(focused, zeroTheme.ink.Render("off")) {
|
||||
t.Fatalf("focused toggle should highlight its prompt, selected off, and dim unselected on: %q", focused)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiPickPathShowsNewUserFirstAndRoutesSelections(t *testing.T) {
|
||||
state := &aimlapiOnboardState{step: aimlapiStepPickPath}
|
||||
view := plainRender(t, strings.Join(state.viewPickPath(64), "\n"))
|
||||
newUserIndex := strings.Index(view, aimlapi.MsgPickPathNewUser)
|
||||
haveKeyIndex := strings.Index(view, aimlapi.MsgPickPathHaveKey)
|
||||
if newUserIndex < 0 || haveKeyIndex < 0 || newUserIndex >= haveKeyIndex {
|
||||
t.Fatalf("new-user option should precede have-key option:\n%s", view)
|
||||
}
|
||||
state.pathCursor = 0
|
||||
state.handlePickPathKey(testKey(tea.KeyEnter))
|
||||
if state.step != aimlapiStepEmailInput {
|
||||
t.Fatalf("first option routes to %v, want email input", state.step)
|
||||
}
|
||||
state.step = aimlapiStepPickPath
|
||||
state.pathCursor = 1
|
||||
state.handlePickPathKey(testKey(tea.KeyEnter))
|
||||
if state.step != aimlapiStepKeyInput {
|
||||
t.Fatalf("second option routes to %v, want key input", state.step)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstRunAimlapiEnvSkipsGuidedOnboarding(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_API_KEY", "env-runtime-secret")
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{
|
||||
Visible: true,
|
||||
Providers: []SetupProviderOption{{
|
||||
ID: "aimlapi", Name: "aimlapi.com", DefaultModel: "anthropic/claude-sonnet-5",
|
||||
}},
|
||||
}})
|
||||
m.setup.stage = setupStageProvider
|
||||
m.setup.selected = 0
|
||||
|
||||
updated, cmd := m.advanceSetup()
|
||||
next := updated.(model)
|
||||
if next.setup.stage != setupStageModel {
|
||||
t.Fatalf("stage = %v, want model selection", next.setup.stage)
|
||||
}
|
||||
if next.setup.aimlapi != nil {
|
||||
t.Fatal("env credential should not create the guided onboarding state")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("env credential should start authenticated model discovery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiWizardDoesNotFilterDiscoveredModelsByDefault(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.providerWizard = m.newProviderWizard()
|
||||
m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "aimlapi")
|
||||
m.providerWizard.aimlapi = &aimlapiOnboardState{
|
||||
apiKey: "sk-issued",
|
||||
model: "anthropic/claude-sonnet-5",
|
||||
}
|
||||
|
||||
next, cmd := m.resolveProviderWizardAimlapi(nil, aimlapiDone)
|
||||
if next.providerWizard.step != providerWizardStepModel || cmd == nil {
|
||||
t.Fatalf("AIMLAPI completion should enter model discovery: step=%v cmd=%v", next.providerWizard.step, cmd != nil)
|
||||
}
|
||||
if next.providerWizard.modelSearch != "" {
|
||||
t.Fatalf("model search = %q, want empty so the full discovered list is visible", next.providerWizard.modelSearch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiAmountDollarUsesInkStyle(t *testing.T) {
|
||||
line := aimlapiAmountInputLine("", "25", 40, true)
|
||||
if !strings.Contains(line, zeroTheme.ink.Render("$")) {
|
||||
t.Fatalf("amount dollar is not rendered with ink style: %q", line)
|
||||
}
|
||||
idle := aimlapiAmountInputLine("25", "25", 40, false)
|
||||
if !strings.Contains(idle, zeroTheme.ink.Render("amount > ")) ||
|
||||
strings.Contains(idle, zeroTheme.accent.Render("amount > ")) {
|
||||
t.Fatalf("unfocused amount prompt should be white: %q", idle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiSafetyBackReturnsToProviderList(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
key rune
|
||||
}{
|
||||
{name: "left", key: tea.KeyLeft},
|
||||
{name: "escape", key: tea.KeyEsc},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{
|
||||
Visible: true,
|
||||
Providers: []SetupProviderOption{
|
||||
{ID: "openai", Name: "OpenAI"},
|
||||
{ID: "aimlapi", Name: "aimlapi.com"},
|
||||
},
|
||||
}})
|
||||
m.setup.selected = 1
|
||||
m.setup.stage = setupStageSafety
|
||||
m.setup.aimlapi = nil // Cleared after the AIMLAPI connect flow completes.
|
||||
|
||||
updated, cmd := m.Update(testKey(test.key))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("back from Safety should not start a command")
|
||||
}
|
||||
if next.setup.stage != setupStageProvider {
|
||||
t.Fatalf("stage = %v, want provider list", next.setup.stage)
|
||||
}
|
||||
if got := next.setupProvider().ID; got != "aimlapi" {
|
||||
t.Fatalf("selected provider = %q, want aimlapi", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiModelBackReturnsToProviderList(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
key rune
|
||||
}{
|
||||
{name: "left", key: tea.KeyLeft},
|
||||
{name: "escape", key: tea.KeyEsc},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{
|
||||
Visible: true,
|
||||
Providers: []SetupProviderOption{
|
||||
{ID: "openai", Name: "OpenAI"},
|
||||
{ID: "aimlapi", Name: "aimlapi.com"},
|
||||
},
|
||||
}})
|
||||
m.setup.selected = 1
|
||||
m.setup.stage = setupStageModel
|
||||
m.setup.aimlapi = nil // Cleared once AIMLAPI has issued the key.
|
||||
|
||||
updated, cmd := m.Update(testKey(test.key))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("back from AIMLAPI model selection should not start a command")
|
||||
}
|
||||
if next.setup.stage != setupStageProvider {
|
||||
t.Fatalf("stage = %v, want provider list", next.setup.stage)
|
||||
}
|
||||
if got := next.setupProvider().ID; got != "aimlapi" {
|
||||
t.Fatalf("selected provider = %q, want aimlapi", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupCredentialSummaryOAuthToken(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{Visible: true}})
|
||||
m.setup.oauthMode = true
|
||||
|
||||
@@ -420,7 +420,7 @@ func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descri
|
||||
k = resolved
|
||||
}
|
||||
}
|
||||
models, err := discover(ctx, providerWizardDiscoveryProfile(descriptor, k, authed.BaseURL))
|
||||
models, err := discover(ctx, providerWizardDiscoveryProfile(descriptor, k))
|
||||
return modelPickerModelsDiscoveredMsg{providerID: providerID, models: models, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestOpenProviderManagerListsSavedProviders(t *testing.T) {
|
||||
if len(m.providerWizard.manageRows) != 2 {
|
||||
t.Fatalf("expected 2 rows, got %d", len(m.providerWizard.manageRows))
|
||||
}
|
||||
rendered := m.providerWizard.render(m.width, m.spinnerGlyph())
|
||||
rendered := m.providerWizard.render(m.width)
|
||||
for _, want := range []string{"opengateway", "backup", "active", "Enter activate"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("manager render missing %q:\n%s", want, rendered)
|
||||
|
||||
+11
-245
@@ -2,7 +2,6 @@ package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -15,7 +14,6 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/aimlapi"
|
||||
"github.com/Gitlawb/zero/internal/browser"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
@@ -76,52 +74,6 @@ func (m model) applyProviderWizardOAuth(msg providerWizardOAuthMsg) (model, tea.
|
||||
return m, m.providerModelDiscoveryCmd()
|
||||
}
|
||||
|
||||
// applyProviderWizardAimlapiOnboard routes an async aimlapi.com onboarding result
|
||||
// into the wizard's shared sub-flow and reacts to its outcome.
|
||||
func (m model) applyProviderWizardAimlapiOnboard(msg aimlapiOnboardMsg) (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
if wizard == nil || wizard.aimlapi == nil || wizard.aimlapi != msg.state {
|
||||
return m, nil
|
||||
}
|
||||
cmd, outcome := wizard.aimlapi.apply(msg)
|
||||
return m.resolveProviderWizardAimlapi(cmd, outcome)
|
||||
}
|
||||
|
||||
// handleProviderWizardAimlapiKey delegates a key press to the wizard's aimlapi.com
|
||||
// sub-flow and reacts to its outcome.
|
||||
func (m model) handleProviderWizardAimlapiKey(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
if wizard == nil || wizard.aimlapi == nil {
|
||||
return m, nil
|
||||
}
|
||||
cmd, outcome := wizard.aimlapi.handleKey(msg)
|
||||
return m.resolveProviderWizardAimlapi(cmd, outcome)
|
||||
}
|
||||
|
||||
// resolveProviderWizardAimlapi lands the sub-flow's outcome on the wizard: on a
|
||||
// terminal key it moves the freshly acquired key into the wizard and advances to
|
||||
// model selection (reusing the standard finalize); on cancel it backs out.
|
||||
func (m model) resolveProviderWizardAimlapi(cmd tea.Cmd, outcome aimlapiOutcome) (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
switch outcome {
|
||||
case aimlapiDone:
|
||||
wizard.apiKey = wizard.aimlapi.apiKey
|
||||
wizard.baseURL = wizard.aimlapi.baseURL
|
||||
wizard.aimlapi = nil
|
||||
wizard.err = ""
|
||||
wizard.step = providerWizardStepModel
|
||||
return m, m.providerModelDiscoveryCmd()
|
||||
case aimlapiCancel:
|
||||
wizard.aimlapi = nil
|
||||
wizard.err = ""
|
||||
wizard.step = providerWizardStepProvider
|
||||
return m, nil
|
||||
}
|
||||
// Keep the shared spinner tick alive whenever the sub-flow just entered a busy
|
||||
// or progress state, so its animated spinner advances during onboarding.
|
||||
return m, tea.Batch(cmd, m.ensureSpinnerTick())
|
||||
}
|
||||
|
||||
// applyProviderWizardDeviceCode handles phase 1 of device-code login: show the
|
||||
// user_code + verification URI, then kick off phase 2 (the token poll). On error
|
||||
// the redacted message is surfaced and the login is abandoned.
|
||||
@@ -348,8 +300,6 @@ const (
|
||||
providerWizardStepManageKey
|
||||
providerWizardStepEndpoint
|
||||
providerWizardStepName
|
||||
providerWizardStepAimlapiConfigured
|
||||
providerWizardStepAimlapi
|
||||
providerWizardStepCredential
|
||||
providerWizardStepModel
|
||||
providerWizardStepDone
|
||||
@@ -421,27 +371,14 @@ type providerWizardState struct {
|
||||
// Manage-key step: shown when the selected provider already has a stored key.
|
||||
manageKeyCursor int
|
||||
manageProviderName string
|
||||
// aimlapiExistingProfile is the persisted credential source selected by the
|
||||
// "Use existing configuration" preflight. aimlapiRuntimeKey is resolved only
|
||||
// for balance/top-up/model requests; it must never replace APIKeyEnv or an
|
||||
// APIKeyStored marker when the profile is saved again.
|
||||
aimlapiConfiguredCursor int
|
||||
aimlapiExistingProfile config.ProviderProfile
|
||||
aimlapiRuntimeKey string
|
||||
aimlapiExistingBusy bool
|
||||
aimlapiExistingGen int
|
||||
aimlapiExistingCancel context.CancelFunc
|
||||
oauthMode bool
|
||||
oauthPending bool
|
||||
oauthAttemptID int
|
||||
oauthErr string
|
||||
oauthMode bool
|
||||
oauthPending bool
|
||||
oauthAttemptID int
|
||||
oauthErr string
|
||||
// Device-code login (RFC 8628) state while an OAuth login is in flight.
|
||||
oauthDevice bool
|
||||
deviceUserCode string
|
||||
deviceVerificationURI string
|
||||
// aimlapi holds the shared aimlapi.com onboarding sub-flow while
|
||||
// the wizard is on providerWizardStepAimlapi.
|
||||
aimlapi *aimlapiOnboardState
|
||||
// Manager state (provider_manager.go): the list-first /provider surface.
|
||||
manage bool
|
||||
manageRows []providerManagerRow
|
||||
@@ -516,34 +453,6 @@ func (wizard *providerWizardState) oauthResultMatches(providerID string, attempt
|
||||
return wizard.currentProvider().ID == providerID && wizard.oauthAttemptID == attemptID
|
||||
}
|
||||
|
||||
// resetAimlapiOnboard drops any in-flight aimlapi.com sub-flow (cancelling a
|
||||
// running top-up stream) when the selected provider changes.
|
||||
func (wizard *providerWizardState) resetAimlapiOnboard() {
|
||||
if wizard.aimlapi != nil {
|
||||
wizard.aimlapi.cancelStream()
|
||||
wizard.aimlapi = nil
|
||||
}
|
||||
wizard.clearAimlapiExisting()
|
||||
}
|
||||
|
||||
// enterAimlapi starts the shared aimlapi.com onboarding sub-flow.
|
||||
func (wizard *providerWizardState) enterAimlapi() {
|
||||
wizard.clearAimlapiExisting()
|
||||
wizard.aimlapi = newAimlapiOnboard(browser.OpenURL)
|
||||
wizard.step = providerWizardStepAimlapi
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) clearAimlapiExisting() {
|
||||
if wizard.aimlapiExistingCancel != nil {
|
||||
wizard.aimlapiExistingCancel()
|
||||
wizard.aimlapiExistingCancel = nil
|
||||
}
|
||||
wizard.aimlapiExistingProfile = config.ProviderProfile{}
|
||||
wizard.aimlapiRuntimeKey = ""
|
||||
wizard.aimlapiExistingBusy = false
|
||||
wizard.aimlapiConfiguredCursor = 0
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) currentModel() providerWizardModel {
|
||||
if wizard == nil {
|
||||
return providerWizardModel{}
|
||||
@@ -592,7 +501,6 @@ func (wizard *providerWizardState) move(delta int) {
|
||||
wizard.modelLoadError = ""
|
||||
wizard.oauthPending = false
|
||||
wizard.oauthErr = ""
|
||||
wizard.resetAimlapiOnboard()
|
||||
wizard.refreshModels()
|
||||
case providerWizardStepModel:
|
||||
wizard.refreshModels()
|
||||
@@ -648,8 +556,6 @@ func (wizard *providerWizardState) advance() {
|
||||
wizard.err = ""
|
||||
if providerWizardNeedsEndpoint(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepEndpoint
|
||||
} else if providerWizardIsAimlapi(wizard.currentProvider()) {
|
||||
wizard.enterAimlapi()
|
||||
} else if providerWizardNeedsCredential(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepCredential
|
||||
} else {
|
||||
@@ -664,9 +570,7 @@ func (wizard *providerWizardState) advance() {
|
||||
wizard.step = providerWizardStepName
|
||||
case providerWizardStepName:
|
||||
wizard.err = ""
|
||||
if providerWizardIsAimlapi(wizard.currentProvider()) {
|
||||
wizard.enterAimlapi()
|
||||
} else if providerWizardNeedsCredential(wizard.currentProvider()) {
|
||||
if providerWizardNeedsCredential(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepCredential
|
||||
} else {
|
||||
wizard.step = providerWizardStepModel
|
||||
@@ -714,15 +618,6 @@ func (wizard *providerWizardState) retreat() {
|
||||
wizard.providerSearch = ""
|
||||
case providerWizardStepName:
|
||||
wizard.step = providerWizardStepEndpoint
|
||||
case providerWizardStepAimlapi:
|
||||
wizard.resetAimlapiOnboard()
|
||||
if providerWizardNeedsProfileName(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepName
|
||||
} else if providerWizardNeedsEndpoint(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepEndpoint
|
||||
} else {
|
||||
wizard.step = providerWizardStepProvider
|
||||
}
|
||||
case providerWizardStepCredential:
|
||||
if providerWizardNeedsProfileName(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepName
|
||||
@@ -732,13 +627,7 @@ func (wizard *providerWizardState) retreat() {
|
||||
wizard.step = providerWizardStepProvider
|
||||
}
|
||||
case providerWizardStepModel:
|
||||
if providerWizardIsAimlapi(wizard.currentProvider()) {
|
||||
if aimlapiProfile(wizard.aimlapiExistingProfile) {
|
||||
wizard.step = providerWizardStepAimlapiConfigured
|
||||
} else {
|
||||
wizard.step = providerWizardStepProvider
|
||||
}
|
||||
} else if providerWizardNeedsCredential(wizard.currentProvider()) {
|
||||
if providerWizardNeedsCredential(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepCredential
|
||||
} else if providerWizardNeedsEndpoint(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepEndpoint
|
||||
@@ -788,10 +677,6 @@ func providerWizardNeedsCredential(provider providercatalog.Descriptor) bool {
|
||||
return provider.RequiresAuth && !provider.Local && len(provider.AuthEnvVars) > 0
|
||||
}
|
||||
|
||||
func providerWizardIsAimlapi(provider providercatalog.Descriptor) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(provider.ID), "aimlapi")
|
||||
}
|
||||
|
||||
func providerWizardNeedsEndpoint(provider providercatalog.Descriptor) bool {
|
||||
switch provider.ID {
|
||||
case "custom-openai-compatible", "custom-anthropic-compatible":
|
||||
@@ -828,10 +713,6 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
wizard.oauthDevice = false
|
||||
wizard.deviceUserCode = ""
|
||||
wizard.deviceVerificationURI = ""
|
||||
// This branch preempts the aimlapi.com key delegation below, so cancel the
|
||||
// embedded sub-flow here too — otherwise its in-flight CreateKey/checkout
|
||||
// keeps running against a background context and mints a key we discard.
|
||||
wizard.resetAimlapiOnboard()
|
||||
wizard.err = ""
|
||||
wizard.oauthErr = ""
|
||||
wizard.step = providerWizardStepManage
|
||||
@@ -931,34 +812,6 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
return m.advanceProviderWizard()
|
||||
}
|
||||
}
|
||||
if m.providerWizard.step == providerWizardStepAimlapi {
|
||||
return m.handleProviderWizardAimlapiKey(msg)
|
||||
}
|
||||
if m.providerWizard.step == providerWizardStepAimlapiConfigured {
|
||||
if m.providerWizard.aimlapiExistingBusy {
|
||||
if keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyLeft) {
|
||||
m.providerWizard.aimlapiExistingGen++
|
||||
m.providerWizard.clearAimlapiExisting()
|
||||
m.providerWizard.step = providerWizardStepProvider
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
switch {
|
||||
case keyIs(msg, tea.KeyEsc):
|
||||
m.providerWizard = nil
|
||||
case keyIs(msg, tea.KeyLeft):
|
||||
m.providerWizard.clearAimlapiExisting()
|
||||
m.providerWizard.step = providerWizardStepProvider
|
||||
case keyIs(msg, tea.KeyUp) || keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
|
||||
m.providerWizard.aimlapiConfiguredCursor = (m.providerWizard.aimlapiConfiguredCursor + 1) % 2
|
||||
case keyIs(msg, tea.KeyEnter) || keyIs(msg, tea.KeyRight):
|
||||
if m.providerWizard.aimlapiConfiguredCursor == 0 {
|
||||
return m.checkExistingAimlapiBalance()
|
||||
}
|
||||
m.providerWizard.enterAimlapi()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.providerWizard.step == providerWizardStepCredential {
|
||||
switch {
|
||||
case keyIs(msg, tea.KeyEsc):
|
||||
@@ -1040,10 +893,6 @@ func (m model) handleProviderWizardPaste(content string) (model, tea.Cmd) {
|
||||
m.providerWizard.appendBaseURL([]rune(content))
|
||||
case providerWizardStepName:
|
||||
m.providerWizard.appendProfileName([]rune(content))
|
||||
case providerWizardStepAimlapi:
|
||||
if m.providerWizard.aimlapi != nil {
|
||||
m.providerWizard.aimlapi.appendInput(content)
|
||||
}
|
||||
case providerWizardStepCredential:
|
||||
m.providerWizard.appendAPIKey([]rune(content))
|
||||
case providerWizardStepModel:
|
||||
@@ -1233,18 +1082,6 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
|
||||
modelChoice := wizard.currentModel()
|
||||
profile := providerWizardProfile(provider, modelChoice.ID, wizard.apiKey, wizard.baseURL, wizard.profileName)
|
||||
runtimeProfile := providerWizardRuntimeProfile(profile)
|
||||
usingExistingAimlapi := aimlapiProfile(wizard.aimlapiExistingProfile)
|
||||
preserveExistingCredentialReference := false
|
||||
if usingExistingAimlapi {
|
||||
// Preserve the configured credential source exactly. The resolved key is
|
||||
// runtime-only and must not turn APIKeyEnv or APIKeyStored into a captured
|
||||
// secret merely because the user changed models or topped up.
|
||||
profile = wizard.aimlapiExistingProfile
|
||||
profile.Model = modelChoice.ID
|
||||
runtimeProfile = profile
|
||||
runtimeProfile.APIKey = wizard.aimlapiRuntimeKey
|
||||
preserveExistingCredentialReference = strings.TrimSpace(profile.APIKeyEnv) != "" || profile.APIKeyStored
|
||||
}
|
||||
|
||||
// Build and persist into LOCALS first, committing live state only once BOTH
|
||||
// succeed. A persist failure (read-only config, disk full) must not leave the
|
||||
@@ -1265,9 +1102,7 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
|
||||
// store before persisting, so config.json never holds the cleartext. The
|
||||
// provider was already built above from runtimeProfile, which has the key.
|
||||
secret := profile.APIKey
|
||||
if !preserveExistingCredentialReference {
|
||||
profile = config.SecureProviderProfile(profile, m.userConfigPath)
|
||||
}
|
||||
profile = config.SecureProviderProfile(profile, m.userConfigPath)
|
||||
if _, err := config.UpsertProvider(m.userConfigPath, profile, true); err != nil {
|
||||
wizard.err = redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{secret, profile.APIKey}})
|
||||
return m, nil // nothing committed to live state yet
|
||||
@@ -1358,12 +1193,10 @@ func (m model) providerWizardOverlay(width int) string {
|
||||
if m.providerWizard == nil {
|
||||
return ""
|
||||
}
|
||||
return m.providerWizard.render(width, m.spinnerGlyph())
|
||||
return m.providerWizard.render(width)
|
||||
}
|
||||
|
||||
// render's spinner glyph is used only by the embedded aimlapi.com onboarding
|
||||
// sub-view; it is the shared global run spinner (spinnerGlyph()), same as everywhere.
|
||||
func (wizard *providerWizardState) render(width int, spinner string) string {
|
||||
func (wizard *providerWizardState) render(width int) string {
|
||||
if wizard == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -1400,12 +1233,6 @@ func (wizard *providerWizardState) render(width int, spinner string) string {
|
||||
lines = append(lines, wizard.renderEndpointStep(innerWidth)...)
|
||||
case providerWizardStepName:
|
||||
lines = append(lines, wizard.renderNameStep(innerWidth)...)
|
||||
case providerWizardStepAimlapiConfigured:
|
||||
lines = append(lines, wizard.renderAimlapiConfiguredStep(innerWidth, spinner)...)
|
||||
case providerWizardStepAimlapi:
|
||||
if wizard.aimlapi != nil {
|
||||
lines = append(lines, wizard.aimlapi.view(innerWidth, spinner)...)
|
||||
}
|
||||
case providerWizardStepCredential:
|
||||
lines = append(lines, wizard.renderCredentialStep(innerWidth)...)
|
||||
case providerWizardStepModel:
|
||||
@@ -1479,10 +1306,6 @@ func (wizard *providerWizardState) footerText() string {
|
||||
return "Enter continue ← back Esc close"
|
||||
case providerWizardStepName:
|
||||
return "Enter/→ continue ← back Esc close"
|
||||
case providerWizardStepAimlapiConfigured:
|
||||
return "↑/↓ move Enter/→ select ← back Esc close"
|
||||
case providerWizardStepAimlapi:
|
||||
return "type/↑↓ choose Enter/→ continue ← back Esc close"
|
||||
case providerWizardStepModel:
|
||||
if canRight {
|
||||
return "↑/↓ move Enter/→ continue ← back Esc close"
|
||||
@@ -1506,7 +1329,7 @@ func providerWizardOverlayWidth(width int, step providerWizardStep) int {
|
||||
switch step {
|
||||
case providerWizardStepProvider:
|
||||
target = providerWizardProviderWidth
|
||||
case providerWizardStepModel, providerWizardStepManage, providerWizardStepAimlapi, providerWizardStepAimlapiConfigured:
|
||||
case providerWizardStepModel, providerWizardStepManage:
|
||||
target = providerWizardModelWidth
|
||||
}
|
||||
target = minInt(target, width)
|
||||
@@ -1544,16 +1367,6 @@ func providerWizardStepLine(wizard *providerWizardState) string {
|
||||
stepLabel{providerWizardStepModel, "3 model"},
|
||||
stepLabel{providerWizardStepDone, "4 ready"},
|
||||
)
|
||||
case providerWizardIsAimlapi(wizard.currentProvider()):
|
||||
connectStep := providerWizardStepAimlapi
|
||||
if aimlapiProfile(wizard.aimlapiExistingProfile) {
|
||||
connectStep = providerWizardStepAimlapiConfigured
|
||||
}
|
||||
steps = append(steps,
|
||||
stepLabel{connectStep, "3 connect"},
|
||||
stepLabel{providerWizardStepModel, "4 model"},
|
||||
stepLabel{providerWizardStepDone, "5 ready"},
|
||||
)
|
||||
case providerWizardNeedsEndpoint(wizard.currentProvider()):
|
||||
steps = append(steps,
|
||||
stepLabel{providerWizardStepEndpoint, "3 endpoint"},
|
||||
@@ -1598,32 +1411,6 @@ func (wizard *providerWizardState) renderMethodStep(width int) []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) renderAimlapiConfiguredStep(width int, spinner string) []string {
|
||||
lines := []string{zeroTheme.accent.Render("aimlapi.com is already configured")}
|
||||
if wizard.aimlapiExistingBusy {
|
||||
glyph := strings.TrimSpace(spinner)
|
||||
if glyph == "" {
|
||||
glyph = "•"
|
||||
}
|
||||
return append(lines, "", zeroTheme.faint.Render(glyph+" checking balance"))
|
||||
}
|
||||
options := []struct{ label, hint string }{
|
||||
{"Use existing configuration", "Continue with your saved API key"},
|
||||
{"Configure again", "Set up a new key or switch accounts"},
|
||||
}
|
||||
for index, option := range options {
|
||||
marker := " "
|
||||
style := zeroTheme.ink
|
||||
if index == wizard.aimlapiConfiguredCursor {
|
||||
marker = "❯ "
|
||||
style = zeroTheme.accent.Bold(true)
|
||||
}
|
||||
lines = append(lines, marker+style.Render(option.label))
|
||||
lines = append(lines, " "+zeroTheme.faint.Render(option.hint))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// renderManageKeyStep shows keep/replace/remove for a provider that already has a
|
||||
// key in the encrypted credential store.
|
||||
func (wizard *providerWizardState) renderManageKeyStep(width int) []string {
|
||||
@@ -2037,25 +1824,14 @@ func maskedProviderWizardKey(value string) string {
|
||||
}
|
||||
|
||||
func providerWizardProfile(provider providercatalog.Descriptor, model string, apiKey string, baseURL string, profileName string) config.ProviderProfile {
|
||||
resolvedBaseURL := firstProviderDisplayValue(strings.TrimSpace(baseURL), provider.DefaultBaseURL)
|
||||
profile := config.ProviderProfile{
|
||||
Name: providerWizardDisplayName(provider, baseURL, profileName),
|
||||
ProviderKind: providerWizardProviderKind(provider),
|
||||
CatalogID: provider.ID,
|
||||
BaseURL: resolvedBaseURL,
|
||||
BaseURL: firstProviderDisplayValue(strings.TrimSpace(baseURL), provider.DefaultBaseURL),
|
||||
APIFormat: providerWizardAPIFormat(provider),
|
||||
Model: firstProviderDisplayValue(model, provider.DefaultModel),
|
||||
}
|
||||
// Catalog custom headers (e.g. aimlapi.com's partner attribution) belong to the
|
||||
// catalog endpoint: the resolver only applies them when the base URL is the
|
||||
// default, so a profile built against a staging/proxy/custom override must not
|
||||
// bake them in either — otherwise attribution leaks to an arbitrary host.
|
||||
if sameProviderBaseURL(resolvedBaseURL, provider.DefaultBaseURL) {
|
||||
profile.CustomHeaders = maps.Clone(provider.CustomHeaders)
|
||||
if providerWizardIsAimlapi(provider) {
|
||||
profile.CustomHeaders = aimlapi.WithResolvedPartnerHeader(profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
|
||||
profile.APIKey = apiKey
|
||||
} else if env := firstProviderDisplayValue(provider.AuthEnvVars...); provider.RequiresAuth && env != "" && !provider.Custom {
|
||||
@@ -2069,16 +1845,6 @@ func providerWizardProfile(provider providercatalog.Descriptor, model string, ap
|
||||
return profile
|
||||
}
|
||||
|
||||
// sameProviderBaseURL reports whether two base URLs address the same endpoint,
|
||||
// normalizing case and a trailing slash — mirroring the resolver's sameBaseURL so
|
||||
// the wizard bakes catalog headers on exactly the endpoints the resolver would.
|
||||
func sameProviderBaseURL(left string, right string) bool {
|
||||
return strings.EqualFold(
|
||||
strings.TrimRight(strings.TrimSpace(left), "/"),
|
||||
strings.TrimRight(strings.TrimSpace(right), "/"),
|
||||
)
|
||||
}
|
||||
|
||||
func providerWizardEndpointError(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
|
||||
@@ -3,14 +3,11 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/aimlapi"
|
||||
"github.com/Gitlawb/zero/internal/browser"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
@@ -27,13 +24,6 @@ type providerModelsDiscoveredMsg struct {
|
||||
secrets []string
|
||||
}
|
||||
|
||||
type aimlapiExistingBalanceMsg struct {
|
||||
wizard *providerWizardState
|
||||
gen int
|
||||
balance aimlapi.BalanceResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (m model) advanceProviderWizard() (model, tea.Cmd) {
|
||||
if m.providerWizard == nil {
|
||||
return m, nil
|
||||
@@ -53,18 +43,6 @@ func (m model) advanceProviderWizard() (model, tea.Cmd) {
|
||||
// A non-OAuth provider that already has a key in the credential store: offer
|
||||
// keep/replace/remove before re-entering credentials.
|
||||
if m.providerWizard.step == providerWizardStepProvider && !m.providerWizard.oauthMode {
|
||||
if providerWizardIsAimlapi(m.providerWizard.currentProvider()) {
|
||||
if profile, runtimeKey, ok := m.existingAimlapiConfiguration(); ok {
|
||||
m.providerWizard.aimlapiExistingProfile = profile
|
||||
m.providerWizard.aimlapiRuntimeKey = runtimeKey
|
||||
m.providerWizard.aimlapiConfiguredCursor = 0
|
||||
m.providerWizard.err = ""
|
||||
m.providerWizard.step = providerWizardStepAimlapiConfigured
|
||||
return m, nil
|
||||
}
|
||||
m.providerWizard.enterAimlapi()
|
||||
return m, nil
|
||||
}
|
||||
if name, ok := m.wizardProviderStoredKey(m.providerWizard.currentProvider()); ok {
|
||||
// Generic/custom providers (custom-openai-compatible etc.) all share
|
||||
// the same CatalogID — matching on CatalogID would block creating a
|
||||
@@ -89,133 +67,6 @@ func (m model) advanceProviderWizard() (model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// existingAimlapiConfiguration returns a usable persisted credential source and
|
||||
// its current runtime key. The active aimlapi profile wins; otherwise the first
|
||||
// usable saved profile is used. A bare AIMLAPI_API_KEY is represented as an env
|
||||
// profile so choosing it never snapshots the secret into the credential store.
|
||||
func (m model) existingAimlapiConfiguration() (config.ProviderProfile, string, bool) {
|
||||
descriptor, descriptorErr := providercatalog.Require("aimlapi")
|
||||
profiles := append([]config.ProviderProfile(nil), m.savedProviders...)
|
||||
activeName := strings.TrimSpace(m.providerProfile.Name)
|
||||
if activeName != "" {
|
||||
for index, profile := range profiles {
|
||||
if strings.EqualFold(strings.TrimSpace(profile.Name), activeName) && aimlapiProfile(profile) {
|
||||
profiles[0], profiles[index] = profiles[index], profiles[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var store config.APIKeyGetter
|
||||
if strings.TrimSpace(m.userConfigPath) != "" {
|
||||
store, _ = config.ProviderKeyStoreAt(filepath.Dir(m.userConfigPath))
|
||||
}
|
||||
for _, persisted := range profiles {
|
||||
if !aimlapiProfile(persisted) {
|
||||
continue
|
||||
}
|
||||
// The guided balance/top-up client talks to AIMLAPI's production account
|
||||
// API. Never send a credential saved for a proxy, staging service, or other
|
||||
// custom inference endpoint to that API. Such profiles remain available to
|
||||
// the normal provider runtime; they are simply ineligible for this preflight.
|
||||
if descriptorErr != nil || (strings.TrimSpace(persisted.BaseURL) != "" && !sameProviderBaseURL(persisted.BaseURL, descriptor.DefaultBaseURL)) {
|
||||
continue
|
||||
}
|
||||
resolved := persisted
|
||||
if strings.TrimSpace(resolved.APIKey) == "" && strings.TrimSpace(resolved.APIKeyEnv) != "" {
|
||||
resolved.APIKey = strings.TrimSpace(os.Getenv(resolved.APIKeyEnv))
|
||||
}
|
||||
resolved = config.ApplyStoredAPIKey(resolved, store)
|
||||
if key := strings.TrimSpace(resolved.APIKey); key != "" {
|
||||
// ResolvedConfig materializes APIKeyEnv into APIKey for runtime use.
|
||||
// Strip that materialized value (and any loaded stored key) from the
|
||||
// persisted copy so finalization retains the reference, not the secret.
|
||||
if strings.TrimSpace(persisted.APIKeyEnv) != "" || persisted.APIKeyStored {
|
||||
persisted.APIKey = ""
|
||||
}
|
||||
return persisted, key, true
|
||||
}
|
||||
}
|
||||
if key := strings.TrimSpace(os.Getenv("AIMLAPI_API_KEY")); key != "" {
|
||||
if descriptorErr == nil {
|
||||
profile := providerWizardProfile(
|
||||
descriptor,
|
||||
descriptor.DefaultModel,
|
||||
"",
|
||||
aimlapi.ResolveEndpoints().InferenceBaseURL,
|
||||
"",
|
||||
)
|
||||
return profile, key, true
|
||||
}
|
||||
}
|
||||
return config.ProviderProfile{}, "", false
|
||||
}
|
||||
|
||||
func aimlapiProfile(profile config.ProviderProfile) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(profile.CatalogID), "aimlapi") ||
|
||||
strings.EqualFold(strings.TrimSpace(profile.Name), "aimlapi")
|
||||
}
|
||||
|
||||
func (m model) checkExistingAimlapiBalance() (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
if wizard == nil || wizard.step != providerWizardStepAimlapiConfigured || wizard.aimlapiExistingBusy {
|
||||
return m, nil
|
||||
}
|
||||
wizard.aimlapiExistingBusy = true
|
||||
wizard.err = ""
|
||||
wizard.aimlapiExistingGen++
|
||||
gen := wizard.aimlapiExistingGen
|
||||
key := wizard.aimlapiRuntimeKey
|
||||
endpoints := aimlapi.ResolveEndpoints()
|
||||
if baseURL := strings.TrimSpace(wizard.aimlapiExistingProfile.BaseURL); baseURL != "" {
|
||||
endpoints.InferenceBaseURL = baseURL
|
||||
} else if descriptor, ok := providercatalog.Get("aimlapi"); ok {
|
||||
// An empty stored BaseURL means the catalog endpoint. Pin it explicitly so
|
||||
// a process-wide inference override cannot redirect this saved key.
|
||||
endpoints.InferenceBaseURL = descriptor.DefaultBaseURL
|
||||
}
|
||||
wizard.baseURL = endpoints.InferenceBaseURL
|
||||
ctx, cancel := context.WithTimeout(m.ctx, 12*time.Second)
|
||||
wizard.aimlapiExistingCancel = cancel
|
||||
cmd := func() tea.Msg {
|
||||
balance, err := aimlapi.NewClient(endpoints, nil).GetBalance(ctx, key)
|
||||
return aimlapiExistingBalanceMsg{wizard: wizard, gen: gen, balance: balance, err: err}
|
||||
}
|
||||
return m, tea.Batch(cmd, m.ensureSpinnerTick())
|
||||
}
|
||||
|
||||
func (m model) applyExistingAimlapiBalance(msg aimlapiExistingBalanceMsg) (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
if wizard == nil || wizard != msg.wizard || wizard.step != providerWizardStepAimlapiConfigured || msg.gen != wizard.aimlapiExistingGen {
|
||||
return m, nil
|
||||
}
|
||||
if wizard.aimlapiExistingCancel != nil {
|
||||
wizard.aimlapiExistingCancel()
|
||||
wizard.aimlapiExistingCancel = nil
|
||||
}
|
||||
wizard.aimlapiExistingBusy = false
|
||||
if msg.err != nil {
|
||||
if aimlapiIsUnauthorized(msg.err) {
|
||||
wizard.err = "The existing AIMLAPI API key is invalid. Choose Configure again."
|
||||
} else {
|
||||
wizard.err = redaction.ErrorMessage(msg.err, redaction.Options{ExtraSecretValues: []string{wizard.aimlapiRuntimeKey}})
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
wizard.apiKey = wizard.aimlapiRuntimeKey
|
||||
if msg.balance.LowBalance {
|
||||
state := newAimlapiOnboard(browser.OpenURL)
|
||||
state.apiKey = wizard.aimlapiRuntimeKey
|
||||
state.baseURL = wizard.baseURL
|
||||
state.byKey = true
|
||||
state.step = aimlapiStepLowBalance
|
||||
wizard.aimlapi = state
|
||||
wizard.step = providerWizardStepAimlapi
|
||||
return m, nil
|
||||
}
|
||||
wizard.step = providerWizardStepModel
|
||||
return m, m.providerModelDiscoveryCmd()
|
||||
}
|
||||
|
||||
func (m model) providerModelDiscoveryCmd() tea.Cmd {
|
||||
wizard := m.providerWizard
|
||||
if wizard == nil {
|
||||
@@ -245,7 +96,6 @@ func (m model) providerModelDiscoveryCmd() tea.Cmd {
|
||||
wizard.discoveryToken++
|
||||
token := wizard.discoveryToken
|
||||
providerID := provider.ID
|
||||
baseURL := wizard.baseURL
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(m.ctx, 12*time.Second)
|
||||
defer cancel()
|
||||
@@ -255,7 +105,7 @@ func (m model) providerModelDiscoveryCmd() tea.Cmd {
|
||||
apiKey = resolved
|
||||
}
|
||||
}
|
||||
profile := providerWizardDiscoveryProfile(provider, apiKey, baseURL)
|
||||
profile := providerWizardDiscoveryProfile(provider, apiKey)
|
||||
models, err := discover(ctx, profile)
|
||||
return providerModelsDiscoveredMsg{providerID: providerID, token: token, models: models, err: err, secrets: []string{apiKey, profile.APIKey}}
|
||||
}
|
||||
@@ -315,8 +165,8 @@ func providerWizardModelsSource(models []providermodeldiscovery.Model) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func providerWizardDiscoveryProfile(provider providercatalog.Descriptor, apiKey string, baseURL string) config.ProviderProfile {
|
||||
profile := providerWizardProfile(provider, provider.DefaultModel, apiKey, baseURL, "")
|
||||
func providerWizardDiscoveryProfile(provider providercatalog.Descriptor, apiKey string) config.ProviderProfile {
|
||||
profile := providerWizardProfile(provider, provider.DefaultModel, apiKey, "", "")
|
||||
if strings.TrimSpace(profile.APIKey) == "" && strings.TrimSpace(profile.APIKeyEnv) != "" {
|
||||
profile.APIKey = strings.TrimSpace(os.Getenv(profile.APIKeyEnv))
|
||||
}
|
||||
|
||||
@@ -5,18 +5,13 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/aimlapi"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
@@ -58,7 +53,7 @@ func TestProviderCommandOpensOnboardingWizard(t *testing.T) {
|
||||
updated, _ = next.Update(testKey(tea.KeyEnter))
|
||||
next = updated.(model)
|
||||
listView := plainRender(t, next.View())
|
||||
for _, want := range []string{"Choose provider", "aimlapi.com", "GitLawb OpenGateway", "OpenAI", "Anthropic", "Google", "OpenRouter", "Ollama"} {
|
||||
for _, want := range []string{"Choose provider", "GitLawb OpenGateway", "OpenAI", "Anthropic", "Google", "OpenRouter", "Ollama"} {
|
||||
assertContains(t, listView, want)
|
||||
}
|
||||
}
|
||||
@@ -1172,39 +1167,6 @@ func TestProviderWizardManageKeyReplaceAndKeep(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderManagerEscCancelsEmbeddedAimlapiFlow(t *testing.T) {
|
||||
opCtx, opCancel := context.WithCancel(context.Background())
|
||||
topupCtx, topupCancel := context.WithCancel(context.Background())
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepProgress,
|
||||
opCancel: opCancel,
|
||||
topupCancel: topupCancel,
|
||||
}
|
||||
m := model{providerWizard: &providerWizardState{
|
||||
manage: true,
|
||||
step: providerWizardStepAimlapi,
|
||||
aimlapi: state,
|
||||
}}
|
||||
|
||||
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEsc))
|
||||
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
|
||||
t.Fatalf("manager escape did not return to provider list: %+v", next.providerWizard)
|
||||
}
|
||||
if next.providerWizard.aimlapi != nil {
|
||||
t.Fatal("manager escape retained embedded aimlapi state")
|
||||
}
|
||||
select {
|
||||
case <-opCtx.Done():
|
||||
default:
|
||||
t.Fatal("pending account/key operation was not cancelled")
|
||||
}
|
||||
select {
|
||||
case <-topupCtx.Done():
|
||||
default:
|
||||
t.Fatal("pending top-up stream was not cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceProviderWizardCustomSkipsManageKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -1270,264 +1232,6 @@ func TestAdvanceProviderWizardNamedShowsManageKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAimlapiConfigurationUsesEnvWithoutCapturingLiteral(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_API_KEY", "aimlapi-env-secret")
|
||||
m := model{savedProviders: []config.ProviderProfile{{
|
||||
Name: "aimlapi", CatalogID: "aimlapi", APIKeyEnv: "AIMLAPI_API_KEY",
|
||||
// ResolvedConfig materializes the env value here before the TUI sees it.
|
||||
APIKey: "aimlapi-env-secret",
|
||||
}}}
|
||||
|
||||
profile, runtimeKey, ok := m.existingAimlapiConfiguration()
|
||||
if !ok {
|
||||
t.Fatal("expected AIMLAPI_API_KEY to be detected")
|
||||
}
|
||||
if runtimeKey != "aimlapi-env-secret" {
|
||||
t.Fatalf("runtime key = %q", runtimeKey)
|
||||
}
|
||||
if profile.APIKeyEnv != "AIMLAPI_API_KEY" || profile.APIKey != "" || profile.APIKeyStored {
|
||||
t.Fatalf("env credential source was not preserved: %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAimlapiEnvironmentFallbackUsesResolvedEndpoint(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_API_KEY", "aimlapi-env-secret")
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", "https://staging.example.test/v1")
|
||||
|
||||
profile, runtimeKey, ok := (model{}).existingAimlapiConfiguration()
|
||||
if !ok || runtimeKey != "aimlapi-env-secret" {
|
||||
t.Fatalf("configuration = (%+v, %q, %v)", profile, runtimeKey, ok)
|
||||
}
|
||||
if profile.BaseURL != "https://staging.example.test/v1" {
|
||||
t.Fatalf("BaseURL = %q, want resolved override", profile.BaseURL)
|
||||
}
|
||||
if profile.APIKeyEnv != "AIMLAPI_API_KEY" || profile.APIKey != "" {
|
||||
t.Fatalf("credential source changed: %+v", profile)
|
||||
}
|
||||
if len(profile.CustomHeaders) != 0 {
|
||||
t.Fatalf("catalog headers leaked to custom endpoint: %#v", profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAimlapiBalanceUsesProfileEndpointAndCancelsOnAbandon(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
canonical := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
close(started)
|
||||
<-r.Context().Done()
|
||||
close(done)
|
||||
}))
|
||||
defer canonical.Close()
|
||||
override := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("production profile key was sent to process override: %s", r.URL)
|
||||
}))
|
||||
defer override.Close()
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", override.URL)
|
||||
|
||||
wizard := &providerWizardState{
|
||||
step: providerWizardStepAimlapiConfigured,
|
||||
aimlapiExistingProfile: config.ProviderProfile{
|
||||
Name: "aimlapi", CatalogID: "aimlapi", BaseURL: canonical.URL,
|
||||
},
|
||||
aimlapiRuntimeKey: "production-secret",
|
||||
}
|
||||
m := model{ctx: context.Background(), providerWizard: wizard}
|
||||
_, cmd := m.checkExistingAimlapiBalance()
|
||||
result := make(chan tea.Msg, 1)
|
||||
go func() { result <- execCmd(cmd) }()
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("profile endpoint did not receive the balance request")
|
||||
}
|
||||
wizard.clearAimlapiExisting()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("abandoning the preflight did not cancel the balance request")
|
||||
}
|
||||
select {
|
||||
case <-result:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("cancelled balance command did not return")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderWizardAimlapiPartnerOverrideOnlyOnCanonicalEndpoint(t *testing.T) {
|
||||
provider, err := providercatalog.Require("aimlapi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("AIMLAPI_PARTNER_ID", "part_override")
|
||||
|
||||
canonical := providerWizardProfile(provider, provider.DefaultModel, "", provider.DefaultBaseURL, "")
|
||||
if got := canonical.CustomHeaders[aimlapi.PartnerHeaderName]; got != "part_override" {
|
||||
t.Fatalf("canonical partner header = %q, want part_override", got)
|
||||
}
|
||||
custom := providerWizardProfile(provider, provider.DefaultModel, "", "https://staging.example.test/v1", "")
|
||||
if len(custom.CustomHeaders) != 0 {
|
||||
t.Fatalf("partner headers leaked to custom endpoint: %#v", custom.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAimlapiConfigurationResolvesStoredKey(t *testing.T) {
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Set("work", "aimlapi-stored-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := model{
|
||||
userConfigPath: configPath,
|
||||
savedProviders: []config.ProviderProfile{{Name: "work", CatalogID: "aimlapi", APIKeyStored: true}},
|
||||
}
|
||||
|
||||
profile, runtimeKey, ok := m.existingAimlapiConfiguration()
|
||||
if !ok || runtimeKey != "aimlapi-stored-secret" {
|
||||
t.Fatalf("stored configuration = (%+v, %q, %v)", profile, runtimeKey, ok)
|
||||
}
|
||||
if !profile.APIKeyStored || profile.APIKey != "" || profile.APIKeyEnv != "" {
|
||||
t.Fatalf("stored credential source was not preserved: %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAimlapiConfigurationSkipsCustomEndpointCredential(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_API_KEY", "")
|
||||
m := model{savedProviders: []config.ProviderProfile{{
|
||||
Name: "aimlapi-staging",
|
||||
CatalogID: "aimlapi",
|
||||
BaseURL: "https://staging.example.test/v1",
|
||||
APIKey: "staging-secret",
|
||||
}}}
|
||||
|
||||
profile, runtimeKey, ok := m.existingAimlapiConfiguration()
|
||||
if ok || profile.Name != "" || runtimeKey != "" {
|
||||
t.Fatalf("custom endpoint credential entered production preflight: (%+v, %q, %v)", profile, runtimeKey, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceAimlapiWithExistingCredentialShowsConfiguredPreflight(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_API_KEY", "aimlapi-env-secret")
|
||||
provider, err := providercatalog.Require("aimlapi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := model{providerWizard: &providerWizardState{
|
||||
step: providerWizardStepProvider,
|
||||
providers: []providercatalog.Descriptor{provider},
|
||||
}}
|
||||
|
||||
next, _ := m.advanceProviderWizard()
|
||||
if next.providerWizard.step != providerWizardStepAimlapiConfigured {
|
||||
t.Fatalf("step = %v, want configured preflight", next.providerWizard.step)
|
||||
}
|
||||
if next.providerWizard.aimlapiExistingProfile.APIKeyEnv != "AIMLAPI_API_KEY" {
|
||||
t.Fatalf("profile = %+v", next.providerWizard.aimlapiExistingProfile)
|
||||
}
|
||||
if !maps.Equal(next.providerWizard.aimlapiExistingProfile.CustomHeaders, provider.CustomHeaders) {
|
||||
t.Fatalf("catalog headers were not preserved: %#v", next.providerWizard.aimlapiExistingProfile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiConfigureAgainUsesTwoPathOnboarding(t *testing.T) {
|
||||
wizard := &providerWizardState{
|
||||
step: providerWizardStepAimlapiConfigured,
|
||||
aimlapiConfiguredCursor: 1,
|
||||
aimlapiExistingProfile: config.ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", APIKeyEnv: "AIMLAPI_API_KEY"},
|
||||
aimlapiRuntimeKey: "secret",
|
||||
}
|
||||
m := model{providerWizard: wizard}
|
||||
|
||||
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
|
||||
if next.providerWizard.step != providerWizardStepAimlapi || next.providerWizard.aimlapi == nil {
|
||||
t.Fatalf("configure again did not enter onboarding: %+v", next.providerWizard)
|
||||
}
|
||||
if next.providerWizard.aimlapiExistingProfile.Name != "" || next.providerWizard.aimlapiRuntimeKey != "" {
|
||||
t.Fatal("configure again must discard the old credential context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAimlapiLowBalanceReusesByKeyTopUp(t *testing.T) {
|
||||
wizard := &providerWizardState{
|
||||
step: providerWizardStepAimlapiConfigured,
|
||||
baseURL: "https://validated.example.test/v1",
|
||||
aimlapiExistingProfile: config.ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://validated.example.test/v1", APIKeyEnv: "AIMLAPI_API_KEY"},
|
||||
aimlapiRuntimeKey: "secret",
|
||||
aimlapiExistingBusy: true,
|
||||
aimlapiExistingGen: 4,
|
||||
}
|
||||
m := model{providerWizard: wizard}
|
||||
next, _ := m.applyExistingAimlapiBalance(aimlapiExistingBalanceMsg{
|
||||
wizard: wizard,
|
||||
gen: 4,
|
||||
balance: aimlapi.BalanceResult{
|
||||
LowBalance: true,
|
||||
},
|
||||
})
|
||||
if next.providerWizard.step != providerWizardStepAimlapi || next.providerWizard.aimlapi == nil {
|
||||
t.Fatalf("low balance did not enter aimlapi top-up: %+v", next.providerWizard)
|
||||
}
|
||||
if !next.providerWizard.aimlapi.byKey || next.providerWizard.aimlapi.step != aimlapiStepLowBalance || next.providerWizard.aimlapi.apiKey != "secret" {
|
||||
t.Fatalf("top-up state = %+v", next.providerWizard.aimlapi)
|
||||
}
|
||||
if next.providerWizard.aimlapi.baseURL != "https://validated.example.test/v1" {
|
||||
t.Fatalf("top-up endpoint = %q, want validated endpoint", next.providerWizard.aimlapi.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExistingAimlapiPreservesEnvCredentialSource(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_API_KEY", "runtime-secret")
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"providers":[]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider := providercatalog.Descriptor{
|
||||
ID: "aimlapi", Name: "aimlapi.com", Transport: providercatalog.TransportOpenAICompatible,
|
||||
CustomHeaders: map[string]string{"X-AIMLAPI-Partner-ID": "partner"},
|
||||
}
|
||||
m := model{
|
||||
userConfigPath: configPath,
|
||||
providerWizard: &providerWizardState{
|
||||
step: providerWizardStepDone,
|
||||
providers: []providercatalog.Descriptor{provider},
|
||||
models: []providerWizardModel{{ID: "model/new"}},
|
||||
selectedModel: 0,
|
||||
modelSource: "live",
|
||||
aimlapiExistingProfile: config.ProviderProfile{
|
||||
Name: "aimlapi", CatalogID: "aimlapi", APIKeyEnv: "AIMLAPI_API_KEY", Model: "model/old",
|
||||
CustomHeaders: maps.Clone(provider.CustomHeaders),
|
||||
},
|
||||
aimlapiRuntimeKey: "runtime-secret",
|
||||
},
|
||||
}
|
||||
|
||||
next, _ := m.applyProviderWizard()
|
||||
if next.providerWizard != nil {
|
||||
t.Fatal("successful apply should close the wizard")
|
||||
}
|
||||
cfg := readProviderWizardConfigFixture(t, configPath)
|
||||
if len(cfg.Providers) != 1 {
|
||||
t.Fatalf("providers = %+v", cfg.Providers)
|
||||
}
|
||||
got := cfg.Providers[0]
|
||||
if got.APIKeyEnv != "AIMLAPI_API_KEY" || got.APIKey != "" || got.APIKeyStored {
|
||||
t.Fatalf("credential source changed: %+v", got)
|
||||
}
|
||||
if got.Model != "model/new" {
|
||||
t.Fatalf("model = %q, want model/new", got.Model)
|
||||
}
|
||||
if !maps.Equal(got.CustomHeaders, provider.CustomHeaders) {
|
||||
t.Fatalf("custom headers changed: %#v", got.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func readProviderWizardConfigFixture(t *testing.T, path string) config.FileConfig {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ func (m model) sidebarAvailable() bool {
|
||||
// list) take over the chat column and render at full width; suppress the
|
||||
// second column while any is active so their geometry and mouse hit-testing
|
||||
// stay full-width as before.
|
||||
if m.setup.visible || m.helpOverlay || m.leaderHelpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
if m.setup.visible || m.helpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -333,38 +333,6 @@ var everforestPalette = palette{
|
||||
cardPerm: "#96896b",
|
||||
}
|
||||
|
||||
// neonPalette is a neon-on-black color scheme: pitch-black surface with
|
||||
// neon green ink and a cyan accent.
|
||||
var neonPalette = palette{
|
||||
panel: "#050b06",
|
||||
promptBg: "#0c180d",
|
||||
line: "#1c3820",
|
||||
line2: "#2c5230",
|
||||
ink: "#c9ffd2",
|
||||
muted: "#80db8f",
|
||||
faint: "#6eca7d",
|
||||
faintest: "#74c468", // brightened from #58af69 so line numbers quantize to #87d75f and stay AA on the xterm-green addBg (#005f00); still dimmer than faint, keeping the ramp monotonic
|
||||
accent: "#00e5c8",
|
||||
green: "#39ff6a",
|
||||
red: "#ff4d6d",
|
||||
amber: "#f4ff3a",
|
||||
blue: "#22e0ff",
|
||||
gitAdd: "#4fdc6a",
|
||||
gitDel: "#ff6f80",
|
||||
addBg: "#083c10", // quantizes to xterm green #005f00 instead of the same gray as delBg, keeping add/del rows distinct on 256-color terminals
|
||||
delBg: "#3c0810", // quantizes to xterm red #5f0000 (see addBg)
|
||||
addBgWord: "#147828", // quantizes to xterm green #008700, distinct from both addBg's #005f00 and delBgWord's red
|
||||
delBgWord: "#74202e", // quantizes to xterm red #870000 (see addBgWord)
|
||||
permBg: "#2a2a0c",
|
||||
selBg: "#123a1e",
|
||||
addInk: "#ecffdc", // quantizes to #ffffd7, which keeps AA on addBgWord's xterm #008700 (the old #c8ffcf quantized to #d7ffd7 at 4.29:1)
|
||||
delInk: "#ffd0d6",
|
||||
onAccent: "#001410",
|
||||
cardRun: "#1f8a6e",
|
||||
cardErr: "#9a4042", // raised from #8a2f42 for the 3:1 non-text border threshold against the panel (2.43:1 before), holding after xterm-256 quantization too
|
||||
cardPerm: "#8a8a1f",
|
||||
}
|
||||
|
||||
// lightPalette is dark-on-light: a warm cream surface (so cards lift off the
|
||||
// terminal page, which Zero never paints) with near-black ink and an olive-lime
|
||||
// accent that keeps the brand identity while clearing AA on the light panel. The
|
||||
@@ -433,38 +401,6 @@ var solarizedLightPalette = palette{
|
||||
cardPerm: "#c4ae63",
|
||||
}
|
||||
|
||||
// dunePalette is a warm sand-and-cream color scheme: sand/cream surface,
|
||||
// charcoal ink, and a soft amber accent.
|
||||
var dunePalette = palette{
|
||||
panel: "#f2e9d8",
|
||||
promptBg: "#e9dcbf",
|
||||
line: "#d9c7a3",
|
||||
line2: "#c2a97c",
|
||||
ink: "#2b241a",
|
||||
muted: "#473e32",
|
||||
faint: "#554a3a",
|
||||
faintest: "#655648",
|
||||
accent: "#724028", // darkened from #8f5215 for AA on selBg (5.46:1) that also survives ANSI-256 downsampling (quantizes to #444444, 6.47:1 on quantized selBg; the previous #7c4712 quantized to #875f00 at 3.81:1)
|
||||
green: "#38572a",
|
||||
red: "#872d24", // darkened from #963328 so delBg contrast survives ANSI-256 downsampling (true 6.57:1, 256 7.86:1)
|
||||
amber: "#6d4600",
|
||||
blue: "#2f5680", // darkened from #3d6a9e for AA on selBg (was 3.61:1, now 4.90:1)
|
||||
gitAdd: "#38572a",
|
||||
gitDel: "#963328",
|
||||
addBg: "#dcecd0",
|
||||
delBg: "#f5dbd5",
|
||||
addBgWord: "#b9dc9e",
|
||||
delBgWord: "#eebba9",
|
||||
permBg: "#f0dfae",
|
||||
selBg: "#e0cf98",
|
||||
addInk: "#264018",
|
||||
delInk: "#5c1810",
|
||||
onAccent: "#fdf6ea",
|
||||
cardRun: "#b08a4a",
|
||||
cardErr: "#b57560",
|
||||
cardPerm: "#c2a04a",
|
||||
}
|
||||
|
||||
// themeEntry is one registered theme: Name is the /theme value + ZERO_THEME/--theme
|
||||
// token (lowercase, kebab), Label is the picker display text, and IsDark groups the
|
||||
// picker (Dark/Light sections) and drives which built-in `auto` resolves to.
|
||||
@@ -490,10 +426,8 @@ var themeRegistry = []themeEntry{
|
||||
{Name: "solarized-dark", Label: "Solarized Dark", Palette: solarizedDarkPalette, IsDark: true},
|
||||
{Name: "rose-pine", Label: "Rosé Pine", Palette: rosePinePalette, IsDark: true},
|
||||
{Name: "everforest", Label: "Everforest", Palette: everforestPalette, IsDark: true},
|
||||
{Name: "neon", Label: "Neon", Palette: neonPalette, IsDark: true},
|
||||
{Name: "light", Label: "light", Palette: lightPalette, IsDark: false},
|
||||
{Name: "solarized-light", Label: "Solarized Light", Palette: solarizedLightPalette, IsDark: false},
|
||||
{Name: "dune", Label: "Dune", Palette: dunePalette, IsDark: false},
|
||||
}
|
||||
|
||||
// themeByName indexes the registry by lowercased name for O(1) lookup. Built as a
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -262,225 +261,6 @@ func TestHandleThemeCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewThemePresetsWired(t *testing.T) {
|
||||
neon, ok := lookupTheme("neon")
|
||||
if !ok {
|
||||
t.Fatal("theme 'neon' is not registered")
|
||||
}
|
||||
if !neon.IsDark {
|
||||
t.Error("theme 'neon' should be marked as dark")
|
||||
}
|
||||
|
||||
dune, ok := lookupTheme("dune")
|
||||
if !ok {
|
||||
t.Fatal("theme 'dune' is not registered")
|
||||
}
|
||||
if dune.IsDark {
|
||||
t.Error("theme 'dune' should be marked as light")
|
||||
}
|
||||
|
||||
for _, name := range []string{"neon", "dune"} {
|
||||
if !validThemeMode(name) {
|
||||
t.Errorf("%q should be a valid --theme/ZERO_THEME value", name)
|
||||
}
|
||||
}
|
||||
|
||||
if !contains(themeModes, "neon") || !contains(themeModes, "dune") {
|
||||
t.Errorf("themeModes = %v, want it to include neon and dune (the /theme picker list)", themeModes)
|
||||
}
|
||||
}
|
||||
|
||||
// The --theme flag and ZERO_THEME both resolve through resolveThemeMode, and
|
||||
// applyTheme must actually swap zeroTheme to the resolved preset's own palette,
|
||||
// not silently fall back to a built-in.
|
||||
func TestNewThemePresetsResolveThroughCLIAndEnvPath(t *testing.T) {
|
||||
defer applyTheme(themeDark, true)
|
||||
|
||||
if got := resolveThemeMode("dune", ""); got != themeMode("dune") {
|
||||
t.Fatalf(`resolveThemeMode("dune", "") = %q, want "dune"`, got)
|
||||
}
|
||||
if got := resolveThemeMode("", "neon"); got != themeMode("neon") {
|
||||
t.Fatalf(`resolveThemeMode("", "neon") = %q, want "neon"`, got)
|
||||
}
|
||||
|
||||
applyTheme(themeMode("dune"), true)
|
||||
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, dunePalette.ink) {
|
||||
t.Error("applying \"dune\" did not swap zeroTheme to the dune palette")
|
||||
}
|
||||
|
||||
applyTheme(themeMode("neon"), true)
|
||||
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, neonPalette.ink) {
|
||||
t.Error("applying \"neon\" did not swap zeroTheme to the neon palette")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendedThemeContrastInvariants(t *testing.T) {
|
||||
// Skip validation for old built-in themes if they have established, non-compliant palettes,
|
||||
// but enforce strict compliance on the newly introduced 'neon' and 'dune' themes.
|
||||
for _, entry := range themeRegistry {
|
||||
if entry.Name != "neon" && entry.Name != "dune" {
|
||||
continue
|
||||
}
|
||||
name, pal := entry.Name, entry.Palette
|
||||
|
||||
// Finding 1: Permission and status/success surfaces
|
||||
if r := wcagRatio(t, pal.amber, pal.permBg); r < 4.5 {
|
||||
t.Errorf("%s: amber on permBg contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.onAccent, pal.amber); r < 4.5 {
|
||||
t.Errorf("%s: onAccent on amber contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.green, pal.panel); r < 4.5 {
|
||||
t.Errorf("%s: green on panel contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.amber, pal.panel); r < 4.5 {
|
||||
t.Errorf("%s: amber on panel contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.red, pal.panel); r < 4.5 {
|
||||
t.Errorf("%s: red on panel contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
|
||||
// Finding 2: Selected row secondary text
|
||||
if r := wcagRatio(t, pal.faint, pal.selBg); r < 4.5 {
|
||||
t.Errorf("%s: faint on selBg contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.faintest, pal.selBg); r < 4.5 {
|
||||
t.Errorf("%s: faintest on selBg contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
|
||||
// Finding 3: Diff gutter pairings
|
||||
if r := wcagRatio(t, pal.faintest, pal.addBg); r < 4.5 {
|
||||
t.Errorf("%s: faintest on addBg contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.faintest, pal.delBg); r < 4.5 {
|
||||
t.Errorf("%s: faintest on delBg contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.green, pal.addBg); r < 4.5 {
|
||||
t.Errorf("%s: green on addBg contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.red, pal.delBg); r < 4.5 {
|
||||
t.Errorf("%s: red on delBg contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hexChannels splits a #rrggbb token into its 8-bit channels.
|
||||
func hexChannels(t *testing.T, hexColor string) (int, int, int) {
|
||||
t.Helper()
|
||||
h := strings.TrimPrefix(hexColor, "#")
|
||||
v, err := strconv.ParseUint(h, 16, 32)
|
||||
if err != nil || len(h) != 6 {
|
||||
t.Fatalf("bad hex %q", hexColor)
|
||||
}
|
||||
return int((v >> 16) & 0xff), int((v >> 8) & 0xff), int(v & 0xff)
|
||||
}
|
||||
|
||||
// xterm256Hex returns the nearest xterm-256 color (the 6x6x6 cube plus the
|
||||
// 24-step grayscale ramp, by squared RGB distance): how a terminal without
|
||||
// truecolor support downsamples the palette's hex tokens before rendering.
|
||||
func xterm256Hex(t *testing.T, hexColor string) string {
|
||||
t.Helper()
|
||||
r, g, b := hexChannels(t, hexColor)
|
||||
levels := []int{0, 95, 135, 175, 215, 255}
|
||||
bestR, bestG, bestB := 0, 0, 0
|
||||
bestDistance := math.MaxFloat64
|
||||
try := func(cr, cg, cb int) {
|
||||
d := float64((r-cr)*(r-cr) + (g-cg)*(g-cg) + (b-cb)*(b-cb))
|
||||
if d < bestDistance {
|
||||
bestDistance, bestR, bestG, bestB = d, cr, cg, cb
|
||||
}
|
||||
}
|
||||
for _, cr := range levels {
|
||||
for _, cg := range levels {
|
||||
for _, cb := range levels {
|
||||
try(cr, cg, cb)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := 0; i < 24; i++ {
|
||||
gray := 8 + 10*i
|
||||
try(gray, gray, gray)
|
||||
}
|
||||
return fmt.Sprintf("#%02x%02x%02x", bestR, bestG, bestB)
|
||||
}
|
||||
|
||||
// Hex-level AA does not guarantee the rendered pairs hold on a 256-color
|
||||
// terminal, which quantizes every token to its nearest xterm entry first.
|
||||
// Guard the pairs that regressed: Dune's selected-row affordances (accent
|
||||
// caret/favorite star and blue local-model dot over selBg via onSel) and
|
||||
// Neon's diff bands, whose previous values all quantized to the same grays.
|
||||
func TestExtendedThemeANSI256Contrast(t *testing.T) {
|
||||
palettes := map[string]palette{}
|
||||
for _, entry := range themeRegistry {
|
||||
palettes[entry.Name] = entry.Palette
|
||||
}
|
||||
q := func(hexColor string) string { return xterm256Hex(t, hexColor) }
|
||||
|
||||
dune := palettes["dune"]
|
||||
for _, pair := range []struct{ name, fg, bg string }{
|
||||
{"accent on selBg", dune.accent, dune.selBg},
|
||||
{"blue on selBg", dune.blue, dune.selBg},
|
||||
{"faintest on selBg", dune.faintest, dune.selBg},
|
||||
{"ink on selBg", dune.ink, dune.selBg},
|
||||
} {
|
||||
if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 {
|
||||
t.Errorf("dune: %s = %.2f < 4.5 after xterm-256 quantization (%s on %s)", pair.name, r, q(pair.fg), q(pair.bg))
|
||||
}
|
||||
}
|
||||
|
||||
neon := palettes["neon"]
|
||||
greenish := func(hexColor string) bool {
|
||||
r, g, b := hexChannels(t, hexColor)
|
||||
return g > r && g > b
|
||||
}
|
||||
reddish := func(hexColor string) bool {
|
||||
r, g, b := hexChannels(t, hexColor)
|
||||
return r > g && r > b
|
||||
}
|
||||
if q(neon.addBg) == q(neon.delBg) || !greenish(q(neon.addBg)) || !reddish(q(neon.delBg)) {
|
||||
t.Errorf("neon: add/del row bands lose their green/red identity after quantization: addBg %s -> %s, delBg %s -> %s",
|
||||
neon.addBg, q(neon.addBg), neon.delBg, q(neon.delBg))
|
||||
}
|
||||
if q(neon.addBgWord) == q(neon.delBgWord) || !greenish(q(neon.addBgWord)) || !reddish(q(neon.delBgWord)) {
|
||||
t.Errorf("neon: word-span bands lose their green/red identity after quantization: addBgWord %s -> %s, delBgWord %s -> %s",
|
||||
neon.addBgWord, q(neon.addBgWord), neon.delBgWord, q(neon.delBgWord))
|
||||
}
|
||||
if q(neon.addBgWord) == q(neon.addBg) {
|
||||
t.Errorf("neon: changed span is indistinguishable from its add row after quantization (both %s)", q(neon.addBg))
|
||||
}
|
||||
if q(neon.delBgWord) == q(neon.delBg) {
|
||||
t.Errorf("neon: changed span is indistinguishable from its del row after quantization (both %s)", q(neon.delBg))
|
||||
}
|
||||
if r := wcagRatio(t, q(neon.green), q(neon.addBg)); r < 4.5 {
|
||||
t.Errorf("neon: green on addBg = %.2f < 4.5 after quantization", r)
|
||||
}
|
||||
if r := wcagRatio(t, q(neon.red), q(neon.delBg)); r < 4.5 {
|
||||
t.Errorf("neon: red on delBg = %.2f < 4.5 after quantization", r)
|
||||
}
|
||||
// The two rendered diff-content pairs a 256-color terminal actually
|
||||
// shows: line numbers (faintest on addBg/delBg) and highlighted changed
|
||||
// spans (addInk/delInk on their word bands).
|
||||
for _, pair := range []struct{ name, fg, bg string }{
|
||||
{"faintest on addBg", neon.faintest, neon.addBg},
|
||||
{"faintest on delBg", neon.faintest, neon.delBg},
|
||||
{"addInk on addBgWord", neon.addInk, neon.addBgWord},
|
||||
{"delInk on delBgWord", neon.delInk, neon.delBgWord},
|
||||
} {
|
||||
if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 {
|
||||
t.Errorf("neon: %s = %.2f < 4.5 after quantization (%s on %s)", pair.name, r, q(pair.fg), q(pair.bg))
|
||||
}
|
||||
}
|
||||
// Error-card borders are non-text UI components: they need 3:1 against
|
||||
// the panel (WCAG 1.4.11) to be distinguishable at all, in both truecolor
|
||||
// and 256-color renderings.
|
||||
if r := wcagRatio(t, neon.cardErr, neon.panel); r < 3.0 {
|
||||
t.Errorf("neon: cardErr border on panel = %.2f < 3.0", r)
|
||||
}
|
||||
if r := wcagRatio(t, q(neon.cardErr), q(neon.panel)); r < 3.0 {
|
||||
t.Errorf("neon: cardErr border on panel = %.2f < 3.0 after quantization", r)
|
||||
}
|
||||
}
|
||||
|
||||
func mustR(t *testing.T, hex string) uint32 {
|
||||
t.Helper()
|
||||
r, _, _, _ := lipgloss.Color(hex).RGBA()
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.4.0",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.4.0",
|
||||
"version": "0.3.0",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.4.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Zero — a fast, multi-provider terminal coding agent.",
|
||||
"type": "module",
|
||||
"module": "bin/zero.js",
|
||||
|
||||
Reference in new issue
Block a user