diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..83ffb02
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,114 @@
+name: CI
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["main"]
+ workflow_call:
+ schedule:
+ - cron: "0 4 * * 1"
+
+concurrency:
+ # Literal "ci-", never ${{ github.workflow }}: inside a reusable workflow that
+ # expression resolves to the *caller's* name, so the `uses: ./.github/workflows/ci.yml`
+ # leg of release.yml would compute "Release-refs/tags/vX" while release.yml's own
+ # group is "release-refs/tags/vX" -- the same group, because GitHub matches
+ # concurrency names case-insensitively, but with the two halves disagreeing about
+ # cancel-in-progress (true here, false there). A literal keeps this group's meaning
+ # fixed no matter who calls the workflow. Do not "restore" the expression.
+ group: ci-${{ github.ref }}
+ # Pull requests and pushes to main only care about the newest commit; superseded
+ # runs are wasted minutes. Tagged releases are serialised by release.yml's own
+ # group instead, which is where cancel-in-progress: false belongs.
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ boundary:
+ name: HTTP boundary intact
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ with:
+ go-version-file: go.mod
+ - run: ./scripts/check-module-boundary.sh
+
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ with:
+ go-version-file: go.mod
+ - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
+ with:
+ version: v2.11.4
+
+ security:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ with:
+ go-version-file: go.mod
+ - run: go run golang.org/x/vuln/cmd/govulncheck@v1.3.0 ./...
+
+ test:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ with:
+ go-version-file: go.mod
+ - run: go mod verify
+ - run: go vet ./...
+ - run: go test -race ./...
+ - run: go build ./cmd/ferro
+
+ integration:
+ name: Contract vs AI Gateway ${{ matrix.gateway_ref }}
+ runs-on: ubuntu-latest
+ # The pinned v1.4.2 leg is the required signal; "main" moves upstream of
+ # this repo and release.yml depends on ci via `needs: ci`, so a failure
+ # there must not block merges or tagged releases — only report it.
+ continue-on-error: ${{ matrix.gateway_ref == 'main' }}
+ strategy:
+ fail-fast: false
+ matrix:
+ gateway_ref: ["v1.4.2", "main"]
+ steps:
+ - name: Check out gateway-cli
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ path: gateway-cli
+ persist-credentials: false
+ - name: Check out AI Gateway
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: ferro-labs/ai-gateway
+ ref: ${{ matrix.gateway_ref }}
+ path: ai-gateway
+ persist-credentials: false
+ - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ with:
+ go-version-file: gateway-cli/go.mod
+ - run: ./scripts/with-gateway.sh
+ working-directory: gateway-cli
+ env:
+ FERRO_GATEWAY_SOURCE: ${{ github.workspace }}/ai-gateway
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..db266e3
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,47 @@
+name: Release
+
+on:
+ push:
+ tags: ["v*"]
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
+permissions: {}
+
+jobs:
+ ci:
+ permissions:
+ contents: read
+ uses: ./.github/workflows/ci.yml
+
+ release:
+ name: GoReleaser
+ needs: ci
+ runs-on: ubuntu-latest
+ permissions:
+ # GoReleaser publishes the GitHub release and uploads archives.
+ contents: write
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ with:
+ go-version-file: go.mod
+ # A restored cache can carry entries from other refs; this job
+ # publishes release artifacts, so a poisoned cache is user-facing.
+ cache: false
+ # .goreleaser.yaml's sboms block shells out to syft, which is not present on
+ # GitHub-hosted runners; without this step the first real tag fails at the
+ # SBOM stage, after the binaries are already built.
+ - uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
+ - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
+ with:
+ distribution: goreleaser
+ version: "2.17.1"
+ args: release --clean
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index aaadf73..93b83c9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,8 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/
+
+# Local builds
+/ferro
+/fakegw
+/dist/
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..09c08a1
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,60 @@
+version: "2"
+
+run:
+ timeout: 5m
+
+linters:
+ enable:
+ - bodyclose
+ - copyloopvar
+ - depguard
+ - dupl
+ - errcheck
+ - errorlint
+ - goconst
+ - gocritic
+ - gocyclo
+ - gosec
+ - govet
+ - ineffassign
+ - maintidx
+ - misspell
+ - noctx
+ - nolintlint
+ - prealloc
+ - revive
+ - staticcheck
+ - unconvert
+ - unparam
+ - unused
+
+ settings:
+ depguard:
+ rules:
+ main:
+ deny:
+ - pkg: gopkg.in/yaml.v3
+ desc: use go.yaml.in/yaml/v3
+ # depguard prefix-matches, so the module root also denies every
+ # current and future subpackage.
+ - pkg: github.com/ferro-labs/ai-gateway
+ desc: gateway-cli communicates with AI Gateway over HTTP only
+ dupl:
+ threshold: 150
+ gocyclo:
+ min-complexity: 30
+ nolintlint:
+ require-explanation: true
+ require-specific: true
+ allow-unused: false
+
+ exclusions:
+ rules:
+ - path: _test\.go
+ linters: [dupl, errorlint, goconst, unparam]
+ - path: _test\.go
+ linters: [staticcheck]
+ text: SA5011
+ - path: internal/command/output\.go
+ linters: [gosec]
+ text: G115
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
new file mode 100644
index 0000000..51a4301
--- /dev/null
+++ b/.goreleaser.yaml
@@ -0,0 +1,66 @@
+# GoReleaser for the standalone ferro operator console.
+version: 2
+project_name: ferro
+dist: dist
+
+builds:
+ - id: ferro
+ main: ./cmd/ferro
+ binary: ferro
+ env: [CGO_ENABLED=0]
+ goos: [linux, darwin, windows]
+ goarch: [amd64, arm64]
+ # Reproducibility: rebuilding a tag must yield the same bytes, or a user has
+ # no way to check that a published binary was built from the source it names.
+ # mod_timestamp pins the file mtimes goreleaser stamps into archives, and
+ # .CommitDate replaces the build wall clock below -- both are needed, since
+ # either one alone still leaves the build time baked in.
+ mod_timestamp: "{{ .CommitTimestamp }}"
+ # -trimpath is the third leg: without it the binary embeds the absolute build
+ # directory, so a rebuild anywhere but the CI runner's own path differs even
+ # with the timestamps pinned -- and the published binary leaks that path.
+ flags: [-trimpath]
+ ldflags:
+ - -s -w
+ - -X github.com/ferro-labs/gateway-cli/internal/version.Version={{.Version}}
+ - -X github.com/ferro-labs/gateway-cli/internal/version.Commit={{.ShortCommit}}
+ - -X github.com/ferro-labs/gateway-cli/internal/version.Date={{.CommitDate}}
+
+# cmd/fakegw is a development tool (a fake gateway for testing the CLI without
+# a server). It is excluded by omission — builds are listed explicitly above.
+# Do not add a build entry for it.
+
+archives:
+ - formats: [tar.gz]
+ name_template: "ferro_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
+ format_overrides:
+ - goos: windows
+ formats: [zip]
+ files:
+ - src: README.md
+ dst: README.md
+ - src: CHANGELOG.md
+ dst: CHANGELOG.md
+ - src: LICENSE
+ dst: LICENSE
+
+checksum:
+ name_template: checksums.txt
+
+# One SBOM per archive (syft's default, SPDX JSON), so a consumer can answer
+# "is this build affected by CVE-x" without unpacking and re-scanning it.
+# Requires syft on the runner -- release.yml installs it, and goreleaser fails
+# the release rather than skipping the SBOM if it is missing.
+sboms:
+ - artifacts: archive
+
+release:
+ name_template: "Ferro CLI v{{ .Version }}"
+
+changelog:
+ use: git
+ filters:
+ include:
+ - "^feat"
+ - "^fix"
+ - "^perf"
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..8bb6e0c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,74 @@
+# Changelog
+
+All notable changes to **Ferro Operator Console**, the scriptable CLI and
+interactive TUI for Ferro Labs AI Gateway. The binary it installs is `ferro`.
+
+The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
+this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## v0.1.0 — 2026-08-14
+
+First release. One binary, `ferro`, with two surfaces over a single HTTP client:
+scriptable commands for automation, and a full-screen console for operators.
+Requires AI Gateway v1.4.2 or later.
+
+### Added
+
+- **Commands** — `status`, `models`, `providers`, `mcp`, `plugins`, `services`,
+ `sessions`, `audit`, `keys list|get|create|rotate|revoke`,
+ `logs list|stats|tail`, `chat`, and `version`. Every one supports
+ `--format table|json|yaml`.
+- **Console** — run bare `ferro` at a TTY for a full-screen operations view with
+ four screens: Home, Request logs, Keys, and Playground. Includes a command
+ composer with completion, history, and reverse search (`ctrl+r`).
+- **Request-log tail** — follow live traffic in `logs tail` or the console, with
+ filters for time, model, provider, stage, and credential.
+- **Key management** — list, create, rotate, and revoke API keys, with derived
+ `active` / `expired` / `revoked` state. New secrets are printed once, to
+ stdout, and never enter the console transcript or history. `rotate` and
+ `revoke` are irreversible, so at a terminal each asks for the key id typed
+ back before it runs; `--yes` skips the prompt, and is required when stdin is
+ not a terminal rather than the verb blocking or proceeding unasked.
+- **Playground** — streaming chat against the gateway with `/model` and
+ `/clear`, showing token usage plus route and cost when available.
+- **Connection profiles** at `os.UserConfigDir()/ferro/config.yaml`. URL
+ resolution: `--gateway-url` > `FERRO_URL` > profile > `http://localhost:8080`.
+ Key resolution: `FERRO_API_KEY` > profile `api_key_env` > `MASTER_KEY`, the
+ last of which applies only when the gateway URL is loopback. `MASTER_KEY` is
+ the gateway server's own variable and is not chosen for ferro by anyone, so
+ it is the one source that could otherwise be forwarded to a remote host the
+ operator named on the command line. Keys are never passed as flags, so they
+ stay out of shell history.
+- **Global flags** `--gateway-url`, `--profile`, `--format`, `--ascii`, plus
+ `NO_COLOR`, TTY, and `TERM=dumb` detection.
+
+### Output contract
+
+- stdout carries data; narration, warnings, and errors go to stderr — so
+ `--format json` is always safe to pipe.
+- `status` exits 1 only when the gateway is unreachable. A reachable but
+ degraded gateway exits 0 and reports its degraded state.
+- Missing measurements render `-`, never `0`. Endpoints the gateway does not
+ serve disable that panel with a hint instead of failing the command.
+- Gateway-supplied text is data, never layout or terminal control. Control
+ characters in a provider name, an upstream error message, or a model's answer
+ are neutralized on every surface, so a table keeps its columns, a pane keeps
+ its line count, and nothing upstream can drive the terminal.
+
+### Distribution
+
+- Release binaries are reproducible: `-trimpath`, and both the build stamp and
+ the archive timestamps come from the tagged commit rather than the build
+ clock, so two builds of one tag are byte-identical.
+- An SPDX SBOM ships beside each archive.
+- `go install` builds, which carry no linker stamp, now report their module
+ version and commit from the embedded build info instead of `dev`/`none`.
+
+### Known limitations
+
+- The gateway does not report its version over HTTP, so the console's version
+ slot renders `—`.
+- The traffic panel and route attribution come from the request log, and show as
+ unavailable when no request-log store is configured.
+- Not yet shipped: config viewer / history / rollback, provider capability
+ matrix, doctor checklist, and init wizard.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..16eed70
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,15 @@
+# Contributing
+
+Ferro Operator Console is a standalone Go module. Keep the runtime boundary
+with AI Gateway HTTP-only; do not import packages from the gateway module.
+
+Before opening a change, run:
+
+```bash
+./scripts/check-module-boundary.sh
+go vet ./...
+go test -race ./...
+golangci-lint run ./...
+```
+
+For contract changes, run `FERRO_GATEWAY_SOURCE=/path/to/ai-gateway make itest`.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..cb45450
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,7 @@
+.PHONY: build test lint fake itest smoke
+build: ; go build -o ferro ./cmd/ferro
+test: ; go test -race ./...
+lint: ; golangci-lint run ./...
+fake: ; go run ./cmd/fakegw
+itest: ; ./scripts/with-gateway.sh
+smoke: build ; ./scripts/smoke.sh
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..783c26f
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,8 @@
+Ferro Operator Console (ferro-cli)
+Copyright 2026 Ferro Labs
+
+This product includes software developed at Ferro Labs
+(https://github.com/ferro-labs).
+
+Licensed under the Apache License, Version 2.0; see the LICENSE file for the
+full terms.
diff --git a/README.md b/README.md
index b2f9c72..9fefd1c 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,239 @@
-# ai-gateway-cli
-Operator Console - a scriptable CLI and interactive TUI for Ferro Labs AI Gateway
+
+
+
+  |
+ Ferro Labs Gateway CLI |
+
+ | Operator Console for the Ferro Labs AI Gateway |
+
+
+**A scriptable CLI and interactive TUI for the [Ferro Labs AI Gateway](https://github.com/ferro-labs/ai-gateway). One static binary — health, request logs, API keys, and a live playground, without opening a browser.**
+
+[](https://go.dev)
+[](https://pkg.go.dev/github.com/ferro-labs/gateway-cli)
+[](LICENSE)
+[](https://github.com/ferro-labs/gateway-cli/actions/workflows/ci.yml)
+
+📖 **Gateway documentation:** [docs.ferrolabs.ai](https://docs.ferrolabs.ai) · this README is the reference for `ferro` itself
+
+

+
+
+
+---
+
+`ferro-cli` ships a single binary named **`ferro`**. Run it with a verb and it
+behaves like any other Unix tool — `ferro status`, `ferro keys list`,
+`ferro logs tail` — with clean stdout you can pipe into `jq`. Run it bare on a
+terminal and it opens the console above.
+
+Every console view has a scriptable twin. The console is a lens, never the only
+door.
+
+## Install
+
+```bash
+go install github.com/ferro-labs/gateway-cli/cmd/ferro@v0.1.0
+```
+
+Or download a release archive — `ferro___.tar.gz` (`.zip` on
+Windows) — from the releases titled **Ferro CLI vX.Y.Z**, with `checksums.txt`
+alongside. Linux, macOS, and Windows on amd64 and arm64. No runtime, no
+dependencies: it is a single static binary.
+
+Works with **AI Gateway v1.4.2 or later**.
+
+> A withdrawn nested module also used v0.1.0 at
+> `github.com/ferro-labs/ai-gateway/cli`. This standalone module has a distinct
+> version namespace. Install only from `github.com/ferro-labs/gateway-cli`.
+
+| Console release | Distribution | AI Gateway compatibility |
+|---|---|---|
+| v0.1.x | `github.com/ferro-labs/gateway-cli` | v1.4.2+ |
+
+`ferro` is optional operator tooling for a running gateway. It is not part of
+the gateway runtime and does not replace `ferrogw serve`, `ferrogw init`, or
+offline `ferrogw validate`.
+
+## Quickstart
+
+```bash
+export FERRO_URL=http://localhost:8080
+export FERRO_API_KEY=fgw_... # or let it fall back to MASTER_KEY
+
+ferro status # health in one line
+ferro # the console (needs a terminal)
+ferro status --format json | jq . # stdout is always pure machine output
+```
+
+## Commands
+
+Every list and get honours `--format json|yaml`.
+
+| Command | What it does |
+|---|---|
+| `ferro` | Opens the console. On a pipe it refuses and points at `--help`. |
+| `ferro status` | State, URL, latency, targets, providers, models, MCP, auth. |
+| `ferro models` | Models the gateway routes — id, owner, mode, context window, capabilities. |
+| `ferro providers` | Providers with status, circuit state, model count, and message. |
+| `ferro mcp` | MCP tool servers — ready, required, last error. |
+| `ferro plugins` | Configured plugins merged with the build's catalog, including fail-open. |
+| `ferro services` | MCP, plugin, session, and audit availability in one report. |
+| `ferro sessions` | Active operator sessions — subject, scopes, created, last seen, expires. |
+| `ferro audit` | Audit trail. `--action --actor --outcome --since --limit`. |
+| `ferro keys list` | Keys — name, masked secret, scopes, expiry, last used, uses, state. |
+| `ferro keys get ` | One key, as structured output. |
+| `ferro keys create` | `--name` (required), `--scope admin\|read_only`, `--expires-in`. |
+| `ferro keys rotate ` | Mint a new secret; the previous one stops authenticating at once. Confirms first; `--yes` to skip. |
+| `ferro keys revoke ` | Immediate and irreversible. Confirms first; `--yes` to skip. |
+| `ferro logs list` | Request log — time, trace, provider, model, stage, duration, cost, tokens. |
+| `ferro logs stats` | Totals, errors, tokens, cost, and latency percentiles over a window. |
+| `ferro logs tail` | Follow the log. Poll-based, deduped, plain lines; Ctrl-C exits clean. |
+| `ferro chat "" --model ` | Streaming completion. Answer to stdout, usage to stderr. |
+| `ferro version` | Version, commit, build date. Works with no gateway. |
+
+**Persistent flags:** `--gateway-url`, `--profile`, `--format table|json|yaml`,
+`--ascii` (use `[OK] [X] [!] [-]` instead of `✓ ✗ ! ·`), and
+`--insecure-http` (explicitly allow plaintext HTTP to a non-loopback gateway).
+
+## Configuration
+
+| Variable | Effect |
+|---|---|
+| `FERRO_URL` | Gateway base URL. Default `http://localhost:8080`. |
+| `FERRO_API_KEY` | Bearer credential sent to the gateway. |
+| `MASTER_KEY` | Last-resort credential fallback, **only when the gateway URL is loopback**. It is the gateway server's own variable, so it is sent to `localhost` and `127.0.0.1` alone — never to a remote host that merely happens to be on the command line. |
+| `NO_COLOR` | Any value suppresses ANSI. (Also off under `TERM=dumb` or a non-TTY stdout.) |
+
+```text
+URL: --gateway-url > FERRO_URL > profile.url > http://localhost:8080
+key: FERRO_API_KEY > profile.api_key_env deref > MASTER_KEY (loopback only)
+```
+
+**There is deliberately no credential flag.** Command-line arguments show up in
+process listings and shell history, so credentials are environment-only.
+Remote gateways require HTTPS by default because admin data and bearer
+credentials must not cross a network in plaintext. Plain HTTP remains enabled
+for loopback development; private-network HTTP requires `--insecure-http` on
+each invocation.
+
+### Profiles
+
+`ferro-cli` stores **connection profiles only**, never gateway data, at
+`os.UserConfigDir()/ferro/config.yaml` (`~/.config/ferro/config.yaml` on Linux).
+A missing file is not an error.
+
+```yaml
+current_profile: prod
+profiles:
+ - name: prod
+ url: https://gw.example.com
+ api_key_env: PROD_KEY # the NAME of an env var, never the secret
+ - name: local
+ url: http://localhost:8080
+```
+
+Select one with `--profile local`. A profile names the environment variable
+holding its credential; the credential itself never touches the file.
+
+## The console
+
+`↵ run · tab complete · ↑ history · ctrl+r search · ? help · esc home · ctrl+c quit`
+
+Four persistent regions: a header carrying identity and connection state, a left
+rail answering *what is connected*, a main frame answering *what happened*, and a
+command composer that is the only navigation surface — there is no tab row.
+
+Type ordinary commands (`status`, `logs --since 15m --model claude-*`,
+`keys create`) or slash aliases (`/logs`, `/keys`, `/playground`). `?` on an
+empty composer prints the verb tree without destroying the transcript.
+
+| Screen | Contents |
+|---|---|
+| **Home** | Bounded command transcript, rendered by the same formatting code the scriptable verbs use. |
+| **Request logs** | Live tail with filters and a per-row detail pane that resolves a credential id to its key name. |
+| **Keys** | Key table with derived state, a create wizard, and typed-confirmation rotate and revoke. |
+| **Playground** | Streaming chat with `/model` and `/clear`, and a metadata line carrying route, latency, tokens, and cost. |
+
+Layout adapts to width: ≥110 columns gets the mark, rail, and panels; 80–109
+collapses the rail into a compact status frame; below 80 leaves output and the
+composer. Nothing is ever clipped mid-border.
+
+## Contracts worth relying on
+
+These are tested behaviours, not aspirations.
+
+- **stdout is the machine channel.** Narrative, warnings, and errors go to
+ stderr, so `ferro --format json | jq` only ever sees JSON.
+- **Exit codes are the API.** `ferro status` exits **1 only when the gateway is
+ unreachable**. A reachable-but-degraded gateway exits **0** and prints its
+ degraded state — so `ferro status || alert` pages on outages, not brownouts.
+- **Secrets are shown exactly once.** `keys create` and `keys rotate` print the
+ new secret once, on stdout, because the gateway stores only a hash. It never
+ reaches the console transcript or the command history.
+- **Missing is not zero.** An absent measurement renders `-`, never `0`. When
+ `/readyz` reports no targets, you get `-`, because "none configured" and
+ "yours are dead" are different answers and the gateway only sent one of them.
+- **Redirects are refused, never followed.** A bearer token is never replayed to
+ whatever host a redirect names; the target is surfaced as a hint instead.
+- **A missing feature degrades one screen.** A 404 or 501 — no request-log
+ store, sessions disabled — disables that panel with a note rather than
+ ending the command or the app.
+- **Colour never carries state alone.** Every status also has a glyph or text,
+ so the full ladder holds: console → `NO_COLOR` → non-TTY plain text →
+ `--format json`.
+- **Nothing is cached on disk.** Everything re-derives from the API on each poll.
+
+### Known gaps in v0.1.0
+
+Stated plainly, because the console shows them rather than inventing values:
+
+- The header reads `gateway —`: the gateway does not serve its own version over
+ HTTP yet.
+- The traffic panel renders `—` without a request-log store, since it derives
+ from log statistics.
+- Streaming responses carry no provider on the wire, so a chat's route is
+ resolved afterwards by matching the response's request id against the request
+ log — and is absent when no log store is configured. Usage still renders.
+- The playground renders answers as word-wrapped plain text rather than
+ formatted markdown. Streaming, batching, and the metadata line are unaffected.
+- Not in this release: the config viewer, the capability matrix, the doctor
+ checklist, and the init wizard. Their gateway endpoints exist; the screens
+ do not.
+
+## Development
+
+This repository versions independently from AI Gateway. HTTP is the only
+runtime boundary: this module imports no gateway package and does not depend on
+the gateway module. Wire shapes are copied, never imported, and CI enforces the
+boundary.
+
+```bash
+make fake # fake gateway (cmd/fakegw) on 127.0.0.1:8080
+make build # go build -o ferro ./cmd/ferro
+make test # go test -race ./...
+make lint # golangci-lint run ./...
+make itest # contract suite against ../ai-gateway or FERRO_GATEWAY_SOURCE
+```
+
+`make fake` is the loop to use almost always. It serves the gateway's HTTP
+contract from fixtures, so the whole CLI and console can be developed with **no
+gateway, no provider credentials, and no network**:
+
+```bash
+make fake &
+FERRO_URL=http://localhost:8080 FERRO_API_KEY=fgw_test go run ./cmd/ferro
+```
+
+`cmd/fakegw` takes `--addr`, `--degraded`, `--no-providers`, `--no-log-store`,
+and `--no-auth` to reproduce failure states a healthy gateway will not show you
+on demand. It is a development tool and is never in a release build.
+
+`make itest` is the acceptance loop: it builds `ferrogw` from a separate AI
+Gateway checkout, boots it, runs the contract suite, and tears it down. The fake
+proves the CLI is internally correct; only this proves the contract is real.
+
+## License
+
+Copyright 2026 Ferro Labs. Apache 2.0 — see [`LICENSE`](LICENSE) and
+[`NOTICE`](NOTICE).
diff --git a/cmd/fakegw/main.go b/cmd/fakegw/main.go
new file mode 100644
index 0000000..8b5cda8
--- /dev/null
+++ b/cmd/fakegw/main.go
@@ -0,0 +1,64 @@
+// Command fakegw serves deterministic gateway responses for local CLI use.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/ferro-labs/gateway-cli/internal/fixture"
+)
+
+func main() {
+ // Loopback-only: --no-auth accepts unauthenticated admin requests, so a
+ // wildcard bind would expose them to anything else on the network.
+ addr := flag.String("addr", "127.0.0.1:8080", "listen address")
+ degraded := flag.Bool("degraded", false, "healthy gateway in trouble: 200 with a half-open circuit and one unroutable target")
+ noProviders := flag.Bool("no-providers", false, "no credential configured: /health 503 no_providers, /readyz 503 not_ready")
+ noAuth := flag.Bool("no-auth", false, "accept unauthenticated admin requests")
+ noLogs := flag.Bool("no-log-store", false, "501 the log endpoints, as a gateway without a store does")
+ flag.Parse()
+
+ s := fixture.Default()
+ // Two independent axes, as on the real gateway: a circuit does not make
+ // /health non-200, and only an empty provider set does.
+ s.Degraded, s.NoProviders = *degraded, *noProviders
+ s.NoLogStore, s.ChatDelay = *noLogs, 60*time.Millisecond
+ s.RequireAuth = !*noAuth
+
+ fmt.Fprintf(os.Stderr, "fakegw on %s — key: %s\n", *addr, s.AcceptKey)
+ // A bare ":8080" needs a host prepended; "127.0.0.1:8101" already has one.
+ host := *addr
+ if strings.HasPrefix(host, ":") {
+ host = "localhost" + host
+ }
+ fmt.Fprintf(os.Stderr, " FERRO_URL=http://%s FERRO_API_KEY=%s go run ./cmd/ferro\n", host, s.AcceptKey)
+ srv := &http.Server{
+ Addr: *addr,
+ Handler: fixture.Handler(s),
+ // ReadHeaderTimeout is the Slowloris bound — a client that opens a
+ // connection and dribbles headers forever is cut off here — and
+ // ReadTimeout bounds the body after it. Neither can cut a stream
+ // short: net/http clears the read deadline the moment the request
+ // body is consumed (connReader.startBackgroundRead), which is before
+ // the first SSE frame is written.
+ ReadHeaderTimeout: 10 * time.Second,
+ ReadTimeout: 30 * time.Second,
+ // WriteTimeout is deliberately zero. It bounds the WHOLE response, and
+ // this server's chat surface is SSE: a finite value would cut a stream
+ // off mid-flight and, worse, do it silently — the client sees a
+ // truncated stream with no error frame and no [DONE], which is exactly
+ // the failure the playground dev loop exists to make visible. Nothing
+ // is given up by leaving it off, because the Slowloris vector
+ // ReadHeaderTimeout covers is the one G114 is about.
+ WriteTimeout: 0,
+ IdleTimeout: 120 * time.Second,
+ }
+ if err := srv.ListenAndServe(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/cmd/ferro/main.go b/cmd/ferro/main.go
new file mode 100644
index 0000000..f4df0f3
--- /dev/null
+++ b/cmd/ferro/main.go
@@ -0,0 +1,34 @@
+// Command ferro is the operator console for the Ferro Labs AI Gateway.
+//
+// It talks to the gateway only over HTTP and does not import gateway packages.
+package main
+
+import (
+ "context"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/charmbracelet/fang"
+
+ "github.com/ferro-labs/gateway-cli/internal/command"
+)
+
+func main() {
+ if err := run(); err != nil {
+ os.Exit(1) // fang has already rendered the error
+ }
+}
+
+// run owns every deferred cleanup, because os.Exit runs none of them. With the
+// signal context built directly in main, the os.Exit(1) on a failed command
+// skipped `defer stop()` entirely, so the SIGINT handler signal.NotifyContext
+// installs was never released on the error path.
+func run() error {
+ // SIGINT cancels in-flight requests and long-running tails cleanly rather
+ // than killing the process mid-write.
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ return fang.Execute(ctx, command.NewRoot())
+}
diff --git a/docs/console.gif b/docs/console.gif
new file mode 100644
index 0000000..9dd231f
Binary files /dev/null and b/docs/console.gif differ
diff --git a/docs/logo.png b/docs/logo.png
new file mode 100644
index 0000000..f3ca42d
Binary files /dev/null and b/docs/logo.png differ
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..7c1dc4b
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,38 @@
+module github.com/ferro-labs/gateway-cli
+
+go 1.25.13
+
+require (
+ charm.land/bubbletea/v2 v2.0.8
+ charm.land/lipgloss/v2 v2.0.5
+ github.com/charmbracelet/fang v1.0.0
+ github.com/charmbracelet/x/ansi v0.11.7
+ github.com/spf13/cobra v1.10.2
+ go.yaml.in/yaml/v3 v3.0.5
+ golang.org/x/term v0.45.0
+)
+
+require (
+ github.com/charmbracelet/colorprofile v0.4.3 // indirect
+ github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
+ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
+ github.com/charmbracelet/x/term v0.2.2 // indirect
+ github.com/charmbracelet/x/termios v0.1.1 // indirect
+ github.com/charmbracelet/x/windows v0.2.2 // indirect
+ github.com/clipperhouse/displaywidth v0.11.0 // indirect
+ github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
+ github.com/mattn/go-runewidth v0.0.24 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/mango v0.1.0 // indirect
+ github.com/muesli/mango-cobra v1.2.0 // indirect
+ github.com/muesli/mango-pflag v0.1.0 // indirect
+ github.com/muesli/roff v0.1.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/spf13/pflag v1.0.9 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/sync v0.21.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.39.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..850f70b
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,76 @@
+charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
+charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
+charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
+charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
+github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
+github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
+github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
+github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
+github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
+github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
+github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw=
+github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
+github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
+github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
+github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
+github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
+github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
+github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
+github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
+github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
+github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
+github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
+github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
+golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
+golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/api/client.go b/internal/api/client.go
new file mode 100644
index 0000000..8fbe7d9
--- /dev/null
+++ b/internal/api/client.go
@@ -0,0 +1,294 @@
+// Package api provides the HTTP, JSON, and SSE client used by ferro.
+// It centralizes redirect refusal, non-2xx errors unless an endpoint tolerates
+// them, empty-response refusal when a payload is expected, and uniform error
+// envelope decoding.
+package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "path"
+ "slices"
+ "strings"
+ "time"
+ "unicode/utf8"
+)
+
+const (
+ // DefaultTimeout bounds a single request/response exchange. SSE uses a
+ // separate no-timeout client with context cancellation instead.
+ DefaultTimeout = 15 * time.Second
+ maxBody = 4 << 20
+)
+
+// Client is a gateway HTTP client. Every endpoint method in this package
+// funnels through the unexported do method, which owns the safety contracts.
+type Client struct {
+ base *url.URL
+ apiKey string
+ hc *http.Client
+ userAgent string
+ streamIdleTimeout time.Duration
+ allowInsecureHTTP bool
+}
+
+// Option customizes a Client at construction time.
+type Option func(*Client)
+
+// WithTimeout overrides DefaultTimeout for this client's requests, including
+// the response-header bound enforced by the shared transport.
+func WithTimeout(d time.Duration) Option {
+ return func(c *Client) {
+ c.hc.Timeout = d
+ if tr, ok := c.hc.Transport.(*http.Transport); ok {
+ clone := tr.Clone()
+ clone.ResponseHeaderTimeout = d
+ c.hc.Transport = clone
+ }
+ }
+}
+
+// WithStreamIdleTimeout overrides the maximum time a chat stream may remain
+// silent. A non-positive duration disables the client-side idle bound.
+func WithStreamIdleTimeout(d time.Duration) Option {
+ return func(c *Client) { c.streamIdleTimeout = d }
+}
+
+// WithUserAgent overrides the User-Agent header sent on every request.
+func WithUserAgent(ua string) Option { return func(c *Client) { c.userAgent = ua } }
+
+// WithInsecureHTTP permits plaintext HTTP to a non-loopback gateway. It must
+// be an explicit operator choice because bearer credentials and admin data are
+// otherwise exposed to the network.
+func WithInsecureHTTP() Option { return func(c *Client) { c.allowInsecureHTTP = true } }
+
+// New builds a Client for the gateway at rawURL, authenticating with apiKey
+// (empty means send no Authorization header).
+func New(rawURL, apiKey string, opts ...Option) (*Client, error) {
+ u, err := url.Parse(strings.TrimRight(rawURL, "/"))
+ if err != nil || u.Scheme == "" || u.Host == "" || u.Hostname() == "" {
+ return nil, fmt.Errorf("invalid gateway URL %q (want e.g. http://localhost:8080)", rawURL)
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return nil, fmt.Errorf("invalid gateway URL %q: scheme must be http or https", rawURL)
+ }
+ if u.User != nil {
+ return nil, fmt.Errorf("invalid gateway URL %q: credentials belong in environment variables, not URL userinfo", rawURL)
+ }
+ // do builds every request's path and query from this base, so a query
+ // string here is either silently dropped or silently replaced, and a
+ // fragment never leaves the process at all. Neither reading produces the
+ // request the operator wrote — the same reason the gateway refuses a query
+ // or fragment in a provider base URL.
+ if u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
+ return nil, fmt.Errorf("invalid gateway URL %q: a gateway URL carries no query string or fragment (each request builds its own)", rawURL)
+ }
+ c := &Client{
+ base: u,
+ apiKey: apiKey,
+ hc: &http.Client{
+ Timeout: DefaultTimeout,
+ Transport: func() http.RoundTripper {
+ tr := http.DefaultTransport.(*http.Transport).Clone()
+ tr.ResponseHeaderTimeout = DefaultTimeout
+ return tr
+ }(),
+ // Redirects are surfaced, never followed: a bearer token must not
+ // replay to whatever host a redirect names.
+ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
+ },
+ userAgent: "ferro-cli",
+ streamIdleTimeout: DefaultStreamIdleTimeout,
+ }
+ for _, o := range opts {
+ o(c)
+ }
+ if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) && !c.allowInsecureHTTP {
+ return nil, fmt.Errorf("refusing plaintext HTTP to non-loopback gateway %q; use HTTPS or explicitly pass --insecure-http", u.Host)
+ }
+ return c, nil
+}
+
+func isLoopbackHost(host string) bool {
+ host = strings.TrimSuffix(strings.ToLower(host), ".")
+ if host == "localhost" || strings.HasSuffix(host, ".localhost") {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
+
+// BaseURL returns the gateway URL this client talks to.
+func (c *Client) BaseURL() string { return c.base.String() }
+
+// resolveURL builds the absolute URL for endpoint path p against c.base. It
+// is the one join every request goes through — do and StreamChat both call
+// it — so a path-prefixed base cannot resolve to two different URLs for the
+// same endpoint depending on which caller built the request.
+func (c *Client) resolveURL(p string) *url.URL {
+ u := *c.base
+ u.Path = path.Join(c.base.Path, p)
+ return &u
+}
+
+// Error is a non-2xx (or refused-redirect) response from the gateway.
+//
+// The name follows net/url's Error: a package-level struct named Error that
+// carries an Error() method is the standard shape for this, and it reads at
+// the call site as api.Error rather than the stuttering api.APIError.
+type Error struct {
+ Status int
+ Message string
+ Type string
+ Code string
+ RedirectTo string
+}
+
+func (e *Error) Error() string {
+ if e.RedirectTo != "" {
+ return fmt.Sprintf("HTTP %d redirect to %s refused — credentials are never replayed to another host (usual cause: an ingress upgrading http to https; point --gateway-url at the target)", e.Status, e.RedirectTo)
+ }
+ if e.Code != "" {
+ return fmt.Sprintf("HTTP %d %s: %s", e.Status, e.Code, e.Message)
+ }
+ return fmt.Sprintf("HTTP %d: %s", e.Status, e.Message)
+}
+
+// IsNotSupported reports whether an optional endpoint is absent on this
+// gateway build (older version or feature disabled): screens degrade, the
+// app never terminates on it.
+func IsNotSupported(err error) bool {
+ var ae *Error
+ return errors.As(err, &ae) && (ae.Status == http.StatusNotFound || ae.Status == http.StatusNotImplemented)
+}
+
+// IsUnauthorized reports whether the gateway refused the credential, as
+// opposed to failing for any other reason.
+//
+// It exists so a caller degrading on an admin-only endpoint can say which
+// happened. "No admin credential accepted" is a specific claim, and answering
+// it to a timeout or a 500 sends the reader to check a credential that was
+// never the problem.
+func IsUnauthorized(err error) bool {
+ var ae *Error
+ return errors.As(err, &ae) &&
+ (ae.Status == http.StatusUnauthorized || ae.Status == http.StatusForbidden)
+}
+
+type doOpts struct {
+ tolerate []int // status codes decoded as success (e.g. 503 for /health)
+}
+
+func (c *Client) do(ctx context.Context, method, p string, q url.Values, in, out any, o doOpts) (int, error) {
+ u := c.resolveURL(p)
+ if q != nil {
+ u.RawQuery = q.Encode()
+ }
+ var body io.Reader
+ if in != nil {
+ b, err := json.Marshal(in)
+ if err != nil {
+ return 0, fmt.Errorf("encode request: %w", err)
+ }
+ body = bytes.NewReader(b)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
+ if err != nil {
+ return 0, err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("User-Agent", c.userAgent)
+ if in != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if c.apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ }
+ resp, err := c.hc.Do(req)
+ if err != nil {
+ return 0, fmt.Errorf("gateway unreachable at %s: %w", c.BaseURL(), err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode >= 300 && resp.StatusCode < 400 {
+ return resp.StatusCode, &Error{
+ Status: resp.StatusCode,
+ Message: "redirect refused",
+ RedirectTo: resp.Header.Get("Location"),
+ }
+ }
+ // Read one byte past maxBody: LimitReader itself is silent about a
+ // truncation, and a truncated JSON body would otherwise fail inside
+ // json.Unmarshal as "decode response" — a report that sends an operator
+ // looking for a malformed payload instead of an oversized one.
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
+ if err != nil {
+ return resp.StatusCode, fmt.Errorf("read response: %w", err)
+ }
+ if len(raw) > maxBody {
+ return resp.StatusCode, fmt.Errorf("response exceeded the %d-byte limit", maxBody)
+ }
+ ok := resp.StatusCode >= 200 && resp.StatusCode < 300
+ if !ok && !slices.Contains(o.tolerate, resp.StatusCode) {
+ return resp.StatusCode, decodeAPIError(resp.StatusCode, raw)
+ }
+ if out == nil {
+ return resp.StatusCode, nil
+ }
+ if len(raw) == 0 {
+ return resp.StatusCode, &Error{Status: resp.StatusCode,
+ Message: fmt.Sprintf("HTTP %d with an empty response body, expected a payload", resp.StatusCode)}
+ }
+ if err := json.Unmarshal(raw, out); err != nil {
+ return resp.StatusCode, fmt.Errorf("decode response: %w", err)
+ }
+ return resp.StatusCode, nil
+}
+
+// maxErrMessage bounds the fallback error message built from a raw,
+// non-envelope body. Without a cap, a proxy's HTML error page or a stack
+// trace flows into terminal output and logs unbounded.
+const maxErrMessage = 2048
+
+// boundMessage caps an operator-facing message at maxErrMessage. Both of
+// decodeAPIError's sources go through it: capping only the raw body left the
+// bound trivially bypassable, because a gateway answering with a well-formed
+// envelope replaces that message with an unbounded one.
+func boundMessage(s string) string {
+ if len(s) <= maxErrMessage {
+ return s
+ }
+ // Back up to a rune boundary before slicing: a cut through the middle of a
+ // multi-byte rune renders as U+FFFD, which reads as corruption in the
+ // gateway's reply rather than as ferro's own truncation.
+ cut := maxErrMessage
+ for cut > 0 && !utf8.RuneStart(s[cut]) {
+ cut--
+ }
+ return s[:cut] + "… (truncated)"
+}
+
+func decodeAPIError(status int, raw []byte) *Error {
+ e := &Error{Status: status, Message: boundMessage(strings.TrimSpace(string(raw)))}
+ var env struct {
+ Error struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+ Code string `json:"code"`
+ } `json:"error"`
+ }
+ if json.Unmarshal(raw, &env) == nil && env.Error.Message != "" {
+ e.Message, e.Type, e.Code = boundMessage(env.Error.Message), env.Error.Type, env.Error.Code
+ }
+ if e.Message == "" {
+ e.Message = http.StatusText(status)
+ }
+ return e
+}
diff --git a/internal/api/client_test.go b/internal/api/client_test.go
new file mode 100644
index 0000000..abc7ecf
--- /dev/null
+++ b/internal/api/client_test.go
@@ -0,0 +1,249 @@
+package api
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+ "unicode/utf8"
+)
+
+func TestBearerHeaderSentAndOmitted(t *testing.T) {
+ var got string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ got = r.Header.Get("Authorization")
+ writeJSON(t, w, http.StatusOK, `{}`)
+ }))
+ defer srv.Close()
+
+ c := testClient(t, srv.URL, "fgw_secret")
+ var out map[string]any
+ if _, err := c.do(context.Background(), http.MethodGet, "/x", nil, nil, &out, doOpts{}); err != nil {
+ t.Fatal(err)
+ }
+ if got != "Bearer fgw_secret" {
+ t.Fatalf("want bearer header, got %q", got)
+ }
+
+ c2 := testClient(t, srv.URL, "")
+ if _, err := c2.do(context.Background(), http.MethodGet, "/x", nil, nil, &out, doOpts{}); err != nil {
+ t.Fatal(err)
+ }
+ if got != "" {
+ t.Fatalf("empty key must send no Authorization header, got %q", got)
+ }
+}
+
+func TestRedirectRefusedNeverFollowed(t *testing.T) {
+ var followed atomic.Int32
+ target := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
+ followed.Add(1)
+ }))
+ defer target.Close()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, target.URL+"/steal", http.StatusFound)
+ }))
+ defer srv.Close()
+
+ c := testClient(t, srv.URL, "fgw_secret")
+ var out map[string]any
+ _, err := c.do(context.Background(), http.MethodGet, "/x", nil, nil, &out, doOpts{})
+ var apiErr *Error
+ if !errors.As(err, &apiErr) || apiErr.Status != http.StatusFound {
+ t.Fatalf("want api.Error 302, got %v", err)
+ }
+ if !strings.Contains(apiErr.RedirectTo, target.URL) {
+ t.Fatalf("error must surface the redirect target, got %+v", apiErr)
+ }
+ if followed.Load() != 0 {
+ t.Fatal("redirect was followed — bearer token would have replayed")
+ }
+}
+
+func TestEmpty2xxWithExpectedPayloadIsError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK) // no body
+ }))
+ defer srv.Close()
+ c := testClient(t, srv.URL, "")
+ var out map[string]any
+ _, err := c.do(context.Background(), http.MethodGet, "/x", nil, nil, &out, doOpts{})
+ if err == nil || !strings.Contains(err.Error(), "empty response body") {
+ t.Fatalf("want empty-2xx refusal, got %v", err)
+ }
+}
+
+func Test204WithNilDestIsSuccess(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer srv.Close()
+ c := testClient(t, srv.URL, "")
+ if _, err := c.do(context.Background(), http.MethodDelete, "/x", nil, nil, nil, doOpts{}); err != nil {
+ t.Fatalf("204 with nil dest must succeed: %v", err)
+ }
+}
+
+func TestErrorEnvelopeDecoded(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, http.StatusUnauthorized,
+ `{"error":{"message":"bad key","type":"authentication_error","code":"unauthorized"}}`)
+ }))
+ defer srv.Close()
+ c := testClient(t, srv.URL, "fgw_bad")
+ var out map[string]any
+ _, err := c.do(context.Background(), http.MethodGet, "/x", nil, nil, &out, doOpts{})
+ var apiErr *Error
+ if !errors.As(err, &apiErr) || apiErr.Message != "bad key" || apiErr.Type != "authentication_error" {
+ t.Fatalf("envelope not decoded: %v", err)
+ }
+}
+
+func TestTolerated503DecodesBody(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, http.StatusServiceUnavailable,
+ `{"status":"not_ready","reason":"no routable targets"}`)
+ }))
+ defer srv.Close()
+ c := testClient(t, srv.URL, "")
+ var out struct {
+ Status string `json:"status"`
+ Reason string `json:"reason"`
+ }
+ status, err := c.do(context.Background(), http.MethodGet, "/readyz", nil, nil, &out, doOpts{tolerate: []int{503}})
+ if err != nil || status != 503 || out.Reason != "no routable targets" {
+ t.Fatalf("tolerated 503 must decode: status=%d out=%+v err=%v", status, out, err)
+ }
+}
+
+func TestNewRejectsUnusableGatewayURLs(t *testing.T) {
+ // A query or a fragment is refused rather than dropped: do builds the path
+ // and the query of every request, so neither could survive, and deleting
+ // the part that cannot work is the same defect one step quieter.
+ for _, raw := range []string{
+ "", "localhost:8080", "http://", "https://:8443", "://nope",
+ "ftp://localhost:8080",
+ "http://localhost:8080?token=abc",
+ "http://localhost:8080/v1?a=b",
+ "http://localhost:8080#frag",
+ "http://localhost:8080/?",
+ "https://u:p@gw.example.com",
+ "http://gw.example.com:8080",
+ } {
+ if c, err := New(raw, ""); err == nil {
+ t.Fatalf("New(%q) must fail, got a client for %q", raw, c.BaseURL())
+ }
+ }
+ // Local HTTP is safe for development; remote gateways must use TLS unless
+ // the operator explicitly acknowledges the risk.
+ for _, raw := range []string{
+ "http://localhost:8080", "http://localhost.:8080",
+ "http://127.0.0.1:8080", "http://[::1]:8080",
+ "https://gw.example.com/ferro/",
+ } {
+ if _, err := New(raw, ""); err != nil {
+ t.Fatalf("New(%q) must succeed: %v", raw, err)
+ }
+ }
+ if _, err := New("http://gateway.internal:8080", "fgw_secret", WithInsecureHTTP()); err != nil {
+ t.Fatalf("explicit insecure HTTP opt-in must succeed: %v", err)
+ }
+}
+
+// TestNewNormalizesBaseURL pins the exact form BaseURL() returns for every
+// accepted input. do and StreamChat both build requests by joining an
+// endpoint path onto this string (do via resolveURL's path.Join, StreamChat
+// via the same resolveURL call), so a trailing slash surviving here is what
+// would put a double slash on every request path, streaming included.
+func TestNewNormalizesBaseURL(t *testing.T) {
+ tests := []struct {
+ raw string
+ want string
+ }{
+ {"http://localhost:8080", "http://localhost:8080"},
+ {"http://localhost:8080/", "http://localhost:8080"},
+ // The load-bearing case: a path-prefixed base must lose its trailing
+ // slash, or concatenating "/v1/chat/completions" onto it doubles up.
+ {"https://gw.example.com/ferro/", "https://gw.example.com/ferro"},
+ {"https://gw.example.com/ferro", "https://gw.example.com/ferro"},
+ }
+ for _, tt := range tests {
+ c, err := New(tt.raw, "")
+ if err != nil {
+ t.Fatalf("New(%q): %v", tt.raw, err)
+ }
+ if got := c.BaseURL(); got != tt.want {
+ t.Errorf("New(%q).BaseURL() = %q, want %q", tt.raw, got, tt.want)
+ }
+ if strings.HasSuffix(c.BaseURL(), "/") {
+ t.Errorf("New(%q).BaseURL() = %q must never carry a trailing slash", tt.raw, c.BaseURL())
+ }
+ }
+}
+
+func TestIsNotSupported(t *testing.T) {
+ if !IsNotSupported(&Error{Status: 501}) || !IsNotSupported(&Error{Status: 404}) || IsNotSupported(&Error{Status: 500}) {
+ t.Fatal("IsNotSupported must be exactly {404, 501}")
+ }
+}
+
+func TestWithTimeoutUpdatesClientAndHeaderBounds(t *testing.T) {
+ c, err := New("http://localhost:8080", "", WithTimeout(3*time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ tr, ok := c.hc.Transport.(*http.Transport)
+ if !ok {
+ t.Fatalf("transport type = %T", c.hc.Transport)
+ }
+ if c.hc.Timeout != 3*time.Second || tr.ResponseHeaderTimeout != 3*time.Second {
+ t.Fatalf("timeouts diverged: client=%s header=%s", c.hc.Timeout, tr.ResponseHeaderTimeout)
+ }
+}
+
+func TestDecodeAPIErrorTruncatesOnARuneBoundary(t *testing.T) {
+ // The multi-byte runes straddle the cut, so a byte slice would keep half of
+ // one and print U+FFFD — a corruption the operator would read as the
+ // gateway's, not ferro's.
+ raw := []byte(strings.Repeat("a", maxErrMessage-1) + strings.Repeat("é", 8))
+ e := decodeAPIError(http.StatusBadGateway, raw)
+ if !strings.HasSuffix(e.Message, "… (truncated)") {
+ t.Fatalf("oversized body must be marked truncated, got %d bytes ending %q",
+ len(e.Message), e.Message[max(0, len(e.Message)-20):])
+ }
+ if strings.ContainsRune(e.Message, utf8.RuneError) || !utf8.ValidString(e.Message) {
+ t.Fatal("truncation split a rune")
+ }
+}
+
+// The envelope path replaces the message the raw-body cap just bounded, so it
+// needs the same cap. Without one, a gateway answering with a well-formed
+// {"error":{"message":...}} sends unbounded text into terminal output and logs
+// while the constant that exists to prevent that reads as enforced.
+func TestDecodeAPIErrorBoundsTheEnvelopeMessageToo(t *testing.T) {
+ body := `{"error":{"message":"` + strings.Repeat("z", maxErrMessage*4) + `","code":"upstream"}}`
+ e := decodeAPIError(http.StatusBadGateway, []byte(body))
+
+ if e.Code != "upstream" {
+ t.Fatalf("envelope must still be decoded, got code %q", e.Code)
+ }
+ if len(e.Message) > maxErrMessage+len("… (truncated)") {
+ t.Errorf("envelope message escaped the %d-byte bound: got %d bytes", maxErrMessage, len(e.Message))
+ }
+ if !strings.HasSuffix(e.Message, "… (truncated)") {
+ t.Error("an oversized envelope message must be marked truncated")
+ }
+}
+
+// A short envelope message is not truncated, so the bound cannot be read as
+// "every structured error loses its tail".
+func TestDecodeAPIErrorLeavesAShortEnvelopeMessageWhole(t *testing.T) {
+ e := decodeAPIError(http.StatusNotFound, []byte(`{"error":{"message":"no such model"}}`))
+ if e.Message != "no such model" {
+ t.Fatalf("got %q", e.Message)
+ }
+}
diff --git a/internal/api/endpoints.go b/internal/api/endpoints.go
new file mode 100644
index 0000000..9451c49
--- /dev/null
+++ b/internal/api/endpoints.go
@@ -0,0 +1,54 @@
+package api
+
+import (
+ "context"
+ "net/http"
+)
+
+// Health reads the unauthenticated GET /health. A 503 is a report, not a
+// failure — the body names which providers are down — so it is decoded and the
+// HTTP status is returned alongside for the caller to act on.
+func (c *Client) Health(ctx context.Context) (*HealthReport, int, error) {
+ var out HealthReport
+ status, err := c.do(ctx, http.MethodGet, "/health", nil, nil, &out,
+ doOpts{tolerate: []int{http.StatusServiceUnavailable}})
+ if err != nil {
+ return nil, status, err
+ }
+ return &out, status, nil
+}
+
+// Ready reads the unauthenticated GET /readyz. As with Health, a 503 carries
+// the reason and is decoded rather than raised.
+func (c *Client) Ready(ctx context.Context) (*ReadyReport, int, error) {
+ var out ReadyReport
+ status, err := c.do(ctx, http.MethodGet, "/readyz", nil, nil, &out,
+ doOpts{tolerate: []int{http.StatusServiceUnavailable}})
+ if err != nil {
+ return nil, status, err
+ }
+ return &out, status, nil
+}
+
+// AdminHealth reads GET /admin/health, which answers 200 for any authenticated
+// caller. A 401/403 therefore means the credential, not the gateway, is the
+// problem — callers use that to decide what they may display.
+func (c *Client) AdminHealth(ctx context.Context) (*AdminHealth, error) {
+ var out AdminHealth
+ if _, err := c.do(ctx, http.MethodGet, "/admin/health", nil, nil, &out, doOpts{}); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// Models lists GET /v1/models, unwrapping the {"object":"list","data":[…]}
+// envelope.
+func (c *Client) Models(ctx context.Context) ([]Model, error) {
+ var out struct {
+ Data []Model `json:"data"`
+ }
+ if _, err := c.do(ctx, http.MethodGet, "/v1/models", nil, nil, &out, doOpts{}); err != nil {
+ return nil, err
+ }
+ return out.Data, nil
+}
diff --git a/internal/api/endpoints_test.go b/internal/api/endpoints_test.go
new file mode 100644
index 0000000..95b8c72
--- /dev/null
+++ b/internal/api/endpoints_test.go
@@ -0,0 +1,213 @@
+package api
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// testClient builds a Client against a stub, failing the test on a bad URL.
+func testClient(t *testing.T, base, key string) *Client {
+ t.Helper()
+ c, err := New(base, key)
+ if err != nil {
+ t.Fatalf("New(%q): %v", base, err)
+ }
+ return c
+}
+
+// writeJSON is the stub-side body writer; status 0 means 200.
+func writeJSON(t *testing.T, w http.ResponseWriter, status int, body string) {
+ t.Helper()
+ w.Header().Set("Content-Type", "application/json")
+ if status != 0 && status != http.StatusOK {
+ w.WriteHeader(status)
+ }
+ if _, err := w.Write([]byte(body)); err != nil {
+ t.Errorf("stub write: %v", err)
+ }
+}
+
+// gatewayStub is the healthy-gateway fixture shared by the endpoint and status
+// tests. admin=false makes /admin/health answer 401 for every caller.
+func gatewayStub(t *testing.T, admin bool) *httptest.Server {
+ t.Helper()
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"ok","providers":[
+ {"name":"anthropic","status":"available","circuit":"closed","models":412},
+ {"name":"openai","status":"available","circuit":"half_open","models":1104}]}`)
+ })
+ mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"ready",
+ "providers":[{"name":"anthropic","circuit":"closed"}],
+ "targets":[{"name":"anthropic-primary","routable":true},{"name":"openai-primary","routable":false}],
+ "mcp_servers":[{"name":"filesystem","ready":true,"required":false},{"name":"search","ready":false,"required":false}]}`)
+ })
+ mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, r *http.Request) {
+ if !admin || r.Header.Get("Authorization") != "Bearer fgw_ok" {
+ writeJSON(t, w, http.StatusUnauthorized,
+ `{"error":{"message":"unauthorized","type":"authentication_error","code":"unauthorized"}}`)
+ return
+ }
+ writeJSON(t, w, 200, `{"status":"healthy",
+ "providers":[{"name":"anthropic","status":"healthy","models":412},
+ {"name":"openai","status":"degraded","models":1104,"message":"rate limited"}],
+ "components":[{"name":"API","status":"healthy"}],
+ "mcp_servers":[{"name":"search","ready":false,"required":false,"last_error":"dial tcp: refused"}],
+ "scopes":["admin"]}`)
+ })
+ mux.HandleFunc("GET /v1/models", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") == "" {
+ writeJSON(t, w, http.StatusUnauthorized, `{"error":{"message":"unauthorized","type":"authentication_error"}}`)
+ return
+ }
+ writeJSON(t, w, 200, `{"object":"list","data":[
+ {"id":"claude-sonnet-4-6","object":"model","created":0,"owned_by":"anthropic","capabilities":["streaming","vision"],"context_window":200000},
+ {"id":"gpt-5.1","object":"model","created":0,"owned_by":"openai"}]}`)
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func TestHealthDecodesAndPath(t *testing.T) {
+ var path string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ path = r.URL.Path
+ writeJSON(t, w, 200, `{"status":"ok","providers":[{"name":"openai","status":"available","circuit":"open","models":7}]}`)
+ }))
+ defer srv.Close()
+
+ h, status, err := testClient(t, srv.URL, "").Health(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != "/health" {
+ t.Fatalf("want GET /health, got %q", path)
+ }
+ if status != 200 || h.Status != "ok" || len(h.Providers) != 1 ||
+ h.Providers[0].Circuit != "open" || h.Providers[0].Models != 7 {
+ t.Fatalf("bad decode: status=%d %+v", status, h)
+ }
+}
+
+func TestHealthTolerates503(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, http.StatusServiceUnavailable, `{"status":"no_providers","providers":[]}`)
+ }))
+ defer srv.Close()
+
+ h, status, err := testClient(t, srv.URL, "").Health(context.Background())
+ if err != nil {
+ t.Fatalf("503 must decode, not error: %v", err)
+ }
+ if status != http.StatusServiceUnavailable || h.Status != "no_providers" {
+ t.Fatalf("bad tolerated 503: status=%d %+v", status, h)
+ }
+}
+
+func TestReadyDecodesNotReady503(t *testing.T) {
+ var path string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ path = r.URL.Path
+ writeJSON(t, w, http.StatusServiceUnavailable, `{"status":"not_ready","reason":"no routable targets"}`)
+ }))
+ defer srv.Close()
+
+ rep, status, err := testClient(t, srv.URL, "").Ready(context.Background())
+ if err != nil {
+ t.Fatalf("503 readyz must decode: %v", err)
+ }
+ if path != "/readyz" {
+ t.Fatalf("want GET /readyz, got %q", path)
+ }
+ if status != http.StatusServiceUnavailable || rep.Status != "not_ready" || rep.Reason != "no routable targets" {
+ t.Fatalf("bad decode: status=%d %+v", status, rep)
+ }
+ // Optional collections stay nil rather than becoming empty non-nil slices.
+ if rep.Targets != nil || rep.MCPServers != nil || rep.Providers != nil {
+ t.Fatalf("absent optional fields must stay nil: %+v", rep)
+ }
+}
+
+func TestReadyDecodesFullBody(t *testing.T) {
+ srv := gatewayStub(t, true)
+ rep, status, err := testClient(t, srv.URL, "").Ready(context.Background())
+ if err != nil || status != 200 {
+ t.Fatalf("status=%d err=%v", status, err)
+ }
+ if len(rep.Targets) != 2 || rep.Targets[0].Name != "anthropic-primary" || !rep.Targets[0].Routable ||
+ rep.Targets[1].Routable {
+ t.Fatalf("targets decode: %+v", rep.Targets)
+ }
+ if len(rep.MCPServers) != 2 || !rep.MCPServers[0].Ready || rep.MCPServers[1].Ready {
+ t.Fatalf("mcp decode: %+v", rep.MCPServers)
+ }
+ if len(rep.Providers) != 1 || rep.Providers[0].Circuit != "closed" {
+ t.Fatalf("providers decode: %+v", rep.Providers)
+ }
+}
+
+func TestAdminHealthDecodesAndPropagates401(t *testing.T) {
+ srv := gatewayStub(t, true)
+
+ ah, err := testClient(t, srv.URL, "fgw_ok").AdminHealth(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ah.Status != "healthy" || len(ah.Providers) != 2 || ah.Providers[1].Message != "rate limited" ||
+ len(ah.Components) != 1 || len(ah.Scopes) != 1 || ah.Scopes[0] != "admin" {
+ t.Fatalf("bad decode: %+v", ah)
+ }
+ if len(ah.MCPServers) != 1 || ah.MCPServers[0].LastError == "" {
+ t.Fatalf("admin mcp servers carry last_error: %+v", ah.MCPServers)
+ }
+
+ _, err = testClient(t, srv.URL, "fgw_bad").AdminHealth(context.Background())
+ var ae *Error
+ if !errors.As(err, &ae) || ae.Status != http.StatusUnauthorized {
+ t.Fatalf("401 must propagate as api.Error: %v", err)
+ }
+}
+
+func TestModelsUnwrapsEnvelope(t *testing.T) {
+ var path string
+ srv := gatewayStub(t, true)
+ c := testClient(t, srv.URL, "fgw_ok")
+ c.hc.Transport = pathRecorder{&path}
+
+ models, err := c.Models(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != "/v1/models" {
+ t.Fatalf("want GET /v1/models, got %q", path)
+ }
+ if len(models) != 2 || models[0].ID != "claude-sonnet-4-6" || models[0].OwnedBy != "anthropic" ||
+ len(models[0].Capabilities) != 2 || models[0].ContextWindow != 200000 {
+ t.Fatalf("bad decode: %+v", models)
+ }
+ // Absent optional fields decode to zero values, not errors.
+ if models[1].ContextWindow != 0 || models[1].Capabilities != nil || models[1].Deprecated {
+ t.Fatalf("optional model fields must be zero: %+v", models[1])
+ }
+}
+
+func TestModelsUnauthorized(t *testing.T) {
+ srv := gatewayStub(t, true)
+ _, err := testClient(t, srv.URL, "").Models(context.Background())
+ if !errors.As(err, new(*Error)) {
+ t.Fatalf("want api.Error, got %v", err)
+ }
+}
+
+// pathRecorder captures the request path without changing transport behavior.
+type pathRecorder struct{ path *string }
+
+func (p pathRecorder) RoundTrip(r *http.Request) (*http.Response, error) {
+ *p.path = r.URL.Path
+ return http.DefaultTransport.RoundTrip(r)
+}
diff --git a/internal/api/keys.go b/internal/api/keys.go
new file mode 100644
index 0000000..0a6e9aa
--- /dev/null
+++ b/internal/api/keys.go
@@ -0,0 +1,134 @@
+package api
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+// Key is one /admin/keys row. Key is masked (head8…tail4) on every read;
+// the full secret appears exactly once, in the CreateKey and RotateKey
+// responses, and the gateway then keeps only a hash of it.
+type Key struct {
+ ID string `json:"id"`
+ Key string `json:"key"`
+ Name string `json:"name"`
+ Scopes []string `json:"scopes"`
+ CreatedAt time.Time `json:"created_at"`
+ RevokedAt *time.Time `json:"revoked_at,omitempty"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
+ RotatedAt *time.Time `json:"rotated_at,omitempty"`
+ LastUsedAt *time.Time `json:"last_used_at,omitempty"`
+ UsageCount int64 `json:"usage_count"`
+ Active bool `json:"active"`
+}
+
+// KeyCreateRequest is the POST /admin/keys body.
+//
+// Scopes is always sent explicitly. A scope-less request is NOT granted admin:
+// the gateway's key store applies defaultScopes and mints a least-privilege
+// read_only key. ferro sends one anyway so the scope a key carries is the
+// operator's stated choice rather than whatever the server's default happens
+// to be in the version it is talking to.
+type KeyCreateRequest struct {
+ Name string `json:"name"`
+ Scopes []string `json:"scopes"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
+}
+
+// The three states KeyState derives, and the value the gateway confirms a
+// revocation with.
+const (
+ KeyStateActive = "active"
+ KeyStateExpired = "expired"
+ KeyStateRevoked = "revoked"
+)
+
+// KeyState derives the state column the gateway does not serve: there is no
+// state or revoked field on the wire, only revoked_at, expires_at and active.
+//
+// Expiry must be read from expires_at, never from active. The gateway clears
+// active in exactly one place — Revoke, which stamps revoked_at in the same
+// write, on both the memory and SQL stores — so a key that has merely expired
+// is still served as active:true with revoked_at:null and a past expires_at.
+// Deriving "expired" from !active therefore never fired for a real expired
+// key, and reported it as active: the one answer that matters here, on a
+// surface an operator reads to decide whether a credential still works.
+// model.KeyIsUsable is the authority and checks all three independently.
+func KeyState(k Key) string {
+ switch {
+ case k.RevokedAt != nil:
+ return KeyStateRevoked
+ case k.ExpiresAt != nil && time.Now().After(*k.ExpiresAt):
+ return KeyStateExpired
+ case !k.Active:
+ // Not reachable from a gateway that only clears active in Revoke, but
+ // if one ever serves it, the key authenticates nothing — and "revoked"
+ // is the administrative reading, where "expired" would name a deadline
+ // that has not passed.
+ return KeyStateRevoked
+ default:
+ return KeyStateActive
+ }
+}
+
+// Keys lists GET /admin/keys, which answers with a bare array — no {"data":…}
+// envelope, unlike every other admin list.
+func (c *Client) Keys(ctx context.Context) ([]Key, error) {
+ var out []Key
+ if _, err := c.do(ctx, http.MethodGet, "/admin/keys", nil, nil, &out, doOpts{}); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// Key reads one key by id.
+func (c *Client) Key(ctx context.Context, id string) (*Key, error) {
+ var out Key
+ if _, err := c.do(ctx, http.MethodGet, keyPath(id), nil, nil, &out, doOpts{}); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// CreateKey creates a key. The returned Key carries the full secret in Key,
+// and this is the only response that ever will.
+func (c *Client) CreateKey(ctx context.Context, req KeyCreateRequest) (*Key, error) {
+ var out Key
+ if _, err := c.do(ctx, http.MethodPost, "/admin/keys", nil, req, &out, doOpts{}); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// RotateKey issues a new secret for an existing key. The previous secret stops
+// authenticating immediately, and the new one is returned once.
+func (c *Client) RotateKey(ctx context.Context, id string) (*Key, error) {
+ var out Key
+ if _, err := c.do(ctx, http.MethodPost, keyPath(id)+"/rotate", nil, nil, &out, doOpts{}); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// RevokeKey revokes a key immediately and irreversibly. The gateway confirms
+// with {"status":"revoked"}; anything else is reported as a failure rather
+// than assumed to have worked.
+func (c *Client) RevokeKey(ctx context.Context, id string) error {
+ var out struct {
+ Status string `json:"status"`
+ }
+ if _, err := c.do(ctx, http.MethodPost, keyPath(id)+"/revoke", nil, nil, &out, doOpts{}); err != nil {
+ return err
+ }
+ if out.Status != KeyStateRevoked {
+ return fmt.Errorf("gateway did not confirm revocation of %s (status %q)", id, out.Status)
+ }
+ return nil
+}
+
+// keyPath escapes the id so a caller-supplied value cannot climb out of
+// /admin/keys via path traversal.
+func keyPath(id string) string { return "/admin/keys/" + url.PathEscape(id) }
diff --git a/internal/api/keys_test.go b/internal/api/keys_test.go
new file mode 100644
index 0000000..54cb055
--- /dev/null
+++ b/internal/api/keys_test.go
@@ -0,0 +1,230 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+// keysStub serves the /admin/keys surface and records the last request body.
+func keysStub(t *testing.T, lastBody *string, lastPath *string) *httptest.Server {
+ t.Helper()
+ record := func(r *http.Request) {
+ *lastPath = r.URL.Path
+ if r.Body == nil {
+ return
+ }
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Errorf("read body: %v", err)
+ }
+ *lastBody = string(b)
+ }
+ mux := http.NewServeMux()
+ // Deliberately a BARE ARRAY — /admin/keys is the one list endpoint with no
+ // {"data":…} envelope.
+ mux.HandleFunc("GET /admin/keys", func(w http.ResponseWriter, r *http.Request) {
+ record(r)
+ writeJSON(t, w, 200, `[
+ {"id":"k1","key":"fgw_head8...il4","name":"ci","scopes":["read_only"],
+ "created_at":"2026-08-01T10:00:00Z","expires_at":"2026-09-01T10:00:00Z",
+ "last_used_at":"2026-08-08T09:00:00Z","usage_count":42,"active":true},
+ {"id":"k2","key":"fgw_dead8...ad4","name":"old","scopes":["admin"],
+ "created_at":"2026-07-01T10:00:00Z","revoked_at":"2026-07-20T10:00:00Z",
+ "usage_count":0,"active":false}]`)
+ })
+ mux.HandleFunc("GET /admin/keys/{id}", func(w http.ResponseWriter, r *http.Request) {
+ record(r)
+ writeJSON(t, w, 200, `{"id":"k1","key":"fgw_head8...il4","name":"ci","scopes":["read_only"],
+ "created_at":"2026-08-01T10:00:00Z","usage_count":42,"active":true}`)
+ })
+ mux.HandleFunc("POST /admin/keys", func(w http.ResponseWriter, r *http.Request) {
+ record(r)
+ writeJSON(t, w, http.StatusCreated, `{"id":"k9","key":"fgw_fullsecret000","name":"ci",
+ "scopes":["read_only"],"created_at":"2026-08-08T10:00:00Z","usage_count":0,"active":true}`)
+ })
+ mux.HandleFunc("POST /admin/keys/{id}/rotate", func(w http.ResponseWriter, r *http.Request) {
+ record(r)
+ writeJSON(t, w, 200, `{"id":"k1","key":"fgw_rotatedsecret","name":"ci","scopes":["read_only"],
+ "created_at":"2026-08-01T10:00:00Z","rotated_at":"2026-08-08T10:00:00Z","usage_count":42,"active":true}`)
+ })
+ mux.HandleFunc("POST /admin/keys/{id}/revoke", func(w http.ResponseWriter, r *http.Request) {
+ record(r)
+ if r.PathValue("id") == "k404" {
+ writeJSON(t, w, http.StatusNotFound, `{"error":{"message":"key not found","type":"not_found_error"}}`)
+ return
+ }
+ if r.PathValue("id") == "kweird" {
+ writeJSON(t, w, 200, `{"status":"queued"}`)
+ return
+ }
+ writeJSON(t, w, 200, `{"status":"revoked"}`)
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func TestKeysDecodesBareArray(t *testing.T) {
+ var body, path string
+ srv := keysStub(t, &body, &path)
+
+ keys, err := testClient(t, srv.URL, "fgw_ok").Keys(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != "/admin/keys" {
+ t.Fatalf("want GET /admin/keys, got %q", path)
+ }
+ if len(keys) != 2 {
+ t.Fatalf("want 2 keys, got %d", len(keys))
+ }
+ k := keys[0]
+ if k.ID != "k1" || k.Name != "ci" || k.UsageCount != 42 || !k.Active ||
+ len(k.Scopes) != 1 || k.Scopes[0] != "read_only" {
+ t.Fatalf("bad decode: %+v", k)
+ }
+ if k.ExpiresAt == nil || !k.ExpiresAt.Equal(time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)) {
+ t.Fatalf("expires_at must decode to a pointer: %+v", k.ExpiresAt)
+ }
+ if k.RevokedAt != nil || k.RotatedAt != nil {
+ t.Fatalf("absent timestamps must stay nil: %+v", k)
+ }
+ if keys[1].RevokedAt == nil {
+ t.Fatal("revoked_at must decode")
+ }
+}
+
+func TestKeyStateIsDerived(t *testing.T) {
+ ts := time.Now()
+ past := ts.Add(-24 * time.Hour)
+ future := ts.Add(24 * time.Hour)
+ cases := []struct {
+ name string
+ key Key
+ want string
+ }{
+ {"active", Key{Active: true}, "active"},
+ {"active with an expiry still ahead", Key{Active: true, ExpiresAt: &future}, "active"},
+ // The shape a real expired key actually has. The gateway clears active
+ // only in Revoke, so an expired-but-not-revoked key is served
+ // active:true with revoked_at:null — this case is the whole reason
+ // KeyState reads expires_at, and it reported "active" until it did.
+ {"expired", Key{Active: true, ExpiresAt: &past}, "expired"},
+ {"revoked", Key{Active: false, RevokedAt: &ts}, "revoked"},
+ // revoked wins even if the gateway still reports the key active.
+ {"revoked beats active", Key{Active: true, RevokedAt: &ts}, "revoked"},
+ // A key can be both; revoked is the more specific answer.
+ {"revoked beats expired", Key{Active: false, RevokedAt: &ts, ExpiresAt: &past}, "revoked"},
+ // Not a shape the gateway serves, but it authenticates nothing, and
+ // "expired" would name a deadline that has not passed.
+ {"inactive without a revocation", Key{Active: false}, "revoked"},
+ }
+ for _, tc := range cases {
+ if got := KeyState(tc.key); got != tc.want {
+ t.Errorf("%s: KeyState = %q, want %q", tc.name, got, tc.want)
+ }
+ }
+}
+
+func TestKeyGetByID(t *testing.T) {
+ var body, path string
+ srv := keysStub(t, &body, &path)
+
+ k, err := testClient(t, srv.URL, "fgw_ok").Key(context.Background(), "k1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != "/admin/keys/k1" {
+ t.Fatalf("want GET /admin/keys/k1, got %q", path)
+ }
+ if k.ID != "k1" {
+ t.Fatalf("bad decode: %+v", k)
+ }
+}
+
+func TestCreateKeyAlwaysSendsScopes(t *testing.T) {
+ var body, path string
+ srv := keysStub(t, &body, &path)
+
+ exp := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)
+ k, err := testClient(t, srv.URL, "fgw_ok").CreateKey(context.Background(),
+ KeyCreateRequest{Name: "ci", Scopes: []string{"read_only"}, ExpiresAt: &exp})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != "/admin/keys" {
+ t.Fatalf("want POST /admin/keys, got %q", path)
+ }
+ var sent map[string]any
+ if err := json.Unmarshal([]byte(body), &sent); err != nil {
+ t.Fatalf("request body is not JSON: %q", body)
+ }
+ scopes, ok := sent["scopes"].([]any)
+ if !ok || len(scopes) == 0 {
+ // The gateway defaults a scope-less request to read_only, not admin.
+ // ferro still always sends one, so the scope is the operator's choice
+ // rather than a server default that can differ between versions.
+ t.Fatalf("scopes must always be sent: %q", body)
+ }
+ if got, _ := sent["expires_at"].(string); got != "2026-09-08T10:00:00Z" {
+ t.Fatalf("expires_at must be RFC3339: %q", body)
+ }
+ if k.Key != "fgw_fullsecret000" {
+ t.Fatalf("create must return the full secret once: %+v", k)
+ }
+}
+
+func TestCreateKeyOmitsAbsentExpiry(t *testing.T) {
+ var body, path string
+ srv := keysStub(t, &body, &path)
+
+ if _, err := testClient(t, srv.URL, "fgw_ok").CreateKey(context.Background(),
+ KeyCreateRequest{Name: "ci", Scopes: []string{"admin"}}); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(body, "expires_at") {
+ t.Fatalf("no expiry means the field is omitted, not null: %q", body)
+ }
+}
+
+func TestRotateKeyReturnsNewSecret(t *testing.T) {
+ var body, path string
+ srv := keysStub(t, &body, &path)
+
+ k, err := testClient(t, srv.URL, "fgw_ok").RotateKey(context.Background(), "k1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != "/admin/keys/k1/rotate" {
+ t.Fatalf("want POST /admin/keys/k1/rotate, got %q", path)
+ }
+ if k.Key != "fgw_rotatedsecret" || k.RotatedAt == nil {
+ t.Fatalf("rotate must return the new secret and rotated_at: %+v", k)
+ }
+}
+
+func TestRevokeKey(t *testing.T) {
+ var body, path string
+ srv := keysStub(t, &body, &path)
+ c := testClient(t, srv.URL, "fgw_ok")
+
+ if err := c.RevokeKey(context.Background(), "k1"); err != nil {
+ t.Fatal(err)
+ }
+ if path != "/admin/keys/k1/revoke" {
+ t.Fatalf("want POST /admin/keys/k1/revoke, got %q", path)
+ }
+ // A 200 that does not say "revoked" is not a revocation.
+ if err := c.RevokeKey(context.Background(), "kweird"); err == nil {
+ t.Fatal("an unexpected status must not be reported as success")
+ }
+ if err := c.RevokeKey(context.Background(), "k404"); err == nil {
+ t.Fatal("a 404 must propagate")
+ }
+}
diff --git a/internal/api/logs.go b/internal/api/logs.go
new file mode 100644
index 0000000..48d62ec
--- /dev/null
+++ b/internal/api/logs.go
@@ -0,0 +1,264 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "slices"
+ "strconv"
+ "time"
+)
+
+// LogEntry is one row of GET /admin/logs. The three measurements are pointers
+// because the gateway sends null for "not measured" — a request that failed
+// before it reached a provider, or one served by a provider with no price in
+// the catalog. Collapsing null into 0 would render as a real zero-cost,
+// zero-latency request.
+type LogEntry struct {
+ TraceID string `json:"trace_id"`
+ Stage string `json:"stage"`
+ Model string `json:"model"`
+ APIKeyID string `json:"api_key_id"`
+ Provider string `json:"provider"`
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ ErrorMessage string `json:"error_message"`
+ CreatedAt time.Time `json:"created_at"`
+ DurationMs *float64 `json:"duration_ms"`
+ TTFTMs *float64 `json:"ttft_ms"`
+ CostUSD *float64 `json:"cost_usd"`
+}
+
+// LogsQuery filters GET /admin/logs. Only non-zero fields are sent: an empty
+// value is not the same as an absent one on every parameter, and sending one
+// can narrow the listing server-side.
+//
+// APIKeyID accepts the gateway's "none" sentinel (rows with no credential),
+// which is passed through untouched.
+type LogsQuery struct {
+ Limit int
+ Offset int
+ Since time.Time
+ Model string
+ Provider string
+ Stage string
+ APIKeyID string
+}
+
+func (q LogsQuery) values() url.Values {
+ v := url.Values{}
+ if q.Limit > 0 {
+ v.Set("limit", strconv.Itoa(q.Limit))
+ }
+ if q.Offset > 0 {
+ v.Set("offset", strconv.Itoa(q.Offset))
+ }
+ if !q.Since.IsZero() {
+ v.Set("since", q.Since.UTC().Format(time.RFC3339))
+ }
+ for k, s := range map[string]string{
+ "model": q.Model, "provider": q.Provider, "stage": q.Stage, "api_key_id": q.APIKeyID,
+ } {
+ if s != "" {
+ v.Set(k, s)
+ }
+ }
+ return v
+}
+
+// LogsPage is one page of GET /admin/logs.
+type LogsPage struct {
+ Data []LogEntry `json:"data"`
+ Summary struct {
+ TotalEntries int `json:"total_entries"`
+ ReturnedEntries int `json:"returned_entries"`
+ } `json:"summary"`
+}
+
+// Percentiles is one latency distribution. The gateway sends null for the
+// whole block when nothing was measured, so callers hold it by pointer.
+type Percentiles struct {
+ P50 float64 `json:"p50"`
+ P95 float64 `json:"p95"`
+ P99 float64 `json:"p99"`
+ Max float64 `json:"max"`
+ Mean float64 `json:"mean"`
+ Count int `json:"count"`
+}
+
+// LogStats is GET /admin/logs/stats. The by_* breakdowns stay raw: their value
+// shape is not part of any contract ferro renders, and decoding them would
+// invent one.
+type LogStats struct {
+ Summary struct {
+ TotalEntries int `json:"total_entries"`
+ ErrorEntries int `json:"error_entries"`
+ TotalTokens int `json:"total_tokens"`
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ CostUSD float64 `json:"cost_usd"`
+ UnpricedRequests int `json:"unpriced_requests"`
+ } `json:"summary"`
+ LatencyMs *Percentiles `json:"latency_ms"`
+ TTFTMs *Percentiles `json:"ttft_ms"`
+ ByProvider map[string]json.RawMessage `json:"by_provider"`
+ ByModel map[string]json.RawMessage `json:"by_model"`
+ TopErrors []struct {
+ Message string `json:"message"`
+ Count int `json:"count"`
+ } `json:"top_errors"`
+}
+
+// Logs reads a page of the request log. A gateway with no log store configured
+// answers 501, for which IsNotSupported reports true.
+func (c *Client) Logs(ctx context.Context, q LogsQuery) (*LogsPage, error) {
+ var out LogsPage
+ if _, err := c.do(ctx, http.MethodGet, "/admin/logs", q.values(), nil, &out, doOpts{}); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// LogStats reads the pre-aggregated request-log statistics: totals, error
+// counts and latency percentiles the CLI would otherwise have to compute from
+// a full page walk. The stats endpoint supports time, model, provider, and
+// stage filters; paging and credential filters are intentionally omitted.
+func (c *Client) LogStats(ctx context.Context, filter LogsQuery) (*LogStats, error) {
+ var out LogStats
+ q := LogsQuery{Since: filter.Since, Model: filter.Model, Provider: filter.Provider, Stage: filter.Stage}.values()
+ if _, err := c.do(ctx, http.MethodGet, "/admin/logs/stats", q, nil, &out, doOpts{}); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// ParseSince reads a --since value: a Go duration ("15m", "2h") meaning that
+// long ago, or an absolute RFC3339 timestamp. A signed duration is read as the
+// same span into the past — a future cursor is never what was meant.
+func ParseSince(s string) (time.Time, error) {
+ if d, err := time.ParseDuration(s); err == nil {
+ if d < 0 {
+ d = -d
+ }
+ return time.Now().Add(-d), nil
+ }
+ t, err := time.Parse(time.RFC3339, s)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("invalid since %q: want a duration (15m, 2h) or an RFC3339 timestamp", s)
+ }
+ return t, nil
+}
+
+// followDedupeWindow bounds the ring of already-emitted row keys.
+//
+// The window is row-count based rather than time based. It covers the maximum
+// bounded page drain so a boundary row cannot be evicted during one poll.
+const (
+ maxFollowPages = 32
+ maxFollowLimit = 200
+ followDedupeWindow = maxFollowPages * maxFollowLimit
+)
+
+// Follower turns the paged /admin/logs listing into a tail: each Poll advances
+// a since-cursor and returns only rows not seen before.
+//
+// It is not safe for concurrent use — one Follower belongs to one tail loop.
+type Follower struct {
+ c *Client
+ q LogsQuery
+ cursor time.Time
+ seen map[string]struct{}
+ order []string // FIFO eviction for the dedupe ring
+}
+
+// NewFollower starts a tail at q.Since (zero means the gateway's own default
+// window).
+func NewFollower(c *Client, q LogsQuery) *Follower {
+ if q.Limit <= 0 {
+ q.Limit = 100
+ } else if q.Limit > maxFollowLimit {
+ q.Limit = maxFollowLimit
+ }
+ return &Follower{c: c, q: q, cursor: q.Since, seen: make(map[string]struct{})}
+}
+
+// dedupeKey identifies a row. A trace has one row per pipeline stage, so the
+// stage is part of the identity; the timestamp separates a retried trace id.
+func dedupeKey(e LogEntry) string {
+ return e.TraceID + "/" + e.Stage + "/" + e.CreatedAt.UTC().Format(time.RFC3339Nano)
+}
+
+// ErrFollowTruncated reports that one poll drained maxFollowPages without
+// reaching the end of its window. The rows returned alongside it are complete
+// and the cursor has advanced past them, so the next poll resumes instead of
+// re-draining the same window: a sustained burst slows the tail down, it never
+// stops it. What is lost is the older end of that one window — the gateway
+// lists newest first, so the pages the bound cut off are the oldest ones.
+var ErrFollowTruncated = fmt.Errorf(
+ "request-log poll exceeded the %d-page safety bound; older rows in this window were skipped — narrow the filters or use a shorter interval",
+ maxFollowPages)
+
+// Poll fetches rows since the cursor and returns the unseen ones in ascending
+// created_at order.
+//
+// On a fetch error the cursor is left untouched, so a transient failure
+// re-fetches the same window rather than skipping the rows that landed during
+// it. The cursor is also second-granular while created_at is not, so the
+// boundary row is re-fetched by design and dropped by the dedupe ring — the
+// safe direction.
+//
+// ErrFollowTruncated is the one error returned with rows: they are valid and
+// the cursor moved, so a caller that renders them loses nothing.
+func (f *Follower) Poll(ctx context.Context) ([]LogEntry, error) {
+ q := f.q
+ q.Since = f.cursor
+ rows := make([]LogEntry, 0, q.Limit)
+ baseOffset := q.Offset
+ drained := false
+ for pageNo := 0; pageNo < maxFollowPages; pageNo++ {
+ q.Offset = baseOffset + pageNo*q.Limit
+ page, err := f.c.Logs(ctx, q)
+ if err != nil {
+ return nil, err
+ }
+ rows = append(rows, page.Data...)
+ // A short page is the end-of-window signal every gateway gives. The
+ // total is only the second one when the gateway actually sent it: an
+ // absent summary.total_entries decodes as 0, which read as "the window
+ // ended here" on the first FULL page and capped the tail at one page —
+ // silently, because drained was set and ErrFollowTruncated never fired.
+ // With no usable total the drain runs to the page bound instead, and
+ // the stated loss that produces beats a silent one.
+ if total := page.Summary.TotalEntries; len(page.Data) < q.Limit ||
+ (total > 0 && q.Offset+len(page.Data) >= total) {
+ drained = true
+ break
+ }
+ }
+ slices.SortStableFunc(rows, func(a, b LogEntry) int { return a.CreatedAt.Compare(b.CreatedAt) })
+
+ fresh := rows[:0]
+ for _, e := range rows {
+ k := dedupeKey(e)
+ if _, dup := f.seen[k]; dup {
+ continue
+ }
+ f.seen[k] = struct{}{}
+ f.order = append(f.order, k)
+ if len(f.order) > followDedupeWindow {
+ delete(f.seen, f.order[0])
+ f.order = f.order[1:]
+ }
+ fresh = append(fresh, e)
+ if e.CreatedAt.After(f.cursor) {
+ f.cursor = e.CreatedAt
+ }
+ }
+ if !drained {
+ return fresh, ErrFollowTruncated
+ }
+ return fresh, nil
+}
diff --git a/internal/api/logs_test.go b/internal/api/logs_test.go
new file mode 100644
index 0000000..60d62ae
--- /dev/null
+++ b/internal/api/logs_test.go
@@ -0,0 +1,537 @@
+package api
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestLogsDecodesNullMeasurements(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"data":[
+ {"trace_id":"t1","stage":"after_request","model":"gpt-5.1","api_key_id":"k1","provider":"openai",
+ "prompt_tokens":10,"completion_tokens":20,"total_tokens":30,"error_message":"",
+ "created_at":"2026-08-08T10:00:01Z","duration_ms":812.5,"ttft_ms":210.25,"cost_usd":0.0042},
+ {"trace_id":"t2","stage":"on_error","model":"gpt-5.1","api_key_id":"","provider":"",
+ "prompt_tokens":0,"completion_tokens":0,"total_tokens":0,"error_message":"upstream unavailable",
+ "created_at":"2026-08-08T10:00:02Z","duration_ms":null,"ttft_ms":null,"cost_usd":null}],
+ "summary":{"total_entries":2,"returned_entries":2}}`)
+ }))
+ defer srv.Close()
+
+ page, err := testClient(t, srv.URL, "fgw_ok").Logs(context.Background(), LogsQuery{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if page.Summary.TotalEntries != 2 || page.Summary.ReturnedEntries != 2 || len(page.Data) != 2 {
+ t.Fatalf("bad page: %+v", page)
+ }
+ a := page.Data[0]
+ if a.DurationMs == nil || *a.DurationMs != 812.5 || a.TTFTMs == nil || a.CostUSD == nil || *a.CostUSD != 0.0042 {
+ t.Fatalf("measured row lost its values: %+v", a)
+ }
+ if a.PromptTokens != 10 || a.TotalTokens != 30 || a.Provider != "openai" {
+ t.Fatalf("bad decode: %+v", a)
+ }
+ b := page.Data[1]
+ // null is "not measured" — it must NOT collapse into 0, which would render
+ // as a real zero-cost, zero-latency request.
+ if b.DurationMs != nil || b.TTFTMs != nil || b.CostUSD != nil {
+ t.Fatalf("null measurements must decode to nil: %+v", b)
+ }
+ if b.ErrorMessage != "upstream unavailable" {
+ t.Fatalf("bad decode: %+v", b)
+ }
+}
+
+func TestLogsQueryEncoding(t *testing.T) {
+ var got url.Values
+ var path string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ got, path = r.URL.Query(), r.URL.Path
+ writeJSON(t, w, 200, `{"data":[],"summary":{"total_entries":0,"returned_entries":0}}`)
+ }))
+ defer srv.Close()
+ c := testClient(t, srv.URL, "fgw_ok")
+
+ since := time.Date(2026, 8, 8, 10, 0, 0, 0, time.UTC)
+ if _, err := c.Logs(context.Background(), LogsQuery{
+ Limit: 50, Offset: 100, Since: since,
+ Model: "gpt-5.1", Provider: "openai", Stage: "all", APIKeyID: "none",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if path != "/admin/logs" {
+ t.Fatalf("want GET /admin/logs, got %q", path)
+ }
+ want := url.Values{
+ "limit": {"50"}, "offset": {"100"}, "since": {"2026-08-08T10:00:00Z"},
+ "model": {"gpt-5.1"}, "provider": {"openai"}, "stage": {"all"},
+ // "none" is the gateway's sentinel for credential-less rows: passed
+ // through untouched, never interpreted.
+ "api_key_id": {"none"},
+ }
+ if fmt.Sprint(got) != fmt.Sprint(want) {
+ t.Fatalf("query = %v, want %v", got, want)
+ }
+
+ // A zero query sends no filters at all — an empty value would narrow the
+ // listing server-side on some parameters.
+ if _, err := c.Logs(context.Background(), LogsQuery{}); err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 0 {
+ t.Fatalf("zero query must send no parameters, got %v", got)
+ }
+}
+
+func TestLogs501IsNotSupported(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, http.StatusNotImplemented,
+ `{"error":{"message":"request log store not configured","type":"server_error"}}`)
+ }))
+ defer srv.Close()
+
+ _, err := testClient(t, srv.URL, "fgw_ok").Logs(context.Background(), LogsQuery{})
+ if err == nil || !IsNotSupported(err) {
+ t.Fatalf("a 501 must be detectable as unsupported, got %v", err)
+ }
+}
+
+func TestLogStatsDecodes(t *testing.T) {
+ var got url.Values
+ var path string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ got, path = r.URL.Query(), r.URL.Path
+ writeJSON(t, w, 200, `{
+ "summary":{"total_entries":1200,"error_entries":13,"total_tokens":90000,"prompt_tokens":60000,
+ "completion_tokens":30000,"cost_usd":1.2345,"unpriced_requests":4},
+ "latency_ms":{"p50":210,"p95":980.5,"p99":1400,"max":2200,"mean":320.25,"count":1200},
+ "ttft_ms":null,
+ "by_stage":{"after_request":{"count":1187}},
+ "by_provider":{"openai":{"count":900}},
+ "by_model":{"gpt-5.1":{"count":900}},
+ "top_errors":[{"message":"upstream unavailable","count":9}],
+ "series":{}}`)
+ }))
+ defer srv.Close()
+
+ since := time.Date(2026, 8, 8, 9, 55, 0, 0, time.UTC)
+ st, err := testClient(t, srv.URL, "fgw_ok").LogStats(context.Background(), LogsQuery{
+ Since: since, Model: "gpt-5.1", Provider: "openai", Stage: "on_error", APIKeyID: "key_ignored",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != "/admin/logs/stats" {
+ t.Fatalf("want GET /admin/logs/stats, got %q", path)
+ }
+ if got.Get("since") != "2026-08-08T09:55:00Z" || got.Get("model") != "gpt-5.1" ||
+ got.Get("provider") != "openai" || got.Get("stage") != "on_error" || got.Has("api_key_id") {
+ t.Fatalf("bad query: %v", got)
+ }
+ if st.Summary.TotalEntries != 1200 || st.Summary.ErrorEntries != 13 || st.Summary.CostUSD != 1.2345 ||
+ st.Summary.UnpricedRequests != 4 {
+ t.Fatalf("bad summary: %+v", st.Summary)
+ }
+ if st.LatencyMs == nil || st.LatencyMs.P95 != 980.5 || st.LatencyMs.Count != 1200 {
+ t.Fatalf("latency percentiles: %+v", st.LatencyMs)
+ }
+ // A null percentile block means "nothing measured" and must stay nil.
+ if st.TTFTMs != nil {
+ t.Fatalf("null ttft_ms must decode to nil, got %+v", st.TTFTMs)
+ }
+ if len(st.TopErrors) != 1 || st.TopErrors[0].Count != 9 {
+ t.Fatalf("top errors: %+v", st.TopErrors)
+ }
+ if string(st.ByProvider["openai"]) != `{"count":900}` || len(st.ByModel) != 1 {
+ t.Fatalf("breakdowns stay raw: %v", st.ByProvider)
+ }
+}
+
+func TestParseSince(t *testing.T) {
+ now := time.Now()
+ got, err := ParseSince("15m")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d := now.Add(-15 * time.Minute).Sub(got); d > time.Second || d < -time.Second {
+ t.Fatalf("15m must mean 15 minutes ago, got %v (off by %v)", got, d)
+ }
+ // A signed duration means the same thing — nobody wants a future cursor.
+ // Bound the difference on both sides: an upper bound alone passes a
+ // timestamp two hours in the future, which is the bug being guarded.
+ got, err = ParseSince("-2h")
+ if err != nil {
+ t.Fatalf("-2h must parse: %v", err)
+ }
+ if d := now.Add(-2 * time.Hour).Sub(got); d > time.Second || d < -time.Second {
+ t.Fatalf("-2h must also mean two hours ago, got %v (off by %v)", got, d)
+ }
+ want := time.Date(2026, 8, 8, 10, 0, 0, 0, time.UTC)
+ if got, err = ParseSince("2026-08-08T10:00:00Z"); err != nil || !got.Equal(want) {
+ t.Fatalf("RFC3339 must parse: %v %v", got, err)
+ }
+ if _, err = ParseSince("yesterday"); err == nil {
+ t.Fatal("an unparseable value must be an error, not a silent zero time")
+ }
+ if _, err = ParseSince(""); err == nil {
+ t.Fatal("empty must be an error")
+ }
+}
+
+// followStub serves pages keyed by the `since` parameter and records every
+// since value it was asked for.
+type followStub struct {
+ mu sync.Mutex
+ since []string
+ pages []string // body per request index
+ statuses []int // 0 means 200
+}
+
+func (s *followStub) handler(t *testing.T) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ s.mu.Lock()
+ i := len(s.since)
+ s.since = append(s.since, r.URL.Query().Get("since"))
+ body, status := "", 0
+ if i < len(s.pages) {
+ body = s.pages[i]
+ }
+ if i < len(s.statuses) {
+ status = s.statuses[i]
+ }
+ s.mu.Unlock()
+ if status != 0 && status != 200 {
+ writeJSON(t, w, status, `{"error":{"message":"boom","type":"server_error"}}`)
+ return
+ }
+ writeJSON(t, w, 200, body)
+ }
+}
+
+func (s *followStub) sinceAt(i int) string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if i >= len(s.since) {
+ return ""
+ }
+ return s.since[i]
+}
+
+func logRow(trace, stage, created string) string {
+ return fmt.Sprintf(`{"trace_id":%q,"stage":%q,"model":"m","api_key_id":"","provider":"openai",
+ "prompt_tokens":0,"completion_tokens":0,"total_tokens":0,"error_message":"",
+ "created_at":%q,"duration_ms":null,"ttft_ms":null,"cost_usd":null}`, trace, stage, created)
+}
+
+func logPage(rows ...string) string {
+ return fmt.Sprintf(`{"data":[%s],"summary":{"total_entries":%d,"returned_entries":%d}}`,
+ strings.Join(rows, ","), len(rows), len(rows))
+}
+
+func TestFollowerCursorAndDedupe(t *testing.T) {
+ a, b, cc := "2026-08-08T10:00:01Z", "2026-08-08T10:00:02Z", "2026-08-08T10:00:03Z"
+ stub := &followStub{pages: []string{
+ // The gateway serves newest first; the follower must not.
+ logPage(logRow("B", "after_request", b), logRow("A", "after_request", a)),
+ // since >= B: the boundary row comes back, plus the new one.
+ logPage(logRow("C", "after_request", cc), logRow("B", "after_request", b)),
+ }}
+ srv := httptest.NewServer(stub.handler(t))
+ defer srv.Close()
+
+ f := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{})
+ rows, err := f.Poll(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != 2 || rows[0].TraceID != "A" || rows[1].TraceID != "B" {
+ t.Fatalf("first poll must be ascending [A B], got %v", traceIDs(rows))
+ }
+ if s := stub.sinceAt(0); s != "" {
+ t.Fatalf("first poll has no cursor yet, sent since=%q", s)
+ }
+
+ rows, err = f.Poll(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != 1 || rows[0].TraceID != "C" {
+ t.Fatalf("B must be deduped, want [C], got %v", traceIDs(rows))
+ }
+ if s := stub.sinceAt(1); s != b {
+ t.Fatalf("cursor must advance to the newest row, sent since=%q want %q", s, b)
+ }
+}
+
+func TestFollowerDrainsEveryPageBeforeAdvancingCursor(t *testing.T) {
+ created := []string{
+ "2026-08-08T10:00:04Z", "2026-08-08T10:00:03Z",
+ "2026-08-08T10:00:02Z", "2026-08-08T10:00:01Z",
+ }
+ var mu sync.Mutex
+ var offsets []int
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
+ limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
+ mu.Lock()
+ offsets = append(offsets, offset)
+ mu.Unlock()
+ end := min(offset+limit, len(created))
+ rows := make([]string, 0, end-offset)
+ for i := offset; i < end; i++ {
+ rows = append(rows, logRow(fmt.Sprintf("T%d", i), "after_request", created[i]))
+ }
+ writeJSON(t, w, http.StatusOK, fmt.Sprintf(
+ `{"data":[%s],"summary":{"total_entries":%d,"returned_entries":%d}}`,
+ strings.Join(rows, ","), len(created), len(rows)))
+ }))
+ defer srv.Close()
+
+ got, err := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{Limit: 2}).Poll(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 4 {
+ t.Fatalf("all pages must be returned oldest first: %v", traceIDs(got))
+ }
+ for i := 1; i < len(got); i++ {
+ if got[i].CreatedAt.Before(got[i-1].CreatedAt) {
+ t.Fatalf("all pages must be returned oldest first: %v", traceIDs(got))
+ }
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if fmt.Sprint(offsets) != "[0 2]" {
+ t.Fatalf("page offsets = %v, want [0 2]", offsets)
+ }
+}
+
+func TestFollowerTruncatedPollKeepsRowsAndAdvances(t *testing.T) {
+ base := time.Date(2026, 8, 8, 10, 10, 0, 0, time.UTC)
+ var mu sync.Mutex
+ var sinces []string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ sinces = append(sinces, r.URL.Query().Get("since"))
+ mu.Unlock()
+ offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
+ // Newest first, and a total the drain can never reach: the sustained
+ // burst that used to stall the tail forever.
+ row := logRow(fmt.Sprintf("T%d", offset), "after_request",
+ base.Add(-time.Duration(offset)*time.Second).Format(time.RFC3339))
+ writeJSON(t, w, http.StatusOK,
+ `{"data":[`+row+`],"summary":{"total_entries":100000,"returned_entries":1}}`)
+ }))
+ defer srv.Close()
+
+ f := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{Limit: 1})
+ rows, err := f.Poll(context.Background())
+ if !errors.Is(err, ErrFollowTruncated) {
+ t.Fatalf("a poll that hits the page bound must say so: %v", err)
+ }
+ if len(rows) != maxFollowPages {
+ t.Fatalf("rows already fetched must be kept, got %d want %d", len(rows), maxFollowPages)
+ }
+ if _, err := f.Poll(context.Background()); !errors.Is(err, ErrFollowTruncated) {
+ t.Fatalf("the second poll is still truncated: %v", err)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ // The advancing cursor is the whole fix: discarding it made every poll
+ // re-drain the same window, so the operator never saw a row.
+ if first, second := sinces[0], sinces[maxFollowPages]; first != "" || second != base.Format(time.RFC3339) {
+ t.Fatalf("cursor must advance past the rows returned: poll1 since=%q poll2 since=%q", first, second)
+ }
+}
+
+// Not every gateway populates summary.total_entries. An absent one decodes as
+// 0, which used to satisfy the end-of-window test on the very first FULL page:
+// the tail stopped after one page and ErrFollowTruncated — the only mechanism
+// that tells the operator rows were skipped — never fired. A tail that cannot
+// see the end of its window must drain to the bound and then SAY so.
+func TestFollowerWithoutTotalEntriesPagesOnAndReportsTruncation(t *testing.T) {
+ base := time.Date(2026, 8, 8, 10, 20, 0, 0, time.UTC)
+ var mu sync.Mutex
+ requests := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ requests++
+ mu.Unlock()
+ offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
+ // Newest first, one full page every time, and no total_entries at all.
+ row := logRow(fmt.Sprintf("T%d", offset), "after_request",
+ base.Add(-time.Duration(offset)*time.Second).Format(time.RFC3339))
+ writeJSON(t, w, http.StatusOK, `{"data":[`+row+`],"summary":{"returned_entries":1}}`)
+ }))
+ defer srv.Close()
+
+ rows, err := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{Limit: 1}).Poll(context.Background())
+ if !errors.Is(err, ErrFollowTruncated) {
+ t.Fatalf("a drain that never reached the end of its window must state the loss, got %v", err)
+ }
+ if len(rows) != maxFollowPages {
+ t.Fatalf("full pages must keep paging to the bound, got %d rows want %d", len(rows), maxFollowPages)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if requests != maxFollowPages {
+ t.Fatalf("the tail stopped after %d pages, want %d", requests, maxFollowPages)
+ }
+}
+
+// The other half of that branch: a SHORT page still ends the drain on a gateway
+// that sends no total, so the fix cannot have turned every quiet poll into a
+// 32-page walk.
+func TestFollowerWithoutTotalEntriesStopsOnAShortPage(t *testing.T) {
+ row := logRow("A", "after_request", "2026-08-08T10:00:01Z")
+ stub := &followStub{pages: []string{`{"data":[` + row + `],"summary":{"returned_entries":1}}`}}
+ srv := httptest.NewServer(stub.handler(t))
+ defer srv.Close()
+
+ rows, err := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{}).Poll(context.Background())
+ if err != nil {
+ t.Fatalf("a short page is the end of the window whatever the summary says: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("want the one row, got %v", traceIDs(rows))
+ }
+}
+
+func TestFollowerErrorKeepsCursor(t *testing.T) {
+ a, b, cc := "2026-08-08T10:00:01Z", "2026-08-08T10:00:02Z", "2026-08-08T10:00:03Z"
+ stub := &followStub{
+ pages: []string{
+ logPage(logRow("A", "after_request", a), logRow("B", "after_request", b)),
+ "",
+ logPage(logRow("C", "after_request", cc)),
+ },
+ statuses: []int{200, http.StatusInternalServerError, 200},
+ }
+ srv := httptest.NewServer(stub.handler(t))
+ defer srv.Close()
+
+ f := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{})
+ if _, err := f.Poll(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.Poll(context.Background()); err == nil {
+ t.Fatal("a 500 must be reported")
+ }
+ rows, err := f.Poll(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != 1 || rows[0].TraceID != "C" {
+ t.Fatalf("want [C] after recovery, got %v", traceIDs(rows))
+ }
+ // The cursor is what guarantees no row is skipped after a transient
+ // failure: it must be unchanged by the failed poll.
+ if s2, s3 := stub.sinceAt(1), stub.sinceAt(2); s2 != b || s3 != b {
+ t.Fatalf("cursor must survive the failure: poll2 since=%q poll3 since=%q want %q", s2, s3, b)
+ }
+}
+
+func TestFollowerSameStageDifferentTraceNotDeduped(t *testing.T) {
+ ts := "2026-08-08T10:00:01Z"
+ stub := &followStub{pages: []string{
+ logPage(logRow("A", "after_request", ts), logRow("A", "on_error", ts), logRow("B", "after_request", ts)),
+ }}
+ srv := httptest.NewServer(stub.handler(t))
+ defer srv.Close()
+
+ rows, err := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{}).Poll(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Dedupe is trace+stage+time: one trace's two stages are two rows.
+ if len(rows) != 3 {
+ t.Fatalf("want 3 distinct rows, got %d: %v", len(rows), traceIDs(rows))
+ }
+}
+
+func TestFollowerDedupeRingStaysBounded(t *testing.T) {
+ base := time.Date(2026, 8, 8, 10, 0, 0, 0, time.UTC)
+ initial := make([]string, 0, followDedupeWindow)
+ for i := range followDedupeWindow {
+ initial = append(initial, logRow(fmt.Sprintf("t%d", i), "after_request",
+ base.Add(time.Duration(i)*time.Second).UTC().Format(time.RFC3339)))
+ }
+ const freshRows = 100
+ next := make([]string, 0, freshRows+1)
+ next = append(next, initial[len(initial)-1]) // inclusive cursor boundary
+ for i := range freshRows {
+ next = append(next, logRow(fmt.Sprintf("new%d", i), "after_request",
+ base.Add(time.Duration(followDedupeWindow+i)*time.Second).UTC().Format(time.RFC3339)))
+ }
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ rows := initial
+ if r.URL.Query().Get("since") != "" {
+ rows = next
+ }
+ offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
+ limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
+ end := min(offset+limit, len(rows))
+ pageRows := rows[offset:end]
+ writeJSON(t, w, http.StatusOK, fmt.Sprintf(
+ `{"data":[%s],"summary":{"total_entries":%d,"returned_entries":%d}}`,
+ strings.Join(pageRows, ","), len(rows), len(pageRows)))
+ }))
+ defer srv.Close()
+
+ f := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{Limit: 1000})
+ got, err := f.Poll(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != followDedupeWindow {
+ t.Fatalf("first poll = %d rows, want %d", len(got), followDedupeWindow)
+ }
+ got, err = f.Poll(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 100 {
+ t.Fatalf("boundary row must dedupe while 100 new rows pass, got %d", len(got))
+ }
+ // A long tail session must not grow a set of every row it ever saw.
+ if len(f.seen) > followDedupeWindow || len(f.order) > followDedupeWindow {
+ t.Fatalf("dedupe ring unbounded: seen=%d order=%d", len(f.seen), len(f.order))
+ }
+}
+
+func TestNewFollowerNormalizesNegativeLimit(t *testing.T) {
+ f := NewFollower(nil, LogsQuery{Limit: -1})
+ if f.q.Limit != 100 {
+ t.Fatalf("negative limit must use the safe default, got %d", f.q.Limit)
+ }
+}
+
+func TestFollowerPropagates501(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, http.StatusNotImplemented, `{"error":{"message":"no log store","type":"server_error"}}`)
+ }))
+ defer srv.Close()
+
+ _, err := NewFollower(testClient(t, srv.URL, "fgw_ok"), LogsQuery{}).Poll(context.Background())
+ if !IsNotSupported(err) {
+ t.Fatalf("the tail must be able to degrade one screen on 501: %v", err)
+ }
+}
+
+func traceIDs(rows []LogEntry) []string {
+ out := make([]string, 0, len(rows))
+ for _, r := range rows {
+ out = append(out, r.TraceID+"/"+r.Stage)
+ }
+ return out
+}
diff --git a/internal/api/services.go b/internal/api/services.go
new file mode 100644
index 0000000..a0ab1bb
--- /dev/null
+++ b/internal/api/services.go
@@ -0,0 +1,135 @@
+package api
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+)
+
+// Session is one dashboard session. There is no device or IP field on the
+// wire — Subject is the credential's name.
+type Session struct {
+ ID string `json:"id"`
+ CredentialID string `json:"credential_id"`
+ Subject string `json:"subject"`
+ Scopes []string `json:"scopes"`
+ CreatedAt time.Time `json:"created_at"`
+ LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+// AuditEntry is one row of the audit trail.
+type AuditEntry struct {
+ OccurredAt time.Time `json:"occurred_at"`
+ Action string `json:"action"`
+ Actor string `json:"actor"`
+ ActorID string `json:"actor_id,omitempty"`
+ TargetID string `json:"target_id,omitempty"`
+ Outcome string `json:"outcome"` // ok|denied|error
+ Detail string `json:"detail,omitempty"`
+ SourceIP string `json:"source_ip,omitempty"`
+ TraceID string `json:"trace_id,omitempty"`
+}
+
+// AuditQuery filters GET /admin/audit. Only non-zero fields are sent —
+// Outcome in particular must be ok|denied|error or the gateway answers 400,
+// so an empty one is omitted rather than sent blank.
+type AuditQuery struct {
+ Action string
+ ActorID string
+ Outcome string
+ Since time.Time
+ Limit int
+ Offset int
+}
+
+func (q AuditQuery) values() url.Values {
+ v := url.Values{}
+ for k, s := range map[string]string{"action": q.Action, "actor_id": q.ActorID, "outcome": q.Outcome} {
+ if s != "" {
+ v.Set(k, s)
+ }
+ }
+ if !q.Since.IsZero() {
+ v.Set("since", q.Since.UTC().Format(time.RFC3339))
+ }
+ if q.Limit > 0 {
+ v.Set("limit", strconv.Itoa(q.Limit))
+ }
+ if q.Offset > 0 {
+ v.Set("offset", strconv.Itoa(q.Offset))
+ }
+ return v
+}
+
+// AuditPage is one page of GET /admin/audit.
+type AuditPage struct {
+ Data []AuditEntry `json:"data"`
+ Summary struct {
+ TotalEntries int `json:"total_entries"`
+ ReturnedEntries int `json:"returned_entries"`
+ } `json:"summary"`
+}
+
+// PluginInfo is a plugin this gateway has configured.
+type PluginInfo struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Enabled bool `json:"enabled"`
+}
+
+// BuiltinPlugin is a plugin this gateway build ships. FailsOpen lives here
+// only, which is why the plugins table merges the two listings by name.
+type BuiltinPlugin struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Summary string `json:"summary"`
+ Settings []string `json:"settings"`
+ FailsOpen bool `json:"fails_open"`
+}
+
+// Sessions lists active dashboard sessions, unwrapping the {"data":…}
+// envelope. A gateway with sessions disabled answers 501, for which
+// IsNotSupported reports true.
+func (c *Client) Sessions(ctx context.Context) ([]Session, error) {
+ var out struct {
+ Data []Session `json:"data"`
+ }
+ if _, err := c.do(ctx, http.MethodGet, "/admin/sessions", nil, nil, &out, doOpts{}); err != nil {
+ return nil, err
+ }
+ return out.Data, nil
+}
+
+// Audit reads a page of the audit trail.
+func (c *Client) Audit(ctx context.Context, q AuditQuery) (*AuditPage, error) {
+ var out AuditPage
+ if _, err := c.do(ctx, http.MethodGet, "/admin/audit", q.values(), nil, &out, doOpts{}); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// Plugins lists the configured plugins. Like /admin/keys and /admin/providers,
+// this one answers with a bare array.
+func (c *Client) Plugins(ctx context.Context) ([]PluginInfo, error) {
+ var out []PluginInfo
+ if _, err := c.do(ctx, http.MethodGet, "/admin/plugins", nil, nil, &out, doOpts{}); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// PluginCatalog lists the plugins this gateway build ships, unwrapping the
+// {"data":…} envelope.
+func (c *Client) PluginCatalog(ctx context.Context) ([]BuiltinPlugin, error) {
+ var out struct {
+ Data []BuiltinPlugin `json:"data"`
+ }
+ if _, err := c.do(ctx, http.MethodGet, "/admin/plugins/catalog", nil, nil, &out, doOpts{}); err != nil {
+ return nil, err
+ }
+ return out.Data, nil
+}
diff --git a/internal/api/services_test.go b/internal/api/services_test.go
new file mode 100644
index 0000000..fae40d5
--- /dev/null
+++ b/internal/api/services_test.go
@@ -0,0 +1,190 @@
+package api
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+)
+
+// servicesStub serves the sessions/audit/plugins surface and records the last
+// path and query it was asked for.
+func servicesStub(t *testing.T, path *string, query *url.Values) *httptest.Server {
+ t.Helper()
+ record := func(r *http.Request) {
+ *path = r.URL.Path
+ q := r.URL.Query()
+ *query = q
+ }
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /admin/sessions", func(w http.ResponseWriter, r *http.Request) {
+ record(r)
+ writeJSON(t, w, 200, `{"data":[
+ {"id":"s1","credential_id":"k1","subject":"ci","scopes":["read_only"],
+ "created_at":"2026-08-08T09:00:00Z","last_seen_at":"2026-08-08T09:30:00Z",
+ "expires_at":"2026-08-09T09:00:00Z"},
+ {"id":"s2","credential_id":"k2","subject":"ops","scopes":["admin"],
+ "created_at":"2026-08-08T08:00:00Z","expires_at":"2026-08-09T08:00:00Z"}]}`)
+ })
+ mux.HandleFunc("GET /admin/audit", func(w http.ResponseWriter, r *http.Request) {
+ record(r)
+ writeJSON(t, w, 200, `{"data":[
+ {"occurred_at":"2026-08-08T09:00:00Z","action":"key.create","actor":"ops","actor_id":"k2",
+ "target_id":"k9","outcome":"ok","detail":"scopes=read_only","source_ip":"10.0.0.1","trace_id":"t1"},
+ {"occurred_at":"2026-08-08T09:01:00Z","action":"session.create","actor":"unknown",
+ "outcome":"denied"}],
+ "summary":{"total_entries":2,"returned_entries":2}}`)
+ })
+ // A BARE ARRAY, unlike /admin/plugins/catalog one path segment down.
+ mux.HandleFunc("GET /admin/plugins", func(w http.ResponseWriter, r *http.Request) {
+ record(r)
+ writeJSON(t, w, 200, `[{"name":"request-logger","type":"logging","enabled":true},
+ {"name":"word-filter","type":"guardrail","enabled":false}]`)
+ })
+ mux.HandleFunc("GET /admin/plugins/catalog", func(w http.ResponseWriter, r *http.Request) {
+ record(r)
+ writeJSON(t, w, 200, `{"data":[
+ {"name":"request-logger","type":"logging","summary":"Logs requests","settings":["persist"],"fails_open":true},
+ {"name":"word-filter","type":"guardrail","summary":"Blocks words","settings":["blocked_words"],"fails_open":false}]}`)
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func TestSessionsUnwrapsDataEnvelope(t *testing.T) {
+ var path string
+ var query url.Values
+ srv := servicesStub(t, &path, &query)
+
+ sessions, err := testClient(t, srv.URL, "fgw_ok").Sessions(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != "/admin/sessions" {
+ t.Fatalf("want GET /admin/sessions, got %q", path)
+ }
+ if len(sessions) != 2 {
+ t.Fatalf("want 2 sessions, got %d", len(sessions))
+ }
+ s := sessions[0]
+ if s.ID != "s1" || s.CredentialID != "k1" || s.Subject != "ci" || len(s.Scopes) != 1 {
+ t.Fatalf("bad decode: %+v", s)
+ }
+ if s.LastSeenAt == nil || !s.LastSeenAt.Equal(time.Date(2026, 8, 8, 9, 30, 0, 0, time.UTC)) {
+ t.Fatalf("last_seen_at must decode: %+v", s.LastSeenAt)
+ }
+ if !s.ExpiresAt.Equal(time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC)) {
+ t.Fatalf("expires_at must decode: %+v", s.ExpiresAt)
+ }
+ // A session that has never been used has no last_seen_at at all.
+ if sessions[1].LastSeenAt != nil {
+ t.Fatalf("absent last_seen_at must stay nil: %+v", sessions[1])
+ }
+}
+
+func TestSessions501IsNotSupported(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, http.StatusNotImplemented,
+ `{"error":{"message":"sessions are disabled","type":"server_error"}}`)
+ }))
+ defer srv.Close()
+
+ _, err := testClient(t, srv.URL, "fgw_ok").Sessions(context.Background())
+ if err == nil || !IsNotSupported(err) {
+ t.Fatalf("sessions disabled must degrade one screen, got %v", err)
+ }
+}
+
+func TestAuditDecodesAndEncodesQuery(t *testing.T) {
+ var path string
+ var query url.Values
+ srv := servicesStub(t, &path, &query)
+ c := testClient(t, srv.URL, "fgw_ok")
+
+ since := time.Date(2026, 8, 8, 0, 0, 0, 0, time.UTC)
+ page, err := c.Audit(context.Background(), AuditQuery{
+ Action: "key.create", ActorID: "k2", Outcome: "ok", Since: since, Limit: 50, Offset: 10,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != "/admin/audit" {
+ t.Fatalf("want GET /admin/audit, got %q", path)
+ }
+ want := url.Values{
+ "action": {"key.create"}, "actor_id": {"k2"}, "outcome": {"ok"},
+ "since": {"2026-08-08T00:00:00Z"}, "limit": {"50"}, "offset": {"10"},
+ }
+ if fmt.Sprint(query) != fmt.Sprint(want) {
+ t.Fatalf("query = %v, want %v", query, want)
+ }
+ if page.Summary.TotalEntries != 2 || len(page.Data) != 2 {
+ t.Fatalf("bad page: %+v", page)
+ }
+ e := page.Data[0]
+ if e.Action != "key.create" || e.Actor != "ops" || e.ActorID != "k2" || e.TargetID != "k9" ||
+ e.Outcome != "ok" || e.Detail == "" || e.SourceIP != "10.0.0.1" || e.TraceID != "t1" {
+ t.Fatalf("bad decode: %+v", e)
+ }
+ if !e.OccurredAt.Equal(time.Date(2026, 8, 8, 9, 0, 0, 0, time.UTC)) {
+ t.Fatalf("occurred_at: %v", e.OccurredAt)
+ }
+ if page.Data[1].ActorID != "" || page.Data[1].Outcome != "denied" {
+ t.Fatalf("optional fields: %+v", page.Data[1])
+ }
+
+ // A zero query sends nothing: an empty outcome is a 400 server-side.
+ if _, err := c.Audit(context.Background(), AuditQuery{}); err != nil {
+ t.Fatal(err)
+ }
+ if len(query) != 0 {
+ t.Fatalf("zero query must send no parameters, got %v", query)
+ }
+}
+
+func TestPluginsDecodesBareArray(t *testing.T) {
+ var path string
+ var query url.Values
+ srv := servicesStub(t, &path, &query)
+
+ plugins, err := testClient(t, srv.URL, "fgw_ok").Plugins(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != "/admin/plugins" {
+ t.Fatalf("want GET /admin/plugins, got %q", path)
+ }
+ if len(plugins) != 2 || plugins[0].Name != "request-logger" || plugins[0].Type != "logging" ||
+ !plugins[0].Enabled || plugins[1].Enabled {
+ t.Fatalf("bad decode: %+v", plugins)
+ }
+}
+
+func TestPluginCatalogUnwrapsDataEnvelope(t *testing.T) {
+ var path string
+ var query url.Values
+ srv := servicesStub(t, &path, &query)
+
+ catalog, err := testClient(t, srv.URL, "fgw_ok").PluginCatalog(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != "/admin/plugins/catalog" {
+ t.Fatalf("want GET /admin/plugins/catalog, got %q", path)
+ }
+ if len(catalog) != 2 {
+ t.Fatalf("want 2 builtins, got %d", len(catalog))
+ }
+ // fails_open lives only in the catalog — it is what the plugins table
+ // merges in by name.
+ if !catalog[0].FailsOpen || catalog[1].FailsOpen {
+ t.Fatalf("fails_open must decode: %+v", catalog)
+ }
+ if catalog[0].Summary == "" || len(catalog[0].Settings) != 1 {
+ t.Fatalf("bad decode: %+v", catalog[0])
+ }
+}
diff --git a/internal/api/sse.go b/internal/api/sse.go
new file mode 100644
index 0000000..3c25458
--- /dev/null
+++ b/internal/api/sse.go
@@ -0,0 +1,404 @@
+package api
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// ChatMessage is one turn of a chat request.
+type ChatMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+}
+
+// ChatRequest is the subset of POST /v1/chat/completions ferro sends.
+//
+// Stream is forced true by StreamChat: the CLI has no non-streaming path. The
+// cost is that a streamed body carries no provider — that is resolved from the
+// request log instead, by TraceAttribution.
+type ChatRequest struct {
+ Model string `json:"model"`
+ Messages []ChatMessage `json:"messages"`
+ Stream bool `json:"stream"`
+}
+
+// Usage is the token accounting the gateway sends in a chunk of its own before
+// [DONE]. It arrives whether or not the client asked for it — the gateway
+// strips usage only on an explicit stream_options.include_usage:false — so a
+// stream that ends without one ended abnormally.
+type Usage struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ ReasoningTokens int `json:"reasoning_tokens,omitempty"`
+ CacheReadTokens int `json:"cache_read_tokens,omitempty"`
+ CacheWriteTokens int `json:"cache_write_tokens,omitempty"`
+}
+
+// MessageDelta is the incremental piece of one choice.
+//
+// ToolCalls is carried raw and unrendered until v0.2: decoding into a typed
+// shape ferro does not display would make a tool-calling stream fail to parse
+// for no benefit, and dropping the field would make it unrecoverable.
+type MessageDelta struct {
+ Role string `json:"role,omitempty"`
+ Content string `json:"content,omitempty"`
+ ReasoningContent string `json:"reasoning_content,omitempty"`
+ ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
+}
+
+// StreamChoice is one candidate's delta within a chunk. FinishReason is a
+// pointer because every chunk carries the field and only the last one carries
+// a value.
+type StreamChoice struct {
+ Index int `json:"index"`
+ Delta MessageDelta `json:"delta"`
+ FinishReason *string `json:"finish_reason"`
+}
+
+// ChatChunk is one `data:` frame of a streamed completion. Choices may be
+// empty — the usage chunk is exactly that shape — but the gateway never sends
+// null for it.
+type ChatChunk struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ Created int64 `json:"created"`
+ Model string `json:"model"`
+ Choices []StreamChoice `json:"choices"`
+ Usage *Usage `json:"usage,omitempty"`
+}
+
+// StreamEvent is one thing that happened on a stream: exactly one of the three
+// fields is set.
+//
+// Err and Done are both terminal and mutually exclusive. A stream that fails
+// mid-flight ends with an error frame and NO [DONE], so a reader that waits for
+// Done after an Err waits forever; read until the channel closes instead.
+type StreamEvent struct {
+ Chunk *ChatChunk // a decoded content/usage frame
+ Err *Error // mid-stream failure — the stream is over
+ Done bool // data: [DONE]
+}
+
+// ChatStream is a live streamed completion.
+//
+// RequestID is the X-Request-ID response header, which equals the trace_id of
+// the matching /admin/logs row. It is the only way to attribute a streamed
+// request: no chunk names a provider.
+//
+// Cancel is always safe to call, safe to call more than once, and never blocks
+// — including while the reader has stopped reading Events. Call it (defer it)
+// even after a clean Done: it is what releases the reader goroutine and the
+// connection.
+type ChatStream struct {
+ RequestID string
+ Events <-chan StreamEvent
+ Cancel context.CancelFunc
+}
+
+// Stream error codes. Only the first is the gateway's own, sent inside an
+// error frame; the other two are ferro's, synthesized locally. Reading
+// stream_timeout as the gateway's verdict is what put "the gateway closed it"
+// into two operator-facing messages for a bound the gateway never saw.
+const (
+ CodeStreamError = "stream_error" // gateway: the upstream or the pipeline failed
+ CodeStreamTimeout = "stream_timeout" // client: this client's idle bound elapsed
+ CodeStreamIncomplete = "stream_incomplete" // client: connection ended without [DONE]
+
+ // DefaultStreamIdleTimeout prevents a connected-but-silent upstream from
+ // leaving a CLI command or console turn waiting forever.
+ DefaultStreamIdleTimeout = 2 * time.Minute
+
+ // maxStreamFrameBytes bounds one SSE frame, which is one line. It is a
+ // memory bound on a stream nothing else limits, so it stays where it is:
+ // raising it only moves the wall, and a frame this size is a broken or
+ // hostile upstream rather than an answer.
+ maxStreamFrameBytes = 1 << 20
+
+ // maxStreamBytes bounds all accepted JSON payloads in one response. A
+ // per-frame limit alone still allows an unending sequence of valid frames
+ // to exhaust the CLI or console.
+ maxStreamBytes = 4 << 20
+)
+
+type scanResult struct {
+ line string
+ err error
+ done bool
+}
+
+// StreamChat opens a streamed chat completion.
+//
+// A non-2xx arriving before the stream starts is returned as an *Error and
+// no ChatStream. Once streaming has begun every failure is delivered on the
+// channel instead, because the HTTP status is already 200 by then.
+//
+// The caller owns the returned stream's lifetime: read Events until it closes,
+// and call Cancel.
+func (c *Client) StreamChat(ctx context.Context, req ChatRequest) (*ChatStream, error) {
+ req.Stream = true // a value copy: the caller's request is not mutated
+ body, err := json.Marshal(req)
+ if err != nil {
+ return nil, err
+ }
+ ctx, cancel := context.WithCancel(ctx)
+ // resolveURL is the same join c.do uses for every other endpoint, so a
+ // path-prefixed --gateway-url (e.g. https://gw.example.com/ferro) cannot
+ // resolve to a different URL here than it would through do.
+ reqURL := c.resolveURL("/v1/chat/completions").String()
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
+ if err != nil {
+ cancel()
+ return nil, err
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
+ httpReq.Header.Set("Accept", "text/event-stream")
+ httpReq.Header.Set("User-Agent", c.userAgent)
+ if c.apiKey != "" {
+ httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
+ }
+
+ // A dedicated client AND a dedicated transport: the shared client carries
+ // DefaultTimeout and its transport carries the same value as
+ // ResponseHeaderTimeout, so escaping only the first still cut every stream
+ // off at the header phase. A gateway or ingress that withholds response
+ // headers until the first token is ordinary, which is what
+ // DefaultStreamIdleTimeout is sized for. The assertion falls back to the
+ // transport as-is because a test double is not an *http.Transport and
+ // carries no bound to set. The redirect refusal is not optional — a bearer token must not
+ // replay to whatever host a redirect names, on this surface as much as any
+ // other.
+ streamTransport := c.hc.Transport
+ if tr, ok := streamTransport.(*http.Transport); ok {
+ clone := tr.Clone()
+ // Widened to the idle bound, not removed. Do runs before the idle timer
+ // below exists and this client carries no Timeout, so zeroing it left a
+ // peer that accepts the connection and then withholds headers blocking
+ // StreamChat for as long as the caller's context allows — which for the
+ // CLI's signal context and the console's is forever. The idle timeout is
+ // already the answer to "how long may this stream say nothing"; the
+ // header phase is the first stretch of exactly that silence. A caller
+ // who disables the idle bound disables this one with it.
+ clone.ResponseHeaderTimeout = c.streamIdleTimeout
+ streamTransport = clone
+ }
+ streamClient := &http.Client{
+ Transport: streamTransport,
+ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
+ }
+ resp, err := streamClient.Do(httpReq)
+ if err != nil {
+ cancel()
+ return nil, err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ defer func() { _ = resp.Body.Close() }()
+ defer cancel()
+ if resp.StatusCode < 400 {
+ return nil, &Error{
+ Status: resp.StatusCode,
+ Message: "redirect refused",
+ RedirectTo: resp.Header.Get("Location"),
+ }
+ }
+ raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxBody))
+ return nil, decodeAPIError(resp.StatusCode, raw)
+ }
+
+ events := make(chan StreamEvent)
+ st := &ChatStream{RequestID: resp.Header.Get("X-Request-ID"), Events: events, Cancel: cancel}
+
+ go func() {
+ defer close(events)
+ defer cancel()
+ defer func() { _ = resp.Body.Close() }()
+
+ lines := make(chan scanResult)
+ go scanStream(ctx, resp.Body, lines)
+
+ var idle <-chan time.Time
+ var timer *time.Timer
+ if c.streamIdleTimeout > 0 {
+ timer = time.NewTimer(c.streamIdleTimeout)
+ idle = timer.C
+ defer timer.Stop()
+ }
+ resetIdle := func() {
+ if timer == nil {
+ return
+ }
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ timer.Reset(c.streamIdleTimeout)
+ }
+
+ var streamBytes int
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-idle:
+ sendEvent(ctx, events, StreamEvent{Err: &Error{
+ Status: http.StatusGatewayTimeout, Type: CodeStreamError, Code: CodeStreamTimeout,
+ Message: "stream produced no events before the idle timeout",
+ }})
+ cancel()
+ return
+ case result := <-lines:
+ if result.done {
+ if ctx.Err() != nil {
+ return
+ }
+ msg := "stream ended without a terminating frame"
+ switch {
+ case errors.Is(result.err, bufio.ErrTooLong):
+ // The stream really is incomplete, so the code is right,
+ // but "read failed: token too long" names a Go type and
+ // sends the reader looking at the network. Say what the
+ // gateway sent instead.
+ msg = fmt.Sprintf("a single stream frame exceeded the %d-byte limit; the answer is truncated", maxStreamFrameBytes)
+ case result.err != nil:
+ msg = "stream read failed: " + result.err.Error()
+ }
+ sendEvent(ctx, events, StreamEvent{Err: &Error{
+ Status: http.StatusBadGateway, Type: CodeStreamError, Code: CodeStreamIncomplete, Message: msg,
+ }})
+ return
+ }
+ line := result.line
+ if !strings.HasPrefix(line, "data:") {
+ continue // blank keep-alives, ": comments", and any field ferro does not read
+ }
+ // TrimSpace absorbs both the optional space after the colon and
+ // the \r of a CRLF stream.
+ payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
+
+ if payload == "[DONE]" {
+ resetIdle()
+ sendEvent(ctx, events, StreamEvent{Done: true})
+ return
+ }
+ if e := decodeErrorFrame(payload); e != nil {
+ resetIdle()
+ // An error frame is the last frame: no [DONE] follows it, so
+ // this goroutine must not go back to waiting for one.
+ sendEvent(ctx, events, StreamEvent{Err: e})
+ return
+ }
+ // Accounted before the decode, never after: an undecodable
+ // frame was still read off the wire, and charging only the ones
+ // that parsed let an endless run of garbage frames walk straight
+ // past the bound this constant exists to enforce.
+ streamBytes += len(payload)
+ if streamBytes > maxStreamBytes {
+ sendEvent(ctx, events, StreamEvent{Err: &Error{
+ Status: http.StatusBadGateway, Type: CodeStreamError, Code: CodeStreamIncomplete,
+ Message: fmt.Sprintf("aggregate stream payload exceeded the %d-byte limit; the answer is truncated", maxStreamBytes),
+ }})
+ return
+ }
+ var chunk ChatChunk
+ if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
+ continue // one malformed frame never kills a live stream
+ }
+ resetIdle()
+ if !sendEvent(ctx, events, StreamEvent{Chunk: &chunk}) {
+ return
+ }
+ }
+ }
+ }()
+ return st, nil
+}
+
+func scanStream(ctx context.Context, r io.Reader, out chan<- scanResult) {
+ sc := bufio.NewScanner(r)
+ sc.Buffer(make([]byte, 0, 64*1024), maxStreamFrameBytes)
+ for sc.Scan() {
+ select {
+ case out <- scanResult{line: sc.Text()}:
+ case <-ctx.Done():
+ return
+ }
+ }
+ select {
+ case out <- scanResult{err: sc.Err(), done: true}:
+ case <-ctx.Done():
+ }
+}
+
+// decodeErrorFrame reports whether payload is a mid-stream error frame, and
+// decodes it. A content chunk carries no "error" key, so this cannot false-fire
+// on one.
+func decodeErrorFrame(payload string) *Error {
+ var probe struct {
+ Error *struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+ Code string `json:"code"`
+ } `json:"error"`
+ }
+ if json.Unmarshal([]byte(payload), &probe) != nil || probe.Error == nil {
+ return nil
+ }
+ // 502: the HTTP response was already 200 by the time this arrived, so the
+ // status is synthesized to say "the upstream failed mid-answer".
+ return &Error{
+ Status: http.StatusBadGateway,
+ Message: probe.Error.Message,
+ Type: probe.Error.Type,
+ Code: probe.Error.Code,
+ }
+}
+
+// sendEvent delivers ev unless ctx is cancelled first, and reports whether it
+// was delivered. Every send goes through it: an unbuffered channel plus a
+// reader that stopped reading is exactly the deadlock Cancel exists to break.
+func sendEvent(ctx context.Context, ch chan<- StreamEvent, ev StreamEvent) bool {
+ select {
+ case ch <- ev:
+ return true
+ case <-ctx.Done():
+ return false
+ }
+}
+
+// TraceAttribution resolves which provider served a request and what it cost,
+// by matching a response's X-Request-ID against /admin/logs trace_id. Streamed
+// chunks carry no provider, so this is the only attribution path.
+//
+// It answers (nil, nil) rather than an error for every "cannot know yet"
+// case — no trace id, no matching row (the log write lags the response), or a
+// gateway with no log store (501). Attribution is a detail shown beside an
+// answer that already arrived; failing the command over its absence would turn
+// a cosmetic gap into a broken chat.
+func (c *Client) TraceAttribution(ctx context.Context, traceID string, since time.Time) (*LogEntry, error) {
+ if traceID == "" {
+ return nil, nil
+ }
+ page, err := c.Logs(ctx, LogsQuery{Limit: 50, Since: since})
+ if err != nil {
+ if IsNotSupported(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ for _, e := range page.Data {
+ if e.TraceID == traceID {
+ return &e, nil
+ }
+ }
+ return nil, nil
+}
diff --git a/internal/api/sse_test.go b/internal/api/sse_test.go
new file mode 100644
index 0000000..fff8027
--- /dev/null
+++ b/internal/api/sse_test.go
@@ -0,0 +1,637 @@
+package api
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// frame wraps one JSON payload in the gateway's framing: `data: \n\n`,
+// no event name.
+func frame(payload string) string { return "data: " + payload + "\n\n" }
+
+const doneFrame = "data: [DONE]\n\n"
+
+// writeSSE writes frames verbatim, flushing between them, so the client reads
+// them as a stream rather than in one buffered shot.
+func writeSSE(w http.ResponseWriter, requestID string, frames ...string) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("X-Request-ID", requestID)
+ w.WriteHeader(http.StatusOK)
+ f, ok := w.(http.Flusher)
+ for _, fr := range frames {
+ // A failed frame write means the client hung up mid-stream, which the
+ // cancellation and early-close tests do on purpose. It is discarded
+ // rather than reported: failing the test here would make those cases
+ // flaky, and the client-side assertions already cover what was read.
+ _, _ = io.WriteString(w, fr)
+ if ok {
+ f.Flush()
+ }
+ }
+}
+
+// drain reads until the channel closes, failing rather than hanging forever if
+// it never does.
+func drain(t *testing.T, st *ChatStream) []StreamEvent {
+ t.Helper()
+ var got []StreamEvent
+ deadline := time.After(5 * time.Second)
+ for {
+ select {
+ case ev, open := <-st.Events:
+ if !open {
+ return got
+ }
+ got = append(got, ev)
+ case <-deadline:
+ t.Fatal("stream never closed its Events channel")
+ }
+ }
+}
+
+func content(events []StreamEvent) string {
+ var b strings.Builder
+ for _, ev := range events {
+ if ev.Chunk == nil {
+ continue
+ }
+ for _, ch := range ev.Chunk.Choices {
+ b.WriteString(ch.Delta.Content)
+ }
+ }
+ return b.String()
+}
+
+func TestStreamChatHappyPath(t *testing.T) {
+ // The handler runs in its own goroutine; the streamed response body is
+ // what the test goroutine synchronizes on to read the events, not these
+ // captured request facts, so they need their own guard.
+ var mu sync.Mutex
+ var gotBody, gotAccept, gotAuth string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ raw, _ := io.ReadAll(r.Body)
+ mu.Lock()
+ gotBody, gotAccept, gotAuth = string(raw), r.Header.Get("Accept"), r.Header.Get("Authorization")
+ mu.Unlock()
+ writeSSE(w, "trace-abc",
+ frame(`{"id":"c1","object":"chat.completion.chunk","created":100,"model":"m","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`),
+ frame(`{"id":"c1","object":"chat.completion.chunk","created":100,"model":"m","choices":[{"index":0,"delta":{"content":"Hello "},"finish_reason":null}]}`),
+ frame(`{"id":"c1","object":"chat.completion.chunk","created":100,"model":"m","choices":[{"index":0,"delta":{"content":"world"},"finish_reason":"stop"}]}`),
+ frame(`{"id":"c1","object":"chat.completion.chunk","created":100,"model":"m","choices":[],"usage":{"prompt_tokens":9,"completion_tokens":3,"total_tokens":12}}`),
+ doneFrame,
+ )
+ }))
+ defer srv.Close()
+
+ c, _ := New(srv.URL, "fgw_secret")
+ st, err := c.StreamChat(context.Background(), ChatRequest{
+ Model: "m",
+ Messages: []ChatMessage{{Role: "user", Content: "hi"}},
+ })
+ if err != nil {
+ t.Fatalf("StreamChat: %v", err)
+ }
+ defer st.Cancel()
+
+ events := drain(t, st)
+ if len(events) != 5 {
+ t.Fatalf("want 4 chunks + Done, got %d events: %+v", len(events), events)
+ }
+ for i, ev := range events[:4] {
+ if ev.Chunk == nil || ev.Err != nil || ev.Done {
+ t.Fatalf("event %d must be a chunk, got %+v", i, ev)
+ }
+ }
+ if !events[4].Done || events[4].Chunk != nil || events[4].Err != nil {
+ t.Fatalf("last event must be Done, got %+v", events[4])
+ }
+ if got := content(events); got != "Hello world" {
+ t.Fatalf("content = %q, want %q", got, "Hello world")
+ }
+ if fr := events[2].Chunk.Choices[0].FinishReason; fr == nil || *fr != "stop" {
+ t.Fatalf("finish_reason not decoded: %v", fr)
+ }
+ usage := events[3].Chunk.Usage
+ if usage == nil || usage.TotalTokens != 12 || usage.PromptTokens != 9 {
+ t.Fatalf("usage chunk not decoded: %+v", usage)
+ }
+ if len(events[3].Chunk.Choices) != 0 {
+ t.Fatalf("usage chunk carries an empty choices array, got %+v", events[3].Chunk.Choices)
+ }
+ if st.RequestID != "trace-abc" {
+ t.Fatalf("RequestID = %q, want the X-Request-ID header", st.RequestID)
+ }
+ // The request itself: stream is forced on, and the SSE surface is asked for.
+ mu.Lock()
+ body, accept, auth := gotBody, gotAccept, gotAuth
+ mu.Unlock()
+ if !strings.Contains(body, `"stream":true`) {
+ t.Fatalf("StreamChat must force stream:true, body was %s", body)
+ }
+ if accept != "text/event-stream" || auth != "Bearer fgw_secret" {
+ t.Fatalf("accept=%q auth=%q", accept, auth)
+ }
+}
+
+func TestStreamChatDoneIgnoresFollowingBufferedFrames(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeSSE(w, "trace-done", doneFrame,
+ frame(`{"id":"late","choices":[{"index":0,"delta":{"content":"must be ignored"}}]}`))
+ }))
+ defer srv.Close()
+ c, _ := New(srv.URL, "")
+ st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer st.Cancel()
+ events := drain(t, st)
+ if len(events) != 1 || !events[0].Done {
+ t.Fatalf("[DONE] must terminate immediately: %+v", events)
+ }
+}
+
+func TestStreamChatErrorFrameEndsStreamWithoutDone(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeSSE(w, "trace-err",
+ frame(`{"id":"c1","choices":[{"index":0,"delta":{"role":"assistant"}}]}`),
+ frame(`{"error":{"message":"boom","type":"stream_error","code":"stream_error"}}`),
+ // No [DONE]: that asymmetry is the gateway's real behavior.
+ )
+ }))
+ defer srv.Close()
+
+ c, _ := New(srv.URL, "")
+ st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"})
+ if err != nil {
+ t.Fatalf("StreamChat: %v", err)
+ }
+ defer st.Cancel()
+
+ events := drain(t, st)
+ var errs, dones int
+ for _, ev := range events {
+ if ev.Err != nil {
+ errs++
+ }
+ if ev.Done {
+ dones++
+ }
+ }
+ if errs != 1 || dones != 0 {
+ t.Fatalf("want exactly one Err and no Done, got %d/%d: %+v", errs, dones, events)
+ }
+ last := events[len(events)-1]
+ if last.Err.Code != CodeStreamError || last.Err.Message != "boom" || last.Err.Type != "stream_error" {
+ t.Fatalf("error frame not decoded: %+v", last.Err)
+ }
+ if !strings.Contains(last.Err.Error(), "boom") {
+ t.Fatalf("api.Error must read usefully: %q", last.Err.Error())
+ }
+}
+
+func TestStreamChatToleratesCRLFAndComments(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeSSE(w, "trace-crlf",
+ ": keep-alive\r\n\r\n",
+ "data: "+`{"id":"c1","choices":[{"index":0,"delta":{"content":"ok"}}]}`+"\r\n\r\n",
+ // The space after the colon is optional in the SSE grammar. The
+ // gateway always writes one; a proxy that re-frames the stream
+ // need not, and dropping the frame would lose content silently.
+ "data:"+`{"id":"c1","choices":[{"index":0,"delta":{"content":"!"}}]}`+"\r\n\r\n",
+ "\r\n",
+ "data: [DONE]\r\n\r\n",
+ )
+ }))
+ defer srv.Close()
+
+ c, _ := New(srv.URL, "")
+ st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"})
+ if err != nil {
+ t.Fatalf("StreamChat: %v", err)
+ }
+ defer st.Cancel()
+
+ events := drain(t, st)
+ if got := content(events); got != "ok!" {
+ t.Fatalf("content = %q, want %q (events %+v)", got, "ok!", events)
+ }
+ if !events[len(events)-1].Done {
+ t.Fatalf("a CRLF [DONE] must terminate the stream: %+v", events)
+ }
+}
+
+func TestStreamChatSkipsMalformedFrame(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeSSE(w, "trace-junk",
+ frame(`{"id":"c1","choices":[{"index":0,"delta":{"content":"a"}}]}`),
+ frame(`{"id":"c1","choices":[{"index":0,"delta":{"content":`), // truncated JSON
+ frame(`{"id":"c1","choices":[{"index":0,"delta":{"content":"b"}}]}`),
+ doneFrame,
+ )
+ }))
+ defer srv.Close()
+
+ c, _ := New(srv.URL, "")
+ st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"})
+ if err != nil {
+ t.Fatalf("StreamChat: %v", err)
+ }
+ defer st.Cancel()
+
+ events := drain(t, st)
+ if got := content(events); got != "ab" {
+ t.Fatalf("one bad frame must not kill the stream: content = %q, events %+v", got, events)
+ }
+ if !events[len(events)-1].Done {
+ t.Fatalf("stream must still reach Done: %+v", events)
+ }
+}
+
+func TestStreamChatTruncatedStreamReportsError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ // A well-formed frame, then the connection ends: no [DONE], no error
+ // frame. A silently closed channel would render as a complete answer.
+ writeSSE(w, "trace-cut", frame(`{"id":"c1","choices":[{"index":0,"delta":{"content":"half"}}]}`))
+ }))
+ defer srv.Close()
+
+ c, _ := New(srv.URL, "")
+ st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"})
+ if err != nil {
+ t.Fatalf("StreamChat: %v", err)
+ }
+ defer st.Cancel()
+
+ events := drain(t, st)
+ last := events[len(events)-1]
+ if last.Err == nil || last.Err.Code != CodeStreamIncomplete {
+ t.Fatalf("a truncated stream must surface an Err, got %+v", events)
+ }
+}
+
+func TestStreamChatOversizedFrameNamesItsOwnCause(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeSSE(w, "trace-big", frame(`{"id":"c1","choices":[{"index":0,"delta":{"content":"`+
+ strings.Repeat("x", maxStreamFrameBytes)+`"}}]}`))
+ }))
+ defer srv.Close()
+
+ c, _ := New(srv.URL, "")
+ st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"})
+ if err != nil {
+ t.Fatalf("StreamChat: %v", err)
+ }
+ defer st.Cancel()
+
+ last := drain(t, st)
+ e := last[len(last)-1].Err
+ if e == nil || e.Code != CodeStreamIncomplete {
+ t.Fatalf("an oversized frame still ends the stream incomplete: %+v", last)
+ }
+ // The stream is genuinely incomplete, so the code is right — but the
+ // message must name the frame size, not a scanner internal, or the reader
+ // goes looking at the network for a limit the client applied.
+ if !strings.Contains(e.Message, "exceeded") || strings.Contains(e.Message, "token too long") {
+ t.Fatalf("the message must name the frame limit, got %q", e.Message)
+ }
+}
+
+func TestStreamChatSilentConnectionTimesOut(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.WriteHeader(http.StatusOK)
+ if f, ok := w.(http.Flusher); ok {
+ f.Flush()
+ }
+ <-r.Context().Done()
+ }))
+ defer srv.Close()
+
+ c, err := New(srv.URL, "", WithStreamIdleTimeout(25*time.Millisecond))
+ if err != nil {
+ t.Fatal(err)
+ }
+ st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer st.Cancel()
+ events := drain(t, st)
+ if len(events) != 1 || events[0].Err == nil || events[0].Err.Code != CodeStreamTimeout {
+ t.Fatalf("silent connection must end with stream_timeout: %+v", events)
+ }
+}
+
+func TestStreamChatMalformedFramesDoNotDefeatIdleTimeout(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.WriteHeader(http.StatusOK)
+ f, _ := w.(http.Flusher)
+ for {
+ if _, err := io.WriteString(w, "data: {not-json}\n\n"); err != nil {
+ return
+ }
+ if f != nil {
+ f.Flush()
+ }
+ select {
+ case <-r.Context().Done():
+ return
+ case <-time.After(10 * time.Millisecond):
+ }
+ }
+ }))
+ defer srv.Close()
+
+ c, err := New(srv.URL, "", WithStreamIdleTimeout(35*time.Millisecond))
+ if err != nil {
+ t.Fatal(err)
+ }
+ st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer st.Cancel()
+ events := drain(t, st)
+ if len(events) != 1 || events[0].Err == nil || events[0].Err.Code != CodeStreamTimeout {
+ t.Fatalf("malformed frames must not keep a broken stream alive: %+v", events)
+ }
+}
+
+func TestStreamChatAggregatePayloadIsBounded(t *testing.T) {
+ payload := `{"id":"c1","choices":[{"index":0,"delta":{"content":"` +
+ strings.Repeat("x", 32<<10) + `"}}]}`
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ frames := make([]string, 0, maxStreamBytes/(32<<10)+2)
+ for range maxStreamBytes/(32<<10) + 1 {
+ frames = append(frames, frame(payload))
+ }
+ writeSSE(w, "trace-aggregate", frames...)
+ }))
+ defer srv.Close()
+
+ c, _ := New(srv.URL, "")
+ st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer st.Cancel()
+ events := drain(t, st)
+ last := events[len(events)-1]
+ if last.Err == nil || last.Err.Code != CodeStreamIncomplete ||
+ !strings.Contains(last.Err.Message, "aggregate") {
+ t.Fatalf("aggregate stream limit must terminate visibly: %+v", last)
+ }
+}
+
+func TestStreamChatUndecodableFramesCountTowardAggregateBound(t *testing.T) {
+ // Nothing here parses, so every frame takes the malformed-frame path — the
+ // path that used to skip the accounting and hand a hostile or broken
+ // upstream an unbounded stream.
+ const size = 512 << 10
+ payload := strings.Repeat("x", size)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ frames := make([]string, 0, maxStreamBytes/size+2)
+ for range maxStreamBytes/size + 1 {
+ frames = append(frames, frame(payload))
+ }
+ // [DONE] last: before the fix the stream reached it and reported a
+ // complete answer after 4 MiB of garbage.
+ writeSSE(w, "trace-garbage", append(frames, doneFrame)...)
+ }))
+ defer srv.Close()
+
+ c, _ := New(srv.URL, "")
+ st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer st.Cancel()
+ events := drain(t, st)
+ last := events[len(events)-1]
+ if last.Done {
+ t.Fatalf("undecodable frames must not ride past the aggregate bound to Done: %+v", events)
+ }
+ if last.Err == nil || last.Err.Code != CodeStreamIncomplete ||
+ !strings.Contains(last.Err.Message, "aggregate") {
+ t.Fatalf("aggregate stream limit must terminate visibly: %+v", last)
+ }
+}
+
+func TestStreamChatSurvivesSlowResponseHeaders(t *testing.T) {
+ // Well past the client's request timeout: a gateway that buffers until the
+ // upstream's first token, or an ingress in front of one, looks exactly like
+ // this, and the idle timer — not the header bound — is what should judge it.
+ const headerDelay = 300 * time.Millisecond
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ time.Sleep(headerDelay)
+ writeSSE(w, "trace-slow-headers",
+ frame(`{"id":"c1","choices":[{"index":0,"delta":{"content":"late"}}]}`), doneFrame)
+ }))
+ defer srv.Close()
+
+ c, err := New(srv.URL, "", WithTimeout(40*time.Millisecond))
+ if err != nil {
+ t.Fatal(err)
+ }
+ st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"})
+ if err != nil {
+ t.Fatalf("a slow response header must not kill a stream: %v", err)
+ }
+ defer st.Cancel()
+ events := drain(t, st)
+ if got := content(events); got != "late" {
+ t.Fatalf("content = %q, want %q (events: %+v)", got, "late", events)
+ }
+ if last := events[len(events)-1]; !last.Done {
+ t.Fatalf("stream must reach Done: %+v", events)
+ }
+}
+
+func TestStreamChatCancelMidStreamDoesNotDeadlock(t *testing.T) {
+ released := make(chan struct{})
+ tick := frame(`{"id":"c1","choices":[{"index":0,"delta":{"content":"tick"}}]}`)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer close(released)
+ // Several frames, so the reader goroutine is blocked handing over a
+ // later one — an unbuffered channel with a reader that walked away —
+ // at the moment Cancel lands.
+ writeSSE(w, "trace-cancel", tick, tick, tick)
+ <-r.Context().Done() // never sends [DONE]: the client hangs up first
+ }))
+ defer srv.Close()
+
+ c, _ := New(srv.URL, "")
+ st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"})
+ if err != nil {
+ t.Fatalf("StreamChat: %v", err)
+ }
+
+ select {
+ case ev := <-st.Events:
+ if ev.Chunk == nil {
+ t.Fatalf("want the first chunk, got %+v", ev)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("no first chunk")
+ }
+
+ st.Cancel()
+ drain(t, st) // must close promptly, with no sender left blocked
+ st.Cancel() // idempotent: always safe to call again
+ <-released // the server saw the hang-up, so the connection is not leaked
+
+ // This asserts that the channel closes, not that no goroutine leaked: leak
+ // detection under httptest means goroutine counting, which is flaky. Add it
+ // if a leak is ever suspected.
+}
+
+func TestStreamChatPreStreamErrorReturnsAPIError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, http.StatusUnauthorized,
+ `{"error":{"message":"bad key","type":"authentication_error","code":"unauthorized"}}`)
+ }))
+ defer srv.Close()
+
+ c, _ := New(srv.URL, "fgw_bad")
+ st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"})
+ if st != nil {
+ t.Fatal("a pre-stream failure must return no ChatStream")
+ }
+ var apiErr *Error
+ if !errors.As(err, &apiErr) || apiErr.Status != http.StatusUnauthorized || apiErr.Message != "bad key" {
+ t.Fatalf("want a decoded 401 envelope, got %v", err)
+ }
+}
+
+func TestStreamChatURLMatchesDoResolutionForPathPrefixedBase(t *testing.T) {
+ // StreamChat builds its URL through the same c.resolveURL join c.do uses
+ // (see client.go), so a path-prefixed --gateway-url cannot land the
+ // request on a different path than every other endpoint — in particular
+ // it cannot produce a double slash on the streaming path.
+ var gotPath atomic.Pointer[string]
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ p := r.URL.Path
+ gotPath.Store(&p)
+ writeSSE(w, "trace-prefix", doneFrame)
+ }))
+ defer srv.Close()
+
+ for _, suffix := range []string{"/ferro/", "/ferro"} {
+ c, err := New(srv.URL+suffix, "")
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ st, err := c.StreamChat(context.Background(), ChatRequest{Model: "m"})
+ if err != nil {
+ t.Fatalf("StreamChat: %v", err)
+ }
+ drain(t, st)
+ st.Cancel()
+
+ want := "/ferro/v1/chat/completions"
+ var got string
+ if p := gotPath.Load(); p != nil {
+ got = *p
+ }
+ if got != want {
+ t.Fatalf("base %q: request path = %q, want %q (no double slash)", srv.URL+suffix, got, want)
+ }
+ if strings.Contains(got, "//") {
+ t.Fatalf("base %q: request path %q contains a double slash", srv.URL+suffix, got)
+ }
+ }
+}
+
+func TestTraceAttribution(t *testing.T) {
+ var calls atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ if r.URL.Path != "/admin/logs" {
+ t.Errorf("attribution must read /admin/logs, got %s", r.URL.Path)
+ }
+ writeJSON(t, w, http.StatusOK, `{"data":[
+ {"trace_id":"other","provider":"openai","cost_usd":9.99},
+ {"trace_id":"trace-abc","stage":"after_request","model":"m","provider":"anthropic","total_tokens":12,"cost_usd":0.0012}
+ ],"summary":{"total_entries":2,"returned_entries":2}}`)
+ }))
+ defer srv.Close()
+ c, _ := New(srv.URL, "fgw_k")
+
+ row, err := c.TraceAttribution(context.Background(), "trace-abc", time.Time{})
+ if err != nil {
+ t.Fatalf("TraceAttribution: %v", err)
+ }
+ if row == nil || row.Provider != "anthropic" || row.CostUSD == nil || *row.CostUSD != 0.0012 {
+ t.Fatalf("want the matching row, got %+v", row)
+ }
+
+ row, err = c.TraceAttribution(context.Background(), "not-in-the-page", time.Time{})
+ if row != nil || err != nil {
+ t.Fatalf("a missing row is (nil, nil) — log writes lag: got %+v, %v", row, err)
+ }
+
+ before := calls.Load()
+ if row, err = c.TraceAttribution(context.Background(), "", time.Time{}); row != nil || err != nil || calls.Load() != before {
+ t.Fatalf("an empty trace id must answer (nil, nil) without a request: %+v %v calls=%d", row, err, calls.Load()-before)
+ }
+}
+
+func TestTraceAttributionNoLogStoreIsNotAnError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, http.StatusNotImplemented,
+ `{"error":{"message":"request logging is not configured","type":"server_error","code":"not_implemented"}}`)
+ }))
+ defer srv.Close()
+
+ c, _ := New(srv.URL, "fgw_k")
+ row, err := c.TraceAttribution(context.Background(), "trace-abc", time.Now().Add(-time.Minute))
+ if row != nil || err != nil {
+ t.Fatalf("a 501 log store must degrade to (nil, nil), got %+v, %v", row, err)
+ }
+}
+
+// A peer that accepts the connection and then withholds response headers must
+// not hold StreamChat open indefinitely. Do runs before the idle timer exists
+// and the stream client carries no Timeout, so the transport's header bound is
+// the only thing standing between a silent peer and a CLI that never returns —
+// the caller's context has no deadline in either the CLI or the console.
+func TestStreamChatBoundsAPeerThatWithholdsHeaders(t *testing.T) {
+ release := make(chan struct{})
+ srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
+ <-release // never sends headers until the test is done with it
+ }))
+ t.Cleanup(func() { close(release); srv.Close() })
+
+ const idle = 150 * time.Millisecond
+ c, err := New(srv.URL, "", WithStreamIdleTimeout(idle))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ done := make(chan error, 1)
+ go func() {
+ // context.Background(): exactly what the console passes, and what the
+ // CLI's signal context amounts to when nobody presses ctrl+c.
+ _, streamErr := c.StreamChat(context.Background(), ChatRequest{Model: "m"})
+ done <- streamErr
+ }()
+
+ select {
+ case streamErr := <-done:
+ if streamErr == nil {
+ t.Fatal("a peer that sent no headers must not yield a usable stream")
+ }
+ case <-time.After(10 * idle):
+ t.Fatalf("StreamChat did not return within %s of a %s idle bound: the header phase is unbounded", 10*idle, idle)
+ }
+}
diff --git a/internal/api/status.go b/internal/api/status.go
new file mode 100644
index 0000000..f821309
--- /dev/null
+++ b/internal/api/status.go
@@ -0,0 +1,210 @@
+package api
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+// The three values StatusReport.State takes. They are part of the frozen
+// `ferro status --format json` contract, so consumers match on these strings
+// rather than on a Go type.
+const (
+ StateConnected = "connected"
+ StateDegraded = "degraded"
+ StateUnreachable = "unreachable"
+)
+
+// The values StatusReport.Auth takes: the two scopes a gateway credential can
+// carry, and the marker for one the gateway refused. Part of the same frozen
+// contract as the states above.
+const (
+ ScopeAdmin = "admin"
+ ScopeReadOnly = "read_only"
+ AuthUnauthorized = "unauthorized"
+)
+
+// TargetSummary counts configured routing targets and how many are reachable.
+type TargetSummary struct {
+ Total int `json:"total"`
+ Routable int `json:"routable"`
+}
+
+// CircuitSummary counts providers whose breaker is not closed.
+type CircuitSummary struct {
+ Open int `json:"open"`
+ HalfOpen int `json:"half_open"`
+}
+
+// MCPSummary counts MCP tool servers and how many completed their handshake.
+type MCPSummary struct {
+ Ready int `json:"ready"`
+ Total int `json:"total"`
+}
+
+// StatusReport is the frozen `ferro status --format json` contract and the
+// shape the TUI header renders. Adding a field is compatible; renaming or
+// removing one is not.
+type StatusReport struct {
+ State string `json:"state"` // connected|degraded|unreachable
+ URL string `json:"url"`
+ LatencyMs int64 `json:"latency_ms"`
+ // Providers and Models are nil when the gateway did not tell us, for the
+ // same reason Targets is: each is populated only from a call that can
+ // fail on its own. /health failing leaves a reachable, degraded gateway
+ // reporting no provider count, and /v1/models is skipped entirely without
+ // a credential or answered 404 by a build that does not serve it. A real
+ // zero and an unanswered question are different facts, and only a pointer
+ // can carry both.
+ Providers *int `json:"providers,omitempty"`
+ Models *int `json:"models,omitempty"`
+ // Targets is nil when the gateway did not tell us. /readyz answers a 503
+ // with only {status, reason} — no targets array — so a zero count there
+ // would mean "none configured" when the truth is "yours are dead", which
+ // is the wrong answer during exactly the outage this command is run in.
+ // Absent is honest; 0 is a claim we cannot support.
+ Targets *TargetSummary `json:"targets,omitempty"`
+ Circuits CircuitSummary `json:"circuits"`
+ MCP *MCPSummary `json:"mcp,omitempty"`
+ Auth string `json:"auth,omitempty"` // admin|read_only|unauthorized|""
+ Warnings []string `json:"warnings,omitempty"`
+}
+
+// Status assembles the report both `ferro status` and the TUI header render.
+// /health and /readyz are unauthenticated ground truth; admin and models
+// enrich it best-effort. An auth problem degrades the report, it never fails
+// it — only an unreachable gateway returns an error, and it returns a report
+// alongside so callers can still say which URL was tried.
+func (c *Client) Status(ctx context.Context) (*StatusReport, error) {
+ r := &StatusReport{State: StateUnreachable, URL: c.BaseURL()}
+
+ start := time.Now()
+ health, healthStatus, err := c.Health(ctx)
+ r.LatencyMs = time.Since(start).Milliseconds()
+ if err != nil {
+ return r, err
+ }
+ r.State = StateConnected
+ applyHealth(r, health, healthStatus)
+
+ readyStatus := 0
+ ready, status, readyErr := c.Ready(ctx)
+ if readyErr == nil {
+ readyStatus = status
+ applyReady(r, ready, status)
+ } else {
+ r.Warnings = append(r.Warnings, "readiness unavailable: "+readyErr.Error())
+ }
+
+ if c.applyAuth(ctx, r) {
+ if models, err := c.Models(ctx); err == nil {
+ r.Models = Count(len(models))
+ } else if !IsNotSupported(err) {
+ r.Warnings = append(r.Warnings, "models unavailable: "+err.Error())
+ }
+ }
+
+ unroutable := r.Targets != nil && r.Targets.Routable < r.Targets.Total
+ // A credential the gateway refused is a degraded gateway from where this
+ // operator sits: /health and /readyz are fine, and every authenticated API
+ // is still shut. Presenting no credential at all is not a fault — nothing
+ // was rejected, so that case reports unauthorized without degrading.
+ credentialRejected := r.Auth == AuthUnauthorized && c.apiKey != ""
+ if healthStatus == http.StatusServiceUnavailable || readyStatus == http.StatusServiceUnavailable ||
+ readyErr != nil || r.Circuits.Open > 0 || r.Circuits.HalfOpen > 0 || unroutable || credentialRejected {
+ r.State = StateDegraded
+ }
+ return r, nil
+}
+
+// Count records a count the gateway actually supplied. A nil *int in a
+// StatusReport means the question went unanswered; Count(0) means the gateway
+// answered zero. Callers building a report by hand — tests, fixtures — use it
+// so that "answered zero" cannot be written accidentally as "never asked".
+func Count(n int) *int { return &n }
+
+// applyHealth folds the /health body into the report: the provider count, the
+// per-provider circuit counts with a warning naming each non-closed one, and a
+// warning carrying the body's own status when /health answered 503.
+func applyHealth(r *StatusReport, health *HealthReport, status int) {
+ r.Providers = Count(len(health.Providers))
+ for _, p := range health.Providers {
+ switch p.Circuit {
+ case "open":
+ r.Circuits.Open++
+ r.Warnings = append(r.Warnings, fmt.Sprintf("%s circuit open", p.Name))
+ case "half_open":
+ r.Circuits.HalfOpen++
+ r.Warnings = append(r.Warnings, fmt.Sprintf("%s circuit half-open", p.Name))
+ }
+ }
+ if status == http.StatusServiceUnavailable {
+ r.Warnings = append(r.Warnings, "health: "+health.Status)
+ }
+}
+
+// applyReady folds a successfully read /readyz body into the report.
+//
+// A not_ready body carries no targets array at all, so only count them when the
+// gateway actually listed some. Leaving Targets nil renders as "—" rather than
+// asserting a zero the server never reported; the same holds for MCP.
+func applyReady(r *StatusReport, ready *ReadyReport, status int) {
+ if ready.Targets != nil {
+ t := &TargetSummary{Total: len(ready.Targets)}
+ for _, target := range ready.Targets {
+ if target.Routable {
+ t.Routable++
+ }
+ }
+ r.Targets = t
+ }
+ if ready.MCPServers != nil {
+ m := &MCPSummary{Total: len(ready.MCPServers)}
+ for _, s := range ready.MCPServers {
+ if s.Ready {
+ m.Ready++
+ }
+ }
+ r.MCP = m
+ }
+ if status == http.StatusServiceUnavailable && ready.Reason != "" {
+ r.Warnings = append(r.Warnings, "not ready: "+ready.Reason)
+ }
+}
+
+// applyAuth probes /admin/health and records the scope the credential carries,
+// preferring admin over read_only. It reports whether the caller authenticated
+// at all, which is what decides whether the model count can be enriched. A
+// 401/403 is recorded as unauthorized and degrades the report — an auth problem
+// is never an error here.
+//
+// Every other failure — a 500, a timeout, an undecodable body — is recorded as
+// a warning instead. Without one the probe's outcome is indistinguishable from
+// a gateway that authenticated fine and serves no models.
+func (c *Client) applyAuth(ctx context.Context, r *StatusReport) bool {
+ ah, err := c.AdminHealth(ctx)
+ if err != nil {
+ var ae *Error
+ if errors.As(err, &ae) && (ae.Status == http.StatusUnauthorized || ae.Status == http.StatusForbidden) {
+ r.Auth = AuthUnauthorized
+ if c.apiKey != "" {
+ r.Warnings = append(r.Warnings, "credential rejected: "+err.Error())
+ }
+ return false
+ }
+ r.Warnings = append(r.Warnings, "admin health unavailable: "+err.Error())
+ return false
+ }
+ for _, scope := range ah.Scopes {
+ if scope == ScopeAdmin {
+ r.Auth = scope
+ break
+ }
+ if scope == ScopeReadOnly && r.Auth == "" {
+ r.Auth = scope
+ }
+ }
+ return true
+}
diff --git a/internal/api/status_test.go b/internal/api/status_test.go
new file mode 100644
index 0000000..77a21ec
--- /dev/null
+++ b/internal/api/status_test.go
@@ -0,0 +1,347 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+// cnt reads a supplied count, and answers -1 for one the gateway never gave —
+// a value no real count can take, so "absent" can never satisfy an assertion
+// about a number.
+func cnt(n *int) int {
+ if n == nil {
+ return -1
+ }
+ return *n
+}
+
+func TestStatusConnectedWithAdmin(t *testing.T) {
+ srv := gatewayStub(t, true)
+ r, err := testClient(t, srv.URL, "fgw_ok").Status(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ // A half-open circuit and an unroutable target both degrade the report.
+ if r.State != "degraded" || cnt(r.Providers) != 2 || cnt(r.Models) != 2 || r.Targets == nil ||
+ r.Targets.Total != 2 || r.Targets.Routable != 1 ||
+ r.Circuits.HalfOpen != 1 || r.Circuits.Open != 0 ||
+ r.MCP == nil || r.MCP.Ready != 1 || r.MCP.Total != 2 ||
+ r.Auth != "admin" || len(r.Warnings) == 0 {
+ t.Fatalf("bad report: %+v", r)
+ }
+ if r.URL != strings.TrimRight(srv.URL, "/") {
+ t.Fatalf("report must name the gateway: %q", r.URL)
+ }
+ if !strings.Contains(strings.Join(r.Warnings, ";"), "openai") {
+ t.Fatalf("the half-open provider must be named: %v", r.Warnings)
+ }
+ if r.LatencyMs < 0 {
+ t.Fatal("latency must be measured")
+ }
+}
+
+func TestStatusUnauthorizedStillReports(t *testing.T) {
+ srv := gatewayStub(t, true)
+ // No key: /health and /readyz still answer; admin and models do not.
+ r, err := testClient(t, srv.URL, "").Status(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Models is nil, not 0: without an accepted credential /v1/models is never
+ // asked, and reporting zero would state a fact the CLI does not have.
+ if r.Auth != "unauthorized" || r.Models != nil || cnt(r.Providers) != 2 {
+ t.Fatalf("unauth status must degrade gracefully: %+v", r)
+ }
+ if r.Targets == nil || r.Targets.Total != 2 || r.MCP == nil {
+ t.Fatalf("unauthenticated ground truth must still be read: %+v", r)
+ }
+}
+
+func TestStatusUnreachable(t *testing.T) {
+ c := testClient(t, "http://127.0.0.1:1", "")
+ r, err := c.Status(context.Background())
+ if err == nil || r == nil || r.State != "unreachable" {
+ t.Fatalf("want unreachable report + error, got %+v, %v", r, err)
+ }
+ if r.URL == "" {
+ t.Fatal("an unreachable report still names the URL it tried")
+ }
+}
+
+func TestStatusDegradedOn503(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, http.StatusServiceUnavailable, `{"status":"no_providers","providers":[]}`)
+ })
+ mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, http.StatusServiceUnavailable, `{"status":"not_ready","reason":"no routable targets"}`)
+ })
+ mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"no_providers","providers":[],"components":[],"scopes":["read_only"]}`)
+ })
+ mux.HandleFunc("GET /v1/models", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"object":"list","data":[]}`)
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ r, err := testClient(t, srv.URL, "fgw_ok").Status(context.Background())
+ if err != nil {
+ t.Fatalf("503 is a report, not a failure: %v", err)
+ }
+ joined := strings.Join(r.Warnings, ";")
+ if r.State != "degraded" || !strings.Contains(joined, "no_providers") || !strings.Contains(joined, "no routable targets") {
+ t.Fatalf("bad 503 report: %+v", r)
+ }
+ if r.Auth != "read_only" {
+ t.Fatalf("scope must be reported: %+v", r)
+ }
+}
+
+func TestStatusConnectedWhenAllHealthy(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"ok","providers":[{"name":"openai","status":"available","circuit":"closed","models":3}]}`)
+ })
+ mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"ready","targets":[{"name":"openai-primary","routable":true}]}`)
+ })
+ mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"healthy","providers":[],"components":[]}`)
+ })
+ mux.HandleFunc("GET /v1/models", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"object":"list","data":[{"id":"gpt-5.1","object":"model","created":0,"owned_by":"openai"}]}`)
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ r, err := testClient(t, srv.URL, "fgw_ok").Status(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.State != "connected" || len(r.Warnings) != 0 || r.MCP != nil {
+ t.Fatalf("healthy gateway must report connected with no warnings: %+v", r)
+ }
+ // No scopes in the body: authenticated, but the scope is unknown — models
+ // are still enriched because the probe did not fail.
+ if r.Auth != "" || cnt(r.Models) != 1 {
+ t.Fatalf("scope-less admin health: %+v", r)
+ }
+}
+
+func TestStatusDegradedWhenReadinessCannotBeRead(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"ok","providers":[]}`)
+ })
+ mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{not-json`)
+ })
+ mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, http.StatusUnauthorized, `{"error":{"message":"nope"}}`)
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ r, err := testClient(t, srv.URL, "").Status(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.State != "degraded" || !strings.Contains(strings.Join(r.Warnings, ";"), "readiness") {
+ t.Fatalf("a failed readiness probe must degrade the report: %+v", r)
+ }
+}
+
+func TestStatusPreservesReportedEmptyTargets(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"ok","providers":[]}`)
+ })
+ mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"ready","targets":[]}`)
+ })
+ mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, http.StatusUnauthorized, `{"error":{"message":"nope"}}`)
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ r, err := testClient(t, srv.URL, "").Status(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.Targets == nil || r.Targets.Total != 0 {
+ t.Fatalf("a reported empty target list is known zero, not absent: %+v", r.Targets)
+ }
+}
+
+func TestStatusMeasuresRealLatency(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
+ time.Sleep(25 * time.Millisecond)
+ writeJSON(t, w, 200, `{"status":"ok","providers":[]}`)
+ })
+ mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"ready"}`)
+ })
+ mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, http.StatusUnauthorized, `{"error":{"message":"nope","type":"authentication_error"}}`)
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ r, err := testClient(t, srv.URL, "").Status(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.LatencyMs < 20 {
+ t.Fatalf("latency must be the measured /health RTT, got %dms", r.LatencyMs)
+ }
+}
+
+// healthyExceptAdmin serves a gateway whose unauthenticated ground truth is
+// perfect, so nothing but the admin probe can degrade the report.
+func healthyExceptAdmin(t *testing.T, adminStatus int, adminBody string) *httptest.Server {
+ t.Helper()
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"ok","providers":[{"name":"openai","status":"available","circuit":"closed","models":3}]}`)
+ })
+ mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"ready","targets":[{"name":"openai-primary","routable":true}]}`)
+ })
+ mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, adminStatus, adminBody)
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func TestStatusDegradesWhenCredentialIsRejected(t *testing.T) {
+ srv := healthyExceptAdmin(t, http.StatusUnauthorized, `{"error":{"message":"key expired"}}`)
+
+ // A credential was presented and refused: healthy probes must not hide it.
+ r, err := testClient(t, srv.URL, "fgw_expired").Status(context.Background())
+ if err != nil {
+ t.Fatalf("an auth failure degrades, it never fails: %v", err)
+ }
+ if r.State != StateDegraded || r.Auth != AuthUnauthorized {
+ t.Fatalf("a rejected credential must degrade: %+v", r)
+ }
+ if !strings.Contains(strings.Join(r.Warnings, ";"), "credential rejected") {
+ t.Fatalf("the report must say why it degraded: %v", r.Warnings)
+ }
+
+ // No credential at all is not a fault — nothing was rejected.
+ r, err = testClient(t, srv.URL, "").Status(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.State != StateConnected || r.Auth != AuthUnauthorized || len(r.Warnings) != 0 {
+ t.Fatalf("an unauthenticated probe must report connected: %+v", r)
+ }
+}
+
+func TestStatusWarnsWhenAdminProbeFailsForANonAuthReason(t *testing.T) {
+ srv := healthyExceptAdmin(t, http.StatusInternalServerError, `{"error":{"message":"boom"}}`)
+
+ r, err := testClient(t, srv.URL, "fgw_ok").Status(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Without the warning a broken probe is indistinguishable from a gateway
+ // that authenticated fine and serves no models.
+ if !strings.Contains(strings.Join(r.Warnings, ";"), "admin health unavailable") {
+ t.Fatalf("a non-auth probe failure must be visible: %+v", r)
+ }
+ if r.Auth != "" {
+ t.Fatalf("a 500 says nothing about the credential: %+v", r)
+ }
+}
+
+func TestStatusPreservesEmptyMCPAndSelectsKnownScope(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"ok","providers":[]}`)
+ })
+ mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"ready","targets":[],"mcp_servers":[]}`)
+ })
+ mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"healthy","providers":[],"components":[],"scopes":["custom","read_only","admin"]}`)
+ })
+ mux.HandleFunc("GET /v1/models", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, http.StatusInternalServerError, `{"error":{"message":"catalog failed"}}`)
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ r, err := testClient(t, srv.URL, "fgw_ok").Status(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.MCP == nil || r.MCP.Total != 0 || r.Auth != "admin" {
+ t.Fatalf("empty-present MCP and scope precedence were lost: %+v", r)
+ }
+ if !strings.Contains(strings.Join(r.Warnings, ";"), "models unavailable") {
+ t.Fatalf("model enrichment failure must be visible: %v", r.Warnings)
+ }
+}
+
+// A gateway that answers zero and one that never answered are different facts.
+// Before Providers/Models became pointers both rendered as a real 0, so a
+// /health that failed on a still-reachable gateway reported "0 providers" —
+// and the status table printed "0" for providers beside "-" for models under
+// exactly the same condition.
+func TestSuppliedZeroIsNotAbsent(t *testing.T) {
+ mux := http.NewServeMux()
+ // /health answers, and honestly reports no providers at all.
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"ok","providers":[]}`)
+ })
+ mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"ready","targets":[]}`)
+ })
+ mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, 200, `{"status":"healthy","providers":[],"components":[],"scopes":["admin"]}`)
+ })
+ // /v1/models is not served by this build at all — never answered.
+ mux.HandleFunc("GET /v1/models", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(t, w, http.StatusNotImplemented, `{"error":{"message":"not supported"}}`)
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ r, err := testClient(t, srv.URL, "fgw_ok").Status(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.Providers == nil || *r.Providers != 0 {
+ t.Fatalf("a reported empty provider list is a known zero, not absent: %v", cnt(r.Providers))
+ }
+ if r.Models != nil {
+ t.Fatalf("an endpoint this build does not serve leaves the count absent, got %v", cnt(r.Models))
+ }
+}
+
+// The frozen --format json contract: an absent count is omitted entirely
+// rather than serialized as 0, so a consumer can tell the two apart too.
+func TestAbsentCountIsOmittedFromJSON(t *testing.T) {
+ zero := 0
+ body, err := json.Marshal(&StatusReport{State: "degraded", Providers: &zero})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(body), `"providers":0`) {
+ t.Fatalf("a supplied zero must serialize as 0: %s", body)
+ }
+ if strings.Contains(string(body), `"models"`) {
+ t.Fatalf("an absent count must be omitted, not sent as 0: %s", body)
+ }
+}
diff --git a/internal/api/types.go b/internal/api/types.go
new file mode 100644
index 0000000..e05069f
--- /dev/null
+++ b/internal/api/types.go
@@ -0,0 +1,88 @@
+package api
+
+// Wire shapes for the gateway's read endpoints. Every json tag here is the
+// gateway's own struct tag, verbatim — the gateway is the schema, this file is
+// only a mirror of it, so nothing is renamed for taste.
+
+// ProviderHealth is one row of the unauthenticated /health provider list.
+type ProviderHealth struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Circuit string `json:"circuit"` // closed|open|half_open
+ Models int `json:"models"`
+}
+
+// HealthReport is GET /health: 200 when serving, 503 when no provider is up.
+type HealthReport struct {
+ Status string `json:"status"` // ok|no_providers
+ Providers []ProviderHealth `json:"providers"`
+}
+
+// ReadyTarget is a configured routing target and whether a request can reach it.
+type ReadyTarget struct {
+ Name string `json:"name"`
+ Routable bool `json:"routable"`
+}
+
+// ReadyProvider is the circuit-only provider view served by /readyz.
+type ReadyProvider struct {
+ Name string `json:"name"`
+ Circuit string `json:"circuit"`
+}
+
+// MCPServer is one MCP tool server. LastError is served by /admin/health only:
+// /readyz is unauthenticated and the reason can quote a URL or a token.
+type MCPServer struct {
+ Name string `json:"name"`
+ Ready bool `json:"ready"`
+ Required bool `json:"required"`
+ LastError string `json:"last_error,omitempty"`
+}
+
+// ReadyReport is GET /readyz: 200 ready, or 503 with Reason set.
+type ReadyReport struct {
+ Status string `json:"status"` // ready|not_ready
+ Reason string `json:"reason,omitempty"`
+ Providers []ReadyProvider `json:"providers,omitempty"`
+ Targets []ReadyTarget `json:"targets,omitempty"`
+ MCPServers []MCPServer `json:"mcp_servers,omitempty"`
+}
+
+// AdminProviderHealth is one row of the authenticated provider list, which
+// carries a message a failing provider explains itself with.
+type AdminProviderHealth struct {
+ Name string `json:"name"`
+ Status string `json:"status"` // healthy|degraded|disabled|unavailable|available
+ Models int `json:"models"`
+ Message string `json:"message,omitempty"`
+}
+
+// AdminComponent is a subsystem (API, key store, log store, …) and its status.
+type AdminComponent struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+}
+
+// AdminHealth is GET /admin/health, which always answers 200 when the caller
+// is authenticated — so it doubles as ferro's auth and scope probe.
+type AdminHealth struct {
+ Status string `json:"status"`
+ Providers []AdminProviderHealth `json:"providers"`
+ Components []AdminComponent `json:"components"`
+ MCPServers []MCPServer `json:"mcp_servers,omitempty"`
+ Scopes []string `json:"scopes,omitempty"`
+}
+
+// Model is one entry of the OpenAI-shaped GET /v1/models listing.
+type Model struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ Created int64 `json:"created"`
+ OwnedBy string `json:"owned_by"`
+ Mode string `json:"mode,omitempty"`
+ ContextWindow int `json:"context_window,omitempty"`
+ MaxOutputTokens int `json:"max_output_tokens,omitempty"`
+ Capabilities []string `json:"capabilities,omitempty"`
+ Status string `json:"status,omitempty"`
+ Deprecated bool `json:"deprecated,omitempty"`
+}
diff --git a/internal/command/chat.go b/internal/command/chat.go
new file mode 100644
index 0000000..ae8a0c0
--- /dev/null
+++ b/internal/command/chat.go
@@ -0,0 +1,286 @@
+package command
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+)
+
+const (
+ // maxPromptBytes prevents piped input from consuming memory without bound.
+ // Command-line arguments already have a much smaller OS-level limit.
+ maxPromptBytes = 1 << 20
+
+ // attributionRetryDelay is how long ferro waits before its single retry of
+ // the trace lookup. The request-log row is written after the stream ends,
+ // so the first lookup routinely races it and finds nothing.
+ attributionRetryDelay = 500 * time.Millisecond
+
+ // attributionSkew widens the log window backwards past the moment the
+ // request started. created_at is stamped by the *gateway's* clock and the
+ // window is filtered against it, so without a margin a client running a
+ // second fast would never match its own row.
+ // It is a fixed margin, not clock synchronization — the row is then matched
+ // by exact trace id, so a wider window costs nothing but rows scanned.
+ // Matches internal/tui's attributionSlack. The TUI found empirically that a
+ // one-minute margin was not always enough, and two callers of the same endpoint
+ // disagreeing about clock tolerance is a bug waiting for a slow gateway.
+ attributionSkew = 2 * time.Minute
+)
+
+// stdinIsTTY reports whether stdin is a terminal, which is how `ferro chat`
+// with no arguments tells "the operator forgot the prompt" from "the prompt is
+// being piped in". It is a variable so a test can state which of the two it is
+// exercising; nothing else reassigns it.
+var stdinIsTTY = isTerminalStdin
+
+func isTerminalStdin() bool { return isTTY(os.Stdin) }
+
+// chatResult is the frozen --format json contract. Provider and cost are
+// omitted rather than zeroed when attribution found nothing: a consumer must be
+// able to tell "served by nobody knows what" from "served by a provider named
+// empty string", and $0.0000 is a real price some requests genuinely have.
+type chatResult struct {
+ Model string `json:"model"`
+ Content string `json:"content"`
+ Usage *api.Usage `json:"usage"`
+ RequestID string `json:"request_id"`
+ Provider string `json:"provider,omitempty"`
+ CostUSD *float64 `json:"cost_usd,omitempty"`
+}
+
+func newChatCmd() *cobra.Command {
+ var model, system string
+ cmd := &cobra.Command{
+ Use: "chat [flags] ...",
+ Short: "Send one prompt through the gateway and stream the answer",
+ Long: "chat sends a single prompt to a model through the gateway and streams the\n" +
+ "answer to stdout as it arrives. The prompt is the arguments joined, or\n" +
+ "stdin when there are none and stdin is not a terminal.\n\n" +
+ "stdout carries the answer and nothing else — no ANSI, no markdown\n" +
+ "rendering — so `ferro chat ... > answer.txt` captures exactly what the\n" +
+ "model said. What served it, what it cost, and any failure go to stderr.\n" +
+ "A stream that fails or ends mid-answer exits 1: a truncated answer that\n" +
+ "exited 0 would be indistinguishable from a complete one.",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ // Validated here rather than left to the gateway: an empty value
+ // satisfies cobra's required-flag check, which only asks whether the
+ // flag was set.
+ model = strings.TrimSpace(model)
+ if model == "" {
+ return errors.New("--model is required and must name a model this gateway serves (ferro models)")
+ }
+ prompt, err := readPrompt(cmd, args)
+ if err != nil {
+ return err
+ }
+ return runChat(cmd, model, system, prompt)
+ },
+ }
+ f := cmd.Flags()
+ f.StringVar(&model, "model", "", "model to send the prompt to (required)")
+ f.StringVar(&system, "system", "", "system message sent ahead of the prompt")
+ _ = cmd.MarkFlagRequired("model")
+ return cmd
+}
+
+// readPrompt takes the prompt from the arguments, or from stdin when there are
+// none. Reading a terminal would block forever on input the operator does not
+// know is wanted, so that case is an error naming both ways to supply one.
+func readPrompt(cmd *cobra.Command, args []string) (string, error) {
+ if len(args) > 0 {
+ return strings.Join(args, " "), nil
+ }
+ if stdinIsTTY() {
+ return "", errors.New(`no prompt: pass one as arguments (ferro chat "explain X" --model M) or pipe it on stdin`)
+ }
+ b, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), maxPromptBytes+1))
+ if err != nil {
+ return "", fmt.Errorf("read prompt from stdin: %w", err)
+ }
+ if len(b) > maxPromptBytes {
+ return "", fmt.Errorf("prompt exceeds the %d-byte stdin limit", maxPromptBytes)
+ }
+ prompt := strings.TrimSpace(string(b))
+ if prompt == "" {
+ return "", errors.New("no prompt: stdin was empty")
+ }
+ return prompt, nil
+}
+
+func runChat(cmd *cobra.Command, model, system, prompt string) error {
+ d := deps(cmd)
+ ctx := cmd.Context()
+
+ msgs := make([]api.ChatMessage, 0, 2)
+ if system != "" {
+ msgs = append(msgs, api.ChatMessage{Role: "system", Content: system})
+ }
+ msgs = append(msgs, api.ChatMessage{Role: "user", Content: prompt})
+
+ started := time.Now().Add(-attributionSkew)
+ st, err := d.Client.StreamChat(ctx, api.ChatRequest{Model: model, Messages: msgs})
+ if err != nil {
+ return err
+ }
+ // Cancel releases the reader goroutine and the connection even after a
+ // clean end, and is safe to call twice.
+ defer st.Cancel()
+
+ // Table format is the streaming, human-facing mode; json and yaml collect
+ // the whole answer and emit one document, so nothing but that document may
+ // touch stdout.
+ streaming := d.Printer.Format == FormatTable
+
+ res := chatResult{Model: model, RequestID: st.RequestID}
+ var content strings.Builder
+ var wroteContent, endedWithNewline bool
+ var streamErr *api.Error
+ done := false
+
+ for ev := range st.Events {
+ switch {
+ case ev.Err != nil:
+ streamErr = ev.Err
+ case ev.Done:
+ done = true
+ case ev.Chunk != nil:
+ // The gateway names the model that actually served the request;
+ // fall back to the requested one only when it does not.
+ if ev.Chunk.Model != "" {
+ res.Model = ev.Chunk.Model
+ }
+ if ev.Chunk.Usage != nil {
+ res.Usage = ev.Chunk.Usage
+ }
+ for _, ch := range ev.Chunk.Choices {
+ // reasoning_content and tool_calls are deliberately not
+ // printed — stdout is the answer, and anything else on it
+ // corrupts the one channel a pipeline reads.
+ if ch.Delta.Content == "" {
+ continue
+ }
+ if streaming {
+ // In production Out is os.Stdout, an *os.File: every write
+ // is a syscall, so deltas appear as they arrive with no
+ // flushing of ours to arrange.
+ _, _ = fmt.Fprint(d.Printer.Out, ch.Delta.Content)
+ wroteContent = true
+ endedWithNewline = strings.HasSuffix(ch.Delta.Content, "\n")
+ } else {
+ content.WriteString(ch.Delta.Content)
+ }
+ }
+ }
+ }
+ res.Content = content.String()
+
+ if streaming && wroteContent && !endedWithNewline {
+ // Models rarely end on a newline; without this the shell prompt lands
+ // mid-answer. It is a terminator, not a rendering.
+ _, _ = fmt.Fprintln(d.Printer.Out)
+ }
+
+ // Checked before the error paths: an interrupt can surface either as a
+ // silent close or as a truncation the reader noticed first, and both mean
+ // the same thing — the operator ended their own request.
+ if !done && ctx.Err() != nil {
+ d.Printer.Warn("interrupted — output is truncated")
+ if !streaming {
+ if err := d.printStructured(res); err != nil {
+ return err
+ }
+ return errors.New("chat interrupted — structured output is truncated")
+ }
+ return ctx.Err()
+ }
+ if streamErr != nil {
+ return streamFailure(streamErr)
+ }
+ if !done {
+ // Belt and braces: internal/api synthesizes stream_incomplete for this,
+ // so arriving here means even that frame could not be delivered. Same
+ // fact, same non-zero exit.
+ return streamFailure(&api.Error{Code: api.CodeStreamIncomplete})
+ }
+
+ if entry := attribute(ctx, d.Client, st.RequestID, started); entry != nil {
+ res.Provider = entry.Provider
+ res.CostUSD = entry.CostUSD
+ }
+ if !streaming {
+ return d.printStructured(res)
+ }
+ printChatMeta(d, res)
+ return nil
+}
+
+// streamFailure turns a terminal stream code into the sentence an operator can
+// act on. All three exit non-zero: the answer on stdout is incomplete in every
+// one of them, and only the exit code can say so to a pipeline.
+func streamFailure(e *api.Error) error {
+ switch e.Code {
+ case api.CodeStreamTimeout:
+ // The bound is ferro's own client-side idle timer (api.
+ // DefaultStreamIdleTimeout) — the gateway takes no part in the
+ // decision, so the duration is a fact this process knows, not a guess.
+ return fmt.Errorf("stream idled out — the gateway sent no events for %s, so ferro closed the connection",
+ api.DefaultStreamIdleTimeout)
+ case api.CodeStreamIncomplete:
+ return errors.New("stream ended mid-answer — output is truncated")
+ default:
+ if e.Message == "" {
+ return fmt.Errorf("stream failed: %s", e.Error())
+ }
+ return fmt.Errorf("stream failed: %s", e.Message)
+ }
+}
+
+// attribute resolves which provider served the answer and what it cost. A
+// streamed body names neither, so the only path is the request log — which is
+// written after the stream ends, hence the one retry.
+//
+// Every failure answers nil: attribution is a detail printed beside an answer
+// the operator already has, and failing the command over its absence would turn
+// a cosmetic gap into a broken chat.
+func attribute(ctx context.Context, c *api.Client, traceID string, since time.Time) *api.LogEntry {
+ for attempt := 0; attempt < 2; attempt++ {
+ if attempt > 0 {
+ select {
+ case <-ctx.Done():
+ return nil
+ case <-time.After(attributionRetryDelay):
+ }
+ }
+ entry, err := c.TraceAttribution(ctx, traceID, since)
+ if err != nil || entry != nil {
+ return entry
+ }
+ }
+ return nil
+}
+
+// printChatMeta writes the one narrative line a chat adds, to stderr. Each
+// segment is omitted when the fact behind it is absent rather than rendered as
+// a zero or an empty name — an invented provider is worse than a shorter line.
+func printChatMeta(d *runtimeDeps, res chatResult) {
+ seg := []string{res.Model}
+ if res.Provider != "" {
+ seg = append(seg, res.Provider)
+ }
+ if res.Usage != nil {
+ seg = append(seg, fmt.Sprintf("%d in / %d out", res.Usage.PromptTokens, res.Usage.CompletionTokens))
+ }
+ if res.CostUSD != nil {
+ seg = append(seg, fmtCost(res.CostUSD))
+ }
+ _, _ = fmt.Fprintln(d.Printer.Err, strings.Join(seg, " "+d.Printer.glyph("·", "|")+" "))
+}
diff --git a/internal/command/chat_test.go b/internal/command/chat_test.go
new file mode 100644
index 0000000..cee848f
--- /dev/null
+++ b/internal/command/chat_test.go
@@ -0,0 +1,416 @@
+package command
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "go.yaml.in/yaml/v3"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/fixture"
+)
+
+// fullReply is what the fixture streams back, one delta per word. The command
+// must reproduce it byte for byte: stdout is the answer, not a rendering of it.
+const fullReply = "Ferro routed this through the fake gateway."
+
+// recorder captures what ferro put on the wire, so a test can assert both what
+// was sent and — for the flag-validation case — that nothing was sent at all.
+type recorder struct {
+ mu sync.Mutex
+ requests int
+ chatBody []byte
+}
+
+func (r *recorder) record(path string, body []byte) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.requests++
+ if path == "/v1/chat/completions" {
+ r.chatBody = body
+ }
+}
+
+func (r *recorder) count() int {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.requests
+}
+
+func (r *recorder) chat() []byte {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.chatBody
+}
+
+// recordingGateway is the fixture with a tap on it. The fixture stays the one
+// definition of the wire contract; this only observes the traffic.
+func recordingGateway(t *testing.T, s fixture.State) (*httptest.Server, *recorder) {
+ t.Helper()
+ rec := &recorder{}
+ inner := fixture.Handler(s)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ r.Body = io.NopCloser(bytes.NewReader(body))
+ rec.record(r.URL.Path, body)
+ inner.ServeHTTP(w, r)
+ }))
+ t.Cleanup(srv.Close)
+ return srv, rec
+}
+
+// runChatWith runs one invocation with control over the three things a chat
+// cares about that the shared `run` helper hides: stdin, the stdout writer, and
+// the context that an interrupt cancels.
+func runChatWith(ctx context.Context, t *testing.T, srv *httptest.Server, stdin io.Reader, stdout io.Writer, args ...string) (stderr string, err error) {
+ t.Helper()
+ // This builds its own root instead of going through execute(), so it does not
+ // inherit execute's config isolation -- see root_test.go. Without this line a
+ // developer's real ~/.config/ferro/config.yaml resolves into PersistentPreRunE
+ // and steers every test below it.
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ t.Setenv("FERRO_API_KEY", "fgw_test")
+ root := NewRoot()
+ var errb bytes.Buffer
+ if stdin != nil {
+ root.SetIn(stdin)
+ }
+ root.SetOut(stdout)
+ root.SetErr(&errb)
+ root.SetArgs(append(args, "--gateway-url", srv.URL))
+ // Executed on its own line: `return errb.String(), root.Execute…` would read
+ // the buffer before the command that fills it has run.
+ err = root.ExecuteContext(ctx)
+ return errb.String(), err
+}
+
+func TestChatStreamsContentToStdoutVerbatim(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+
+ stdout, _, err := run(t, srv, "chat", "hello", "there", "--model", "claude-sonnet-4-6")
+ if err != nil {
+ t.Fatalf("a complete stream must exit 0: %v", err)
+ }
+ if stdout != fullReply+"\n" {
+ t.Fatalf("stdout must carry the answer verbatim, got %q want %q", stdout, fullReply+"\n")
+ }
+ if strings.Contains(stdout, "\x1b") {
+ t.Fatalf("stdout must be free of ANSI — it is the pipeable channel: %q", stdout)
+ }
+}
+
+// The answer belongs on stdout; everything ferro knows *about* the answer
+// belongs on stderr, or `ferro chat ... > answer.txt` captures the narrative too.
+func TestChatMetaLineGoesToStderrNotStdout(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+
+ stdout, stderr, err := run(t, srv, "chat", "hi", "--model", "claude-sonnet-4-6")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"claude-sonnet-4-6", "anthropic", "24 in / 7 out", "$0.0031"} {
+ if !strings.Contains(stderr, want) {
+ t.Fatalf("meta line missing %q, stderr was %q", want, stderr)
+ }
+ }
+ if stdout != fullReply+"\n" {
+ t.Fatalf("meta leaked into stdout: %q", stdout)
+ }
+}
+
+func TestChatJSONIsExactlyOneDocument(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+
+ stdout, _, err := run(t, srv, "chat", "hi", "--model", "claude-sonnet-4-6", "--format", "json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ doc := oneJSONDoc(t, stdout)
+ if doc["content"] != fullReply {
+ t.Fatalf("json content must be the whole answer: %v", doc["content"])
+ }
+ for _, k := range []string{"model", "content", "usage", "request_id", "provider", "cost_usd"} {
+ if _, ok := doc[k]; !ok {
+ t.Fatalf("json document missing %q: %v", k, doc)
+ }
+ }
+ if strings.Contains(stdout, fullReply+"\n"+fullReply) {
+ t.Fatalf("json mode must not also stream prose: %q", stdout)
+ }
+}
+
+// The model is not a detail the gateway can supply a default for, and a request
+// missing it is a wasted round trip against a live gateway.
+func TestChatWithoutModelFailsBeforeAnyRequest(t *testing.T) {
+ srv, rec := recordingGateway(t, fixture.Default())
+
+ _, err := runChatWith(context.Background(), t, srv, nil, io.Discard, "chat", "hi")
+ if err == nil {
+ t.Fatal("a chat with no --model must fail")
+ }
+ if !strings.Contains(err.Error(), "model") {
+ t.Fatalf("the error must name the missing flag, got %v", err)
+ }
+ if n := rec.count(); n != 0 {
+ t.Fatalf("nothing may reach the gateway before --model is validated, saw %d requests", n)
+ }
+}
+
+// An empty value passes cobra's required-flag check (the flag *was* set), so
+// the guard has to be ferro's own.
+func TestChatEmptyModelFailsBeforeAnyRequest(t *testing.T) {
+ srv, rec := recordingGateway(t, fixture.Default())
+
+ _, err := runChatWith(context.Background(), t, srv, nil, io.Discard, "chat", "hi", "--model", " ")
+ if err == nil {
+ t.Fatal("a blank --model must fail")
+ }
+ if n := rec.count(); n != 0 {
+ t.Fatalf("a blank --model must not reach the gateway, saw %d requests", n)
+ }
+}
+
+func TestChatSendsSystemAndUserMessages(t *testing.T) {
+ srv, rec := recordingGateway(t, fixture.Default())
+
+ if _, err := runChatWith(context.Background(), t, srv, nil, io.Discard,
+ "chat", "how", "are", "you", "--model", "claude-sonnet-4-6", "--system", "be terse"); err != nil {
+ t.Fatal(err)
+ }
+ var sent struct {
+ Model string `json:"model"`
+ Stream bool `json:"stream"`
+ Messages []struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.chat(), &sent); err != nil {
+ t.Fatalf("chat body: %v (%s)", err, rec.chat())
+ }
+ if sent.Model != "claude-sonnet-4-6" || !sent.Stream {
+ t.Fatalf("model/stream wrong: %+v", sent)
+ }
+ if len(sent.Messages) != 2 {
+ t.Fatalf("want system + user, got %+v", sent.Messages)
+ }
+ if sent.Messages[0].Role != "system" || sent.Messages[0].Content != "be terse" {
+ t.Fatalf("system message: %+v", sent.Messages[0])
+ }
+ if sent.Messages[1].Role != "user" || sent.Messages[1].Content != "how are you" {
+ t.Fatalf("prompt must be the args joined: %+v", sent.Messages[1])
+ }
+}
+
+// A half-written answer that exits 0 is indistinguishable from a complete one
+// to anything consuming this command — so a mid-stream failure is exit 1 with
+// the reason on stderr.
+func TestChatMidStreamErrorExitsNonZero(t *testing.T) {
+ s := fixture.Default()
+ s.ChatFails = true
+ srv := gateway(t, s)
+
+ stdout, stderr, err := run(t, srv, "chat", "hi", "--model", "claude-sonnet-4-6")
+ if err == nil {
+ t.Fatal("a mid-stream error frame must exit non-zero")
+ }
+ if !strings.Contains(stderr, "upstream error from provider anthropic") {
+ t.Fatalf("stderr must carry the gateway's reason, got %q", stderr)
+ }
+ if !strings.Contains(stdout, "Ferro routed") {
+ t.Fatalf("the part that did arrive still belongs on stdout: %q", stdout)
+ }
+}
+
+// A gateway with no request-log store cannot attribute anything. That is a
+// missing detail beside an answer that arrived, not a failed chat.
+func TestChatWithoutLogStoreSucceedsAndOmitsProvider(t *testing.T) {
+ s := fixture.Default()
+ s.NoLogStore = true
+ srv := gateway(t, s)
+
+ stdout, stderr, err := run(t, srv, "chat", "hi", "--model", "claude-sonnet-4-6")
+ if err != nil {
+ t.Fatalf("absent attribution must not fail the chat: %v", err)
+ }
+ if stdout != fullReply+"\n" {
+ t.Fatalf("the answer must still arrive: %q", stdout)
+ }
+ if !strings.Contains(stderr, "24 in / 7 out") {
+ t.Fatalf("usage comes from the stream, not the log: %q", stderr)
+ }
+ if strings.Contains(stderr, "anthropic") || strings.Contains(stderr, "$") {
+ t.Fatalf("an unattributed answer must not name a provider or a cost: %q", stderr)
+ }
+}
+
+// The other two terminal codes cannot be produced by the fixture — one is the
+// gateway's idle bound elapsing, the other a dropped connection — so their
+// wording is asserted here. All three are errors, which is what makes them
+// exit non-zero.
+func TestStreamFailureWording(t *testing.T) {
+ cases := []struct {
+ name string
+ err api.Error
+ want string
+ }{
+ {"timeout", api.Error{Code: api.CodeStreamTimeout}, "stream idled out — the gateway sent no events for 2m0s, so ferro closed the connection"},
+ {"incomplete", api.Error{Code: api.CodeStreamIncomplete}, "stream ended mid-answer — output is truncated"},
+ {"error", api.Error{Code: api.CodeStreamError, Message: "upstream exploded"}, "stream failed: upstream exploded"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := streamFailure(&tc.err)
+ if got == nil {
+ t.Fatal("every terminal stream code must be an error, or a truncated answer exits 0")
+ }
+ if got.Error() != tc.want {
+ t.Fatalf("got %q want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+// cancelingWriter cancels as soon as the first byte of the answer lands, so
+// "the operator pressed ctrl-c mid-stream" is a deterministic event rather than
+// a race against a sleep.
+type cancelingWriter struct {
+ mu sync.Mutex
+ buf strings.Builder
+ cancel context.CancelFunc
+}
+
+func (w *cancelingWriter) Write(p []byte) (int, error) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ n, err := w.buf.Write(p)
+ if w.cancel != nil {
+ w.cancel()
+ w.cancel = nil
+ }
+ return n, err
+}
+
+func (w *cancelingWriter) String() string {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.buf.String()
+}
+
+// A partial answer must never exit zero: shell consumers have no other way to
+// distinguish it from a complete response.
+func TestChatInterruptedFailsAndSaysSo(t *testing.T) {
+ s := fixture.Default()
+ s.ChatDelay = 40 * time.Millisecond
+ srv := gateway(t, s)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ out := &cancelingWriter{cancel: cancel}
+
+ stderr, err := runChatWith(ctx, t, srv, nil, out, "chat", "hi", "--model", "claude-sonnet-4-6")
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("an interrupted chat must return cancellation, got: %v", err)
+ }
+ if !strings.Contains(stderr, "interrupted") {
+ t.Fatalf("an interrupted chat must say the output is truncated: %q", stderr)
+ }
+ if got := out.String(); got == "" || strings.Contains(got, "gateway.") {
+ t.Fatalf("want the partial answer, got %q", got)
+ }
+}
+
+func TestChatStructuredInterruptPrintsPartialDocumentAndFails(t *testing.T) {
+ for _, format := range []string{"json", "yaml"} {
+ t.Run(format, func(t *testing.T) {
+ s := fixture.Default()
+ s.ChatDelay = 40 * time.Millisecond
+ srv := gateway(t, s)
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ timer := time.AfterFunc(150*time.Millisecond, cancel)
+ defer timer.Stop()
+ var out bytes.Buffer
+ stderr, err := runChatWith(ctx, t, srv, nil, &out,
+ "chat", "hi", "--model", "claude-sonnet-4-6", "--format", format)
+ if err == nil || !strings.Contains(err.Error(), "structured output is truncated") {
+ t.Fatalf("structured interruption must exit non-zero: %v", err)
+ }
+ if !strings.Contains(stderr, "interrupted") {
+ t.Fatalf("interruption warning missing: %q", stderr)
+ }
+ var doc map[string]any
+ if format == "json" {
+ if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
+ t.Fatalf("partial JSON must remain one valid document: %v (%q)", err, out.String())
+ }
+ } else if err := yaml.Unmarshal(out.Bytes(), &doc); err != nil {
+ t.Fatalf("partial YAML must remain one valid document: %v (%q)", err, out.String())
+ }
+ if _, ok := doc["content"]; !ok {
+ t.Fatalf("partial document lost its content field: %v", doc)
+ }
+ })
+ }
+}
+
+func TestChatReadsPromptFromStdinWhenNotATTY(t *testing.T) {
+ srv, rec := recordingGateway(t, fixture.Default())
+ stdinIsTTY = func() bool { return false }
+ t.Cleanup(func() { stdinIsTTY = isTerminalStdin })
+
+ if _, err := runChatWith(context.Background(), t, srv, strings.NewReader("summarize this\n"), io.Discard,
+ "chat", "--model", "claude-sonnet-4-6"); err != nil {
+ t.Fatal(err)
+ }
+ var sent struct {
+ Messages []struct{ Content string } `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.chat(), &sent); err != nil {
+ t.Fatal(err)
+ }
+ if len(sent.Messages) != 1 || sent.Messages[0].Content != "summarize this" {
+ t.Fatalf("prompt must come from stdin: %+v", sent.Messages)
+ }
+}
+
+func TestChatRejectsOversizedStdinPrompt(t *testing.T) {
+ srv, rec := recordingGateway(t, fixture.Default())
+ stdinIsTTY = func() bool { return false }
+ t.Cleanup(func() { stdinIsTTY = isTerminalStdin })
+
+ _, err := runChatWith(context.Background(), t, srv,
+ strings.NewReader(strings.Repeat("x", maxPromptBytes+1)), io.Discard,
+ "chat", "--model", "claude-sonnet-4-6")
+ if err == nil || !strings.Contains(err.Error(), "prompt exceeds") {
+ t.Fatalf("oversized stdin must fail clearly, got %v", err)
+ }
+ if n := rec.count(); n != 0 {
+ t.Fatalf("oversized stdin must not reach the gateway, saw %d requests", n)
+ }
+}
+
+// With no args and a terminal on stdin there is no prompt and nothing to wait
+// for: say so rather than block forever on a read that will never return.
+func TestChatWithNoPromptAndATTYFailsWithGuidance(t *testing.T) {
+ srv, rec := recordingGateway(t, fixture.Default())
+ stdinIsTTY = func() bool { return true }
+ t.Cleanup(func() { stdinIsTTY = isTerminalStdin })
+
+ _, err := runChatWith(context.Background(), t, srv, nil, io.Discard, "chat", "--model", "claude-sonnet-4-6")
+ if err == nil || !strings.Contains(err.Error(), "prompt") {
+ t.Fatalf("want a prompt-shaped error, got %v", err)
+ }
+ if n := rec.count(); n != 0 {
+ t.Fatalf("a promptless chat must not reach the gateway, saw %d requests", n)
+ }
+}
diff --git a/internal/command/keys.go b/internal/command/keys.go
new file mode 100644
index 0000000..9c5badd
--- /dev/null
+++ b/internal/command/keys.go
@@ -0,0 +1,254 @@
+package command
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/table"
+)
+
+func newKeysCmd() *cobra.Command {
+ keys := &cobra.Command{
+ Use: "keys",
+ Short: "Manage gateway API keys",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() },
+ }
+ keys.AddCommand(newKeysListCmd(), newKeysGetCmd(), newKeysCreateCmd(),
+ newKeysRotateCmd(), newKeysRevokeCmd())
+ return keys
+}
+
+func newKeysListCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: verbList,
+ Short: "List API keys with their derived state",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ d := deps(cmd)
+ rows, err := d.Client.Keys(cmd.Context())
+ if err != nil {
+ return err
+ }
+ if d.Printer.Format != FormatTable {
+ return d.printStructured(rows)
+ }
+ cells := make([][]string, 0, len(rows))
+ for _, k := range rows {
+ cells = append(cells, []string{
+ // ID leads because rotate and revoke take one, and the
+ // listing is the only place to find it.
+ k.ID,
+ k.Name,
+ // Already masked on the wire; ferro never unmasks.
+ k.Key,
+ strings.Join(k.Scopes, ","),
+ table.FmtTimePtr(k.ExpiresAt),
+ table.FmtTimePtr(k.LastUsedAt),
+ fmt.Sprintf("%d", k.UsageCount),
+ api.KeyState(k),
+ })
+ }
+ d.Printer.Table([]string{"ID", colName, "KEY", colScopes, colExpires, "LAST USED", "USES", colState}, cells)
+ return nil
+ },
+ }
+}
+
+func newKeysGetCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "get ",
+ Short: "Show one key",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ d := deps(cmd)
+ k, err := d.Client.Key(cmd.Context(), args[0])
+ if err != nil {
+ // A gateway build without GET /admin/keys/{id} still serves the
+ // row in the list, so fall back rather than fail.
+ if !api.IsNotSupported(err) {
+ return err
+ }
+ if k, err = findKey(cmd, args[0]); err != nil {
+ return err
+ }
+ }
+ return d.printStructured(k)
+ },
+ }
+}
+
+// findKey resolves one key out of the full listing.
+func findKey(cmd *cobra.Command, id string) (*api.Key, error) {
+ rows, err := deps(cmd).Client.Keys(cmd.Context())
+ if err != nil {
+ return nil, err
+ }
+ for i := range rows {
+ if rows[i].ID == id {
+ return &rows[i], nil
+ }
+ }
+ return nil, fmt.Errorf("no key with id %q on this gateway", id)
+}
+
+func newKeysCreateCmd() *cobra.Command {
+ create := &cobra.Command{
+ Use: "create",
+ Short: "Create an API key (the secret is shown exactly once)",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ d := deps(cmd)
+ name, _ := cmd.Flags().GetString("name")
+ scope, _ := cmd.Flags().GetString("scope")
+ expiresIn, _ := cmd.Flags().GetDuration("expires-in")
+
+ req, err := keyCreateRequest(name, scope, expiresIn)
+ if err != nil {
+ return err
+ }
+ k, err := d.Client.CreateKey(cmd.Context(), req)
+ if err != nil {
+ return err
+ }
+ return printSecretOnce(d, k, "secret shown once — the gateway stores only a hash")
+ },
+ }
+ create.Flags().String("name", "", "key name (required)")
+ create.Flags().String("scope", api.ScopeReadOnly,
+ api.ScopeAdmin+"|"+api.ScopeReadOnly+" (always sent explicitly)")
+ create.Flags().Duration("expires-in", 720*time.Hour, "lifetime from now; 0 for no expiry")
+ return create
+}
+
+// keyCreateRequest validates client-side so a bad flag costs no request.
+func keyCreateRequest(name, scope string, expiresIn time.Duration) (api.KeyCreateRequest, error) {
+ // These messages lead with a word rather than a flag: the styled error
+ // renderer capitalises the first character, which mangles "--name".
+ if name == "" {
+ return api.KeyCreateRequest{}, errors.New(
+ "a key name is required, so pass --name (a log row names the key, and an unnamed one is unattributable once it is revoked)")
+ }
+ // The two scope names come from internal/api, which derives the same
+ // vocabulary from the wire; a second copy here could drift from it.
+ if scope != api.ScopeAdmin && scope != api.ScopeReadOnly {
+ return api.KeyCreateRequest{}, fmt.Errorf(
+ "scope %q is not valid: --scope must be %s|%s — ferro always sends one explicitly rather than relying on the gateway's default",
+ scope, api.ScopeAdmin, api.ScopeReadOnly)
+ }
+ if expiresIn < 0 {
+ return api.KeyCreateRequest{}, errors.New("expiry must be 0 for no expiry or a positive duration")
+ }
+ req := api.KeyCreateRequest{Name: name, Scopes: []string{scope}}
+ if expiresIn > 0 {
+ t := time.Now().Add(expiresIn).UTC()
+ req.ExpiresAt = &t
+ }
+ return req, nil
+}
+
+func newKeysRotateCmd() *cobra.Command {
+ // Rotation is as destructive as revocation from the outside: every client
+ // still holding the current secret stops authenticating the moment it
+ // lands, and no flag brings the old one back.
+ rotate := &cobra.Command{
+ Use: "rotate ",
+ Short: "Rotate a key (the previous secret dies immediately)",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ d := deps(cmd)
+ if err := confirmDestructive(cmd, "rotate", args[0]); err != nil {
+ return err
+ }
+ k, err := d.Client.RotateKey(cmd.Context(), args[0])
+ if err != nil {
+ return err
+ }
+ return printSecretOnce(d, k,
+ "previous secret stopped authenticating; the new one is shown once")
+ },
+ }
+ addYesFlag(rotate)
+ return rotate
+}
+
+// printSecretOnce is the one deliberate credential display: the secret goes to
+// stdout so it can be piped into a secret store, and every word about it goes
+// to stderr so the pipe stays clean.
+func printSecretOnce(d *runtimeDeps, k *api.Key, warning string) error {
+ d.Printer.Warn("%s", warning)
+ if d.Printer.Format != FormatTable {
+ return d.printStructured(k)
+ }
+ _, err := fmt.Fprintln(d.Printer.Out, k.Key)
+ return err
+}
+
+func newKeysRevokeCmd() *cobra.Command {
+ revoke := &cobra.Command{
+ Use: "revoke ",
+ Short: "Revoke a key (immediate and irreversible)",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ d := deps(cmd)
+ if err := confirmDestructive(cmd, "revoke", args[0]); err != nil {
+ return err
+ }
+ if err := d.Client.RevokeKey(cmd.Context(), args[0]); err != nil {
+ return err
+ }
+ d.Printer.OK("revoked %s", args[0])
+ return nil
+ },
+ }
+ addYesFlag(revoke)
+ return revoke
+}
+
+// flagYes is the one spelling of "I have read the blast radius", shared by both
+// destructive key verbs so a script that learnt it on one keeps it on the other.
+const flagYes = "yes"
+
+func addYesFlag(cmd *cobra.Command) {
+ cmd.Flags().Bool(flagYes, false, "skip the confirmation prompt (required when there is no terminal)")
+}
+
+// confirmDestructive gates rotate and revoke on the operator typing the key id
+// back. Typing the id rather than "y" is the console's discipline -- its modal
+// demands the key's exact name before it will revoke (internal/tui's
+// openRevoke) -- and the id is what the CLI has on the command line, so it is
+// the thing that can actually be checked against a typo in the argument.
+//
+// The prompt goes to stderr and the answer comes from cmd's stdin: stdout stays
+// the payload channel even here, where there is no payload.
+func confirmDestructive(cmd *cobra.Command, verb, id string) error {
+ if yes, _ := cmd.Flags().GetBool(flagYes); yes {
+ return nil
+ }
+ // A pipe, a CI job, or a cron line has nobody to answer. Blocking on a read
+ // that will never return is the worst of the three possible behaviours, and
+ // proceeding unasked is the second worst.
+ if !stdinIsTTY() || !isTTY(os.Stderr) {
+ return fmt.Errorf(
+ "%s %s is irreversible and there is no terminal to confirm on: pass --yes to run it non-interactively",
+ verb, id)
+ }
+ deps(cmd).Printer.Warn("%s %s is irreversible — type the key id to confirm:", verb, id)
+ answer, err := bufio.NewReader(cmd.InOrStdin()).ReadString('\n')
+ // EOF (a closed terminal) is a valid end to the answer, not a read failure.
+ if err != nil && !errors.Is(err, io.EOF) {
+ return fmt.Errorf("read confirmation: %w", err)
+ }
+ if strings.TrimSpace(answer) != id {
+ return fmt.Errorf("aborted: the id typed did not match %q, so nothing was %sd", id, verb)
+ }
+ return nil
+}
diff --git a/internal/command/keys_test.go b/internal/command/keys_test.go
new file mode 100644
index 0000000..a6cf8a0
--- /dev/null
+++ b/internal/command/keys_test.go
@@ -0,0 +1,289 @@
+package command
+
+import (
+ "bytes"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/fixture"
+)
+
+// runInteractive is run() with the terminal faked and an answer already waiting
+// on stdin. Faking is the only way in: isTTY asks real descriptors, and a test
+// binary's stdin is never one of them.
+func runInteractive(t *testing.T, srv *httptest.Server, answer string, args ...string) (stdout, stderr string, err error) {
+ t.Helper()
+ t.Setenv("FERRO_API_KEY", "fgw_test")
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ oldTTY, oldStdin := isTTY, stdinIsTTY
+ isTTY = func(*os.File) bool { return true }
+ stdinIsTTY = func() bool { return true }
+ t.Cleanup(func() { isTTY, stdinIsTTY = oldTTY, oldStdin })
+
+ root := NewRoot()
+ var out, errb bytes.Buffer
+ root.SetOut(&out)
+ root.SetErr(&errb)
+ root.SetIn(strings.NewReader(answer))
+ root.SetArgs(append(args, "--gateway-url", srv.URL))
+ err = root.Execute()
+ return out.String(), errb.String(), err
+}
+
+// keyIsLive reports whether the fixture still serves id as an active key. The
+// fixture's key store is stateful, so this is what tells an aborted revoke
+// apart from one that ran and printed an error afterwards.
+func keyIsLive(t *testing.T, srv *httptest.Server, id string) bool {
+ t.Helper()
+ stdout, _, err := run(t, srv, "keys", "get", id)
+ if err != nil {
+ t.Fatalf("keys get %s: %v", id, err)
+ }
+ var got map[string]any
+ if err := decodeOne(stdout, &got); err != nil {
+ t.Fatalf("keys get %s: %v (%q)", id, err, stdout)
+ }
+ return got["revoked_at"] == nil
+}
+
+func TestKeysListDerivesStateAndNeverPrintsASecret(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "keys", "list")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // ID is here and not in the plan's column set on purpose: rotate and revoke
+ // take an id, and a table listing that omits it cannot start that workflow.
+ for _, col := range []string{"ID", "NAME", "KEY", "SCOPES", "EXPIRES", "LAST USED", "USES", "STATE"} {
+ if !strings.Contains(stdout, col) {
+ t.Fatalf("missing column %s in %q", col, stdout)
+ }
+ }
+ // The gateway serves no state field; all three states are derived from
+ // revoked_at / active, and the seeded store carries one of each.
+ for _, state := range []string{"active", "revoked", "expired"} {
+ if !strings.Contains(stdout, state) {
+ t.Fatalf("derived STATE %q missing: %q", state, stdout)
+ }
+ }
+ // The wire masks reads as head8...tail4; anything longer would mean the
+ // listing had grown a full secret.
+ for _, line := range strings.Split(stdout, "\n") {
+ for _, f := range strings.Fields(line) {
+ if strings.HasPrefix(f, "fgw_") && !strings.Contains(f, "...") {
+ t.Fatalf("list printed an unmasked credential: %q", f)
+ }
+ }
+ }
+}
+
+func TestKeysListJSONIsOneDocument(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "keys", "list", "--format", "json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var rows []map[string]any
+ if err := decodeOne(stdout, &rows); err != nil {
+ t.Fatalf("stdout must be one JSON document: %v (%q)", err, stdout)
+ }
+ if len(rows) == 0 {
+ t.Fatalf("expected the seeded keys: %q", stdout)
+ }
+}
+
+func TestKeysCreateSecretOnStdoutWarningOnStderr(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, stderr, err := run(t, srv, "keys", "create", "--name", "ci", "--scope", "read_only", "--expires-in", "720h")
+ if err != nil {
+ t.Fatal(err)
+ }
+ secret := strings.TrimSpace(stdout)
+ if !strings.HasPrefix(secret, "fgw_") || strings.Contains(secret, "...") {
+ t.Fatalf("the full secret must go to stdout so it can be piped, got %q", stdout)
+ }
+ if !strings.Contains(stderr, "shown once") {
+ t.Fatalf("the shown-once warning belongs on stderr, got %q", stderr)
+ }
+ if strings.Contains(stdout, "shown once") {
+ t.Fatalf("narrative leaked into the pipeable channel: %q", stdout)
+ }
+}
+
+func TestKeysCreateRejectsBadScopeBeforeAnyRequest(t *testing.T) {
+ _, _, err := execute(t, "keys", "create", "--name", "x", "--scope", "root",
+ "--gateway-url", "http://127.0.0.1:1")
+ if err == nil || !strings.Contains(err.Error(), "admin|read_only") {
+ t.Fatalf("scope must be validated client-side, naming the valid values: %v", err)
+ }
+ if !strings.Contains(err.Error(), "always sends one explicitly") {
+ t.Fatalf("the error must explain why ferro always sends a scope: %v", err)
+ }
+ // The gateway defaults a scope-less create to read_only, so the message
+ // must not claim the opposite — it said "granted admin server-side" while
+ // defaultScopes was minting least privilege.
+ if strings.Contains(err.Error(), "server-side") {
+ t.Fatalf("the message must not restate the old backwards claim: %v", err)
+ }
+}
+
+func TestKeysCreateRequiresName(t *testing.T) {
+ _, _, err := execute(t, "keys", "create", "--gateway-url", "http://127.0.0.1:1")
+ if err == nil || !strings.Contains(err.Error(), "--name") {
+ t.Fatalf("a nameless key is unattributable later: %v", err)
+ }
+}
+
+func TestKeysCreateRejectsNegativeExpiry(t *testing.T) {
+ _, err := keyCreateRequest("ci", "read_only", -time.Hour)
+ if err == nil || !strings.Contains(err.Error(), "positive duration") {
+ t.Fatalf("negative expiry must not become a non-expiring key: %v", err)
+ }
+}
+
+func TestKeysRotateShowsNewSecretOnce(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, stderr, err := run(t, srv, "keys", "rotate", "key_02", "--yes")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(strings.TrimSpace(stdout), "fgw_") {
+ t.Fatalf("rotate must print the new secret to stdout: %q", stdout)
+ }
+ if !strings.Contains(stderr, "shown once") || !strings.Contains(stderr, "stopped authenticating") {
+ t.Fatalf("stderr must say the old secret is dead and the new one is shown once: %q", stderr)
+ }
+}
+
+func TestKeysRevokeConfirmsOnStderrAndPrintsNothingToStdout(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, stderr, err := run(t, srv, "keys", "revoke", "key_01", "--yes")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.TrimSpace(stdout) != "" {
+ t.Fatalf("revoke has no payload; stdout must stay empty: %q", stdout)
+ }
+ if !strings.Contains(stderr, "key_01") {
+ t.Fatalf("the confirmation belongs on stderr: %q", stderr)
+ }
+}
+
+func TestKeysGetResolvesOneKey(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "keys", "get", "key_01")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got map[string]any
+ if err := decodeOne(stdout, &got); err != nil {
+ t.Fatalf("keys get renders a structured document: %v (%q)", err, stdout)
+ }
+ if got["id"] != "key_01" {
+ t.Fatalf("wrong key: %v", got)
+ }
+}
+
+func TestKeysGetUnknownIDFails(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ if _, _, err := run(t, srv, "keys", "get", "key_nope"); err == nil {
+ t.Fatal("an unknown key id must be an error, not an empty success")
+ }
+}
+
+// The operator-facing symptom of the KeyState bug: a key whose expiry has
+// passed is served active:true with revoked_at:null, and STATE showed
+// "active" — the one answer that matters on a surface read to decide whether
+// a credential still works.
+func TestKeysListReportsAnExpiredKeyAsExpired(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "keys", "list")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var row string
+ for _, l := range strings.Split(stdout, "\n") {
+ if strings.Contains(l, "demo-temp") {
+ row = l
+ }
+ }
+ if row == "" {
+ t.Fatalf("expected the seeded expired key: %q", stdout)
+ }
+ if !strings.Contains(row, api.KeyStateExpired) {
+ t.Fatalf("a key past its expiry must report %q, got %q", api.KeyStateExpired, row)
+ }
+ if strings.Contains(row, api.KeyStateRevoked) {
+ t.Fatalf("an expired key was never revoked: %q", row)
+ }
+}
+
+// The pipeline case: no terminal to prompt on, so the only two acceptable
+// outcomes are --yes or a refusal. Hanging on a read nobody will answer would
+// wedge a CI job, and revoking unasked is what this gate exists to stop.
+func TestKeysDestructiveVerbsRefuseWithoutTerminalOrYes(t *testing.T) {
+ for _, verb := range []string{"rotate", "revoke"} {
+ t.Run(verb, func(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "keys", verb, "key_01")
+ if err == nil {
+ t.Fatal("a non-interactive destructive verb must refuse, not proceed")
+ }
+ if !strings.Contains(err.Error(), "--yes") {
+ t.Fatalf("the refusal must name the way through it: %v", err)
+ }
+ if strings.TrimSpace(stdout) != "" {
+ t.Fatalf("nothing ran, so stdout must be empty: %q", stdout)
+ }
+ if !keyIsLive(t, srv, "key_01") {
+ t.Fatal("the refused verb reached the gateway anyway")
+ }
+ })
+ }
+}
+
+func TestKeysRevokeProceedsWhenTheIDIsTypedBack(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, stderr, err := runInteractive(t, srv, "key_01\n", "keys", "revoke", "key_01")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(stderr, "type the key id") {
+ t.Fatalf("the prompt belongs on stderr: %q", stderr)
+ }
+ if strings.TrimSpace(stdout) != "" {
+ t.Fatalf("neither prompt nor result is a payload: %q", stdout)
+ }
+ if keyIsLive(t, srv, "key_01") {
+ t.Fatal("a confirmed revoke must actually revoke")
+ }
+}
+
+// "y" is exactly the answer this prompt refuses to accept: the console makes
+// the operator type the key's name for the same reason.
+func TestKeysRevokeAbortsOnAnythingButTheID(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ _, _, err := runInteractive(t, srv, "y\n", "keys", "revoke", "key_01")
+ if err == nil || !strings.Contains(err.Error(), "aborted") {
+ t.Fatalf("a wrong answer must abort: %v", err)
+ }
+ if !keyIsLive(t, srv, "key_01") {
+ t.Fatal("an aborted revoke revoked the key anyway")
+ }
+}
+
+// Ctrl-D at the prompt: an empty answer is a wrong answer, not a default yes.
+func TestKeysRotateAbortsOnEOFAndMintsNoSecret(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := runInteractive(t, srv, "", "keys", "rotate", "key_02")
+ if err == nil || !strings.Contains(err.Error(), "aborted") {
+ t.Fatalf("EOF at the prompt must abort: %v", err)
+ }
+ if strings.Contains(stdout, "fgw_") {
+ t.Fatalf("an aborted rotate must mint nothing: %q", stdout)
+ }
+}
diff --git a/internal/command/logs.go b/internal/command/logs.go
new file mode 100644
index 0000000..b5a4894
--- /dev/null
+++ b/internal/command/logs.go
@@ -0,0 +1,330 @@
+package command
+
+import (
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/table"
+)
+
+const (
+ // tailInterval is the poll cadence of `logs tail`; tailMaxInterval caps the
+ // ×2 backoff a failing gateway earns.
+ tailInterval = 2 * time.Second
+ tailMaxInterval = 30 * time.Second
+
+ // metricCostUSD is the one spelling of the cost total: `logs stats` prints
+ // it as a row name and the JSON form of the same report carries it as a
+ // field, so a script greps one name whichever --format it asked for.
+ metricCostUSD = "cost_usd"
+
+ // tailLookback is the window the first poll covers when no --since was
+ // given. It is a cursor, not a report: enough history that a row written
+ // while the command was starting is not missed, little enough that a bare
+ // tail does not replay the gateway's whole default window as though it had
+ // just happened.
+ tailLookback = 2 * time.Minute
+)
+
+func newLogsCmd() *cobra.Command {
+ logs := &cobra.Command{
+ Use: "logs",
+ Short: "Read the gateway request log",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() },
+ }
+ logs.AddCommand(newLogsListCmd(), newLogsStatsCmd(), newLogsTailCmd())
+ return logs
+}
+
+// addLogFilterFlags installs the filter set `list` and `tail` share, so a flag
+// cannot mean one thing in one verb and another in the next.
+func addLogFilterFlags(cmd *cobra.Command) {
+ f := cmd.Flags()
+ f.String("since", "", "duration (15m, 2h) or an RFC3339 timestamp")
+ f.String("model", "", "only rows for this model")
+ f.String("provider", "", "only rows served by this provider")
+ f.String("stage", "", "pipeline stage; 'all' for every stage (default: terminal stages only)")
+ f.String("api-key-id", "", "only rows for this credential id ('none' for credential-less rows)")
+}
+
+func addLogStatsFilterFlags(cmd *cobra.Command) {
+ f := cmd.Flags()
+ f.String("since", "", "duration (15m, 2h) or an RFC3339 timestamp")
+ f.String("model", "", "only rows for this model")
+ f.String("provider", "", "only rows served by this provider")
+ f.String("stage", "", "only rows for this pipeline stage")
+}
+
+func logsQueryFromFlags(cmd *cobra.Command) (api.LogsQuery, error) {
+ f := cmd.Flags()
+ q := api.LogsQuery{}
+ q.Model, _ = f.GetString("model")
+ q.Provider, _ = f.GetString("provider")
+ q.Stage, _ = f.GetString("stage")
+ q.APIKeyID, _ = f.GetString("api-key-id")
+
+ if since, _ := f.GetString("since"); since != "" {
+ t, err := api.ParseSince(since)
+ if err != nil {
+ return q, err
+ }
+ q.Since = t
+ }
+ // list-only paging flags; absent on tail, where they make no sense.
+ if f.Lookup("limit") != nil {
+ q.Limit, _ = f.GetInt("limit")
+ }
+ if f.Lookup("offset") != nil {
+ q.Offset, _ = f.GetInt("offset")
+ }
+ return q, nil
+}
+
+// noStore turns a 501 into the sentence an operator can act on. The raw error
+// stays wrapped for anyone who needs the status code.
+func noStore(err error) error {
+ if api.IsNotSupported(err) {
+ return fmt.Errorf("this gateway has no request-log store configured, so there are no request logs to read (set REQUEST_LOG_STORE_BACKEND and REQUEST_LOG_STORE_DSN): %w", err)
+ }
+ return err
+}
+
+func newLogsListCmd() *cobra.Command {
+ list := &cobra.Command{
+ Use: verbList,
+ Short: "List request-log rows",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ d := deps(cmd)
+ q, err := logsQueryFromFlags(cmd)
+ if err != nil {
+ return err
+ }
+ page, err := d.Client.Logs(cmd.Context(), q)
+ if err != nil {
+ return noStore(err)
+ }
+ if d.Printer.Format != FormatTable {
+ return d.printStructured(page)
+ }
+ rows := make([][]string, 0, len(page.Data))
+ for _, e := range page.Data {
+ rows = append(rows, []string{
+ table.FmtTime(e.CreatedAt), e.TraceID, table.OrDash(e.Provider), table.OrDash(e.Model),
+ e.Stage, fmtMillis(e.DurationMs), fmtCost(e.CostUSD),
+ fmt.Sprintf("%d", e.TotalTokens),
+ })
+ }
+ d.Printer.Table([]string{colTime, "TRACE", colProvider, "MODEL", "STAGE", "MS", "COST", "TOKENS"}, rows)
+ return nil
+ },
+ }
+ addLogFilterFlags(list)
+ list.Flags().Int("limit", 50, "rows to return (gateway caps at 200)")
+ list.Flags().Int("offset", 0, "rows to skip")
+ return list
+}
+
+func newLogsStatsCmd() *cobra.Command {
+ stats := &cobra.Command{
+ Use: "stats",
+ Short: "Aggregate request-log statistics (totals, errors, latency percentiles)",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ d := deps(cmd)
+ q, err := logsQueryFromFlags(cmd)
+ if err != nil {
+ return err
+ }
+ s, err := d.Client.LogStats(cmd.Context(), q)
+ if err != nil {
+ return noStore(err)
+ }
+ if d.Printer.Format != FormatTable {
+ return d.printStructured(s)
+ }
+ sum := s.Summary
+ rows := slices.Concat(
+ [][]string{
+ {"requests", fmt.Sprintf("%d", sum.TotalEntries)},
+ {"errors", fmt.Sprintf("%d", sum.ErrorEntries)},
+ {"error_rate", fmtRate(sum.ErrorEntries, sum.TotalEntries)},
+ {"tokens", fmt.Sprintf("%d", sum.TotalTokens)},
+ {"prompt_tokens", fmt.Sprintf("%d", sum.PromptTokens)},
+ {"completion_tokens", fmt.Sprintf("%d", sum.CompletionTokens)},
+ {metricCostUSD, fmt.Sprintf("%.4f", sum.CostUSD)},
+ {"unpriced_requests", fmt.Sprintf("%d", sum.UnpricedRequests)},
+ },
+ percentileRows("latency", s.LatencyMs),
+ percentileRows("ttft", s.TTFTMs),
+ )
+ d.Printer.Table([]string{"METRIC", "VALUE"}, rows)
+ return nil
+ },
+ }
+ addLogStatsFilterFlags(stats)
+ return stats
+}
+
+// percentileRows renders one distribution. A nil block means nothing was
+// measured, which is a "-" and not a zero-latency gateway.
+func percentileRows(prefix string, p *api.Percentiles) [][]string {
+ // The unmeasured table, which is also the answer when p is nil.
+ rows := [][]string{
+ {prefix + "_p50_ms", "-"},
+ {prefix + "_p95_ms", "-"},
+ {prefix + "_p99_ms", "-"},
+ {prefix + "_max_ms", "-"},
+ }
+ if p == nil {
+ return rows
+ }
+ for i, v := range []float64{p.P50, p.P95, p.P99, p.Max} {
+ rows[i][1] = fmt.Sprintf("%.1f", v)
+ }
+ return rows
+}
+
+func newLogsTailCmd() *cobra.Command {
+ tail := &cobra.Command{
+ Use: "tail",
+ Short: "Follow the request log (one plain line per new row)",
+ Long: "tail polls the request log every 2s and prints each new row as a line.\n" +
+ "A failing poll backs off ×2 to 30s and keeps the last cursor, so no row\n" +
+ "is skipped. Interrupting a tail is a clean exit, not a failure.",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ d := deps(cmd)
+ q, err := logsQueryFromFlags(cmd)
+ if err != nil {
+ return err
+ }
+ if q.Since.IsZero() {
+ q.Since = time.Now().Add(-tailLookback)
+ }
+ f := api.NewFollower(d.Client, q)
+ delay := tailInterval
+ for {
+ rows, err := f.Poll(cmd.Context())
+ // A truncated poll is progress, not a failure: its rows are
+ // complete and its cursor moved past them, so they are printed,
+ // the cadence stays, and only the older end it could not reach
+ // is reported — on stderr, where every loss is stated.
+ truncated := errors.Is(err, api.ErrFollowTruncated)
+ switch {
+ case err == nil || truncated:
+ delay = tailInterval
+ if truncated {
+ d.Printer.Warn("%v", err)
+ }
+ for _, e := range rows {
+ _, _ = fmt.Fprintln(d.Printer.Out, formatLogLine(e))
+ }
+ case cmd.Context().Err() != nil:
+ // The failing poll is the interrupt itself, not a fault.
+ return nil
+ case api.IsNotSupported(err):
+ // A feature that is absent will not appear by retrying.
+ return noStore(err)
+ default:
+ // Backed off before it is reported: the warning names the
+ // wait the operator is about to sit through, not the one
+ // that has already elapsed.
+ delay = backoff(delay)
+ d.Printer.Warn("poll failed (%v) — retrying in %s", err, delay)
+ }
+ select {
+ case <-cmd.Context().Done():
+ return nil // an interrupted tail is a clean exit
+ case <-time.After(delay):
+ }
+ }
+ },
+ }
+ addLogFilterFlags(tail)
+ return tail
+}
+
+func backoff(d time.Duration) time.Duration {
+ return min(d*2, tailMaxInterval)
+}
+
+// formatLogLine is the tail's one-row-per-line rendering. Fields are
+// space-separated and normalized to one physical line, so `awk` still works;
+// the free-form error message remains last. Every fixed-width field goes
+// through oneField, not only the error message: a Provider, Model or Stage
+// the gateway sent with an embedded space or newline would otherwise widen
+// into an extra column, and a newline in any field would break the
+// one-physical-line contract `awk` depends on.
+func formatLogLine(e api.LogEntry) string {
+ fields := []string{
+ oneField(table.FmtTime(e.CreatedAt)),
+ oneField(e.TraceID),
+ oneField(table.OrDash(e.Provider)),
+ oneField(table.OrDash(e.Model)),
+ oneField(e.Stage),
+ oneField(fmtMillis(e.DurationMs)),
+ oneField(fmtCost(e.CostUSD)),
+ fmt.Sprintf("%d", e.TotalTokens),
+ }
+ line := strings.Join(fields, " ")
+ if e.ErrorMessage != "" {
+ line += " " + oneLine(e.ErrorMessage)
+ }
+ return line
+}
+
+// oneField collapses s to a single whitespace-free token, joining any
+// embedded words with "_" rather than a space, so the value can never widen
+// into more than the one column it occupies in formatLogLine's fixed-width
+// prefix. A value that normalizes to nothing (empty, or all whitespace)
+// becomes "-": an empty column still occupies a position in the joined
+// string but disappears under an `awk`-style whitespace split, silently
+// shifting every column after it.
+func oneField(s string) string {
+ f := strings.Fields(table.SanitizeCell(s))
+ if len(f) == 0 {
+ return "-"
+ }
+ return strings.Join(f, "_")
+}
+
+// oneLine collapses s's internal whitespace runs to single spaces. Used only
+// for the trailing, free-form error message, which may keep its internal
+// spaces because it is always the last thing on the row -- past every column
+// a script indexes into by position -- and only a literal newline or tab
+// inside it can break the one-line contract.
+func oneLine(s string) string {
+ return strings.Join(strings.Fields(table.SanitizeCell(s)), " ")
+}
+
+// fmtMillis and fmtCost keep the nullable-measurement rule in one place: the
+// gateway sends null for "not measured", and a rendered 0 would claim a real
+// instantaneous or free request.
+func fmtMillis(v *float64) string {
+ if v == nil {
+ return "-"
+ }
+ return fmt.Sprintf("%.1f", *v)
+}
+
+func fmtCost(v *float64) string {
+ if v == nil {
+ return "-"
+ }
+ return fmt.Sprintf("$%.4f", *v)
+}
+
+func fmtRate(part, total int) string {
+ if total == 0 {
+ return "-"
+ }
+ return fmt.Sprintf("%.2f%%", 100*float64(part)/float64(total))
+}
diff --git a/internal/command/logs_test.go b/internal/command/logs_test.go
new file mode 100644
index 0000000..b058ff5
--- /dev/null
+++ b/internal/command/logs_test.go
@@ -0,0 +1,374 @@
+package command
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/fixture"
+)
+
+// executeCtx is execute() with a caller-owned context, which is what a tail
+// needs: cancelling it is how SIGINT reaches the poll loop.
+func executeCtx(ctx context.Context, t *testing.T, args ...string) (stdout, stderr string, err error) {
+ t.Helper()
+ // See execute's identical guard in root_test.go: without it, a real ferro
+ // config on the host machine could resolve into these tests' connection
+ // setup.
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ root := NewRoot()
+ var out, errb bytes.Buffer
+ root.SetOut(&out)
+ root.SetErr(&errb)
+ root.SetArgs(args)
+ err = root.ExecuteContext(ctx)
+ return out.String(), errb.String(), err
+}
+
+func TestLogsListRendersDashForNullMeasurements(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "logs", "list")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, col := range []string{"TIME", "TRACE", "PROVIDER", "MODEL", "STAGE", "MS", "COST", "TOKENS"} {
+ if !strings.Contains(stdout, col) {
+ t.Fatalf("missing column %s in %q", col, stdout)
+ }
+ }
+ // The seeded gpt-4o-mini row has null duration_ms, ttft_ms and cost_usd.
+ // Rendering those as 0 would claim a free, instantaneous request.
+ var row string
+ for _, l := range strings.Split(stdout, "\n") {
+ if strings.Contains(l, "gpt-4o-mini") {
+ row = l
+ }
+ }
+ if row == "" {
+ t.Fatalf("expected the seeded gpt-4o-mini row: %q", stdout)
+ }
+ // TIME TRACE PROVIDER MODEL STAGE MS COST TOKENS — MS and COST are the two
+ // nullable ones on this row, and both must be a dash rather than a zero
+ // that would claim a free, instantaneous request.
+ fields := strings.Fields(row)
+ if len(fields) != 8 {
+ t.Fatalf("want 8 whitespace-free columns, got %d in %q", len(fields), row)
+ }
+ if fields[5] != "-" || fields[6] != "-" {
+ t.Fatalf("null duration_ms/cost_usd must render as -, got ms=%q cost=%q", fields[5], fields[6])
+ }
+ if fields[7] != "856" {
+ t.Fatalf("a measured token count must survive: %q", row)
+ }
+}
+
+func TestLogsListSendsSinceAndFilters(t *testing.T) {
+ var got atomic.Pointer[url.Values]
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ got.Store(&q)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[],"summary":{"total_entries":0,"returned_entries":0}}`))
+ }))
+ defer srv.Close()
+
+ if _, _, err := execute(t, "logs", "list", "--since", "15m", "--provider", "anthropic",
+ "--stage", "all", "--limit", "7", "--gateway-url", srv.URL); err != nil {
+ t.Fatal(err)
+ }
+ q := got.Load()
+ if q == nil {
+ t.Fatal("no request reached the gateway")
+ }
+ since := q.Get("since")
+ if since == "" {
+ t.Fatalf("--since must reach the wire: %v", *q)
+ }
+ ts, err := time.Parse(time.RFC3339, since)
+ if err != nil {
+ t.Fatalf("since must be RFC3339, got %q", since)
+ }
+ if d := time.Since(ts); d < 14*time.Minute || d > 16*time.Minute {
+ t.Fatalf("15m must mean 15 minutes ago, got %s", d)
+ }
+ if q.Get("provider") != "anthropic" || q.Get("stage") != "all" || q.Get("limit") != "7" {
+ t.Fatalf("filters lost on the way to the wire: %v", *q)
+ }
+}
+
+func TestLogsListRejectsBadSince(t *testing.T) {
+ _, _, err := execute(t, "logs", "list", "--since", "yesterday", "--gateway-url", "http://127.0.0.1:1")
+ if err == nil || !strings.Contains(err.Error(), "since") {
+ t.Fatalf("an unparseable --since must be reported before any request: %v", err)
+ }
+}
+
+func TestLogsStatsRendersPercentiles(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "logs", "stats")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Read the rendered rows rather than matching literal totals. The fixture
+ // aggregates its own seeded rows now, so a hardcoded "1240" pinned this
+ // test to that data and failed the moment the fixture started answering
+ // honestly — which is a change to the fixture, not to this renderer.
+ got := map[string]string{}
+ for _, line := range strings.Split(stdout, "\n") {
+ if f := strings.Fields(line); len(f) == 2 {
+ got[f[0]] = f[1]
+ }
+ }
+ for _, want := range []string{
+ "requests", "error_rate", "cost_usd",
+ "latency_p50_ms", "latency_p95_ms", "latency_p99_ms", "ttft_p50_ms",
+ } {
+ if got[want] == "" {
+ t.Fatalf("stats must render %q:\n%s", want, stdout)
+ }
+ }
+ // Percentiles are non-decreasing by construction, so this holds for any
+ // data — and it fails if the renderer ever transposes or mislabels the
+ // columns, which matching one value in isolation cannot detect.
+ p50, p95, p99 := statNum(t, got, "latency_p50_ms"), statNum(t, got, "latency_p95_ms"), statNum(t, got, "latency_p99_ms")
+ if p50 > p95 || p95 > p99 {
+ t.Fatalf("latency percentiles must be non-decreasing, got %v/%v/%v:\n%s", p50, p95, p99, stdout)
+ }
+}
+
+func statNum(t *testing.T, rows map[string]string, name string) float64 {
+ t.Helper()
+ v, err := strconv.ParseFloat(strings.TrimSuffix(rows[name], "%"), 64)
+ if err != nil {
+ t.Fatalf("%s must render as a number, got %q", name, rows[name])
+ }
+ return v
+}
+
+func TestLogsStatsExposesOnlySupportedFilters(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ if _, _, err := run(t, srv, "logs", "stats", "--stage", "on_error"); err != nil {
+ t.Fatalf("stage is supported by the stats endpoint: %v", err)
+ }
+ if _, _, err := run(t, srv, "logs", "stats", "--api-key-id", "key_01"); err == nil {
+ t.Fatal("stats must not advertise the list-only api-key-id filter")
+ }
+}
+
+func TestFormatLogLineKeepsErrorsOnOneLine(t *testing.T) {
+ line := formatLogLine(api.LogEntry{Stage: "on_error", ErrorMessage: "first\nsecond\tthird"})
+ if strings.ContainsAny(line, "\n\r\t") || !strings.HasSuffix(line, "first second third") {
+ t.Fatalf("tail rows must remain one line: %q", line)
+ }
+}
+
+// A Provider, Model or Stage carrying a space or newline must not widen into
+// an extra column or break the one-physical-line contract: every fixed-width
+// field is normalized, not only ErrorMessage.
+func TestFormatLogLineNormalizesEveryField(t *testing.T) {
+ line := formatLogLine(api.LogEntry{
+ TraceID: "trace 1",
+ Provider: "open\nai rogue",
+ Model: "gpt 4\to",
+ Stage: "custom\nstage",
+ })
+ if strings.ContainsAny(line, "\n\r\t") {
+ t.Fatalf("a field carrying whitespace must not break the one-line contract: %q", line)
+ }
+ fields := strings.Fields(line)
+ if len(fields) != 8 {
+ t.Fatalf("want 8 whitespace-delimited columns even with dirty fields, got %d in %q", len(fields), line)
+ }
+ if fields[1] != "trace_1" {
+ t.Fatalf("trace id's embedded space must not split into a second column: %q", line)
+ }
+ if fields[2] != "open_ai_rogue" || fields[3] != "gpt_4_o" || fields[4] != "custom_stage" {
+ t.Fatalf("provider/model/stage must stay one column each: %q", line)
+ }
+}
+
+// A gateway with no log store answers 501. That is a feature that is absent,
+// not a crash: the message names the missing store instead of the status code.
+func TestLogsDegradeWithAHintWhenNoStore(t *testing.T) {
+ s := fixture.Default()
+ s.NoLogStore = true
+ srv := gateway(t, s)
+
+ for _, verb := range []string{"list", "stats", "tail"} {
+ _, _, err := run(t, srv, "logs", verb)
+ if err == nil {
+ t.Fatalf("logs %s must fail when the store is absent", verb)
+ }
+ if !strings.Contains(err.Error(), "request-log store") {
+ t.Fatalf("logs %s must name the missing store, got %v", verb, err)
+ }
+ }
+}
+
+func TestLogsTailPrintsRowsAndExitsCleanlyOnCancel(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ t.Setenv("FERRO_API_KEY", "fgw_test")
+ ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond)
+ defer cancel()
+
+ stdout, _, err := executeCtx(ctx, t, "logs", "tail", "--since", "1h",
+ "--gateway-url", srv.URL)
+ if err != nil {
+ t.Fatalf("a tail the operator interrupted is not a failure: %v", err)
+ }
+ if !strings.Contains(stdout, "anthropic") {
+ t.Fatalf("tail must print each new row as a line: %q", stdout)
+ }
+ if strings.Contains(stdout, "TRACE") {
+ t.Fatalf("tail emits lines, not a re-drawn table header: %q", stdout)
+ }
+}
+
+func TestLogsTailBacksOffAndKeepsGoingOnFailure(t *testing.T) {
+ var calls atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ calls.Add(1)
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"error":{"message":"boom","type":"server_error","code":"server_error"}}`))
+ }))
+ defer srv.Close()
+
+ ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
+ defer cancel()
+
+ stdout, stderr, err := executeCtx(ctx, t, "logs", "tail",
+ "--gateway-url", srv.URL)
+ if err != nil {
+ t.Fatalf("a poll failure is a warning, not an exit: %v", err)
+ }
+ if calls.Load() == 0 {
+ t.Fatal("the tail never polled")
+ }
+ if !strings.Contains(stderr, "retrying") {
+ t.Fatalf("the failure and the retry delay belong on stderr: %q", stderr)
+ }
+ if strings.TrimSpace(stdout) != "" {
+ t.Fatalf("a failed poll must not write to the machine channel: %q", stdout)
+ }
+}
+
+// A poll that hits the follower's page bound keeps the rows it fetched and
+// moves its cursor past them, so it is progress: the rows belong on stdout, the
+// gap belongs on stderr, and the cadence must not back off as it does for a
+// poll that failed and fetched nothing.
+func TestLogsTailReportsTruncationWithoutBackingOff(t *testing.T) {
+ // The follower asks for 100 rows a page and stops paging early on a short
+ // one, so a full page is what keeps it going. The rows carry only an id:
+ // this is about the page bound, not about rendering.
+ rows := make([]string, 0, 100)
+ for i := range 100 {
+ rows = append(rows, fmt.Sprintf(`{"trace_id":"trace-%03d"}`, i))
+ }
+ // A full page every time, against a total the follower can never reach:
+ // every poll exhausts its page bound with rows still outstanding.
+ body := []byte(`{"data":[` + strings.Join(rows, ",") +
+ `],"summary":{"total_entries":1000000,"returned_entries":100}}`)
+
+ // A fixed wall-clock budget for "one whole poll" races the page count
+ // against however long 32 sequential round trips take under load — land
+ // the deadline mid-poll and Poll returns a context error with stdout
+ // still empty, no product defect behind it. Cancel off the 32nd response
+ // instead of off a clock, so the interrupt cannot land before the poll
+ // that must finish first, regardless of how slow the run is.
+ var calls atomic.Int32
+ done := make(chan struct{})
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write(body)
+ if calls.Add(1) == 32 {
+ close(done)
+ }
+ }))
+ defer srv.Close()
+
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ go func() {
+ <-done
+ // The 32nd response is written, not yet read: give the client time to
+ // receive and decode it before pulling the context out from under it.
+ // This only has to outlast one small JSON decode, not 32 round trips,
+ // so it stays well clear of the flake it replaces.
+ time.Sleep(50 * time.Millisecond)
+ cancel()
+ }()
+
+ stdout, stderr, err := executeCtx(ctx, t, "logs", "tail", "--gateway-url", srv.URL)
+ if err != nil {
+ t.Fatalf("a truncated poll is not a failed command: %v", err)
+ }
+ if !strings.Contains(stdout, "trace-000") {
+ t.Fatalf("the rows a truncated poll did fetch must still reach stdout: %q (stderr %q)", stdout, stderr)
+ }
+ if !strings.Contains(stderr, "skipped") {
+ t.Fatalf("the skipped older rows must be stated on stderr: %q", stderr)
+ }
+ if strings.Contains(stderr, "retrying") {
+ t.Fatalf("a truncated poll made progress and must not back off: %q", stderr)
+ }
+}
+
+func TestBackoffDoublesToCap(t *testing.T) {
+ d := tailInterval
+ for range 10 {
+ d = backoff(d)
+ }
+ if d != tailMaxInterval {
+ t.Fatalf("backoff must double to a %s cap, got %s", tailMaxInterval, d)
+ }
+}
+
+// The flag set is shared by list and tail, so it is worth one direct test.
+func TestLogsQueryFromFlagsPassesNoneSentinel(t *testing.T) {
+ cmd := &cobra.Command{}
+ addLogFilterFlags(cmd)
+ if err := cmd.ParseFlags([]string{"--api-key-id", "none"}); err != nil {
+ t.Fatal(err)
+ }
+ q, err := logsQueryFromFlags(cmd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if q.APIKeyID != "none" {
+ t.Fatalf("the gateway's none sentinel must pass through untouched, got %q", q.APIKeyID)
+ }
+}
+
+// `logs tail` writes straight to stdout, and strings.Fields splits on
+// whitespace only — an ESC survives it untouched. A colour or OSC sequence an
+// upstream provider put in a name or an error_message is terminal control this
+// tool has no business forwarding to whatever is reading the pipe.
+func TestFormatLogLineStripsTerminalControl(t *testing.T) {
+ line := formatLogLine(api.LogEntry{
+ TraceID: "tr_esc",
+ Provider: "op\x1b[31mai",
+ Model: "gpt-4o",
+ Stage: "on_error",
+ ErrorMessage: "upstream said \x1b]0;pwned\x07 and \x1b[2J",
+ })
+ if strings.Contains(line, "\x1b") {
+ t.Fatalf("an escape sequence must never reach stdout: %q", line)
+ }
+ if strings.ContainsAny(line, "\n\r\t") {
+ t.Fatalf("tail rows must remain one line: %q", line)
+ }
+ if got := strings.Fields(line)[2]; got != "op_[31mai" {
+ t.Fatalf("a neutralized ESC must not widen the provider into a second column: %q (%q)", got, line)
+ }
+}
diff --git a/internal/command/models.go b/internal/command/models.go
new file mode 100644
index 0000000..c27a9c7
--- /dev/null
+++ b/internal/command/models.go
@@ -0,0 +1,27 @@
+package command
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ferro-labs/gateway-cli/internal/table"
+)
+
+func newModelsCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "models",
+ Short: "List the models this gateway routes",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ d := deps(cmd)
+ models, err := d.Client.Models(cmd.Context())
+ if err != nil {
+ return err
+ }
+ if d.Printer.Format != FormatTable {
+ return d.printStructured(models)
+ }
+ d.Printer.Table(table.ModelHeaders, table.ModelRows(models))
+ return nil
+ },
+ }
+}
diff --git a/internal/command/output.go b/internal/command/output.go
new file mode 100644
index 0000000..ec836cc
--- /dev/null
+++ b/internal/command/output.go
@@ -0,0 +1,204 @@
+package command
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+
+ "go.yaml.in/yaml/v3"
+ "golang.org/x/term"
+
+ "github.com/ferro-labs/gateway-cli/internal/table"
+)
+
+// Format is the --format value. Report commands may refuse some of these
+// rather than invent an unstable encoding of prose.
+type Format string
+
+// The three output formats. Table is for reading, JSON and YAML for piping.
+const (
+ FormatTable Format = "table"
+ FormatJSON Format = "json"
+ FormatYAML Format = "yaml"
+)
+
+// Printer enforces ferro's output discipline: stdout carries machine-readable
+// payloads and nothing else, while human narrative, warnings, and errors go to
+// stderr. That split is what makes `ferro status --format json | jq` safe.
+type Printer struct {
+ Out, Err io.Writer
+ Format Format
+ Color bool
+ ASCII bool
+}
+
+// isTTY reports whether f is attached to a terminal. It is the package's one
+// answer to that question: the stdout/stderr split, the colour decision, and
+// chat's piped-prompt detection all ask it here rather than each spelling out
+// the same descriptor dance. A var, not a func, so a test can swap it in to
+// simulate one stream being a terminal and another not being one -- real
+// *os.File values do not let a test fake that any other way.
+//
+// The int conversion is imposed by the two standard APIs on either side of it —
+// os.File.Fd returns a uintptr, golang.org/x/term.IsTerminal takes an int, and
+// x/term offers no *os.File-shaped alternative. A descriptor never approaches
+// the range where that conversion could lose information.
+var isTTY = func(f *os.File) bool { return term.IsTerminal(int(f.Fd())) }
+
+// NoColor reports whether ANSI must be suppressed for writes to f. Any one of
+// these is enough: the NO_COLOR convention, a dumb terminal, or f not being a
+// TTY (a pipe or file must never receive escape codes).
+//
+// The decision is taken per stream rather than globally off stdout: a
+// Printer's narrative (OK/Warn/Fail) always writes to p.Err, so with
+// `ferro status 2> run.log` -- stdout still a terminal, stderr redirected to
+// a file -- colour must be decided from stderr, or the escape codes land in
+// run.log even though nothing asked for them there. Callers that draw on
+// stdout (the full-screen console) still pass os.Stdout.
+func NoColor(f *os.File) bool {
+ if _, set := os.LookupEnv("NO_COLOR"); set {
+ return true
+ }
+ if os.Getenv("TERM") == "dumb" {
+ return true
+ }
+ return !isTTY(f)
+}
+
+const (
+ ansiGreen = "\x1b[32m"
+ ansiYellow = "\x1b[33m"
+ ansiRed = "\x1b[31m"
+ ansiReset = "\x1b[0m"
+)
+
+func (p *Printer) glyph(unicode, ascii string) string {
+ if p.ASCII {
+ return ascii
+ }
+ return unicode
+}
+
+// note writes one narrative line to stderr. The glyph carries the state on its
+// own so meaning survives NO_COLOR, a pipe, and colour-blind readers; colour
+// is only ever an enhancement.
+func (p *Printer) note(color, glyph, format string, a ...any) {
+ msg := fmt.Sprintf(format, a...)
+ if p.Color {
+ _, _ = fmt.Fprintf(p.Err, "%s%s%s %s\n", color, glyph, ansiReset, msg)
+ return
+ }
+ _, _ = fmt.Fprintf(p.Err, "%s %s\n", glyph, msg)
+}
+
+// OK reports success on stderr, so it never contaminates a piped payload.
+func (p *Printer) OK(format string, a ...any) {
+ p.note(ansiGreen, p.glyph("✓", "[OK]"), format, a...)
+}
+
+// Warn reports a non-fatal condition on stderr.
+func (p *Printer) Warn(format string, a ...any) {
+ p.note(ansiYellow, p.glyph("!", "[!]"), format, a...)
+}
+
+// Fail reports a failure on stderr. The caller still decides the exit code.
+func (p *Printer) Fail(format string, a ...any) {
+ p.note(ansiRed, p.glyph("✗", "[X]"), format, a...)
+}
+
+// The shared column-header vocabulary. A fact keeps one word wherever it is
+// printed — TIME is always when it happened, NAME the object's own name, STATE
+// the condition ferro derived, STATUS the one the gateway reported — so a
+// reader who has learnt one table can read the next, and a script locating a
+// column by name finds it under that name everywhere.
+//
+// STATUS is not here: the provider listing was its only caller and that table's
+// shape now lives in internal/table alongside the merge that builds it, so both
+// this package and the console spell it from there.
+const (
+ colTime = "TIME"
+ colName = "NAME"
+ colState = "STATE"
+ colScopes = "SCOPES"
+ colExpires = "EXPIRES"
+ colProvider = "PROVIDER"
+ colModels = "MODELS"
+ colURL = "URL"
+)
+
+// Table writes a plain, alignment-padded table to stdout. No ANSI, no box
+// drawing: this output is as likely to be piped into awk as read by a human.
+// The layout itself lives in internal/table, shared with the console's own
+// rendering of the same verb — see internal/tui/home.go's tableRows.
+func (p *Printer) Table(headers []string, rows [][]string) {
+ table.Write(p.Out, headers, rows)
+}
+
+// JSON writes v to stdout as one indented document.
+func (p *Printer) JSON(v any) error {
+ enc := json.NewEncoder(p.Out)
+ enc.SetIndent("", " ")
+ // This output is piped to jq or a file, never embedded in HTML, so the
+ // encoder's default <, >, & escaping only mangles audit details, provider
+ // error messages, and URLs that legitimately contain them.
+ enc.SetEscapeHTML(false)
+ return enc.Encode(v)
+}
+
+// YAML renders v using the same key names the JSON output uses.
+//
+// Every wire type in internal/api carries `json:` tags and no `yaml:` tags,
+// and yaml.v3 does not read json tags — it lowercases the Go field name
+// instead, so total_entries would print as totalentries and cost_usd as
+// costusd. Round-tripping through JSON makes one set of tags serve both
+// formats, which also means a new field cannot be correct in JSON and wrong
+// in YAML.
+func (p *Printer) YAML(v any) error {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+ // UseNumber keeps integers exact; without it every number decodes as
+ // float64 and a trace timestamp renders as 1.786193769e+09.
+ dec := json.NewDecoder(bytes.NewReader(b))
+ dec.UseNumber()
+ var doc any
+ if err := dec.Decode(&doc); err != nil {
+ return err
+ }
+ out, err := yaml.Marshal(nativeNumbers(doc))
+ if err != nil {
+ return err
+ }
+ _, err = p.Out.Write(out)
+ return err
+}
+
+// nativeNumbers hands the json.Number values UseNumber preserved to yaml as
+// plain scalars. Left as json.Number they are a string type and YAML would
+// quote them: total_entries: "1500".
+func nativeNumbers(v any) any {
+ switch t := v.(type) {
+ case map[string]any:
+ for k, val := range t {
+ t[k] = nativeNumbers(val)
+ }
+ return t
+ case []any:
+ for i, val := range t {
+ t[i] = nativeNumbers(val)
+ }
+ return t
+ case json.Number:
+ // The gateway's own digits, unquoted, with nothing re-parsed in
+ // between. Converting to int64 or float64 first would round anything
+ // past 2^53 that is not an int64 — and the by_provider / by_model
+ // breakdowns reach here as raw gateway JSON, so what fits is the
+ // gateway's to decide rather than this package's to assume.
+ return &yaml.Node{Kind: yaml.ScalarNode, Value: t.String()}
+ default:
+ return v
+ }
+}
diff --git a/internal/command/output_test.go b/internal/command/output_test.go
new file mode 100644
index 0000000..062e1c2
--- /dev/null
+++ b/internal/command/output_test.go
@@ -0,0 +1,235 @@
+package command
+
+import (
+ "bytes"
+ "encoding/json"
+ "os"
+ "strings"
+ "testing"
+)
+
+func newTestPrinter(format Format) (*Printer, *bytes.Buffer, *bytes.Buffer) {
+ var out, errb bytes.Buffer
+ return &Printer{Out: &out, Err: &errb, Format: format}, &out, &errb
+}
+
+func TestTableRendersHeaderUnderlineAndRows(t *testing.T) {
+ p, out, _ := newTestPrinter(FormatTable)
+ p.Table([]string{"STATE", "URL"}, [][]string{{"connected", "http://localhost:8080"}})
+
+ lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
+ if len(lines) != 3 {
+ t.Fatalf("want header + underline + 1 row, got %d lines:\n%s", len(lines), out.String())
+ }
+ if !strings.HasPrefix(lines[0], "STATE") {
+ t.Fatalf("header line: %q", lines[0])
+ }
+ if !strings.HasPrefix(lines[1], "-----") {
+ t.Fatalf("underline must be dashes matching header width, got %q", lines[1])
+ }
+ if !strings.Contains(lines[2], "connected") {
+ t.Fatalf("row line: %q", lines[2])
+ }
+}
+
+// stdout is the machine channel: `ferro status --format json | jq` must see a
+// JSON document and nothing else, no matter how much narrative was produced.
+func TestJSONPurityNarrativeGoesToStderr(t *testing.T) {
+ p, out, errb := newTestPrinter(FormatJSON)
+ p.OK("connected in 23ms")
+ p.Warn("anthropic circuit half-open")
+ p.Fail("something failed")
+ if err := p.JSON(map[string]any{"state": "degraded", "latency_ms": 23}); err != nil {
+ t.Fatal(err)
+ }
+
+ var doc map[string]any
+ if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
+ t.Fatalf("stdout must be exactly one JSON document, got %q: %v", out.String(), err)
+ }
+ if doc["state"] != "degraded" {
+ t.Fatalf("payload lost: %v", doc)
+ }
+ for _, want := range []string{"connected in 23ms", "half-open", "something failed"} {
+ if !strings.Contains(errb.String(), want) {
+ t.Fatalf("narrative %q must reach stderr, got %q", want, errb.String())
+ }
+ }
+}
+
+// JSON is piped to jq or a file, never embedded in HTML, so <, >, and & in an
+// audit detail, a provider error message, or a URL must survive literally
+// rather than becoming <-style escapes.
+func TestJSONDoesNotEscapeHTMLCharacters(t *testing.T) {
+ p, out, _ := newTestPrinter(FormatJSON)
+ if err := p.JSON(map[string]string{"url": "http://x?a=1&b=2", "detail": "a was denied"}); err != nil {
+ t.Fatal(err)
+ }
+ got := out.String()
+ // Verbatim substring match is the assertion: SetEscapeHTML(false) means
+ // these bytes appear exactly as given rather than as <-style escapes,
+ // and a literal match fails on its own if any got turned into an escape.
+ if !strings.Contains(got, "a was denied") || !strings.Contains(got, "http://x?a=1&b=2") {
+ t.Fatalf("HTML characters must stay literal, not escaped, got %q", got)
+ }
+}
+
+func TestYAMLGoesToStdout(t *testing.T) {
+ p, out, _ := newTestPrinter(FormatYAML)
+ if err := p.YAML(map[string]string{"state": "connected"}); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "state: connected") {
+ t.Fatalf("yaml output: %q", out.String())
+ }
+}
+
+// Color must never be the only carrier of meaning, and it must be absent
+// entirely when the printer is not in color mode.
+func TestNoColorEmitsNoANSI(t *testing.T) {
+ p, _, errb := newTestPrinter(FormatTable)
+ p.Color = false
+ p.OK("all good")
+ p.Fail("all bad")
+ if strings.Contains(errb.String(), "\x1b[") {
+ t.Fatalf("ANSI leaked with Color=false: %q", errb.String())
+ }
+ // The glyph still distinguishes the two, so meaning survives without color.
+ if !strings.Contains(errb.String(), "✓") || !strings.Contains(errb.String(), "✗") {
+ t.Fatalf("glyphs must carry state when color cannot: %q", errb.String())
+ }
+}
+
+func TestColorEmitsANSIWhenEnabled(t *testing.T) {
+ p, _, errb := newTestPrinter(FormatTable)
+ p.Color = true
+ p.OK("all good")
+ if !strings.Contains(errb.String(), "\x1b[") {
+ t.Fatalf("want ANSI with Color=true, got %q", errb.String())
+ }
+}
+
+func TestASCIIGlyphVocabulary(t *testing.T) {
+ p, _, errb := newTestPrinter(FormatTable)
+ p.ASCII = true
+ p.OK("ok")
+ p.Warn("warn")
+ p.Fail("fail")
+ got := errb.String()
+ for _, want := range []string{"[OK]", "[!]", "[X]"} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("ascii mode must use %s, got %q", want, got)
+ }
+ }
+ if strings.ContainsAny(got, "✓✗") {
+ t.Fatalf("ascii mode leaked unicode glyphs: %q", got)
+ }
+}
+
+// The api wire types carry json tags and no yaml tags. yaml.v3 does not read
+// json tags, so a naive Marshal renders total_entries as totalentries — the
+// same field, named differently depending on --format.
+func TestYAMLUsesJSONFieldNames(t *testing.T) {
+ p, out, _ := newTestPrinter(FormatYAML)
+ payload := struct {
+ TotalEntries int `json:"total_entries"`
+ CostUSD float64 `json:"cost_usd"`
+ CreatedUnix int64 `json:"created_unix"`
+ TraceID string `json:"trace_id"`
+ }{TotalEntries: 1500, CostUSD: 0.0031, CreatedUnix: 1786193769, TraceID: "tr_abc"}
+
+ if err := p.YAML(payload); err != nil {
+ t.Fatal(err)
+ }
+ got := out.String()
+ for _, want := range []string{"total_entries: 1500", "cost_usd: 0.0031", "trace_id: tr_abc"} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("want %q in:\n%s", want, got)
+ }
+ }
+ // Large integers must stay integers: neither quoted (json.Number leaking
+ // through as a string) nor rendered in scientific notation (float64).
+ if !strings.Contains(got, "created_unix: 1786193769") {
+ t.Fatalf("integer precision lost:\n%s", got)
+ }
+ for _, bad := range []string{"totalentries", "costusd", `"1500"`, "e+09"} {
+ if strings.Contains(got, bad) {
+ t.Fatalf("unexpected %q in:\n%s", bad, got)
+ }
+ }
+}
+
+// The by_provider / by_model breakdowns reach the printer as raw gateway JSON,
+// so their numbers are not bounded by any Go type this package declares. A
+// number is rendered with the digits it arrived with; re-parsing it as int64 or
+// float64 first would round anything that does not fit and print the result as
+// though the gateway had sent it.
+func TestYAMLPreservesDigitsBeyondInt64(t *testing.T) {
+ p, out, _ := newTestPrinter(FormatYAML)
+ payload := map[string]json.RawMessage{
+ "by_provider": json.RawMessage(`{"requests":18446744073709551615,"cost_usd":0.0031}`),
+ }
+ if err := p.YAML(payload); err != nil {
+ t.Fatal(err)
+ }
+ got := out.String()
+ if !strings.Contains(got, "requests: 18446744073709551615") {
+ t.Fatalf("a number past int64 must survive intact, not be rounded:\n%s", got)
+ }
+ if !strings.Contains(got, "cost_usd: 0.0031") || strings.Contains(got, `"0.0031"`) {
+ t.Fatalf("ordinary numbers must stay unquoted YAML numbers:\n%s", got)
+ }
+}
+
+// NoColor takes the stream it is deciding for as a parameter: it must never
+// answer for os.Stderr by inspecting os.Stdout, which is the bug that let
+// `ferro status 2> run.log` leak ANSI into the redirected file while stdout
+// stayed a terminal.
+func TestNoColorHonoursNOCOLOREnvRegardlessOfStream(t *testing.T) {
+ t.Setenv("NO_COLOR", "1")
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = r.Close() }()
+ defer func() { _ = w.Close() }()
+ // The stream must read as a terminal for this to test anything: NoColor
+ // checks NO_COLOR before !isTTY, so against a bare pipe it returns true on
+ // the TTY check alone and the assertion holds even if NO_COLOR were
+ // ignored outright.
+ orig := isTTY
+ isTTY = func(*os.File) bool { return true }
+ t.Cleanup(func() { isTTY = orig })
+ if !NoColor(w) {
+ t.Fatal("NO_COLOR must suppress colour on any stream, TTY or not")
+ }
+}
+
+func TestNoColorRejectsNonTTYStream(t *testing.T) {
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = r.Close() }()
+ defer func() { _ = w.Close() }()
+ // A pipe is never a terminal, independent of NO_COLOR/TERM.
+ if !NoColor(w) {
+ t.Fatal("a non-TTY stream must suppress colour")
+ }
+}
+
+func TestYAMLKeepsNestedNumbersNative(t *testing.T) {
+ p, out, _ := newTestPrinter(FormatYAML)
+ payload := map[string]any{
+ "summary": map[string]any{"count": int64(9007199254740991)},
+ "series": []any{map[string]any{"value": 12.5}},
+ }
+ if err := p.YAML(payload); err != nil {
+ t.Fatal(err)
+ }
+ got := out.String()
+ if !strings.Contains(got, "count: 9007199254740991") || !strings.Contains(got, "value: 12.5") ||
+ strings.Contains(got, `"9007199254740991"`) {
+ t.Fatalf("nested numbers lost their native YAML types:\n%s", got)
+ }
+}
diff --git a/internal/command/providers.go b/internal/command/providers.go
new file mode 100644
index 0000000..6a4875c
--- /dev/null
+++ b/internal/command/providers.go
@@ -0,0 +1,48 @@
+package command
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/table"
+)
+
+// The merged row, its five columns and the dash-for-absent rendering live in
+// internal/table (providers.go) because the console renders the same listing
+// and cannot import this package. table.ProviderRow's json tags are this
+// command's --format json|yaml contract, unchanged by the move.
+func newProvidersCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "providers",
+ Short: "Provider health, circuit state and model counts",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ d := deps(cmd)
+ health, _, err := d.Client.Health(cmd.Context())
+ if err != nil {
+ return err
+ }
+ // Best effort: an unusable credential costs the status and message
+ // columns, not the command.
+ admin, adminErr := d.Client.AdminHealth(cmd.Context())
+ if adminErr != nil {
+ admin = nil
+ // Only a refused credential is reported as one; a timeout or a
+ // gateway error says so instead, so the reader is not sent to
+ // check a credential that was never the problem.
+ why := "admin health unavailable"
+ if api.IsUnauthorized(adminErr) {
+ why = "no admin credential accepted"
+ }
+ d.Printer.Warn("%s — status and message come from /health only (%v)", why, adminErr)
+ }
+
+ rows := table.MergeProviders(health, admin)
+ if d.Printer.Format != FormatTable {
+ return d.printStructured(rows)
+ }
+ d.Printer.Table(table.ProviderHeaders, table.ProviderRows(rows))
+ return nil
+ },
+ }
+}
diff --git a/internal/command/root.go b/internal/command/root.go
new file mode 100644
index 0000000..636b9a1
--- /dev/null
+++ b/internal/command/root.go
@@ -0,0 +1,207 @@
+// Package command holds ferro's scriptable Cobra surface. It owns the
+// stdout/stderr split, output formats, and exit codes; it holds no terminal
+// screen state. Bare `ferro` hands off to internal/tui.
+package command
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/config"
+ "github.com/ferro-labs/gateway-cli/internal/tui"
+ "github.com/ferro-labs/gateway-cli/internal/tui/theme"
+ "github.com/ferro-labs/gateway-cli/internal/version"
+)
+
+type depsKey struct{}
+
+// verbList is the one spelling of the listing verb. Every noun that can be
+// listed spells it this way, so an operator who has typed `keys list` can guess
+// `logs list` rather than discover it is `logs ls`.
+const verbList = "list"
+
+// runtimeDeps is what every verb needs and none of them should assemble for
+// itself: the resolved connection, an output printer honouring the global
+// flags, and a client pointed at that connection.
+type runtimeDeps struct {
+ Resolved config.Resolved
+ Printer *Printer
+ Client *api.Client
+}
+
+// deps returns the runtime wiring built by PersistentPreRunE. It is nil only
+// for commands that opt out of setup (version), which never call it.
+func deps(cmd *cobra.Command) *runtimeDeps {
+ d, _ := cmd.Context().Value(depsKey{}).(*runtimeDeps)
+ return d
+}
+
+// printStructured renders a value for --format json|yaml. Table-formatted
+// commands that have no sensible column layout fall back to JSON rather than
+// inventing one.
+func (d *runtimeDeps) printStructured(v any) error {
+ if d.Printer.Format == FormatYAML {
+ return d.Printer.YAML(v)
+ }
+ return d.Printer.JSON(v)
+}
+
+// rootFlags are the persistent flags every subcommand inherits. They are
+// resolved against environment variables and profiles in PersistentPreRunE.
+type rootFlags struct {
+ configPath string
+ gatewayURL string
+ profile string
+ format string
+ ascii bool
+ insecureHTTP bool
+}
+
+// NewRoot builds the command tree. Tests construct it directly so they can
+// swap stdout/stderr; main() wraps it in fang for styled help and errors.
+func NewRoot() *cobra.Command {
+ f := &rootFlags{}
+ root := &cobra.Command{
+ Use: "ferro",
+ Short: "Operator console for the Ferro Labs AI Gateway",
+ Long: "ferro manages a running Ferro Labs AI Gateway over its HTTP admin API.\n" +
+ "With no arguments it opens a full-screen operations console; with a verb\n" +
+ "it runs a scriptable command whose output belongs to your pipeline.",
+ // Errors go to stderr on their own; the usage wall on every failure
+ // buries the one line that says what went wrong.
+ SilenceUsage: true,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ // The full-screen console needs a terminal. On a pipe or a file,
+ // drawing it would spray escape codes into somebody's stdout, so
+ // refuse and point at the scriptable surface instead.
+ if !stdinIsTTY() || !stdoutIsTTY() {
+ return errors.New("ferro's full-screen console needs terminal input and output; " +
+ "run `ferro --help` for the scriptable commands")
+ }
+ d := deps(cmd)
+ // tui takes the command tree rather than importing this package:
+ // root.go calls tui.Run, so the reverse import would be a cycle.
+ // Passing cmd.Root() keeps the console's verb vocabulary derived
+ // from the real cobra tree, so the two cannot drift. It always draws
+ // on stdout, so its colour decision is stdout's TTY state -- unlike
+ // the Printer's below, which follows stderr, the stream it writes to.
+ return tui.Run(d.Client, d.Resolved,
+ theme.Mode{Color: !NoColor(os.Stdout), ASCII: f.ascii}, cmd.Root())
+ },
+ PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
+ // version must work offline, with no config and no credential.
+ if cmd.Name() == "version" || cmd.Name() == "help" || isCompletion(cmd) {
+ return nil
+ }
+
+ // --config outranks FERRO_CONFIG, which outranks the OS default --
+ // the same precedence --gateway-url takes over FERRO_URL below.
+ path := config.DefaultPath()
+ if v := os.Getenv(config.EnvConfigPath); v != "" {
+ path = v
+ }
+ if f.configPath != "" {
+ path = f.configPath
+ }
+ file, err := config.Load(path)
+ if err != nil {
+ return err
+ }
+ resolved, err := config.Resolve(file, f.gatewayURL, f.profile, os.Getenv)
+ if err != nil {
+ return err
+ }
+
+ format := Format(f.format)
+ switch format {
+ case FormatTable, FormatJSON, FormatYAML:
+ default:
+ return fmt.Errorf("unknown --format %q (want table, json, or yaml)", f.format)
+ }
+
+ clientOpts := []api.Option{api.WithUserAgent("ferro-cli/" + version.Version)}
+ if f.insecureHTTP {
+ clientOpts = append(clientOpts, api.WithInsecureHTTP())
+ }
+ client, err := api.New(resolved.URL, resolved.APIKey, clientOpts...)
+ if err != nil {
+ return err
+ }
+
+ d := &runtimeDeps{
+ Resolved: resolved,
+ Printer: &Printer{
+ Out: cmd.OutOrStdout(),
+ Err: cmd.ErrOrStderr(),
+ Format: format,
+ // note() (OK/Warn/Fail) always writes to Err, so the colour
+ // decision follows stderr's own TTY state, not stdout's --
+ // `ferro status 2> run.log` must not leak ANSI into the file.
+ Color: !NoColor(os.Stderr),
+ ASCII: f.ascii,
+ },
+ Client: client,
+ }
+ cmd.SetContext(context.WithValue(cmd.Context(), depsKey{}, d))
+ return nil
+ },
+ }
+
+ pf := root.PersistentFlags()
+ pf.StringVar(&f.configPath, "config", "", "path to the ferro config file (env "+config.EnvConfigPath+")")
+ pf.StringVar(&f.gatewayURL, "gateway-url", "", "gateway base URL (env FERRO_URL)")
+ pf.StringVar(&f.profile, "profile", "", "connection profile from the ferro config file")
+ pf.StringVar(&f.format, "format", "table", "output format: table|json|yaml")
+ pf.BoolVar(&f.ascii, "ascii", false, "ASCII-only glyphs ([OK] [X] [!] [-])")
+ pf.BoolVar(&f.insecureHTTP, "insecure-http", false, "allow plaintext HTTP to a non-loopback gateway")
+
+ root.AddCommand(
+ newVersionCmd(),
+ newStatusCmd(),
+ newKeysCmd(),
+ newLogsCmd(),
+ newModelsCmd(),
+ newProvidersCmd(),
+ newServicesCmd(),
+ newMCPCmd(),
+ newPluginsCmd(),
+ newSessionsCmd(),
+ newAuditCmd(),
+ newChatCmd(),
+ )
+ return root
+}
+
+var stdoutIsTTY = func() bool { return isTTY(os.Stdout) }
+
+// completionVerb is the name cobra gives the generated shell-completion
+// command. The hidden helpers a shell actually runs on every Tab
+// (__complete, __completeNoDesc) share cobra's own ShellCompRequestCmd as
+// their prefix. None of them may load config or resolve a credential.
+const completionVerb = "completion"
+
+func isCompletion(cmd *cobra.Command) bool {
+ for ; cmd != nil; cmd = cmd.Parent() {
+ if cmd.Name() == completionVerb || strings.HasPrefix(cmd.Name(), cobra.ShellCompRequestCmd) {
+ return true
+ }
+ }
+ return false
+}
+
+func newVersionCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "version",
+ Short: "Print ferro version, commit, and build date",
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ _, _ = fmt.Fprintf(cmd.OutOrStdout(), "ferro %s\n", version.String())
+ return nil
+ },
+ }
+}
diff --git a/internal/command/root_test.go b/internal/command/root_test.go
new file mode 100644
index 0000000..d9f6f8b
--- /dev/null
+++ b/internal/command/root_test.go
@@ -0,0 +1,225 @@
+package command
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+func execute(t *testing.T, args ...string) (stdout, stderr string, err error) {
+ t.Helper()
+ // A developer's real ~/.config/ferro/config.yaml must not leak into a
+ // test run: point the OS default config dir at an empty one so DefaultPath
+ // resolves to a file that never exists, leaving --config and FERRO_CONFIG
+ // free to override it as they always could.
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ root := NewRoot()
+ var out, errb bytes.Buffer
+ root.SetOut(&out)
+ root.SetErr(&errb)
+ root.SetArgs(args)
+ err = root.Execute()
+ return out.String(), errb.String(), err
+}
+
+func TestVersionCommand(t *testing.T) {
+ stdout, _, err := execute(t, "version")
+ if err != nil {
+ t.Fatalf("version: %v", err)
+ }
+ if !strings.Contains(stdout, "ferro") || !strings.Contains(stdout, "dev") {
+ t.Fatalf("want binary name + version on stdout, got %q", stdout)
+ }
+}
+
+func TestUnknownCommandFails(t *testing.T) {
+ _, _, err := execute(t, "definitely-not-a-verb")
+ if err == nil {
+ t.Fatal("unknown command must error (exit 1)")
+ }
+}
+
+func TestBareCommandRequiresTerminalInput(t *testing.T) {
+ oldStdin, oldStdout := stdinIsTTY, stdoutIsTTY
+ stdinIsTTY = func() bool { return false }
+ stdoutIsTTY = func() bool { return true }
+ t.Cleanup(func() { stdinIsTTY, stdoutIsTTY = oldStdin, oldStdout })
+ _, _, err := execute(t)
+ if err == nil || !strings.Contains(err.Error(), "input and output") {
+ t.Fatalf("bare ferro with redirected stdin must refuse the TUI: %v", err)
+ }
+}
+
+func TestRootDoesNotAcceptAPIKeyInArguments(t *testing.T) {
+ if flag := NewRoot().PersistentFlags().Lookup("api-key"); flag != nil {
+ t.Fatal("bearer credentials must not be accepted through process arguments")
+ }
+}
+
+func TestCompletionSkipsMalformedConfig(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ if err := os.MkdirAll(filepath.Join(dir, "ferro"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "ferro", "config.yaml"), []byte(": bad: [\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ root := NewRoot()
+ root.AddCommand(&cobra.Command{Use: "completion", Run: func(*cobra.Command, []string) {}})
+ root.SetArgs([]string{"completion"})
+ if err := root.Execute(); err != nil {
+ t.Fatalf("static completion must not parse connection config: %v", err)
+ }
+}
+
+// The version command must work with no gateway and no credentials, so it
+// cannot be gated behind the connection setup every other verb needs.
+func TestVersionNeedsNoGateway(t *testing.T) {
+ t.Setenv("FERRO_URL", "http://127.0.0.1:1")
+ if _, _, err := execute(t, "version"); err != nil {
+ t.Fatalf("version must not touch the network: %v", err)
+ }
+}
+
+// version opts out of connection setup entirely: it must work with no config
+// file, no credential, and no reachable gateway.
+func TestVersionSkipsConnectionSetup(t *testing.T) {
+ t.Setenv("FERRO_URL", "http://127.0.0.1:1")
+ if _, _, err := execute(t, "version", "--format", "xml"); err != nil {
+ t.Fatalf("version must bypass flag validation and client setup: %v", err)
+ }
+}
+
+// Connection setup runs for every verb except version. Probed with a stub
+// command so this stays a test of the wiring rather than of whichever verb
+// happens to exist.
+func withProbe(t *testing.T, args ...string) (*runtimeDeps, error) {
+ t.Helper()
+ // See execute's identical guard: without it, a real ferro config on the
+ // host machine could resolve into these tests' connection setup.
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ root := NewRoot()
+ var got *runtimeDeps
+ root.AddCommand(&cobra.Command{
+ Use: "probe",
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ got = deps(cmd)
+ return nil
+ },
+ })
+ root.SetOut(io.Discard)
+ root.SetErr(io.Discard)
+ root.SetArgs(append([]string{"probe"}, args...))
+ // Execute first, then read got. Go orders function calls in a return
+ // statement left to right, but it does not order a variable read against
+ // a call beside it — and got is assigned inside this one, by RunE. As
+ // `return got, root.Execute()` the read may legally happen first, handing
+ // every caller a nil deps and passing anyway.
+ err := root.Execute()
+ return got, err
+}
+
+func TestBadFormatRejectedWithGuidance(t *testing.T) {
+ _, err := withProbe(t, "--format", "xml")
+ if err == nil || !strings.Contains(err.Error(), "table") {
+ t.Fatalf("bad --format must name the valid values, got %v", err)
+ }
+}
+
+func TestDepsBuiltFromFlagsAndEnv(t *testing.T) {
+ t.Setenv("FERRO_API_KEY", "fgw_from_env")
+ d, err := withProbe(t, "--gateway-url", "http://gw.example:9000", "--insecure-http")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d == nil {
+ t.Fatal("every non-version verb must receive runtime deps")
+ }
+ if d.Resolved.URL != "http://gw.example:9000" {
+ t.Fatalf("flag must win for URL, got %q", d.Resolved.URL)
+ }
+ if d.Resolved.KeySource != "env:FERRO_API_KEY" {
+ t.Fatalf("key source: %q", d.Resolved.KeySource)
+ }
+ if d.Client == nil || d.Printer == nil {
+ t.Fatal("deps must carry a client and a printer")
+ }
+}
+
+func writeConfigFile(t *testing.T, dir, name, profile, url string) string {
+ t.Helper()
+ path := filepath.Join(dir, name)
+ body := "current_profile: " + profile + "\nprofiles:\n - name: " + profile + "\n url: " + url + "\n"
+ if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+// --config names a config file DefaultPath would never find, so PersistentPreRunE
+// must resolve the connection from it rather than from the OS default location.
+func TestConfigFlagOverridesDefaultPath(t *testing.T) {
+ path := writeConfigFile(t, t.TempDir(), "custom.yaml", "from-flag", "https://from-flag:1")
+ d, err := withProbe(t, "--config", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d.Resolved.URL != "https://from-flag:1" {
+ t.Fatalf("--config must load the named file, got %+v", d.Resolved)
+ }
+}
+
+// FERRO_CONFIG is the env-var override the config package's own doc comment
+// promises; without wiring it in, no flag or env var could ever change the
+// path config.Load reads, no matter what the comment said.
+func TestFerroConfigEnvOverridesDefaultPath(t *testing.T) {
+ path := writeConfigFile(t, t.TempDir(), "env.yaml", "from-env", "https://from-env:2")
+ t.Setenv("FERRO_CONFIG", path)
+ d, err := withProbe(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d.Resolved.URL != "https://from-env:2" {
+ t.Fatalf("FERRO_CONFIG must override the default path, got %+v", d.Resolved)
+ }
+}
+
+func TestConfigFlagBeatsFerroConfigEnv(t *testing.T) {
+ dir := t.TempDir()
+ envPath := writeConfigFile(t, dir, "env.yaml", "from-env", "https://from-env:1")
+ flagPath := writeConfigFile(t, dir, "flag.yaml", "from-flag", "https://from-flag:2")
+ t.Setenv("FERRO_CONFIG", envPath)
+ d, err := withProbe(t, "--config", flagPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d.Resolved.URL != "https://from-flag:2" {
+ t.Fatalf("--config must win over FERRO_CONFIG, got %+v", d.Resolved)
+ }
+}
+
+// Regression for the Printer's colour decision being taken from stdout while
+// its narrative (OK/Warn/Fail) is always written to stderr: with a terminal
+// stdout and a redirected stderr -- `ferro status 2> run.log` -- the old
+// stdout-only check would turn colour on and leak ANSI escapes into the file.
+// The decision must follow the stream the Printer actually writes to.
+func TestPrinterColorFollowsStderrNotStdout(t *testing.T) {
+ oldIsTTY := isTTY
+ // stdout is a terminal; stderr is not -- the shape `2> run.log` takes.
+ isTTY = func(f *os.File) bool { return f == os.Stdout }
+ t.Cleanup(func() { isTTY = oldIsTTY })
+
+ d, err := withProbe(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d.Printer.Color {
+ t.Fatal("colour must follow stderr's terminal state, not stdout's")
+ }
+}
diff --git a/internal/command/services.go b/internal/command/services.go
new file mode 100644
index 0000000..5811e62
--- /dev/null
+++ b/internal/command/services.go
@@ -0,0 +1,328 @@
+package command
+
+import (
+ "fmt"
+ "slices"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/table"
+)
+
+// auditOutcomes is the gateway's whole vocabulary for ?outcome=. Anything else
+// is a 400 there, so it is refused here before a request is spent.
+var auditOutcomes = []string{"ok", "denied", "error"}
+
+// The four services `ferro services` summarises, one row each. Each is named
+// once so the row a reachable service produces and the row its absence produces
+// cannot end up calling the same service two different things.
+const (
+ svcMCP = "MCP servers"
+ svcPlugins = "Plugins"
+ svcSessions = "Sessions"
+ svcAudit = "Audit"
+)
+
+// stateUnsupported is the state a service row carries when this gateway does
+// not serve that feature at all — a 501 rather than a count of zero, which
+// would claim the feature is present and empty.
+const stateUnsupported = "unsupported"
+
+type serviceSummary struct {
+ Name string `json:"name"`
+ State string `json:"state"`
+}
+
+// serviceProbe is one row's name and the reading behind it. read is a closure
+// rather than a value because the four services answer with four different
+// types and only the sentence they reduce to is shared -- and because the
+// reading must not be evaluated before its error is checked: audit's row reads
+// through a pointer the gateway leaves nil when it refuses the call.
+type serviceProbe struct {
+ name string
+ read func() (string, error)
+}
+
+// summarize applies the rule every service row obeys: a gateway that does not
+// serve the endpoint at all answers 501, which reports the feature absent
+// rather than present-and-empty, and is not a failure. Anything else is.
+//
+// It exists so that rule is written once. Four copies of it meant a change to
+// what counts as "absent" -- tolerating a 404 as well, say -- was four edits
+// with nothing to catch the one that got missed.
+func (p serviceProbe) summarize() (serviceSummary, error) {
+ state, err := p.read()
+ switch {
+ case api.IsNotSupported(err):
+ return serviceSummary{Name: p.name, State: stateUnsupported}, nil
+ case err != nil:
+ return serviceSummary{}, err
+ }
+ return serviceSummary{Name: p.name, State: state}, nil
+}
+
+func newServicesCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "services",
+ Short: "MCP, plugin, session, and audit service summary",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ d := deps(cmd)
+ probes := []serviceProbe{
+ {svcMCP, func() (string, error) {
+ servers, err := mcpServers(cmd)
+ if err != nil {
+ return "", err
+ }
+ ready := 0
+ for _, s := range servers {
+ if s.Ready {
+ ready++
+ }
+ }
+ return fmt.Sprintf("%d/%d ready", ready, len(servers)), nil
+ }},
+ {svcPlugins, func() (string, error) {
+ plugins, err := d.Client.Plugins(cmd.Context())
+ if err != nil {
+ return "", err
+ }
+ active := 0
+ for _, p := range plugins {
+ if p.Enabled {
+ active++
+ }
+ }
+ return fmt.Sprintf("%d active", active), nil
+ }},
+ {svcSessions, func() (string, error) {
+ sessions, err := d.Client.Sessions(cmd.Context())
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%d operators", len(sessions)), nil
+ }},
+ {svcAudit, func() (string, error) {
+ audit, err := d.Client.Audit(cmd.Context(), api.AuditQuery{Limit: 1})
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%d events", audit.Summary.TotalEntries), nil
+ }},
+ }
+
+ rows := make([]serviceSummary, 0, len(probes))
+ for _, p := range probes {
+ row, err := p.summarize()
+ if err != nil {
+ return err
+ }
+ rows = append(rows, row)
+ }
+
+ if d.Printer.Format != FormatTable {
+ return d.printStructured(rows)
+ }
+ cells := make([][]string, 0, len(rows))
+ for _, row := range rows {
+ cells = append(cells, []string{row.Name, row.State})
+ }
+ d.Printer.Table([]string{"SERVICE", colState}, cells)
+ return nil
+ },
+ }
+}
+
+func newMCPCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "mcp",
+ Short: "MCP tool servers and their readiness",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ d := deps(cmd)
+ // /admin/health is the richer source: it alone carries last_error,
+ // which /readyz withholds because it is unauthenticated and the
+ // reason can quote a server URL or a token.
+ servers, err := mcpServers(cmd)
+ if err != nil {
+ return err
+ }
+ if d.Printer.Format != FormatTable {
+ return d.printStructured(servers)
+ }
+ if len(servers) == 0 {
+ d.Printer.Warn("this gateway has no MCP servers configured")
+ }
+ rows := make([][]string, 0, len(servers))
+ for _, s := range servers {
+ rows = append(rows, []string{s.Name, table.BoolYN(s.Ready), table.BoolYN(s.Required), table.OrDash(s.LastError)})
+ }
+ d.Printer.Table([]string{colName, "READY", "REQUIRED", "LAST ERROR"}, rows)
+ return nil
+ },
+ }
+}
+
+func mcpServers(cmd *cobra.Command) ([]api.MCPServer, error) {
+ d := deps(cmd)
+ ah, err := d.Client.AdminHealth(cmd.Context())
+ if err == nil {
+ return ah.MCPServers, nil
+ }
+ d.Printer.Warn("admin health unavailable (%v) — falling back to /readyz, which withholds last_error", err)
+ ready, _, err := d.Client.Ready(cmd.Context())
+ if err != nil {
+ return nil, err
+ }
+ return ready.MCPServers, nil
+}
+
+// pluginRow merges the two listings: /admin/plugins says what this gateway has
+// configured, /admin/plugins/catalog says what this build ships — and only the
+// catalog knows whether a plugin fails open.
+type pluginRow struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Enabled bool `json:"enabled"`
+ Fails string `json:"fails"`
+ Summary string `json:"summary,omitempty"`
+}
+
+func mergePlugins(configured []api.PluginInfo, catalog []api.BuiltinPlugin) []pluginRow {
+ shipped := table.NewPluginCatalog(catalog)
+ rows := make([]pluginRow, 0, len(configured))
+ for _, p := range configured {
+ policy := shipped.For(p.Name)
+ rows = append(rows, pluginRow{
+ Name: p.Name, Type: p.Type, Enabled: p.Enabled,
+ Fails: policy.Fails, Summary: policy.Summary,
+ })
+ }
+ return rows
+}
+
+func newPluginsCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "plugins",
+ Short: "Configured plugins, merged with this build's catalog",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ d := deps(cmd)
+ configured, err := d.Client.Plugins(cmd.Context())
+ if err != nil {
+ return err
+ }
+ catalog, err := d.Client.PluginCatalog(cmd.Context())
+ if err != nil {
+ // A build without the catalog route still lists what it runs;
+ // it just cannot say what fails open.
+ if !api.IsNotSupported(err) {
+ return err
+ }
+ d.Printer.Warn("this gateway serves no plugin catalog — fail-open policy and summaries are unavailable")
+ catalog = nil
+ }
+ rows := mergePlugins(configured, catalog)
+ if d.Printer.Format != FormatTable {
+ return d.printStructured(rows)
+ }
+ cells := make([][]string, 0, len(rows))
+ for _, r := range rows {
+ cells = append(cells, []string{r.Name, r.Type, table.BoolYN(r.Enabled), r.Fails, table.OrDash(r.Summary)})
+ }
+ d.Printer.Table([]string{colName, "TYPE", "ENABLED", "FAILS", "SUMMARY"}, cells)
+ return nil
+ },
+ }
+}
+
+func newSessionsCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "sessions",
+ Short: "Active dashboard sessions",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ d := deps(cmd)
+ sessions, err := d.Client.Sessions(cmd.Context())
+ if err != nil {
+ if api.IsNotSupported(err) {
+ return fmt.Errorf("dashboard sessions are not enabled on this gateway, so there are none to list: %w", err)
+ }
+ return err
+ }
+ if d.Printer.Format != FormatTable {
+ return d.printStructured(sessions)
+ }
+ rows := make([][]string, 0, len(sessions))
+ for _, s := range sessions {
+ rows = append(rows, []string{
+ s.Subject, strings.Join(s.Scopes, ","),
+ table.FmtTime(s.CreatedAt), table.FmtTimePtr(s.LastSeenAt), table.FmtTime(s.ExpiresAt),
+ })
+ }
+ d.Printer.Table([]string{"SUBJECT", colScopes, "CREATED", "LAST SEEN", colExpires}, rows)
+ return nil
+ },
+ }
+}
+
+func newAuditCmd() *cobra.Command {
+ audit := &cobra.Command{
+ Use: "audit",
+ Short: "Read the credential and configuration audit trail",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ d := deps(cmd)
+ q, err := auditQueryFromFlags(cmd)
+ if err != nil {
+ return err
+ }
+ page, err := d.Client.Audit(cmd.Context(), q)
+ if err != nil {
+ return err
+ }
+ if d.Printer.Format != FormatTable {
+ return d.printStructured(page)
+ }
+ rows := make([][]string, 0, len(page.Data))
+ for _, e := range page.Data {
+ rows = append(rows, []string{
+ table.FmtTime(e.OccurredAt), e.Action, table.OrDash(e.Actor), e.Outcome, table.OrDash(e.TargetID),
+ })
+ }
+ d.Printer.Table([]string{colTime, "ACTION", "ACTOR", "OUTCOME", "TARGET"}, rows)
+ return nil
+ },
+ }
+ f := audit.Flags()
+ f.String("action", "", "only this action (key.create, session.create, …)")
+ f.String("actor", "", "only this actor's credential id")
+ f.String("outcome", "", "ok|denied|error")
+ f.String("since", "", "duration (15m, 2h) or an RFC3339 timestamp")
+ f.Int("limit", 50, "rows to return (gateway caps at 200)")
+ return audit
+}
+
+func auditQueryFromFlags(cmd *cobra.Command) (api.AuditQuery, error) {
+ f := cmd.Flags()
+ q := api.AuditQuery{}
+ q.Action, _ = f.GetString("action")
+ q.ActorID, _ = f.GetString("actor")
+ q.Outcome, _ = f.GetString("outcome")
+ q.Limit, _ = f.GetInt("limit")
+
+ if q.Outcome != "" && !slices.Contains(auditOutcomes, q.Outcome) {
+ return q, fmt.Errorf("outcome %q is not valid: --outcome must be one of %s, and the gateway answers 400 to anything else",
+ q.Outcome, strings.Join(auditOutcomes, ", "))
+ }
+ if since, _ := f.GetString("since"); since != "" {
+ t, err := api.ParseSince(since)
+ if err != nil {
+ return q, err
+ }
+ q.Since = t
+ }
+ return q, nil
+}
diff --git a/internal/command/services_test.go b/internal/command/services_test.go
new file mode 100644
index 0000000..1d13c4d
--- /dev/null
+++ b/internal/command/services_test.go
@@ -0,0 +1,366 @@
+package command
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/fixture"
+ "github.com/ferro-labs/gateway-cli/internal/table"
+)
+
+func TestModelsTable(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "models")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, col := range []string{"ID", "OWNED BY", "MODE", "CONTEXT", "CAPABILITIES", "STATUS"} {
+ if !strings.Contains(stdout, col) {
+ t.Fatalf("missing column %s in %q", col, stdout)
+ }
+ }
+ if !strings.Contains(stdout, "claude-sonnet-4-6") || !strings.Contains(stdout, "anthropic") {
+ t.Fatalf("model rows missing: %q", stdout)
+ }
+}
+
+func TestModelsJSONIsOneDocument(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "models", "--format", "json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var rows []map[string]any
+ if err := decodeOne(stdout, &rows); err != nil {
+ t.Fatalf("stdout must be one JSON document: %v (%q)", err, stdout)
+ }
+ if len(rows) == 0 {
+ t.Fatalf("expected models: %q", stdout)
+ }
+}
+
+func TestProvidersMergesCircuitFromHealth(t *testing.T) {
+ s := fixture.Default()
+ s.Degraded = true
+ srv := gateway(t, s)
+
+ stdout, _, err := run(t, srv, "providers")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, col := range []string{"PROVIDER", "STATUS", "CIRCUIT", "MODELS", "MESSAGE"} {
+ if !strings.Contains(stdout, col) {
+ t.Fatalf("missing column %s in %q", col, stdout)
+ }
+ }
+ // Circuit comes from /health, status/models/message from /admin/health:
+ // neither endpoint alone can fill this table.
+ if !strings.Contains(stdout, "half_open") {
+ t.Fatalf("circuit must come from /health: %q", stdout)
+ }
+ if !strings.Contains(stdout, "circuit half_open after") {
+ t.Fatalf("message must come from /admin/health: %q", stdout)
+ }
+}
+
+// Unauthenticated is a smaller answer, not an error: /health alone still names
+// every provider and its circuit.
+func TestMergeProvidersFallsBackToHealthAlone(t *testing.T) {
+ h := &api.HealthReport{Providers: []api.ProviderHealth{
+ {Name: "anthropic", Status: "available", Circuit: "open", Models: 412},
+ }}
+ rows := table.MergeProviders(h, nil)
+ if len(rows) != 1 || rows[0].Circuit != "open" || rows[0].Models != 412 {
+ t.Fatalf("health-only fallback lost data: %+v", rows)
+ }
+ if rows[0].Message != "" {
+ t.Fatalf("there is no message without /admin/health: %+v", rows)
+ }
+}
+
+func TestMergeProvidersKeepsHealthOnlyRowsAndNumericCounts(t *testing.T) {
+ h := &api.HealthReport{Providers: []api.ProviderHealth{
+ {Name: "openai", Status: "available", Circuit: "closed", Models: 10},
+ {Name: "fallback", Status: "available", Circuit: "open", Models: 2},
+ }}
+ ah := &api.AdminHealth{Providers: []api.AdminProviderHealth{{Name: "openai", Status: "healthy", Models: 10}}}
+ rows := table.MergeProviders(h, ah)
+ if len(rows) != 2 || rows[1].Name != "fallback" || rows[1].Models != 2 {
+ t.Fatalf("admin health omissions must not hide /health providers: %+v", rows)
+ }
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "providers", "--format", "json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var doc []map[string]any
+ if err := decodeOne(stdout, &doc); err != nil {
+ t.Fatal(err)
+ }
+ if len(doc) == 0 {
+ t.Fatal("expected provider rows")
+ }
+ if _, ok := doc[0]["models"].(float64); !ok {
+ t.Fatalf("models must be a JSON number, got %T (%v)", doc[0]["models"], doc[0]["models"])
+ }
+}
+
+func TestPluginsMergeShowsFailsOpenFromCatalog(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "plugins")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, col := range []string{"NAME", "TYPE", "ENABLED", "FAILS", "SUMMARY"} {
+ if !strings.Contains(stdout, col) {
+ t.Fatalf("missing column %s in %q", col, stdout)
+ }
+ }
+ // fails_open lives only in the catalog, so its presence proves the merge.
+ if !strings.Contains(stdout, "open") || !strings.Contains(stdout, "closed") {
+ t.Fatalf("FAILS column must be merged from the catalog: %q", stdout)
+ }
+ if !strings.Contains(stdout, "Records one row per request stage") {
+ t.Fatalf("SUMMARY must be merged from the catalog: %q", stdout)
+ }
+}
+
+// A plugin the build does not ship (registered out of tree) still lists, with
+// the catalog-only columns blank rather than guessed.
+func TestMergePluginsKeepsUncatalogedPlugins(t *testing.T) {
+ rows := mergePlugins(
+ []api.PluginInfo{{Name: "vendor-thing", Type: "guardrail", Enabled: true}},
+ []api.BuiltinPlugin{{Name: "budget", Type: "guardrail", FailsOpen: false}},
+ )
+ if len(rows) != 1 || rows[0].Name != "vendor-thing" {
+ t.Fatalf("configured plugins drive the listing: %+v", rows)
+ }
+ if rows[0].Fails != "-" || rows[0].Summary != "" {
+ t.Fatalf("unknown fail policy must not be guessed: %+v", rows)
+ }
+}
+
+func TestMCPTable(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "mcp")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, col := range []string{"NAME", "READY", "REQUIRED", "LAST ERROR"} {
+ if !strings.Contains(stdout, col) {
+ t.Fatalf("missing column %s in %q", col, stdout)
+ }
+ }
+ if !strings.Contains(stdout, "filesystem") {
+ t.Fatalf("mcp rows missing: %q", stdout)
+ }
+}
+
+func TestServicesAggregateIsReachable(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "services")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, service := range []string{"MCP servers", "Plugins", "Sessions", "Audit"} {
+ if !strings.Contains(stdout, service) {
+ t.Fatalf("services output missing %q: %s", service, stdout)
+ }
+ }
+}
+
+func TestServicesMarksUnsupportedComponentWithoutDroppingOthers(t *testing.T) {
+ s := fixture.Default()
+ s.NoSessions = true
+ srv := gateway(t, s)
+ stdout, _, err := run(t, srv, "services", "--format", "json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var rows []serviceSummary
+ if err := decodeOne(stdout, &rows); err != nil {
+ t.Fatalf("stdout must be one JSON document: %v (%q)", err, stdout)
+ }
+ if len(rows) != 4 {
+ t.Fatalf("one unsupported component must not hide the others: %+v", rows)
+ }
+ for _, row := range rows {
+ if row.Name == "Sessions" && row.State != "unsupported" {
+ t.Fatalf("unsupported sessions must not be reported as a healthy zero: %+v", rows)
+ }
+ }
+}
+
+// /admin/health carries last_error but needs a credential; /readyz carries the
+// same servers unauthenticated. A bad key degrades to the smaller answer.
+func TestMCPFallsBackToReadyzWithoutCredential(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ t.Setenv("FERRO_API_KEY", "fgw_wrong")
+ stdout, stderr, err := execute(t, "mcp", "--gateway-url", srv.URL)
+ if err != nil {
+ t.Fatalf("an unauthorized caller still gets the readyz view: %v", err)
+ }
+ if !strings.Contains(stdout, "filesystem") {
+ t.Fatalf("fallback lost the rows: %q", stdout)
+ }
+ if !strings.Contains(stderr, "readyz") {
+ t.Fatalf("the degraded source must be stated on stderr: %q", stderr)
+ }
+}
+
+func TestSessionsTable(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "sessions")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, col := range []string{"SUBJECT", "SCOPES", "CREATED", "LAST SEEN", "EXPIRES"} {
+ if !strings.Contains(stdout, col) {
+ t.Fatalf("missing column %s in %q", col, stdout)
+ }
+ }
+ if !strings.Contains(stdout, "ops-laptop") {
+ t.Fatalf("session rows missing: %q", stdout)
+ }
+ // ci-pipeline has never been seen. Only its LAST SEEN cell may be a dash:
+ // a "-" anywhere in the table would also be satisfied by a zero-time
+ // 0001-01-01 in that cell, or by every cell collapsing to a dash.
+ var row string
+ for _, l := range strings.Split(stdout, "\n") {
+ if strings.Contains(l, "ci-pipeline") {
+ row = l
+ }
+ }
+ if row == "" {
+ t.Fatalf("expected the seeded ci-pipeline session row: %q", stdout)
+ }
+ // SUBJECT SCOPES CREATED LAST-SEEN EXPIRES — every cell is whitespace-free,
+ // so the table still splits into fields for anything piping it.
+ fields := strings.Fields(row)
+ if len(fields) != 5 {
+ t.Fatalf("want 5 whitespace-free columns, got %d in %q", len(fields), row)
+ }
+ if fields[3] != "-" {
+ t.Fatalf("a null last_seen_at must render as -, got %q in %q", fields[3], row)
+ }
+ if fields[2] == "-" || fields[4] == "-" {
+ t.Fatalf("created_at and expires_at are set on this session and must render as dates: %q", row)
+ }
+}
+
+func TestSessionsDisabledDegradesWithAHint(t *testing.T) {
+ s := fixture.Default()
+ s.NoSessions = true
+ srv := gateway(t, s)
+
+ _, _, err := run(t, srv, "sessions")
+ if err == nil || !strings.Contains(err.Error(), "sessions") {
+ t.Fatalf("a 501 must be reported as a disabled feature: %v", err)
+ }
+ if strings.Contains(err.Error(), "501") && !strings.Contains(err.Error(), "not enabled") {
+ t.Fatalf("the hint must lead, not the status code: %v", err)
+ }
+}
+
+func TestAuditTableAndFilters(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "audit", "--outcome", "ok", "--limit", "10")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, col := range []string{"TIME", "ACTION", "ACTOR", "OUTCOME", "TARGET"} {
+ if !strings.Contains(stdout, col) {
+ t.Fatalf("missing column %s in %q", col, stdout)
+ }
+ }
+ if !strings.Contains(stdout, "key.create") {
+ t.Fatalf("audit rows missing: %q", stdout)
+ }
+}
+
+// The gateway answers 400 for an outcome outside its vocabulary. Refusing it
+// here spends no request and gives the same vocabulary back.
+func TestAuditRejectsBadOutcomeClientSide(t *testing.T) {
+ _, _, err := execute(t, "audit", "--outcome", "bogus", "--gateway-url", "http://127.0.0.1:1")
+ if err == nil || !strings.Contains(err.Error(), "denied") {
+ t.Fatalf("--outcome must be validated client-side against ok|denied|error: %v", err)
+ }
+}
+
+// A dash is a rendering of absence, not a value the gateway sent. Every field
+// of table.ProviderRow must therefore stay literal in --format json, and be
+// dashed only where the table is drawn. circuit briefly broke that rule on its own,
+// so one document carried "-" for an absent circuit beside an empty string
+// for an absent status — two conventions in one object.
+func TestProvidersJSONKeepsAbsenceLiteral(t *testing.T) {
+ h := &api.HealthReport{Providers: []api.ProviderHealth{
+ {Name: "anthropic", Status: "available", Models: 412}, // no circuit reported
+ }}
+ rows := table.MergeProviders(h, nil)
+ if len(rows) != 1 {
+ t.Fatalf("want one row, got %d", len(rows))
+ }
+ if rows[0].Circuit != "" {
+ t.Fatalf("an absent circuit stays empty in the structured row, got %q", rows[0].Circuit)
+ }
+
+ body, err := json.Marshal(rows[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(body), `"-"`) {
+ t.Fatalf("no field may serialize a table dash: %s", body)
+ }
+ if !strings.Contains(string(body), `"circuit":""`) {
+ t.Fatalf("circuit must serialize as the empty string it is: %s", body)
+ }
+}
+
+// "No admin credential accepted" is a specific claim. A gateway that times out
+// or 500s on /admin/health costs the same two columns, but says so — telling
+// the operator to check a credential that was never the problem is a wrong
+// answer, and the one they would act on first.
+func TestProvidersNamesWhyAdminHealthWasUnavailable(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ status int
+ body string
+ want string
+ absent string
+ }{
+ {"refused credential", http.StatusUnauthorized, `{"error":{"message":"nope"}}`,
+ "no admin credential accepted", "admin health unavailable"},
+ {"broken gateway", http.StatusInternalServerError, `{"error":{"message":"boom"}}`,
+ "admin health unavailable", "no admin credential accepted"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"status":"ok","providers":[{"name":"openai","status":"available","circuit":"closed","models":3}]}`))
+ })
+ mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(tc.status)
+ _, _ = w.Write([]byte(tc.body))
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ _, stderr, err := run(t, srv, "providers")
+ if err != nil {
+ t.Fatalf("an unusable admin probe costs columns, not the command: %v", err)
+ }
+ if !strings.Contains(stderr, tc.want) {
+ t.Fatalf("want %q in the warning, got %q", tc.want, stderr)
+ }
+ if strings.Contains(stderr, tc.absent) {
+ t.Fatalf("must not claim %q for a %d, got %q", tc.absent, tc.status, stderr)
+ }
+ })
+ }
+}
diff --git a/internal/command/status.go b/internal/command/status.go
new file mode 100644
index 0000000..46d6755
--- /dev/null
+++ b/internal/command/status.go
@@ -0,0 +1,73 @@
+package command
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ferro-labs/gateway-cli/internal/table"
+)
+
+func newStatusCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "status",
+ Short: "Health of a running gateway (exit 1 only when unreachable)",
+ Long: "status prints one row describing the gateway this profile points at.\n\n" +
+ "The exit code is the contract: 1 means the gateway could not be reached\n" +
+ "at all, so `ferro status || alert` pages on an outage and stays quiet for\n" +
+ "a degraded-but-serving gateway, which exits 0 with its state printed.",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ d := deps(cmd)
+ // Status returns a report even when it fails, naming the URL and the
+ // state it reached — printing it is what makes state:"unreachable"
+ // visible to a `--format json` consumer instead of an empty stdout.
+ report, statusErr := d.Client.Status(cmd.Context())
+
+ if d.Printer.Format != FormatTable {
+ if err := d.printStructured(report); err != nil {
+ return err
+ }
+ } else {
+ mcp := "-"
+ if report.MCP != nil {
+ mcp = fmt.Sprintf("%d/%d", report.MCP.Ready, report.MCP.Total)
+ }
+ // A not_ready gateway reports no targets array at all. Rendering
+ // "0/0" there would read as "none configured" when the truth is
+ // "yours are dead" — the warnings below carry the real reason.
+ targets := "-"
+ if report.Targets != nil {
+ targets = fmt.Sprintf("%d/%d", report.Targets.Routable, report.Targets.Total)
+ }
+ // statusErr is non-nil only when the gateway was never reached at
+ // all (Status returns early on a failed /health). The elapsed
+ // time up to that failure is not the gateway's RTT, so printing
+ // it as one would read as an instant reply on the exact row that
+ // reports an outage.
+ latency := "-"
+ if statusErr == nil {
+ latency = fmt.Sprintf("%dms", report.LatencyMs)
+ }
+ d.Printer.Table(
+ []string{colState, colURL, "LATENCY", "TARGETS", "PROVIDERS", colModels, "MCP", "AUTH"},
+ [][]string{{
+ report.State,
+ report.URL,
+ latency,
+ targets,
+ table.CountOrDash(report.Providers),
+ table.CountOrDash(report.Models),
+ mcp,
+ table.OrDash(report.Auth),
+ }},
+ )
+ }
+ for _, w := range report.Warnings {
+ d.Printer.Warn("%s", w)
+ }
+ // Degraded stays exit 0; only an unreachable gateway is exit 1.
+ return statusErr
+ },
+ }
+}
diff --git a/internal/command/status_test.go b/internal/command/status_test.go
new file mode 100644
index 0000000..0a88f36
--- /dev/null
+++ b/internal/command/status_test.go
@@ -0,0 +1,167 @@
+package command
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/ferro-labs/gateway-cli/internal/fixture"
+)
+
+// gateway starts the one in-repo definition of the wire contract. Every command
+// test talks to it instead of a hand-rolled stub, so a fixture that drifts from
+// the gateway fails these tests rather than quietly blessing the drift.
+func gateway(t *testing.T, s fixture.State) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(fixture.Handler(s))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+// run executes a verb against srv with the fixture's credential. Config
+// isolation is execute's (root_test.go) and reaches every test through here --
+// a test that builds its own root instead has to repeat it, as chat_test.go does.
+func run(t *testing.T, srv *httptest.Server, args ...string) (stdout, stderr string, err error) {
+ t.Helper()
+ t.Setenv("FERRO_API_KEY", "fgw_test")
+ return execute(t, append(args, "--gateway-url", srv.URL)...)
+}
+
+// decodeOne decodes stdout into v and fails if a second document follows: the
+// stdout-is-the-machine-channel rule means exactly one payload, never a payload
+// with narrative wrapped around it.
+func decodeOne(stdout string, v any) error {
+ dec := json.NewDecoder(strings.NewReader(stdout))
+ if err := dec.Decode(v); err != nil {
+ return err
+ }
+ var extra any
+ if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
+ if err != nil {
+ return err
+ }
+ return errMoreThanOneDoc
+ }
+ return nil
+}
+
+var errMoreThanOneDoc = errors.New("stdout carried more than one JSON document")
+
+// oneJSONDoc asserts stdout is exactly one JSON document — the whole point of
+// keeping narrative on stderr.
+func oneJSONDoc(t *testing.T, stdout string) map[string]any {
+ t.Helper()
+ var doc map[string]any
+ if err := decodeOne(stdout, &doc); err != nil {
+ t.Fatalf("stdout must be one JSON document, got %q: %v", stdout, err)
+ }
+ return doc
+}
+
+func TestStatusTableExitAndColumns(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "status")
+ if err != nil {
+ t.Fatalf("healthy status must exit 0: %v", err)
+ }
+ for _, col := range []string{"STATE", "URL", "LATENCY", "TARGETS", "PROVIDERS", "MODELS", "MCP", "AUTH"} {
+ if !strings.Contains(stdout, col) {
+ t.Fatalf("missing column %s in %q", col, stdout)
+ }
+ }
+ if !strings.Contains(stdout, "connected") {
+ t.Fatalf("healthy gateway must report connected: %q", stdout)
+ }
+}
+
+func TestStatusJSONIsPureAndStable(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ stdout, _, err := run(t, srv, "status", "--format", "json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ doc := oneJSONDoc(t, stdout)
+ for _, k := range []string{"state", "url", "latency_ms", "targets", "circuits"} {
+ if _, ok := doc[k]; !ok {
+ t.Fatalf("frozen status contract missing %q: %v", k, doc)
+ }
+ }
+}
+
+// Degraded is not a failure: `ferro status || alert` must mean "unreachable",
+// so a half-open circuit still exits 0 with the degraded state on stdout.
+func TestStatusDegradedExits0AndWarnsOnStderr(t *testing.T) {
+ s := fixture.Default()
+ s.Degraded = true
+ srv := gateway(t, s)
+
+ stdout, stderr, err := run(t, srv, "status")
+ if err != nil {
+ t.Fatalf("degraded must still exit 0: %v", err)
+ }
+ if !strings.Contains(stdout, "degraded") {
+ t.Fatalf("degraded state must be printed: %q", stdout)
+ }
+ if !strings.Contains(stderr, "half-open") {
+ t.Fatalf("warnings belong on stderr, got %q", stderr)
+ }
+ if strings.Contains(stdout, "half-open") {
+ t.Fatalf("narrative leaked into stdout: %q", stdout)
+ }
+}
+
+func TestStatusUnreachableExits1ButStillReportsState(t *testing.T) {
+ stdout, _, err := execute(t, "status", "--gateway-url", "http://127.0.0.1:1", "--format", "json")
+ if err == nil {
+ t.Fatal("unreachable must exit 1")
+ }
+ doc := oneJSONDoc(t, stdout)
+ if doc["state"] != "unreachable" {
+ t.Fatalf("the report must still name the state that was reached: %v", doc)
+ }
+}
+
+// Missing is not zero (README's "Contracts worth relying on"): an unreachable
+// gateway never answered, so the elapsed time until the dial failed is not a
+// latency measurement and must not print as one.
+func TestStatusUnreachableRendersDashLatencyNot0ms(t *testing.T) {
+ stdout, _, err := execute(t, "status", "--gateway-url", "http://127.0.0.1:1")
+ if err == nil {
+ t.Fatal("unreachable must exit 1")
+ }
+ if strings.Contains(stdout, "0ms") {
+ t.Fatalf("an unreachable gateway must never print a latency, got %q", stdout)
+ }
+ lines := strings.Split(strings.TrimRight(stdout, "\n"), "\n")
+ if len(lines) != 3 {
+ t.Fatalf("want header + underline + 1 row, got %d lines:\n%s", len(lines), stdout)
+ }
+ headers := strings.Fields(lines[0])
+ row := strings.Fields(lines[2])
+ for i, h := range headers {
+ if h == "LATENCY" {
+ if row[i] != "-" {
+ t.Fatalf("LATENCY must render \"-\" when the gateway was never reached, got %q in %q", row[i], lines[2])
+ }
+ return
+ }
+ }
+ t.Fatalf("no LATENCY column in header: %q", lines[0])
+}
+
+// A gateway reachable without a usable credential is still reachable: the
+// report degrades to what /health and /readyz say rather than failing.
+func TestStatusWithoutCredentialDegradesNotFails(t *testing.T) {
+ srv := gateway(t, fixture.Default())
+ t.Setenv("FERRO_API_KEY", "fgw_wrong")
+ stdout, _, err := execute(t, "status", "--gateway-url", srv.URL)
+ if err != nil {
+ t.Fatalf("a bad credential must not make status exit 1: %v", err)
+ }
+ if !strings.Contains(stdout, "unauthorized") {
+ t.Fatalf("auth column must report unauthorized: %q", stdout)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..bfa8d59
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,198 @@
+// Package config resolves where ferro connects and with which credential.
+// Precedence — path: --config > FERRO_CONFIG > DefaultPath()
+//
+// URL: --gateway-url > FERRO_URL > profile.url > http://localhost:8080
+// key: FERRO_API_KEY > profile.api_key_env deref > MASTER_KEY (loopback URL only) > ""
+//
+// The MASTER_KEY step is restricted because, unlike the two above it, nobody
+// named it for ferro: it is the gateway server's own variable and is simply
+// present in the shell on a gateway host. Honouring it for any URL would let
+// `ferro --gateway-url https://elsewhere status`, typed in that shell, hand the
+// gateway's root credential to a stranger.
+//
+// ferro stores no gateway data; this file holds connection profiles only.
+package config
+
+import (
+ "fmt"
+ "net"
+ "net/url"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "go.yaml.in/yaml/v3"
+)
+
+// DefaultURL is where ferro looks when nothing else says otherwise.
+const DefaultURL = "http://localhost:8080"
+
+// The environment variables Resolve reads, and the KeySource label it reports
+// for each source. Building the labels from the variable names is what keeps
+// the two spellings from drifting: a doctor screen naming a variable nothing
+// reads is worse than no label at all.
+const (
+ envURL = "FERRO_URL"
+ envFerroKey = "FERRO_API_KEY"
+ envMasterKey = "MASTER_KEY"
+
+ keySourceFerroEnv = "env:" + envFerroKey
+ keySourceMasterEnv = "env:" + envMasterKey
+ keySourceProfile = "profile:"
+
+ // keySourceMasterSkipped is reported instead of a credential when
+ // MASTER_KEY is set but the gateway is remote. It rides on KeySource
+ // rather than a new field so any display that already shows where the
+ // credential came from also explains why there is none.
+ keySourceMasterSkipped = keySourceMasterEnv + " skipped (gateway is not loopback)"
+)
+
+// Profile is one named connection. It holds no credential: api_key_env names
+// an environment variable, so the config file never carries a secret.
+type Profile struct {
+ Name string `yaml:"name"`
+ URL string `yaml:"url"`
+ APIKeyEnv string `yaml:"api_key_env,omitempty"`
+}
+
+// File is the on-disk ferro config: connection profiles and nothing else.
+type File struct {
+ CurrentProfile string `yaml:"current_profile,omitempty"`
+ Profiles []Profile `yaml:"profiles,omitempty"`
+}
+
+// Resolved is what the rest of ferro consumes. KeySource is for doctor-style
+// display ("env:FERRO_API_KEY", "profile:PROD_KEY", "env:MASTER_KEY"), and is
+// the only place a refused MASTER_KEY is visible -- APIKey is then empty and
+// the gateway's 401 is all ferro would otherwise have to go on.
+type Resolved struct {
+ ProfileName string
+ URL string
+ APIKey string
+ KeySource string
+}
+
+// EnvConfigPath is the environment variable that overrides DefaultPath.
+// --config on the CLI outranks it in turn (see the precedence note above). A
+// config path names a filesystem location, never a credential, so unlike
+// FERRO_API_KEY it carries nothing that must not appear in a shell history.
+const EnvConfigPath = "FERRO_CONFIG"
+
+// DefaultPath is the config location, empty when the OS reports no config dir.
+func DefaultPath() string {
+ dir, err := os.UserConfigDir()
+ if err != nil {
+ return ""
+ }
+ return filepath.Join(dir, "ferro", "config.yaml")
+}
+
+// Load reads the config file. A missing file is not an error -- ferro works
+// with flags and environment alone.
+func Load(path string) (File, error) {
+ var f File
+ if path == "" {
+ return f, nil
+ }
+ // Normalise before use so the file that is read and the file a parse error
+ // names are spelled the same way, whatever lexical noise (`a/../b`, a
+ // trailing slash) the flag or FERRO_CONFIG carried.
+ clean := filepath.Clean(path)
+ b, err := os.ReadFile(clean)
+ if os.IsNotExist(err) {
+ return f, nil
+ }
+ if err != nil {
+ return f, err
+ }
+ if err := yaml.Unmarshal(b, &f); err != nil {
+ return f, fmt.Errorf("parse %s: %w", clean, err)
+ }
+ return f, nil
+}
+
+// Resolve applies the precedence documented on this package to produce the
+// connection ferro will use.
+func Resolve(f File, flagURL, flagProfile string, getenv func(string) string) (Resolved, error) {
+ r := Resolved{URL: DefaultURL}
+
+ name := flagProfile
+ if name == "" {
+ name = f.CurrentProfile
+ }
+ var prof *Profile
+ if name != "" {
+ for i := range f.Profiles {
+ if f.Profiles[i].Name == name {
+ prof = &f.Profiles[i]
+ break
+ }
+ }
+ if prof == nil {
+ names := make([]string, 0, len(f.Profiles))
+ for _, p := range f.Profiles {
+ names = append(names, p.Name)
+ }
+ sort.Strings(names)
+ if len(names) == 0 {
+ return r, fmt.Errorf("unknown profile %q (no profiles are defined)", name)
+ }
+ return r, fmt.Errorf("unknown profile %q (valid: %s)", name, strings.Join(names, ", "))
+ }
+ }
+ if prof != nil {
+ r.ProfileName = prof.Name
+ if prof.URL != "" {
+ r.URL = prof.URL
+ }
+ }
+ if v := getenv(envURL); v != "" {
+ r.URL = v
+ }
+ if flagURL != "" {
+ r.URL = flagURL
+ }
+
+ switch {
+ case getenv(envFerroKey) != "":
+ r.APIKey, r.KeySource = getenv(envFerroKey), keySourceFerroEnv
+ case prof != nil && prof.APIKeyEnv != "" && getenv(prof.APIKeyEnv) != "":
+ r.APIKey, r.KeySource = getenv(prof.APIKeyEnv), keySourceProfile+prof.APIKeyEnv
+ case getenv(envMasterKey) != "":
+ // Loopback only -- see the package comment. Falling through to no
+ // credential rather than erroring keeps the failure the gateway's to
+ // report: it answers 401 and `ferro status` prints auth: unauthorized,
+ // which is the same story an expired key tells.
+ if isLoopbackURL(r.URL) {
+ r.APIKey, r.KeySource = getenv(envMasterKey), keySourceMasterEnv
+ } else {
+ r.KeySource = keySourceMasterSkipped
+ }
+ }
+ return r, nil
+}
+
+// isLoopbackURL reports whether raw names a host on this machine.
+//
+// internal/api holds the other copy of this test (isLoopbackHost, which gates
+// plaintext HTTP). They are duplicated rather than shared because config sits
+// below the client and must not import it to answer a question about a string,
+// and exporting one of them would put an internal predicate in a package API
+// for a single caller. Keep the two in step: if they disagree about what
+// loopback means, ferro would ship MASTER_KEY to a host it also refuses to
+// speak plaintext to, or withhold it from one it will.
+func isLoopbackURL(raw string) bool {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return false
+ }
+ // A trailing dot is the same name in DNS ("localhost." resolves like
+ // "localhost"), so it must not be a way past this check.
+ host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")
+ if host == "localhost" || strings.HasSuffix(host, ".localhost") {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..349ff3f
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,138 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func env(m map[string]string) func(string) string {
+ return func(k string) string { return m[k] }
+}
+
+func TestResolvePrecedence(t *testing.T) {
+ file := File{
+ CurrentProfile: "prod",
+ Profiles: []Profile{
+ {Name: "prod", URL: "https://gw.example.com", APIKeyEnv: "PROD_KEY"},
+ {Name: "local", URL: "http://localhost:9999"},
+ },
+ }
+ cases := []struct {
+ name string
+ flagURL string
+ flagProfile string
+ envv map[string]string
+ wantURL, wantKey string
+ wantSource string
+ }{
+ {name: "URL flag and key environment beat profile", flagURL: "http://flag:1",
+ envv: map[string]string{"FERRO_URL": "http://env:1", "FERRO_API_KEY": "fgw_env"},
+ wantURL: "http://flag:1", wantKey: "fgw_env", wantSource: "env:FERRO_API_KEY"},
+ {name: "env beats profile",
+ envv: map[string]string{"FERRO_URL": "http://env:1", "FERRO_API_KEY": "fgw_env", "PROD_KEY": "fgw_prod"},
+ wantURL: "http://env:1", wantKey: "fgw_env", wantSource: "env:FERRO_API_KEY"},
+ {name: "profile url + key env deref",
+ envv: map[string]string{"PROD_KEY": "fgw_prod"},
+ wantURL: "https://gw.example.com", wantKey: "fgw_prod", wantSource: "profile:PROD_KEY"},
+ {name: "MASTER_KEY is the last key fallback",
+ flagProfile: "local", envv: map[string]string{"MASTER_KEY": "fgw_master"},
+ wantURL: "http://localhost:9999", wantKey: "fgw_master", wantSource: "env:MASTER_KEY"},
+ // The whole point of the restriction: MASTER_KEY is in the shell on a
+ // gateway host, and this invocation is aimed at somebody else's host.
+ {name: "MASTER_KEY is withheld from a remote gateway", flagURL: "https://someone-else.example.com",
+ flagProfile: "local", envv: map[string]string{"MASTER_KEY": "fgw_master"},
+ wantURL: "https://someone-else.example.com", wantKey: "",
+ wantSource: "env:MASTER_KEY skipped (gateway is not loopback)"},
+ // A named credential is named for this tool, so the restriction must
+ // not spread to it: the two upper steps still reach any host.
+ {name: "a named credential still reaches a remote gateway", flagURL: "https://someone-else.example.com",
+ envv: map[string]string{"FERRO_API_KEY": "fgw_env", "MASTER_KEY": "fgw_master"},
+ wantURL: "https://someone-else.example.com", wantKey: "fgw_env", wantSource: "env:FERRO_API_KEY"},
+ {name: "defaults", flagProfile: "none-selected", envv: map[string]string{},
+ flagURL: "", wantURL: "http://localhost:8080", wantKey: "", wantSource: ""},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ f := file
+ if tc.flagProfile == "none-selected" {
+ f = File{} // no profiles at all
+ tc.flagProfile = ""
+ }
+ r, err := Resolve(f, tc.flagURL, tc.flagProfile, env(tc.envv))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.URL != tc.wantURL || r.APIKey != tc.wantKey || r.KeySource != tc.wantSource {
+ t.Fatalf("got url=%q key=%q src=%q", r.URL, r.APIKey, r.KeySource)
+ }
+ })
+ }
+}
+
+func TestResolveUnknownProfileErrors(t *testing.T) {
+ _, err := Resolve(File{Profiles: []Profile{{Name: "a"}}}, "", "nope", env(nil))
+ if err == nil {
+ t.Fatal("unknown --profile must error and list valid names")
+ }
+}
+
+func TestResolveStaleCurrentProfileErrors(t *testing.T) {
+ _, err := Resolve(File{CurrentProfile: "removed", Profiles: []Profile{{Name: "prod"}}}, "", "", env(nil))
+ if err == nil || !strings.Contains(err.Error(), `unknown profile "removed"`) {
+ t.Fatalf("a stale current_profile must not silently connect to localhost: %v", err)
+ }
+ _, err = Resolve(File{CurrentProfile: "removed"}, "", "", env(nil))
+ if err == nil || !strings.Contains(err.Error(), "no profiles are defined") {
+ t.Fatalf("an empty profile list needs a clear error: %v", err)
+ }
+}
+
+func TestLoadMissingFileIsZero(t *testing.T) {
+ f, err := Load("/definitely/not/here.yaml")
+ if err != nil || len(f.Profiles) != 0 {
+ t.Fatalf("missing file must be zero value, got %+v, %v", f, err)
+ }
+}
+
+func TestLoadMalformedYAMLNamesTheFile(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.yaml")
+ if err := os.WriteFile(path, []byte("profiles: [\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ _, err := Load(path)
+ if err == nil || !strings.Contains(err.Error(), path) {
+ t.Fatalf("malformed YAML must fail and name its file: %v", err)
+ }
+}
+
+// TestIsLoopbackURL pins the semantics against internal/api's isLoopbackHost,
+// the other copy of this test. A case that changes here without changing there
+// means MASTER_KEY and the plaintext-HTTP gate have started disagreeing about
+// what counts as this machine.
+func TestIsLoopbackURL(t *testing.T) {
+ loopback := []string{
+ "http://localhost:8080", "http://LOCALHOST", "http://localhost.:8080",
+ "http://app.localhost:3000", "http://127.0.0.1:8080", "http://127.9.9.9",
+ "http://[::1]:8080", "https://localhost",
+ }
+ remote := []string{
+ "https://gw.example.com", "http://localhost.evil.com", "http://notlocalhost",
+ "http://0.0.0.0:8080", "http://10.0.0.1", "http://[fe80::1]", "",
+ // A scheme-less string parses as scheme "localhost" with an empty
+ // host, so it must not be read as the loopback name it resembles.
+ "localhost:8080",
+ "://not a url",
+ }
+ for _, u := range loopback {
+ if !isLoopbackURL(u) {
+ t.Errorf("%q is this machine; MASTER_KEY must be usable", u)
+ }
+ }
+ for _, u := range remote {
+ if isLoopbackURL(u) {
+ t.Errorf("%q is not this machine; MASTER_KEY must not be sent there", u)
+ }
+ }
+}
diff --git a/internal/fixture/handler.go b/internal/fixture/handler.go
new file mode 100644
index 0000000..e6cbcfb
--- /dev/null
+++ b/internal/fixture/handler.go
@@ -0,0 +1,890 @@
+package fixture
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "math"
+ "net/http"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Handler serves the whole v0.1.0 surface for the world State describes. The
+// key store is stateful for the handler's lifetime: create/rotate/revoke/delete
+// mutate it and later GETs reflect the change.
+func Handler(s State) http.Handler {
+ if s.AcceptKey == "" {
+ s.AcceptKey = defaultKey
+ }
+ ks := newKeyStore()
+ tr := newTracer()
+ mux := http.NewServeMux()
+ registerHealth(mux, s)
+ registerKeys(mux, ks)
+ registerChat(mux, s, tr)
+ registerLogs(mux, s, tr)
+ registerAdmin(mux, s)
+ registerModels(mux)
+
+ // Anything unrouted answers in the documented envelope rather than the
+ // mux's plain-text 404, so the CLI's decoder is exercised even here.
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ writeErr(w, http.StatusNotFound, kindNotFound, "no route for "+r.Method+" "+r.URL.Path)
+ })
+
+ return withRequestID(withAuth(s, mux))
+}
+
+// registerHealth wires the liveness and readiness surface. /livez, /health and
+// /readyz are unauthenticated on the real router and so are they here;
+// /admin/health is the bearer-guarded probe that also reports component state.
+func registerHealth(mux *http.ServeMux, s State) {
+ mux.HandleFunc("GET /livez", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{fieldStatus: statusOK})
+ })
+
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
+ // Verified against a live gateway: no_providers <=> providers is EMPTY,
+ // and 503 <=> no_providers. Nothing else makes /health non-200 — a
+ // half-open circuit with two live providers still answers 200 "ok".
+ //
+ // Degraded and provider-less are therefore independent axes, and an
+ // earlier version of this handler conflated them: it served 503
+ // "no_providers" alongside two populated providers, so ferro reported
+ // "providers=2" next to the warning "health: no_providers" — a pairing
+ // live traffic can never show. Degraded now means what it means on the
+ // real server: a 200 whose circuit is half-open.
+ if s.NoProviders {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]any{
+ fieldStatus: statusNoProviders,
+ fieldProviders: []map[string]any{},
+ })
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ fieldStatus: statusOK,
+ fieldProviders: []map[string]any{
+ {fieldName: providerAnthropic, fieldStatus: statusAvailable, fieldCircuit: circuitOf(s), fieldModels: 412},
+ {fieldName: providerOpenAI, fieldStatus: statusAvailable, fieldCircuit: circuitClosed, fieldModels: 1104},
+ },
+ })
+ })
+
+ mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
+ // Every not-ready path on the real server goes through one writer that
+ // emits ONLY {status, reason} — no providers, no targets, no
+ // mcp_servers. Serving the arrays on a 503 taught callers to read
+ // counts that will not be there, which is how ferro status came to
+ // report "0/0 targets" for a gateway whose targets were merely dead.
+ if s.NoProviders {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]any{
+ fieldStatus: statusNotReady,
+ "reason": "no routable targets",
+ })
+ return
+ }
+ // A gateway with some routable targets is still ready — only "none
+ // routable" is 503 — so the degraded world reports 200 with one target
+ // marked unroutable.
+ out := map[string]any{
+ fieldStatus: statusReady,
+ fieldProviders: []map[string]any{
+ {fieldName: providerAnthropic, fieldCircuit: circuitOf(s)},
+ {fieldName: providerOpenAI, fieldCircuit: circuitClosed},
+ },
+ "targets": []map[string]any{
+ {fieldName: providerAnthropic, "routable": !s.Degraded},
+ {fieldName: providerOpenAI, "routable": true},
+ },
+ }
+ if !s.NoMCP {
+ out["mcp_servers"] = []map[string]any{
+ {fieldName: "filesystem", fieldReady: true, "required": false},
+ }
+ }
+ writeJSON(w, http.StatusOK, out)
+ })
+
+ mux.HandleFunc("GET /admin/health", func(w http.ResponseWriter, _ *http.Request) {
+ anthropic := map[string]any{fieldName: providerAnthropic, fieldStatus: statusAvailable, fieldModels: 412}
+ status := statusHealthy
+ if s.NoProviders {
+ status = statusNoProviders // the default state of a credential-free gateway
+ }
+ if s.Degraded {
+ status = statusDegraded
+ anthropic[fieldMessage] = "circuit half_open after 3 consecutive failures"
+ }
+ // Component names and vocabulary verified against a live gateway:
+ // Title Case with spaces (not snake_case), status healthy/disabled/
+ // unavailable (not "ok"), and five entries -- API and Audit log were
+ // both missing here. A missing log store reports "disabled", not an
+ // absent row.
+ logStore := statusHealthy
+ if s.NoLogStore {
+ logStore = statusDisabled
+ }
+ components := []map[string]any{
+ {fieldName: "API", fieldStatus: statusHealthy},
+ {fieldName: "Key store", fieldStatus: statusHealthy},
+ {fieldName: "Config store", fieldStatus: statusHealthy},
+ {fieldName: "Request logs", fieldStatus: logStore},
+ {fieldName: "Audit log", fieldStatus: statusHealthy},
+ }
+ out := map[string]any{
+ // Always 200: this is the auth/scope probe.
+ fieldStatus: status,
+ fieldProviders: []map[string]any{
+ anthropic,
+ {fieldName: providerOpenAI, fieldStatus: statusAvailable, fieldModels: 1104},
+ },
+ "components": components,
+ fieldScopes: []string{scopeAdmin},
+ }
+ if !s.NoMCP {
+ out["mcp_servers"] = []map[string]any{
+ {fieldName: "filesystem", fieldReady: true, "required": false},
+ }
+ }
+ writeJSON(w, http.StatusOK, out)
+ })
+
+}
+
+// registerLogs wires the request-log surface. Both routes answer 501 when no
+// store is configured, which is how the CLI feature-detects it.
+func registerLogs(mux *http.ServeMux, s State, tr *tracer) {
+ mux.HandleFunc("GET /admin/logs", func(w http.ResponseWriter, r *http.Request) {
+ if s.NoLogStore {
+ writeErr(w, http.StatusNotImplemented, kindServer, "no request log store configured")
+ return
+ }
+ q := r.URL.Query()
+ since, ok := parseSince(w, q)
+ if !ok {
+ return
+ }
+ rows := filterLogs(logRows(tr), q, since)
+ total := len(rows)
+ rows, ok = page(w, rows, q)
+ if !ok {
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ fieldData: rows,
+ fieldSummary: map[string]any{
+ fieldTotalEntries: total,
+ "returned_entries": len(rows),
+ },
+ "filters": map[string]any{
+ fieldLimit: q.Get(fieldLimit),
+ fieldOffset: q.Get(fieldOffset),
+ fieldSince: q.Get(fieldSince),
+ fieldModel: q.Get(fieldModel),
+ fieldProvider: q.Get(fieldProvider),
+ fieldStage: q.Get(fieldStage),
+ fieldAPIKeyID: q.Get(fieldAPIKeyID),
+ },
+ })
+ })
+
+ // GET /admin/logs/stats aggregates the same seeded rows /admin/logs lists,
+ // filtered the way the real handler's Stats query filters them
+ // (internal/admin/handlers/logs.go's logsStats + requestlog.SQLWriter.Stats):
+ // stage, model and provider are exact-match-or-absent and since keeps rows
+ // at or after the cursor. There is deliberately no default-to-terminal-
+ // stages narrowing here (unlike /admin/logs) and no api_key_id filter — the
+ // real endpoint applies neither, and internal/api.LogStats never sends one.
+ // An earlier version of this handler ignored every parameter and served one
+ // fixed aggregate regardless of the query, so a CLI bug that dropped a
+ // filter before it reached the wire was invisible against this fixture.
+ mux.HandleFunc("GET /admin/logs/stats", func(w http.ResponseWriter, r *http.Request) {
+ if s.NoLogStore {
+ writeErr(w, http.StatusNotImplemented, kindServer, "no request log store configured")
+ return
+ }
+ q := r.URL.Query()
+ since, ok := parseSince(w, q)
+ if !ok {
+ return
+ }
+ writeJSON(w, http.StatusOK, logStatsResponse(filterStats(logRows(tr), q, since)))
+ })
+
+}
+
+// registerAdmin wires the remaining admin control-plane reads: sessions, the
+// audit trail, the plugin surfaces and the provider inventory.
+func registerAdmin(mux *http.ServeMux, s State) {
+ mux.HandleFunc("GET /admin/sessions", func(w http.ResponseWriter, _ *http.Request) {
+ if s.NoSessions {
+ writeErr(w, http.StatusNotImplemented, kindServer, "dashboard sessions are disabled")
+ return
+ }
+ now := time.Now().UTC()
+ writeJSON(w, http.StatusOK, map[string]any{
+ fieldData: []map[string]any{
+ {
+ "id": "sess_01", "credential_id": keyIDOps, "subject": actorOps,
+ fieldScopes: []string{scopeAdmin}, "created_at": ts(now.Add(-3 * time.Hour)),
+ "last_seen_at": ts(now.Add(-4 * time.Minute)), "expires_at": ts(now.Add(21 * time.Hour)),
+ },
+ {
+ "id": "sess_02", "credential_id": keyIDCI, "subject": "ci-pipeline",
+ fieldScopes: []string{scopeReadOnly}, "created_at": ts(now.Add(-40 * time.Minute)),
+ "last_seen_at": nil, "expires_at": ts(now.Add(23 * time.Hour)),
+ },
+ },
+ })
+ })
+
+ mux.HandleFunc("GET /admin/audit", func(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ if out := q.Get(fieldOutcome); out != "" &&
+ out != outcomeOK && out != outcomeDenied && out != outcomeError {
+ writeErr(w, http.StatusBadRequest, kindInvalidRequest,
+ // Worded exactly as internal/admin/handlers/audit_read.go does.
+ "invalid outcome: must be one of ok, denied, error")
+ return
+ }
+ // Shared with /admin/logs and /admin/logs/stats: the real handler's
+ // since is the same parseSince on every route that accepts it
+ // (internal/admin/handlers/queryparams.go), so all three reject a
+ // malformed value with the identical 400 message.
+ since, ok := parseSince(w, q)
+ if !ok {
+ return
+ }
+ rows := filterAudit(auditRows(), q, since)
+ total := len(rows)
+ rows, ok = page(w, rows, q)
+ if !ok {
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ fieldData: rows,
+ fieldSummary: map[string]any{
+ fieldTotalEntries: total,
+ "returned_entries": len(rows),
+ },
+ })
+ })
+
+ mux.HandleFunc("GET /admin/plugins", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, http.StatusOK, []map[string]any{ // bare array
+ {fieldName: "request-logger", fieldType: typeLogging, fieldEnabled: true},
+ {fieldName: "budget", fieldType: typeGuardrail, fieldEnabled: true},
+ {fieldName: "rate-limit", fieldType: typeRateLimit, fieldEnabled: true},
+ {fieldName: "word-filter", fieldType: typeGuardrail, fieldEnabled: false},
+ })
+ })
+
+ mux.HandleFunc("GET /admin/plugins/catalog", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{
+ fieldData: []map[string]any{
+ {
+ fieldName: "request-logger", fieldType: typeLogging,
+ fieldSummary: "Records one row per request stage",
+ fieldSettings: []string{"persist", "log_bodies"}, fieldFailsOpen: true,
+ },
+ {
+ fieldName: "budget", fieldType: typeGuardrail,
+ fieldSummary: "Refuses requests once a spend ceiling is reached",
+ fieldSettings: []string{"limit_usd", "window", "store_id"}, fieldFailsOpen: false,
+ },
+ {
+ fieldName: "rate-limit", fieldType: typeRateLimit,
+ fieldSummary: "Per-credential request rate ceiling",
+ fieldSettings: []string{"requests_per_second", "burst"}, fieldFailsOpen: false,
+ },
+ {
+ fieldName: "word-filter", fieldType: typeGuardrail,
+ fieldSummary: "Rejects prompts containing blocked words",
+ fieldSettings: []string{"blocked_words"}, fieldFailsOpen: false,
+ },
+ },
+ })
+ })
+
+ mux.HandleFunc("GET /admin/providers", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, http.StatusOK, []map[string]any{ // bare array
+ {fieldName: providerAnthropic, fieldModels: modelsOwnedBy(providerAnthropic)},
+ {fieldName: providerOpenAI, fieldModels: modelsOwnedBy(providerOpenAI)},
+ })
+ })
+}
+
+// registerModels wires the /v1 discovery surface; the /v1 chat surface is
+// registerChat's (stream.go).
+func registerModels(mux *http.ServeMux) {
+ mux.HandleFunc("GET /v1/models", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{
+ fieldObject: "list",
+ fieldData: modelCatalog(),
+ })
+ })
+}
+
+// withAuth enforces the bearer on /admin/ AND /v1/, matching the real router.
+//
+// /health, /livez and /readyz are unauthenticated there and so are they here.
+// Everything else is not: the gateway puts /v1/* behind the same middleware as
+// /admin/* (only ALLOW_UNAUTHENTICATED_PROXY, a dev-only switch, lifts it). A
+// fake that let /v1/models and /v1/chat/completions through unauthenticated
+// would let the CLI's model discovery and playground be built without sending
+// a credential, and the omission would surface as a 401 on first contact with
+// a real gateway — precisely the drift this package must not introduce.
+func withAuth(s State, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ guarded := strings.HasPrefix(r.URL.Path, "/admin/") ||
+ strings.HasPrefix(r.URL.Path, "/v1/")
+ if s.RequireAuth && guarded &&
+ r.Header.Get("Authorization") != "Bearer "+s.AcceptKey {
+ writeErr(w, http.StatusUnauthorized, kindAuthentication,
+ "missing or invalid bearer token")
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// withRequestID stamps X-Request-ID on every response, as the gateway does. It
+// is the id a log row carries as trace_id.
+func withRequestID(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Request-ID", newTraceID())
+ next.ServeHTTP(w, r)
+ })
+}
+
+// tracer remembers the last chat stream so /admin/logs can answer with a row
+// whose trace_id equals that stream's X-Request-ID.
+type tracer struct {
+ mu sync.RWMutex
+ id string
+ model string
+}
+
+func newTracer() *tracer {
+ return &tracer{id: newTraceID(), model: modelSonnet}
+}
+
+func (t *tracer) set(id, model string) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ t.id, t.model = id, model
+}
+
+func (t *tracer) last() (id, model string) {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ return t.id, t.model
+}
+
+// logRow is a /admin/logs entry, tags exactly as the gateway serves them.
+// duration_ms, ttft_ms and cost_usd are nullable — the CLI renders "-" for a
+// null, never 0 — and there is deliberately no cache/cached flag.
+type logRow struct {
+ TraceID string `json:"trace_id"`
+ Stage string `json:"stage"`
+ Model string `json:"model"`
+ APIKeyID string `json:"api_key_id"`
+ Provider string `json:"provider"`
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ ErrorMessage string `json:"error_message"`
+ CreatedAt string `json:"created_at"`
+ DurationMS *float64 `json:"duration_ms"`
+ TTFTMS *float64 `json:"ttft_ms"`
+ CostUSD *float64 `json:"cost_usd"`
+}
+
+// logRows is the seeded request log, newest first. The first row attributes the
+// most recent chat stream — same trace_id as its X-Request-ID — which is what
+// lets the playground resolve provider and cost against the fake.
+//
+// The rows are in descending created_at order because that is the only order a
+// real log is read in, and the tail's since-cursor advances on it: a seed that
+// put an older row last-but-one would validate paging and cursor behavior
+// against an order the gateway cannot produce.
+func logRows(tr *tracer) []logRow {
+ id, model := tr.last()
+ now := time.Now().UTC()
+ return []logRow{
+ {
+ TraceID: id, Stage: stageAfterRequest, Model: model, APIKeyID: keyIDOps,
+ Provider: providerAnthropic, PromptTokens: 24, CompletionTokens: 7, TotalTokens: 31,
+ CreatedAt: ts(now.Add(-2 * time.Second)),
+ DurationMS: fp(1284.5), TTFTMS: fp(312), CostUSD: fp(0.0031),
+ },
+ {
+ // Non-terminal stage: visible only with stage=all, so the default
+ // terminal-stage filter is observably doing something. It is the
+ // row above's own earlier stage — same request, one second before
+ // it completed — so it sorts here rather than at the end.
+ TraceID: id, Stage: stageBeforeRequest, Model: model, APIKeyID: keyIDOps,
+ Provider: providerAnthropic, CreatedAt: ts(now.Add(-3 * time.Second)),
+ },
+ {
+ // Nullable measurements: the CLI must render "-", never 0.
+ TraceID: "8f2c1d4ab9e34f7c9a0d5e6b71c28d3f", Stage: stageAfterRequest,
+ Model: modelGPT4oMini, APIKeyID: keyIDCI, Provider: providerOpenAI,
+ PromptTokens: 812, CompletionTokens: 44, TotalTokens: 856,
+ CreatedAt: ts(now.Add(-51 * time.Second)),
+ },
+ {
+ TraceID: "b31a77c0e5d24a1e8c93f0a6d2471be5", Stage: stageOnError,
+ Model: modelOpus, APIKeyID: keyIDOps, Provider: providerAnthropic,
+ ErrorMessage: "upstream_unavailable: circuit open",
+ CreatedAt: ts(now.Add(-4 * time.Minute)),
+ DurationMS: fp(30001.2),
+ },
+ {
+ // Credential-less request: api_key_id is empty (?api_key_id=none).
+ TraceID: "c07e5f9138b64d2fa1c6e7092b4d8a11", Stage: stageAfterRequest,
+ Model: modelGPT4o, Provider: providerOpenAI,
+ PromptTokens: 128, CompletionTokens: 512, TotalTokens: 640,
+ CreatedAt: ts(now.Add(-9 * time.Minute)),
+ DurationMS: fp(4102), TTFTMS: fp(180), CostUSD: fp(0.0184),
+ },
+ }
+}
+
+// parseSince reads the optional since query parameter as RFC3339, writing the
+// real handler's exact 400 (internal/admin/handlers/queryparams.go's
+// parseSince) on anything that fails to parse and reporting ok=false so the
+// caller returns. Reading it through url.Values.Get keeps a present-but-empty
+// value read as absent — the same present-but-empty rule page applies to
+// limit and offset — so `?since=` is not a second, silent way to ask for
+// "unfiltered". Shared by /admin/logs, /admin/logs/stats and /admin/audit,
+// the three routes that accept it.
+func parseSince(w http.ResponseWriter, q url.Values) (time.Time, bool) {
+ raw := q.Get(fieldSince)
+ if raw == "" {
+ return time.Time{}, true
+ }
+ t, err := time.Parse(time.RFC3339, raw)
+ if err != nil {
+ writeErr(w, http.StatusBadRequest, kindInvalidRequest, "invalid since: must be RFC3339 format")
+ return time.Time{}, false
+ }
+ return t, true
+}
+
+// filterLogs applies the exact-match filters the endpoint documents. A fake
+// that ignored them would answer rows the query excluded — a lie downstream
+// tests would then enshrine. since is pre-parsed by parseSince rather than
+// reparsed here, so a malformed value 400s before any row is ever filtered.
+func filterLogs(rows []logRow, q url.Values, since time.Time) []logRow {
+ stage, model, provider, keyID := q.Get(fieldStage), q.Get(fieldModel), q.Get(fieldProvider), q.Get(fieldAPIKeyID)
+
+ // `since` is what a live tail advances on: Follower re-sends its cursor
+ // every poll and expects to be given only what is newer. Echoing the
+ // parameter without applying it made every poll return the whole page, so
+ // the tail depended entirely on client-side dedupe to look correct.
+ out := make([]logRow, 0, len(rows))
+ for _, row := range rows {
+ switch {
+ case stage == "" && row.Stage != stageAfterRequest && row.Stage != stageOnError:
+ continue // default filter: terminal stages only, one row per request
+ case stage != "" && stage != "all" && row.Stage != stage:
+ continue
+ case model != "" && row.Model != model:
+ continue
+ case provider != "" && row.Provider != provider:
+ continue
+ case keyID == "none" && row.APIKeyID != "":
+ continue
+ case keyID != "" && keyID != "none" && row.APIKeyID != keyID:
+ continue
+ case !since.IsZero() && olderThan(row.CreatedAt, since):
+ continue
+ }
+ out = append(out, row)
+ }
+ return out
+}
+
+// filterStats applies the filters GET /admin/logs/stats actually documents:
+// stage, model and provider match exactly or are absent; since keeps rows at
+// or after the cursor. There is no default-to-terminal-stages narrowing (the
+// real query's WHERE clause never adds one — internal/requestlog/store.go's
+// SQLWriter.Stats) and no api_key_id filter (the real handler never reads
+// one), so this is deliberately not filterLogs with a parameter dropped.
+func filterStats(rows []logRow, q url.Values, since time.Time) []logRow {
+ stage, model, provider := q.Get(fieldStage), q.Get(fieldModel), q.Get(fieldProvider)
+
+ out := make([]logRow, 0, len(rows))
+ for _, row := range rows {
+ switch {
+ case stage != "" && row.Stage != stage:
+ continue
+ case model != "" && row.Model != model:
+ continue
+ case provider != "" && row.Provider != provider:
+ continue
+ case !since.IsZero() && olderThan(row.CreatedAt, since):
+ continue
+ }
+ out = append(out, row)
+ }
+ return out
+}
+
+// dimensionStat is one group's contribution to by_stage/by_provider/by_model —
+// the same five fields the real handler's encodeDimension serves per group
+// (internal/admin/handlers/logs.go) — accumulated here from the filtered rows
+// instead of the store's SQL aggregation.
+type dimensionStat struct {
+ Count int
+ Errors int
+ Tokens int
+ CostUSD float64
+ Unpriced int
+}
+
+func (d *dimensionStat) add(row logRow) {
+ d.Count++
+ d.Tokens += row.TotalTokens
+ if row.ErrorMessage != "" {
+ d.Errors++
+ }
+ if row.CostUSD != nil {
+ d.CostUSD += *row.CostUSD
+ } else {
+ d.Unpriced++
+ }
+}
+
+func encodeDimensions(groups map[string]*dimensionStat) map[string]any {
+ out := make(map[string]any, len(groups))
+ for name, stat := range groups {
+ out[name] = map[string]any{
+ fieldCount: stat.Count, "errors": stat.Errors, "tokens": stat.Tokens,
+ "cost_usd": stat.CostUSD, "unpriced": stat.Unpriced,
+ }
+ }
+ return out
+}
+
+// logStatsResponse aggregates exactly the rows filterStats left in, the way
+// the real handler's Stats does: an empty result answers zeroed counts and
+// null percentiles rather than 200-with-yesterday's-numbers, so a filter that
+// matches nothing reads differently from one that was silently dropped.
+func logStatsResponse(rows []logRow) map[string]any {
+ byStage := map[string]*dimensionStat{}
+ byProvider := map[string]*dimensionStat{}
+ byModel := map[string]*dimensionStat{}
+ errorCounts := map[string]int{}
+ errorEntries, totalTokens, promptTokens, completionTokens, unpriced := 0, 0, 0, 0, 0
+ cost := 0.0
+ var latencies, ttfts []float64
+
+ group := func(m map[string]*dimensionStat, key string, row logRow) {
+ if m[key] == nil {
+ m[key] = &dimensionStat{}
+ }
+ m[key].add(row)
+ }
+
+ for _, row := range rows {
+ if row.ErrorMessage != "" {
+ errorEntries++
+ errorCounts[row.ErrorMessage]++
+ }
+ totalTokens += row.TotalTokens
+ promptTokens += row.PromptTokens
+ completionTokens += row.CompletionTokens
+ if row.CostUSD != nil {
+ cost += *row.CostUSD
+ } else {
+ unpriced++
+ }
+ group(byStage, row.Stage, row)
+ group(byProvider, row.Provider, row)
+ group(byModel, row.Model, row)
+ if row.DurationMS != nil {
+ latencies = append(latencies, *row.DurationMS)
+ }
+ if row.TTFTMS != nil {
+ ttfts = append(ttfts, *row.TTFTMS)
+ }
+ }
+
+ return map[string]any{
+ fieldSummary: map[string]any{
+ fieldTotalEntries: len(rows),
+ "error_entries": errorEntries,
+ "total_tokens": totalTokens,
+ "prompt_tokens": promptTokens,
+ "completion_tokens": completionTokens,
+ "cost_usd": cost,
+ "unpriced_requests": unpriced,
+ },
+ "latency_ms": percentilesOf(latencies),
+ "ttft_ms": percentilesOf(ttfts),
+ "by_stage": encodeDimensions(byStage),
+ "by_provider": encodeDimensions(byProvider),
+ "by_model": encodeDimensions(byModel),
+ "top_errors": topErrors(errorCounts),
+ // The API defines series's inner shape (internal/admin/handlers/logs.go's
+ // encodeSeries), but no CLI surface reads it (internal/api.LogStats
+ // intentionally decodes no series field), so the fixture keeps the
+ // cheaper empty object rather than modeling bucket math nothing consumes.
+ "series": map[string]any{},
+ }
+}
+
+// topErrors mirrors the real handler's encodeErrorGroups shape
+// ({message, count}), ranked highest count first with message as the
+// tiebreaker so JSON array order — unlike a map's — is deterministic.
+func topErrors(counts map[string]int) []map[string]any {
+ type group struct {
+ message string
+ count int
+ }
+ groups := make([]group, 0, len(counts))
+ for msg, n := range counts {
+ groups = append(groups, group{msg, n})
+ }
+ sort.Slice(groups, func(i, j int) bool {
+ if groups[i].count != groups[j].count {
+ return groups[i].count > groups[j].count
+ }
+ return groups[i].message < groups[j].message
+ })
+ out := make([]map[string]any, 0, len(groups))
+ for _, g := range groups {
+ out = append(out, map[string]any{fieldMessage: g.message, fieldCount: g.count})
+ }
+ return out
+}
+
+// percentilesOf mirrors the real handler's encodePercentiles: nil when
+// nothing was measured — never a zero-filled block, which would read as a
+// real, implausibly fast measurement — otherwise nearest-rank p50/p95/p99
+// plus max/mean/count.
+func percentilesOf(vals []float64) any {
+ if len(vals) == 0 {
+ return nil
+ }
+ sorted := append([]float64(nil), vals...)
+ sort.Float64s(sorted)
+ rank := func(p float64) float64 {
+ idx := int(math.Ceil(p/100*float64(len(sorted)))) - 1
+ if idx < 0 {
+ idx = 0
+ }
+ return sorted[idx]
+ }
+ sum := 0.0
+ for _, v := range sorted {
+ sum += v
+ }
+ return map[string]any{
+ "p50": rank(50), "p95": rank(95), "p99": rank(99),
+ "max": sorted[len(sorted)-1], "mean": sum / float64(len(sorted)), fieldCount: len(sorted),
+ }
+}
+
+// page applies offset then limit (limit is capped at 200, as documented).
+// Shared by /admin/logs and /admin/audit, whose rows differ only in type.
+//
+// A limit that fails strconv.Atoi or is <= 0 writes the same 400 the real
+// handler's parseLimit returns (internal/admin/handlers/queryparams.go) and
+// reports ok=false, so the caller must return without serving its normal 200
+// body.
+//
+// Both bounds mirror internal/admin/handlers/queryparams.go exactly, including
+// the case that is easy to get wrong: the parameter PRESENT BUT EMPTY.
+// parseLimit and parseOffset both read through url.Values.Get and return their
+// default on "", so `?limit=` is the same request as no limit at all — the
+// convention the gateway applies to model, provider, stage and api_key_id too.
+// Reading it through the same Get is what keeps that from drifting; matching on
+// len(q[key]) instead would 400 a request the real gateway serves, and the
+// resulting "bug" would look like it lived in the CLI.
+func page[T any](w http.ResponseWriter, rows []T, q url.Values) ([]T, bool) {
+ limit := 100
+ if raw := q.Get(fieldLimit); raw != "" {
+ n, err := strconv.Atoi(raw)
+ if err != nil || n <= 0 {
+ writeErr(w, http.StatusBadRequest, kindInvalidRequest, "invalid limit: must be a positive integer")
+ return nil, false
+ }
+ limit = n
+ }
+ limit = min(limit, 200)
+
+ offset := 0
+ if raw := q.Get(fieldOffset); raw != "" {
+ n, err := strconv.Atoi(raw)
+ if err != nil || n < 0 {
+ writeErr(w, http.StatusBadRequest, kindInvalidRequest, "invalid offset: must be a non-negative integer")
+ return nil, false
+ }
+ offset = n
+ }
+ offset = min(offset, len(rows))
+
+ rows = rows[offset:]
+ if limit < len(rows) {
+ rows = rows[:limit]
+ }
+ return rows, true
+}
+
+func auditRows() []map[string]any {
+ now := time.Now().UTC()
+ return []map[string]any{
+ {
+ fieldOccurredAt: ts(now.Add(-3 * time.Minute)), fieldAction: "key.create",
+ fieldActor: actorOps, fieldActorID: keyIDOps, "target_id": "key_05",
+ fieldOutcome: outcomeOK, fieldDetail: "scopes=read_only", fieldSourceIP: sourceIPLocal,
+ "trace_id": "3a1f7c02d94b41e6b0f5a8c7d2e91b64",
+ },
+ {
+ fieldOccurredAt: ts(now.Add(-2 * time.Hour)), fieldAction: "session.create",
+ fieldActor: "unknown", fieldOutcome: outcomeDenied, fieldDetail: "invalid credential",
+ fieldSourceIP: "10.0.4.19",
+ },
+ {
+ fieldOccurredAt: ts(now.AddDate(0, 0, -12)), fieldAction: "key.revoke",
+ fieldActor: actorOps, fieldActorID: keyIDOps, "target_id": "key_03",
+ fieldOutcome: outcomeOK, fieldSourceIP: sourceIPLocal,
+ },
+ {
+ fieldOccurredAt: ts(now.AddDate(0, 0, -20)), fieldAction: "logs.purge",
+ fieldActor: actorOps, fieldActorID: keyIDOps, fieldOutcome: outcomeError,
+ fieldDetail: "log store unavailable", fieldSourceIP: sourceIPLocal,
+ },
+ }
+}
+
+func filterAudit(rows []map[string]any, q url.Values, since time.Time) []map[string]any {
+ out := make([]map[string]any, 0, len(rows))
+ for _, row := range rows {
+ occurred, _ := time.Parse(time.RFC3339Nano, stringValue(row[fieldOccurredAt]))
+ if action := q.Get("action"); action != "" && stringValue(row["action"]) != action {
+ continue
+ }
+ if actorID := q.Get(fieldActorID); actorID != "" && stringValue(row[fieldActorID]) != actorID {
+ continue
+ }
+ if outcome := q.Get(fieldOutcome); outcome != "" && stringValue(row[fieldOutcome]) != outcome {
+ continue
+ }
+ if !since.IsZero() && occurred.Before(since) {
+ continue
+ }
+ out = append(out, row)
+ }
+ return out
+}
+
+func stringValue(v any) string {
+ s, _ := v.(string)
+ return s
+}
+
+// modelCatalog is the /v1/models payload; /admin/providers serves the same rows
+// grouped by owner.
+func modelCatalog() []map[string]any {
+ return []map[string]any{
+ {
+ "id": modelSonnet, fieldObject: objectModel, fieldCreated: 1748736000,
+ fieldOwnedBy: providerAnthropic, fieldMode: modalityChat, fieldContextWindow: 200000,
+ fieldMaxOutputTokens: 64000, fieldStatus: statusStable, fieldDeprecated: false,
+ fieldCapabilities: []string{modalityChat, capabilityStreaming, capabilityTools, capabilityVision},
+ },
+ {
+ "id": modelOpus, fieldObject: objectModel, fieldCreated: 1751328000,
+ fieldOwnedBy: providerAnthropic, fieldMode: modalityChat, fieldContextWindow: 200000,
+ fieldMaxOutputTokens: 32000, fieldStatus: statusStable, fieldDeprecated: false,
+ fieldCapabilities: []string{modalityChat, capabilityStreaming, capabilityTools, capabilityVision},
+ },
+ {
+ "id": modelGPT4o, fieldObject: objectModel, fieldCreated: 1715558400,
+ fieldOwnedBy: providerOpenAI, fieldMode: modalityChat, fieldContextWindow: 128000,
+ fieldMaxOutputTokens: 16384, fieldStatus: statusStable, fieldDeprecated: false,
+ fieldCapabilities: []string{modalityChat, capabilityStreaming, capabilityTools, capabilityVision},
+ },
+ {
+ "id": modelGPT4oMini, fieldObject: objectModel, fieldCreated: 1721260800,
+ fieldOwnedBy: providerOpenAI, fieldMode: modalityChat, fieldContextWindow: 128000,
+ fieldMaxOutputTokens: 16384, fieldStatus: statusStable, fieldDeprecated: false,
+ fieldCapabilities: []string{modalityChat, capabilityStreaming, capabilityTools},
+ },
+ {
+ "id": modelEmbedding3Small, fieldObject: objectModel, fieldCreated: 1705363200,
+ fieldOwnedBy: providerOpenAI, fieldMode: modalityEmbedding, fieldContextWindow: 8191,
+ fieldStatus: statusStable, fieldDeprecated: false,
+ fieldCapabilities: []string{modalityEmbedding},
+ },
+ }
+}
+
+func modelsOwnedBy(owner string) []map[string]any {
+ out := []map[string]any{}
+ for _, m := range modelCatalog() {
+ if m[fieldOwnedBy] == owner {
+ out = append(out, m)
+ }
+ }
+ return out
+}
+
+func circuitOf(s State) string {
+ if s.Degraded {
+ return circuitHalfOpen
+ }
+ return circuitClosed
+}
+
+func writeJSON(w http.ResponseWriter, code int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+// writeErr emits the gateway's error envelope, so the CLI's decodeAPIError is
+// exercised by the fixture rather than mocked around.
+func writeErr(w http.ResponseWriter, code int, kind, msg string) {
+ writeJSON(w, code, map[string]any{
+ fieldError: map[string]any{fieldMessage: msg, fieldType: kind, "code": kind},
+ })
+}
+
+// ts renders a wire timestamp: RFC3339, UTC.
+func ts(t time.Time) string { return t.UTC().Format(time.RFC3339) }
+
+func sp(s string) *string { return &s }
+func fp(f float64) *float64 { return &f }
+
+// newTraceID mints a 32-hex id shaped like the gateway's (its X-Request-ID is
+// the OTel trace id).
+func newTraceID() string {
+ var b [16]byte
+ _, _ = rand.Read(b[:])
+ return hex.EncodeToString(b[:])
+}
+
+// randToken mints a random base32 token for a key secret.
+func randToken() string { return rand.Text() }
+
+// olderThan reports whether an RFC3339 created_at precedes the cursor. A row
+// whose timestamp will not parse is kept: dropping rows on a formatting
+// problem would make a tail silently lossy, which is worse than showing one
+// row twice.
+func olderThan(createdAt string, since time.Time) bool {
+ t, err := time.Parse(time.RFC3339, createdAt)
+ if err != nil {
+ return false
+ }
+ return t.Before(since)
+}
diff --git a/internal/fixture/handler_test.go b/internal/fixture/handler_test.go
new file mode 100644
index 0000000..21323e5
--- /dev/null
+++ b/internal/fixture/handler_test.go
@@ -0,0 +1,636 @@
+package fixture
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+// reply is everything that outlives a finished request. do reads the body to
+// completion and closes it, so there is no *http.Response worth handing back —
+// only a spent connection a caller could forget to release.
+type reply struct {
+ status int
+ header http.Header
+ body []byte
+}
+
+// serve starts one server for state's handler. It is the unit a test shares:
+// the fixture's key store lives in the handler, so a create followed by a read
+// is one flow against one gateway, and opening a listener per request modelled
+// it as four unrelated ones.
+func serve(t *testing.T, state State) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(Handler(state))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+// do runs one request against srv. Every test goes through it, so the request
+// context, the body read and the close are done in one place rather than
+// re-hand-rolled per test.
+func do(t *testing.T, srv *httptest.Server, method, path, bearer, body string) reply {
+ t.Helper()
+ var payload io.Reader
+ if body != "" {
+ payload = strings.NewReader(body)
+ }
+ req, err := http.NewRequestWithContext(t.Context(), method, srv.URL+path, payload)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ resp, err := srv.Client().Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ t.Errorf("close response body: %v", err)
+ }
+ }()
+ out, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return reply{status: resp.StatusCode, header: resp.Header, body: out}
+}
+
+// get is do for the read surface, which is most of it.
+func get(t *testing.T, srv *httptest.Server, method, path, bearer string) reply {
+ t.Helper()
+ return do(t, srv, method, path, bearer, "")
+}
+
+func TestHealthShapeMatchesContract(t *testing.T) {
+ res := get(t, serve(t, Default()), "GET", "/health", "")
+ if res.status != 200 {
+ t.Fatalf("healthy /health must be 200, got %d", res.status)
+ }
+ var out struct {
+ Status string `json:"status"`
+ Providers []struct {
+ Name string `json:"name"`
+ Circuit string `json:"circuit"`
+ Models int `json:"models"`
+ } `json:"providers"`
+ }
+ if err := json.Unmarshal(res.body, &out); err != nil {
+ t.Fatal(err)
+ }
+ if out.Status != "ok" || len(out.Providers) == 0 || out.Providers[0].Models == 0 {
+ t.Fatalf("fixture drifted from the documented /health shape: %s", res.body)
+ }
+}
+
+// Degraded and provider-less are independent axes on the real gateway, and an
+// earlier version of this fixture conflated them into one 503. Verified live:
+// a half-open circuit with live providers still answers 200 "ok"; only an
+// empty provider set makes /health non-200.
+func TestDegradedIsA200WithAHalfOpenCircuit(t *testing.T) {
+ res := get(t, serve(t, State{Degraded: true}), "GET", "/health", "")
+ if res.status != 200 {
+ t.Fatalf("a circuit does not make /health non-200, got %d", res.status)
+ }
+ if !strings.Contains(string(res.body), "half_open") ||
+ !strings.Contains(string(res.body), `"status":"ok"`) {
+ t.Fatalf("degraded must report ok with a half-open circuit: %s", res.body)
+ }
+}
+
+func TestNoProvidersIs503WithAnEmptyProviderSet(t *testing.T) {
+ res := get(t, serve(t, State{NoProviders: true}), "GET", "/health", "")
+ if res.status != 503 {
+ t.Fatalf("an empty provider set is the one 503 /health has, got %d", res.status)
+ }
+ if !strings.Contains(string(res.body), `"no_providers"`) ||
+ !strings.Contains(string(res.body), `"providers":[]`) {
+ t.Fatalf("no_providers must come with an EMPTY providers array: %s", res.body)
+ }
+}
+
+// The not-ready body carries only status and reason. Serving the arrays here
+// taught ferro status to read target counts that are not there, and report
+// "0/0 targets" for a gateway whose targets were merely dead.
+func TestNotReadyCarriesOnlyStatusAndReason(t *testing.T) {
+ res := get(t, serve(t, State{NoProviders: true}), "GET", "/readyz", "")
+ if res.status != 503 {
+ t.Fatalf("want 503, got %d", res.status)
+ }
+ // Assert on decoded KEYS, not substrings: the reason text is literally
+ // "no routable targets", which contains the very word being banned.
+ var doc map[string]any
+ if err := json.Unmarshal(res.body, &doc); err != nil {
+ t.Fatalf("decode: %v (%s)", err, res.body)
+ }
+ // doc["reason"] is nil when the key is absent, and nil != "", so comparing
+ // against the empty string alone cannot fail. Assert presence and a
+ // non-empty string instead.
+ reason, ok := doc["reason"].(string)
+ if doc["status"] != "not_ready" || !ok || reason == "" {
+ t.Fatalf("want status and a non-empty reason: %s", res.body)
+ }
+ for _, absent := range []string{"targets", "providers", "mcp_servers"} {
+ if _, present := doc[absent]; present {
+ t.Fatalf("a not_ready body must not carry %q: %s", absent, res.body)
+ }
+ }
+}
+
+func TestAdminRoutesRequireBearer(t *testing.T) {
+ srv := serve(t, Default())
+ res := get(t, srv, "GET", "/admin/keys", "")
+ if res.status != 401 || !strings.Contains(string(res.body), "authentication_error") {
+ t.Fatalf("want the documented 401 envelope, got %d %s", res.status, res.body)
+ }
+ if res = get(t, srv, "GET", "/admin/keys", "fgw_test"); res.status != 200 {
+ t.Fatalf("valid bearer must be accepted, got %d", res.status)
+ }
+}
+
+func TestFeatureAbsenceIs501(t *testing.T) {
+ res := get(t, serve(t, State{NoLogStore: true}), "GET", "/admin/logs", "")
+ if res.status != 501 {
+ t.Fatalf("absent log store must 501 so the CLI can feature-detect, got %d", res.status)
+ }
+}
+
+func TestKeysAreStatefulAcrossRequests(t *testing.T) {
+ srv := serve(t, Default()) // one server, two requests: the store must persist
+ res := do(t, srv, "POST", "/admin/keys", "fgw_test",
+ `{"name":"itest","scopes":["read_only"]}`)
+ if res.status != 201 {
+ t.Fatalf("create must be 201: %d %s", res.status, res.body)
+ }
+ var created struct {
+ ID string `json:"id"`
+ Key string `json:"key"`
+ }
+ if err := json.Unmarshal(res.body, &created); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(created.Key, "fgw_") || len(created.Key) < 20 {
+ t.Fatalf("create must return the full secret once, got %q", created.Key)
+ }
+ list := get(t, srv, "GET", "/admin/keys", "fgw_test")
+ if !strings.Contains(string(list.body), "itest") {
+ t.Fatal("created key must appear in the list — the fake is stateful by design")
+ }
+ if strings.Contains(string(list.body), created.Key) {
+ t.Fatal("list must mask the secret, like the real gateway does")
+ }
+}
+
+// GET/rotate/revoke/delete had no unit coverage: only cli/itest exercised
+// them, and only against a live gateway, skipping entirely when
+// FERRO_ITEST_KEY is unset — so a regression in keyStore.rotate/revoke/remove
+// could pass CI. This covers the mutating half of the key surface the way
+// TestKeysAreStatefulAcrossRequests covers create/list.
+func TestKeyLifecycleMutatesTheStore(t *testing.T) {
+ srv := serve(t, Default())
+
+ rotated := do(t, srv, http.MethodPost, "/admin/keys/key_01/rotate", "fgw_test", "")
+ if rotated.status != http.StatusOK {
+ t.Fatalf("rotate must be 200: %d %s", rotated.status, rotated.body)
+ }
+ var row struct {
+ Key string `json:"key"`
+ RotatedAt *string `json:"rotated_at"`
+ }
+ if err := json.Unmarshal(rotated.body, &row); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(row.Key, "fgw_") || row.RotatedAt == nil {
+ t.Fatalf("rotate must serve the new secret once and stamp rotated_at: %s", rotated.body)
+ }
+ if listed := get(t, srv, http.MethodGet, "/admin/keys/key_01", "fgw_test"); strings.Contains(string(listed.body), row.Key) {
+ t.Fatal("a later read must mask the rotated secret")
+ }
+
+ if res := do(t, srv, http.MethodPost, "/admin/keys/key_02/revoke", "fgw_test", ""); res.status != http.StatusOK {
+ t.Fatalf("revoke must be 200: %d %s", res.status, res.body)
+ }
+ if res := do(t, srv, http.MethodDelete, "/admin/keys/key_02", "fgw_test", ""); res.status != http.StatusNoContent {
+ t.Fatalf("delete must be 204: %d %s", res.status, res.body)
+ }
+ res := get(t, srv, http.MethodGet, "/admin/keys/key_02", "fgw_test")
+ if res.status != http.StatusNotFound || !strings.Contains(string(res.body), "not_found_error") {
+ t.Fatalf("a deleted key must 404 in the documented envelope: %d %s", res.status, res.body)
+ }
+}
+
+// The real gateway defaults an unspecified scope list to read_only, in the key
+// store both its backends share. A fake that stored the empty list instead
+// would hand every fixture-backed test a scopeless key the gateway never mints.
+func TestCreateWithoutScopesDefaultsToReadOnly(t *testing.T) {
+ srv := serve(t, Default())
+ for _, body := range []string{
+ `{"name":"absent"}`,
+ `{"name":"null","scopes":null}`,
+ `{"name":"empty","scopes":[]}`,
+ } {
+ res := do(t, srv, "POST", "/admin/keys", "fgw_test", body)
+ if res.status != 201 {
+ t.Fatalf("create must be 201: %d %s", res.status, res.body)
+ }
+ var created struct {
+ Scopes []string `json:"scopes"`
+ }
+ if err := json.Unmarshal(res.body, &created); err != nil {
+ t.Fatal(err)
+ }
+ if len(created.Scopes) != 1 || created.Scopes[0] != "read_only" {
+ t.Fatalf("%s must mint a read-only key, got %v", body, created.Scopes)
+ }
+ }
+}
+
+// A real log is read newest first, and the tail's since-cursor advances on that
+// order. A seeded row out of order would validate paging against an order the
+// gateway cannot produce.
+func TestSeededLogIsNewestFirst(t *testing.T) {
+ res := get(t, serve(t, Default()), "GET", "/admin/logs?stage=all", "fgw_test")
+ var page struct {
+ Data []struct {
+ Stage string `json:"stage"`
+ CreatedAt string `json:"created_at"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(res.body, &page); err != nil {
+ t.Fatal(err)
+ }
+ if len(page.Data) < 2 {
+ t.Fatalf("stage=all must expose every seeded row: %s", res.body)
+ }
+ previous := time.Now().UTC().Add(time.Hour)
+ for _, row := range page.Data {
+ at, err := time.Parse(time.RFC3339, row.CreatedAt)
+ if err != nil {
+ t.Fatalf("created_at must be RFC3339: %v", err)
+ }
+ if at.After(previous) {
+ t.Fatalf("row %q at %s breaks descending created_at order: %s",
+ row.Stage, row.CreatedAt, res.body)
+ }
+ previous = at
+ }
+}
+
+func TestChatStreamFraming(t *testing.T) {
+ res := do(t, serve(t, Default()), "POST", "/v1/chat/completions", "fgw_test",
+ `{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hi"}],"stream":true}`)
+ if ct := res.header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
+ t.Fatalf("want SSE content type, got %q", ct)
+ }
+ if res.header.Get("X-Request-ID") == "" {
+ t.Fatal("X-Request-ID is how the CLI attributes a stream — it must be set")
+ }
+ got := string(res.body)
+ if !strings.Contains(got, "data: ") || !strings.Contains(got, `"usage"`) ||
+ !strings.Contains(got, "data: [DONE]") {
+ t.Fatalf("stream must be data-framed, carry a usage chunk, and end with [DONE]:\n%s", got)
+ }
+}
+
+func TestChatErrorFrameHasNoDone(t *testing.T) {
+ // The real gateway ends an errored stream WITHOUT [DONE]; a fake that sends
+ // one anyway would hide the CLI bug this behavior exists to catch.
+ //
+ // The WHOLE stream is read, not its first frame: "there is no [DONE]" is a
+ // claim about the end of the stream, and a partial read can only fail to
+ // find one that was in fact sent.
+ res := do(t, serve(t, State{ChatFails: true}), "POST", "/v1/chat/completions", "",
+ `{"model":"m","messages":[],"stream":true}`)
+ got := string(res.body)
+ if !strings.Contains(got, "stream_error") || strings.Contains(got, "[DONE]") {
+ t.Fatalf("errored stream must carry stream_error and no [DONE]:\n%s", got)
+ }
+}
+
+// The real gateway puts /v1/* behind the same bearer check as /admin/*. If the
+// fake did not, the CLI's model discovery and playground could be built without
+// sending a credential and would 401 on first contact with a real gateway.
+func TestV1RoutesRequireBearerToo(t *testing.T) {
+ srv := serve(t, Default())
+ // Each route is probed with the METHOD the CLI reaches it by. A handler
+ // that authenticated GET /v1/chat/completions but not the POST the
+ // playground sends would pass a GET-only loop and 401 in production.
+ for _, tc := range []struct{ method, path, body string }{
+ {http.MethodGet, "/v1/models", ""},
+ {http.MethodPost, "/v1/chat/completions", `{"model":"m","messages":[],"stream":true}`},
+ } {
+ res := do(t, srv, tc.method, tc.path, "", tc.body)
+ if res.status != 401 {
+ t.Fatalf("%s %s without a bearer must be 401, got %d", tc.method, tc.path, res.status)
+ }
+ if !strings.Contains(string(res.body), "authentication_error") {
+ t.Fatalf("%s %s must use the documented error envelope: %s", tc.method, tc.path, res.body)
+ }
+ }
+ if res := get(t, srv, "GET", "/v1/models", "fgw_test"); res.status != 200 {
+ t.Fatalf("valid bearer must be accepted on /v1/models, got %d", res.status)
+ }
+}
+
+// ...while the liveness surface stays open, as it is on the real router.
+func TestHealthSurfaceStaysUnauthenticated(t *testing.T) {
+ srv := serve(t, Default())
+ for _, path := range []string{"/health", "/readyz"} {
+ if res := get(t, srv, "GET", path, ""); res.status == 401 {
+ t.Fatalf("%s must not require a credential", path)
+ }
+ }
+ res := get(t, srv, "GET", "/livez", "")
+ if res.status != http.StatusOK || strings.TrimSpace(string(res.body)) != `{"status":"ok"}` {
+ t.Fatalf("/livez must be an open 200 status payload, got %d %s", res.status, res.body)
+ }
+}
+
+func TestChatRequiresStreaming(t *testing.T) {
+ srv := serve(t, Default())
+ for _, body := range []string{
+ `{"model":"m"}`,
+ `{"model":"m","stream":false}`,
+ `{"model":"m","stream":true}{"stream":true}`,
+ `{"stream":false,"stream":true}`,
+ } {
+ res := do(t, srv, http.MethodPost, "/v1/chat/completions", "fgw_test", body)
+ if res.status != http.StatusBadRequest {
+ t.Fatalf("non-streaming request must be rejected, got %d", res.status)
+ }
+ }
+}
+
+func TestAuditFiltersAndPagesRows(t *testing.T) {
+ srv := serve(t, Default())
+ res := get(t, srv, "GET", "/admin/audit?outcome=ok&limit=1&offset=1", "fgw_test")
+ if res.status != http.StatusOK {
+ t.Fatalf("audit query failed: %d %s", res.status, res.body)
+ }
+ var page struct {
+ Data []map[string]any `json:"data"`
+ Summary struct {
+ TotalEntries int `json:"total_entries"`
+ } `json:"summary"`
+ }
+ if err := json.Unmarshal(res.body, &page); err != nil {
+ t.Fatal(err)
+ }
+ if page.Summary.TotalEntries != 2 || len(page.Data) != 1 ||
+ page.Data[0]["outcome"] != "ok" || page.Data[0]["action"] != "key.revoke" {
+ t.Fatalf("filters must apply before paging: %s", res.body)
+ }
+}
+
+func TestAuditRejectsInvalidSince(t *testing.T) {
+ res := get(t, serve(t, Default()), "GET", "/admin/audit?since=not-a-time", "fgw_test")
+ if res.status != http.StatusBadRequest ||
+ !strings.Contains(string(res.body), `"type":"invalid_request_error"`) {
+ t.Fatalf("invalid since must use the invalid-request envelope: %d %s", res.status, res.body)
+ }
+}
+
+// The real gateway's parseLimit 400s a limit that is <= 0 or not an integer
+// (internal/admin/handlers/queryparams.go); an earlier version of this fixture
+// silently fell back to its default and served an empty page instead. Both
+// endpoints share the page[T] helper, so one table covers both.
+func TestNonPositiveLimitIs400OnLogsAndAudit(t *testing.T) {
+ srv := serve(t, Default())
+ for _, path := range []string{
+ "/admin/logs?limit=0",
+ "/admin/logs?limit=-1",
+ "/admin/logs?limit=abc",
+ "/admin/audit?limit=0",
+ "/admin/audit?limit=-1",
+ "/admin/audit?limit=abc",
+ } {
+ res := get(t, srv, "GET", path, "fgw_test")
+ if res.status != http.StatusBadRequest ||
+ !strings.Contains(string(res.body), `"type":"invalid_request_error"`) {
+ t.Fatalf("%s: want the documented 400 envelope for a non-positive limit, got %d %s", path, res.status, res.body)
+ }
+ }
+}
+
+// parseOffset rejects the same two shapes parseLimit does, one bound over.
+func TestNegativeOffsetIs400OnLogsAndAudit(t *testing.T) {
+ srv := serve(t, Default())
+ for _, path := range []string{
+ "/admin/logs?offset=-1",
+ "/admin/logs?offset=abc",
+ "/admin/audit?offset=-1",
+ "/admin/audit?offset=abc",
+ } {
+ res := get(t, srv, "GET", path, "fgw_test")
+ if res.status != http.StatusBadRequest ||
+ !strings.Contains(string(res.body), `"type":"invalid_request_error"`) {
+ t.Fatalf("%s: want the documented 400 envelope for a bad offset, got %d %s", path, res.status, res.body)
+ }
+ }
+}
+
+// A present-but-empty bound is the one shape that must NOT 400: parseLimit and
+// parseOffset both read through url.Values.Get and return their default on "",
+// so `?limit=` is the same request as no limit at all. A fixture that rejected
+// it would send someone hunting a CLI bug that does not exist.
+func TestEmptyBoundIsReadAsAbsentNotRejected(t *testing.T) {
+ srv := serve(t, Default())
+ for _, path := range []string{
+ "/admin/logs?limit=",
+ "/admin/logs?offset=",
+ "/admin/logs?limit=&offset=",
+ "/admin/audit?limit=",
+ "/admin/audit?offset=",
+ } {
+ res := get(t, srv, "GET", path, "fgw_test")
+ if res.status != http.StatusOK {
+ t.Fatalf("%s: an empty bound means absent, want 200, got %d %s", path, res.status, res.body)
+ }
+ }
+}
+
+// The real stats endpoint 400s a malformed since exactly as /admin/logs and
+// /admin/audit do (internal/admin/handlers/queryparams.go's parseSince is
+// shared by all three) — this fixture used to accept anything on
+// /admin/logs/stats because it never looked at the query at all.
+func TestMalformedSinceIs400OnLogsAndStats(t *testing.T) {
+ srv := serve(t, Default())
+ for _, path := range []string{
+ "/admin/logs?since=not-a-time",
+ "/admin/logs/stats?since=not-a-time",
+ } {
+ res := get(t, srv, "GET", path, "fgw_test")
+ if res.status != http.StatusBadRequest ||
+ !strings.Contains(string(res.body), `"type":"invalid_request_error"`) {
+ t.Fatalf("%s: want the documented 400 envelope for a malformed since, got %d %s", path, res.status, res.body)
+ }
+ }
+}
+
+type statsSummary struct {
+ Summary struct {
+ TotalEntries int `json:"total_entries"`
+ } `json:"summary"`
+ ByProvider map[string]json.RawMessage `json:"by_provider"`
+}
+
+func decodeStats(t *testing.T, srv *httptest.Server, path string) statsSummary {
+ t.Helper()
+ res := get(t, srv, "GET", path, "fgw_test")
+ if res.status != http.StatusOK {
+ t.Fatalf("%s: want 200, got %d %s", path, res.status, res.body)
+ }
+ var out statsSummary
+ if err := json.Unmarshal(res.body, &out); err != nil {
+ t.Fatalf("%s: %v", path, err)
+ }
+ return out
+}
+
+// Before this fix /admin/logs/stats served the same fixed aggregate no matter
+// what the query asked for, so a CLI regression that dropped a filter before
+// it reached the wire was invisible against this fixture. Each of these
+// narrows a five-row seed a different way, and the unfiltered count (5) proves
+// the baseline itself is real data, not a coincidence of one filter.
+func TestStatsFiltersNarrowTheAggregate(t *testing.T) {
+ srv := serve(t, Default())
+ base := decodeStats(t, srv, "/admin/logs/stats")
+ if base.Summary.TotalEntries != 5 {
+ t.Fatalf("unfiltered stats: want all 5 seeded rows, got %d", base.Summary.TotalEntries)
+ }
+ if _, ok := base.ByProvider["anthropic"]; !ok {
+ t.Fatalf("unfiltered by_provider must include anthropic: %+v", base.ByProvider)
+ }
+ if _, ok := base.ByProvider["openai"]; !ok {
+ t.Fatalf("unfiltered by_provider must include openai: %+v", base.ByProvider)
+ }
+
+ for _, tc := range []struct {
+ path string
+ want int
+ }{
+ {"/admin/logs/stats?provider=openai", 2},
+ {"/admin/logs/stats?stage=before_request", 1},
+ {"/admin/logs/stats?model=does-not-exist", 0},
+ } {
+ got := decodeStats(t, srv, tc.path)
+ if got.Summary.TotalEntries != tc.want {
+ t.Fatalf("%s: want %d entries, got %d (a fixture that dropped this filter would still report %d)",
+ tc.path, tc.want, got.Summary.TotalEntries, base.Summary.TotalEntries)
+ }
+ }
+
+ // provider=openai must also drop anthropic out of the by_provider
+ // breakdown, not just shrink the total.
+ openaiOnly := decodeStats(t, srv, "/admin/logs/stats?provider=openai")
+ if _, ok := openaiOnly.ByProvider["anthropic"]; ok {
+ t.Fatalf("provider=openai must drop anthropic from by_provider: %+v", openaiOnly.ByProvider)
+ }
+}
+
+// A present-but-empty filter value means absent, the same rule the other
+// admin list routes apply (TestEmptyBoundIsReadAsAbsentNotRejected): it must
+// not 400, and — because /admin/logs/stats used to ignore every parameter —
+// it must not silently narrow the aggregate either.
+func TestStatsEmptyFilterValueIsAbsentNotRejected(t *testing.T) {
+ srv := serve(t, Default())
+ for _, path := range []string{
+ "/admin/logs/stats?model=",
+ "/admin/logs/stats?provider=",
+ "/admin/logs/stats?stage=",
+ "/admin/logs/stats?since=",
+ } {
+ got := decodeStats(t, srv, path)
+ if got.Summary.TotalEntries != 5 {
+ t.Fatalf("%s: an empty value means absent, want all 5 seeded rows, got %d", path, got.Summary.TotalEntries)
+ }
+ }
+}
+
+// since keeps only rows at or after the cursor (internal/requestlog/store.go's
+// SQLWriter.Stats: "created_at >= ?"), so a cursor five minutes back must drop
+// exactly the row seeded nine minutes back and keep the other four.
+func TestStatsSinceKeepsOnlyRowsAtOrAfterTheCursor(t *testing.T) {
+ srv := serve(t, Default())
+ since := time.Now().Add(-5 * time.Minute).UTC().Format(time.RFC3339)
+ got := decodeStats(t, srv, "/admin/logs/stats?since="+since)
+ if got.Summary.TotalEntries != 4 {
+ t.Fatalf("since=-5m must drop the row seeded 9 minutes ago, want 4 entries, got %d", got.Summary.TotalEntries)
+ }
+}
+
+// model.ValidateScopes refuses anything outside the closed set with a 400, so
+// the fixture must too — a fake that accepts "readonly" lets a CLI that sends
+// the wrong spelling pass every test here and fail against a real gateway.
+func TestCreateRejectsAnUnknownScope(t *testing.T) {
+ srv := serve(t, Default())
+ res := do(t, srv, http.MethodPost, "/admin/keys", "fgw_test",
+ `{"name":"typo","scopes":["readonly"]}`)
+ if res.status != http.StatusBadRequest {
+ t.Fatalf("an unknown scope must be 400, got %d %s", res.status, res.body)
+ }
+ for _, want := range []string{`unknown scope \"readonly\"`, "admin, read_only", "invalid_request_error"} {
+ if !strings.Contains(string(res.body), want) {
+ t.Fatalf("want %s in the gateway's own wording: %s", want, res.body)
+ }
+ }
+ // The refusal must not have created anything.
+ if list := get(t, srv, http.MethodGet, "/admin/keys", "fgw_test"); strings.Contains(string(list.body), "typo") {
+ t.Fatalf("a refused create must not reach the store: %s", list.body)
+ }
+}
+
+// Both members of the closed set are accepted, so the guard above cannot pass
+// by rejecting everything.
+func TestCreateAcceptsBothValidScopes(t *testing.T) {
+ srv := serve(t, Default())
+ for _, scope := range []string{scopeAdmin, scopeReadOnly} {
+ res := do(t, srv, http.MethodPost, "/admin/keys", "fgw_test",
+ `{"name":"ok-`+scope+`","scopes":["`+scope+`"]}`)
+ if res.status != http.StatusCreated {
+ t.Fatalf("%s must be accepted, got %d %s", scope, res.status, res.body)
+ }
+ }
+}
+
+// An expired key is served the way the gateway serves one: active:true,
+// revoked_at:null, expires_at in the past. Seeding active:false here was
+// fixture-only fiction that hid a real CLI bug.
+func TestExpiredKeyKeepsActiveTrueOnTheWire(t *testing.T) {
+ res := get(t, serve(t, Default()), http.MethodGet, "/admin/keys", "fgw_test")
+ var rows []struct {
+ Name string `json:"name"`
+ RevokedAt *string `json:"revoked_at"`
+ ExpiresAt *string `json:"expires_at"`
+ Active bool `json:"active"`
+ }
+ if err := json.Unmarshal(res.body, &rows); err != nil {
+ t.Fatal(err)
+ }
+ var found bool
+ for _, r := range rows {
+ if r.Name != "demo-temp" {
+ continue
+ }
+ found = true
+ if !r.Active || r.RevokedAt != nil || r.ExpiresAt == nil {
+ t.Fatalf("an expired key stays active:true with revoked_at:null and an expires_at: %+v", r)
+ }
+ expiry, err := time.Parse(time.RFC3339, *r.ExpiresAt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !time.Now().After(expiry) {
+ t.Fatalf("the seeded expiry must already have passed, got %s", *r.ExpiresAt)
+ }
+ }
+ if !found {
+ t.Fatal("the expired fixture key must be seeded")
+ }
+}
diff --git a/internal/fixture/keys.go b/internal/fixture/keys.go
new file mode 100644
index 0000000..1700ade
--- /dev/null
+++ b/internal/fixture/keys.go
@@ -0,0 +1,284 @@
+package fixture
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+// keyRow is the /admin/keys row, tags exactly as the gateway serves them.
+// There is deliberately no "state" and no "revoked" field: the CLI derives
+// revoked/expired/active from revoked_at, active and expires_at.
+type keyRow struct {
+ ID string `json:"id"`
+ Key string `json:"key"` // masked head8...tail4 on reads
+ Name string `json:"name"`
+ Scopes []string `json:"scopes"`
+ CreatedAt string `json:"created_at"`
+ RevokedAt *string `json:"revoked_at"`
+ ExpiresAt *string `json:"expires_at"`
+ RotatedAt *string `json:"rotated_at"`
+ LastUsedAt *string `json:"last_used_at"`
+ UsageCount int `json:"usage_count"`
+ Active bool `json:"active"`
+}
+
+// keyStore is the stateful half of the fake: create/rotate/revoke/delete mutate
+// it and later GETs reflect the change, so the TUI key wizard can be exercised
+// end to end. One lock guards the whole store — a fake serves one operator, so
+// a mutex and a slice are enough.
+type keyStore struct {
+ mu sync.RWMutex
+ rows []keyRow
+ secrets map[string]string // key id -> full secret, never serialized on reads
+ n int
+}
+
+func newKeyStore() *keyStore {
+ now := time.Now().UTC()
+ ks := &keyStore{secrets: map[string]string{}}
+ // Four seeded rows covering every state the CLI derives: active, active
+ // with an expiry, revoked, and expired.
+ ks.seed(actorOps, []string{scopeAdmin}, now.AddDate(0, -3, 0), func(r *keyRow) {
+ r.LastUsedAt = sp(ts(now.Add(-4 * time.Minute)))
+ r.UsageCount = 18422
+ })
+ ks.seed("ci-pipeline", []string{scopeReadOnly}, now.AddDate(0, -1, 0), func(r *keyRow) {
+ r.ExpiresAt = sp(ts(now.AddDate(0, 2, 0)))
+ r.LastUsedAt = sp(ts(now.Add(-51 * time.Minute)))
+ r.UsageCount = 3907
+ })
+ ks.seed("laptop-old", []string{scopeAdmin}, now.AddDate(-1, 0, 0), func(r *keyRow) {
+ r.RevokedAt = sp(ts(now.AddDate(0, 0, -12)))
+ r.LastUsedAt = sp(ts(now.AddDate(0, 0, -12)))
+ r.UsageCount = 240
+ r.Active = false
+ })
+ // An expired key keeps active:true and revoked_at:null. The gateway clears
+ // active only in Revoke (memory store keys.go, and the SQL store's own
+ // UPDATE), so nothing flips it when a deadline passes — model.KeyIsUsable
+ // checks expires_at separately for exactly that reason. Seeding this row
+ // as active:false was fixture-only fiction, and it hid a real bug: the CLI
+ // derived "expired" from !active, which a real expired key never sets, so
+ // `ferro keys list` called it active.
+ ks.seed("demo-temp", []string{scopeReadOnly}, now.AddDate(0, 0, -40), func(r *keyRow) {
+ r.ExpiresAt = sp(ts(now.AddDate(0, 0, -6)))
+ r.UsageCount = 12
+ })
+ return ks
+}
+
+// seed appends one row; opt tweaks it into the state being seeded.
+func (ks *keyStore) seed(name string, scopes []string, created time.Time, opt func(*keyRow)) {
+ id, secret := ks.nextID(), newSecret()
+ row := keyRow{
+ ID: id,
+ Key: mask(secret),
+ Name: name,
+ Scopes: scopes,
+ CreatedAt: ts(created),
+ Active: true,
+ }
+ if opt != nil {
+ opt(&row)
+ }
+ ks.rows = append(ks.rows, row)
+ ks.secrets[id] = secret
+}
+
+// nextID mints a readable, stable-looking id. Callers hold the lock (or are
+// constructing the store).
+func (ks *keyStore) nextID() string {
+ ks.n++
+ return fmt.Sprintf("key_%02d", ks.n)
+}
+
+// newSecret mints a master-key-shaped credential: `fgw_` + random token.
+func newSecret() string { return "fgw_" + strings.ToLower(randToken()) }
+
+func (ks *keyStore) list() []keyRow {
+ ks.mu.RLock()
+ defer ks.mu.RUnlock()
+ out := make([]keyRow, len(ks.rows))
+ copy(out, ks.rows)
+ return out
+}
+
+// unknownScope reports the first scope outside the gateway's closed set, and
+// ok=false with it. An empty list is valid — the store defaults it — so this
+// rejects only a scope that was actually asked for and cannot be granted.
+func unknownScope(scopes []string) (string, bool) {
+ for _, s := range scopes {
+ if s != scopeAdmin && s != scopeReadOnly {
+ return s, false
+ }
+ }
+ return "", true
+}
+
+// defaultScopes gives an unspecified scope list the gateway's own default. The
+// real server applies it in the key store rather than the handler — both
+// backends call one defaultScopes on the way into Create — so an absent, null
+// and empty `scopes` all mint a least-privilege read-only key. Storing the
+// empty list instead would have handed every fixture-backed test a scopeless
+// key the real gateway never issues.
+func defaultScopes(scopes []string) []string {
+ if len(scopes) == 0 {
+ return []string{scopeReadOnly}
+ }
+ return scopes
+}
+
+// create returns the stored (masked) row and the full secret, which the caller
+// serves exactly once.
+func (ks *keyStore) create(name string, scopes []string, expiresAt *string) (keyRow, string) {
+ ks.mu.Lock()
+ defer ks.mu.Unlock()
+ id, secret := ks.nextID(), newSecret()
+ row := keyRow{
+ ID: id,
+ Key: mask(secret),
+ Name: name,
+ Scopes: defaultScopes(scopes),
+ CreatedAt: ts(time.Now().UTC()),
+ ExpiresAt: expiresAt,
+ Active: true,
+ }
+ ks.rows = append(ks.rows, row)
+ ks.secrets[id] = secret
+ return row, secret
+}
+
+// rotate replaces the secret in place, returning the row and the new secret.
+func (ks *keyStore) rotate(id string) (keyRow, string, bool) {
+ ks.mu.Lock()
+ defer ks.mu.Unlock()
+ i := ks.indexOf(id)
+ if i < 0 {
+ return keyRow{}, "", false
+ }
+ secret := newSecret()
+ ks.rows[i].Key = mask(secret)
+ ks.rows[i].RotatedAt = sp(ts(time.Now().UTC()))
+ ks.secrets[id] = secret
+ return ks.rows[i], secret, true
+}
+
+func (ks *keyStore) revoke(id string) bool {
+ ks.mu.Lock()
+ defer ks.mu.Unlock()
+ i := ks.indexOf(id)
+ if i < 0 {
+ return false
+ }
+ ks.rows[i].RevokedAt = sp(ts(time.Now().UTC()))
+ ks.rows[i].Active = false
+ return true
+}
+
+func (ks *keyStore) remove(id string) bool {
+ ks.mu.Lock()
+ defer ks.mu.Unlock()
+ i := ks.indexOf(id)
+ if i < 0 {
+ return false
+ }
+ ks.rows = append(ks.rows[:i], ks.rows[i+1:]...)
+ delete(ks.secrets, id)
+ return true
+}
+
+// indexOf is called with the lock held.
+func (ks *keyStore) indexOf(id string) int {
+ for i := range ks.rows {
+ if ks.rows[i].ID == id {
+ return i
+ }
+ }
+ return -1
+}
+
+// mask is the gateway's read masking: first 8 characters, "...", last 4.
+func mask(secret string) string {
+ if len(secret) < 12 {
+ return "..."
+ }
+ return secret[:8] + "..." + secret[len(secret)-4:]
+}
+
+// registerKeys wires the /admin/keys surface onto the mux.
+func registerKeys(mux *http.ServeMux, ks *keyStore) {
+ mux.HandleFunc("GET /admin/keys", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, http.StatusOK, ks.list()) // bare array, no envelope
+ })
+
+ // The real gateway serves this (internal/admin/handlers/server.go); leaving
+ // it out made `ferro keys get` 404 against the fake while working against a
+ // live gateway — a fixture gap that reads as a CLI bug.
+ mux.HandleFunc("GET /admin/keys/{id}", func(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ for _, row := range ks.list() {
+ if row.ID == id {
+ writeJSON(w, http.StatusOK, row) // masked, like every read
+ return
+ }
+ }
+ writeErr(w, http.StatusNotFound, kindNotFound, "api key not found")
+ })
+
+ mux.HandleFunc("POST /admin/keys", func(w http.ResponseWriter, r *http.Request) {
+ var in struct {
+ Name string `json:"name"`
+ Scopes []string `json:"scopes"`
+ ExpiresAt *string `json:"expires_at"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ writeErr(w, http.StatusBadRequest, kindInvalidRequest, "malformed request body")
+ return
+ }
+ if in.Name == "" {
+ writeErr(w, http.StatusBadRequest, kindInvalidRequest, "name is required")
+ return
+ }
+ if bad, ok := unknownScope(in.Scopes); !ok {
+ // model.ValidateScopes, worded and ordered exactly as it words it.
+ writeErr(w, http.StatusBadRequest, kindInvalidRequest,
+ fmt.Sprintf("unknown scope %q: valid scopes are %s, %s", bad, scopeAdmin, scopeReadOnly))
+ return
+ }
+ // No nil-to-empty normalisation here: the store defaults an unspecified
+ // list, so a created row always carries at least one scope.
+ row, secret := ks.create(in.Name, in.Scopes, in.ExpiresAt)
+ row.Key = secret // the full secret, served exactly once
+ writeJSON(w, http.StatusCreated, row)
+ })
+
+ mux.HandleFunc("POST /admin/keys/{id}/rotate", func(w http.ResponseWriter, r *http.Request) {
+ row, secret, ok := ks.rotate(r.PathValue("id"))
+ if !ok {
+ writeErr(w, http.StatusNotFound, kindNotFound, msgKeyNotFound)
+ return
+ }
+ row.Key = secret // the new secret, served exactly once
+ writeJSON(w, http.StatusOK, row)
+ })
+
+ mux.HandleFunc("POST /admin/keys/{id}/revoke", func(w http.ResponseWriter, r *http.Request) {
+ if !ks.revoke(r.PathValue("id")) {
+ writeErr(w, http.StatusNotFound, kindNotFound, msgKeyNotFound)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{fieldStatus: statusRevoked})
+ })
+
+ mux.HandleFunc("DELETE /admin/keys/{id}", func(w http.ResponseWriter, r *http.Request) {
+ if !ks.remove(r.PathValue("id")) {
+ writeErr(w, http.StatusNotFound, kindNotFound, msgKeyNotFound)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent) // 204, empty body
+ })
+}
diff --git a/internal/fixture/state.go b/internal/fixture/state.go
new file mode 100644
index 0000000..4d7e48b
--- /dev/null
+++ b/internal/fixture/state.go
@@ -0,0 +1,37 @@
+// Package fixture serves stable gateway response shapes for the CLI and TUI.
+// The shapes mirror supported HTTP responses and deliberately omit
+// fields whose contract is unknown.
+package fixture
+
+import "time"
+
+// defaultKey is the credential Default() accepts. Master keys are `fgw_`-prefixed.
+const defaultKey = "fgw_test"
+
+// State picks which world the fake serves, so one handler covers the happy
+// path, the degraded path, and the feature-absent path that drives the CLI's
+// 404/501 degradation. Zero value = healthy and unauthenticated.
+type State struct {
+ RequireAuth bool // admin routes 401 without a Bearer token
+ AcceptKey string // the credential RequireAuth accepts (default "fgw_test")
+ // Degraded is a HEALTHY gateway in trouble: /health stays 200 "ok" with a
+ // half-open circuit, and /readyz reports one target unroutable. Verified
+ // against a live gateway — a circuit does not make /health non-200.
+ Degraded bool
+ // NoProviders is the separate axis: no credential is configured, so
+ // /health is 503 "no_providers" with an EMPTY providers array. This is the
+ // default state of a credential-free gateway, and it is the one thing that
+ // makes /health non-200.
+ NoProviders bool
+ NoLogStore bool // /admin/logs and /admin/logs/stats return 501
+ NoSessions bool // /admin/sessions returns 501
+ NoMCP bool // omit mcp_servers from /readyz and /admin/health
+ ChatFails bool // emit a mid-stream error frame instead of [DONE]
+ ChatDelay time.Duration // per-chunk delay; 0 in tests, ~60ms for a visible demo
+}
+
+// Default is the world most tests want: healthy, authenticated, every feature
+// present.
+func Default() State {
+ return State{RequireAuth: true, AcceptKey: defaultKey}
+}
diff --git a/internal/fixture/stream.go b/internal/fixture/stream.go
new file mode 100644
index 0000000..76c94f7
--- /dev/null
+++ b/internal/fixture/stream.go
@@ -0,0 +1,163 @@
+package fixture
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+// registerChat wires the streaming chat surface.
+//
+// Only streaming requests are supported because the CLI contract defines the
+// SSE response and the playground always streams.
+func registerChat(mux *http.ServeMux, s State, tr *tracer) {
+ mux.HandleFunc("POST /v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
+ in, err := decodeChatRequest(r.Body)
+ if err != nil {
+ writeErr(w, http.StatusBadRequest, kindInvalidRequest, "malformed request body")
+ return
+ }
+ if in.Stream == nil || !*in.Stream {
+ writeErr(w, http.StatusBadRequest, kindInvalidRequest, "stream must be true")
+ return
+ }
+ if in.Model == "" {
+ in.Model = modelSonnet
+ }
+ // The X-Request-ID the middleware minted is the log row's trace_id.
+ // Recording it here is what closes the attribution loop the playground
+ // depends on: /admin/logs answers with a row carrying this exact id.
+ trace := w.Header().Get("X-Request-ID")
+ tr.set(trace, in.Model)
+ streamChat(w, s, in.Model, trace)
+ })
+}
+
+type chatInput struct {
+ Model string
+ Stream *bool
+}
+
+func decodeChatRequest(r io.Reader) (chatInput, error) {
+ dec := json.NewDecoder(r)
+ tok, err := dec.Token()
+ if err != nil || tok != json.Delim('{') {
+ return chatInput{}, fmt.Errorf("chat request must be an object")
+ }
+ var in chatInput
+ sawStream := false
+ for dec.More() {
+ tok, err = dec.Token()
+ if err != nil {
+ return chatInput{}, err
+ }
+ key, ok := tok.(string)
+ if !ok {
+ return chatInput{}, fmt.Errorf("chat request member must be a string")
+ }
+ switch key {
+ case fieldModel:
+ err = dec.Decode(&in.Model)
+ case "stream":
+ if sawStream {
+ return chatInput{}, fmt.Errorf("duplicate stream member")
+ }
+ sawStream = true
+ err = dec.Decode(&in.Stream)
+ default:
+ err = dec.Decode(&json.RawMessage{})
+ }
+ if err != nil {
+ return chatInput{}, err
+ }
+ }
+ if _, err = dec.Token(); err != nil {
+ return chatInput{}, err
+ }
+ if err = dec.Decode(&struct{}{}); err != io.EOF {
+ return chatInput{}, fmt.Errorf("chat request must contain one JSON value")
+ }
+ return in, nil
+}
+
+// streamedText is the reply the fake types out, one delta per element.
+var streamedText = []string{"Ferro ", "routed ", "this ", "through ", "the ", "fake ", "gateway."}
+
+// streamChat writes the documented SSE framing: `data: \n\n` lines only,
+// no `event:` names, terminated by `data: [DONE]` — except on an errored
+// stream, which ends with an error frame and NO [DONE]. That asymmetry is real
+// gateway behavior; a fake that smoothed it over would hide the CLI bug it
+// exists to catch.
+func streamChat(w http.ResponseWriter, s State, model, id string) {
+ h := w.Header()
+ h.Set("Content-Type", "text/event-stream")
+ h.Set("Cache-Control", "no-cache")
+ h.Set("Connection", "keep-alive")
+ w.WriteHeader(http.StatusOK)
+
+ flusher, canFlush := w.(http.Flusher)
+ // Flush per frame only when a delay is configured (the demo path, where a
+ // human watches text arrive). With ChatDelay == 0 the whole stream is
+ // written in one shot, so a test that does a single Read observes complete
+ // framing instead of racing the flusher.
+ live := s.ChatDelay > 0
+ created := time.Now().Unix()
+
+ frame := func(v any) {
+ b, _ := json.Marshal(v)
+ _, _ = fmt.Fprintf(w, "data: %s\n\n", b)
+ if live && canFlush {
+ flusher.Flush()
+ time.Sleep(s.ChatDelay)
+ }
+ }
+ chunk := func(choices []map[string]any) map[string]any {
+ return map[string]any{
+ "id": id,
+ fieldObject: "chat.completion.chunk",
+ fieldCreated: created,
+ fieldModel: model,
+ "choices": choices,
+ }
+ }
+ delta := func(d map[string]any) []map[string]any {
+ return []map[string]any{{"index": 0, "delta": d}}
+ }
+
+ frame(chunk(delta(map[string]any{"role": "assistant"})))
+
+ for i, word := range streamedText {
+ if s.ChatFails && i == 2 {
+ // Mid-stream failure: error frame, then the stream just ends.
+ frame(map[string]any{fieldError: map[string]any{
+ fieldMessage: "upstream error from provider anthropic",
+ fieldType: "stream_error",
+ "code": "stream_error",
+ }})
+ if canFlush {
+ flusher.Flush()
+ }
+ return
+ }
+ frame(chunk(delta(map[string]any{"content": word})))
+ }
+
+ frame(chunk([]map[string]any{{"index": 0, "delta": map[string]any{}, "finish_reason": "stop"}}))
+
+ // The usage chunk always arrives (the gateway strips it only on an explicit
+ // include_usage:false); choices is [], never null.
+ usage := chunk([]map[string]any{})
+ usage["usage"] = map[string]any{
+ "prompt_tokens": 24,
+ "completion_tokens": len(streamedText),
+ "total_tokens": 24 + len(streamedText),
+ }
+ frame(usage)
+
+ _, _ = fmt.Fprint(w, "data: [DONE]\n\n")
+ if canFlush {
+ flusher.Flush()
+ }
+}
diff --git a/internal/fixture/wire.go b/internal/fixture/wire.go
new file mode 100644
index 0000000..00a7f11
--- /dev/null
+++ b/internal/fixture/wire.go
@@ -0,0 +1,148 @@
+package fixture
+
+// The gateway's wire vocabulary, in one place.
+//
+// This package is the in-repo definition of the gateway's HTTP contract, and
+// the same field names recur across handlers that were written months apart —
+// `status` alone appears in the health surface, the key surface, the plugin
+// catalog and the model catalog. Spelled inline, one of them can drift from
+// the rest and still compile, and the fixture then teaches every downstream
+// test the wrong name for a field.
+//
+// The rule the file follows: a spelling written in three or more places gets a
+// constant. A spelling below that gets one only when it completes a set already
+// named here — the other provider, the other plugin type, the other stage — so
+// no vocabulary ends up half constant and half literal. Everything a single
+// handler writes once stays inline, where the wire shape reads as the wire.
+
+// JSON field names served on the wire. /admin/logs and /admin/audit name their
+// query parameters after the fields they filter on, deliberately, so the
+// filters share these constants with the rows they select.
+const (
+ fieldAction = "action"
+ fieldActor = "actor"
+ fieldActorID = "actor_id"
+ fieldAPIKeyID = "api_key_id"
+ fieldCapabilities = "capabilities"
+ fieldCircuit = "circuit"
+ fieldContextWindow = "context_window"
+ fieldCount = "count"
+ fieldCreated = "created"
+ fieldData = "data"
+ fieldDeprecated = "deprecated"
+ fieldDetail = "detail"
+ fieldEnabled = "enabled"
+ fieldError = "error"
+ fieldFailsOpen = "fails_open"
+ fieldLimit = "limit"
+ fieldMaxOutputTokens = "max_output_tokens"
+ fieldMessage = "message"
+ fieldMode = "mode"
+ fieldModel = "model"
+ fieldModels = "models"
+ fieldName = "name"
+ fieldObject = "object"
+ fieldOccurredAt = "occurred_at"
+ fieldOffset = "offset"
+ fieldOutcome = "outcome"
+ fieldOwnedBy = "owned_by"
+ fieldProvider = "provider"
+ fieldProviders = "providers"
+ fieldReady = "ready"
+ fieldScopes = "scopes"
+ fieldSettings = "settings"
+ fieldSince = "since"
+ fieldSourceIP = "source_ip"
+ fieldStage = "stage"
+ fieldStatus = "status"
+ fieldSummary = "summary"
+ fieldTotalEntries = "total_entries"
+ fieldType = "type"
+)
+
+// The error envelope's `type`/`code` vocabulary, as writeErr serves it.
+const (
+ kindAuthentication = "authentication_error"
+ kindInvalidRequest = "invalid_request_error"
+ kindNotFound = "not_found_error"
+ kindServer = "server_error"
+)
+
+// msgKeyNotFound is the one message three key routes share.
+const msgKeyNotFound = "key not found"
+
+// Provider and model identity, repeated across /health, /readyz, /admin/health,
+// /admin/providers, /admin/logs and /v1/models.
+const (
+ providerAnthropic = "anthropic"
+ providerOpenAI = "openai"
+
+ modelSonnet = "claude-sonnet-4-6"
+ modelOpus = "claude-opus-4-6"
+ modelGPT4o = "gpt-4o"
+ modelGPT4oMini = "gpt-4o-mini"
+ modelEmbedding3Small = "text-embedding-3-small"
+
+ // objectModel is the OpenAI object type a /v1/models row reports. It shares
+ // a spelling with fieldModel and means something else: one is the name of a
+ // field, the other is a value that field never carries.
+ objectModel = "model"
+)
+
+// The closed vocabularies the gateway serves: key scopes, plugin stages, plugin
+// types, the health words the CLI branches on, and the audit outcomes
+// /admin/audit validates its query against. statusOK and outcomeOK share a
+// spelling and nothing else — a health status and an audit outcome are separate
+// sets that happen to have both settled on "ok".
+const (
+ scopeAdmin = "admin"
+ scopeReadOnly = "read_only"
+
+ stageBeforeRequest = "before_request"
+ stageAfterRequest = "after_request"
+ stageOnError = "on_error"
+
+ typeGuardrail = "guardrail"
+ typeLogging = "logging"
+ typeRateLimit = "ratelimit"
+
+ // Every value the fixture ever serves under `status` is named here, so a
+ // reader can see the whole vocabulary the CLI branches on in one place and
+ // a new one cannot be added inline without joining it.
+ statusOK = "ok"
+ statusHealthy = "healthy"
+ statusDegraded = "degraded"
+ statusDisabled = "disabled"
+ statusAvailable = "available"
+ statusStable = "stable"
+ statusReady = "ready"
+ statusNotReady = "not_ready"
+ statusNoProviders = "no_providers"
+ statusRevoked = "revoked"
+
+ circuitClosed = "closed"
+ circuitHalfOpen = "half_open"
+
+ outcomeOK = "ok"
+ outcomeDenied = "denied"
+ outcomeError = "error"
+
+ // modalityChat and modalityEmbedding are each both a model's mode and one
+ // of its capabilities — the same word for the same thing, so each is one
+ // constant rather than two.
+ modalityChat = "chat"
+ modalityEmbedding = "embedding"
+ capabilityStreaming = "streaming"
+ capabilityTools = "tools"
+ capabilityVision = "vision"
+)
+
+// Seed identity: the ids and address the seeded rows cross-reference, so a key
+// row, a session, an audit entry and a log row agree on which credential and
+// which host they are talking about.
+const (
+ keyIDOps = "key_01"
+ keyIDCI = "key_02"
+ actorOps = "ops-laptop"
+ sourceIPLocal = "127.0.0.1"
+)
diff --git a/internal/table/format.go b/internal/table/format.go
new file mode 100644
index 0000000..00deef2
--- /dev/null
+++ b/internal/table/format.go
@@ -0,0 +1,65 @@
+package table
+
+import (
+ "fmt"
+ "time"
+)
+
+// OrDash renders an empty string as "-". Every table cell that can be absent
+// uses this rather than printing "": an empty cell collapses a column for
+// anything splitting the table on whitespace, and reads as a blank status
+// rather than as its absence.
+func OrDash(s string) string {
+ if s == "" {
+ return "-"
+ }
+ return s
+}
+
+// CountOrDash renders a count the gateway never supplied as "-", and one it
+// supplied as the number it gave — a real zero included. Taking *int rather
+// than int is what keeps the two apart: the gateway never asked and the
+// gateway answered zero must not print the same cell.
+func CountOrDash(n *int) string {
+ if n == nil {
+ return "-"
+ }
+ return fmt.Sprintf("%d", *n)
+}
+
+// PositiveOrDash renders a measurement whose zero is not a real value — a
+// model's context window, which no model publishes as 0 — so unlike
+// CountOrDash a plain int is honest here: the two readings CountOrDash keeps
+// apart genuinely coincide for this kind of value.
+func PositiveOrDash(n int) string {
+ if n <= 0 {
+ return "-"
+ }
+ return fmt.Sprintf("%d", n)
+}
+
+// BoolYN renders a bool as the table's yes/no vocabulary.
+func BoolYN(b bool) string {
+ if b {
+ return "yes"
+ }
+ return "no"
+}
+
+// FmtTime renders a wire timestamp in local time. RFC3339 keeps every table
+// cell whitespace-free, so a piped table still splits into fields.
+func FmtTime(t time.Time) string {
+ if t.IsZero() {
+ return "-"
+ }
+ return t.Local().Format(time.RFC3339)
+}
+
+// FmtTimePtr renders an optional timestamp: "-" when the gateway did not
+// supply one.
+func FmtTimePtr(t *time.Time) string {
+ if t == nil {
+ return "-"
+ }
+ return FmtTime(*t)
+}
diff --git a/internal/table/models.go b/internal/table/models.go
new file mode 100644
index 0000000..e0aa29c
--- /dev/null
+++ b/internal/table/models.go
@@ -0,0 +1,36 @@
+package table
+
+import (
+ "strconv"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+)
+
+// ModelHeaders and ModelRows are the model listing's shape, held in one place
+// because two callers render it: `ferro models` and the console's models verb.
+// They were separate copies of the same six columns and the same six
+// accessors, which is a drift waiting to happen — a column added to one reads
+// as a column missing from the other, and the console's whole contract is that
+// it shows what the scriptable verb shows.
+//
+// This is the shape NewPluginCatalog already established: the wire type is
+// internal/api's, the presentation of it is here, and neither caller owns it.
+var ModelHeaders = []string{"ID", "OWNED BY", "MODE", "CONTEXT", "CAPABILITIES", "STATUS"}
+
+// ModelRows renders models in ModelHeaders' order. Capabilities is a count,
+// not a list: the names are unbounded and would push STATUS off a narrow
+// terminal, and the count is what a listing is scanned for.
+func ModelRows(models []api.Model) [][]string {
+ rows := make([][]string, 0, len(models))
+ for _, m := range models {
+ rows = append(rows, []string{
+ m.ID,
+ OrDash(m.OwnedBy),
+ OrDash(m.Mode),
+ PositiveOrDash(m.ContextWindow),
+ strconv.Itoa(len(m.Capabilities)),
+ OrDash(m.Status),
+ })
+ }
+ return rows
+}
diff --git a/internal/table/plugins.go b/internal/table/plugins.go
new file mode 100644
index 0000000..18a6adb
--- /dev/null
+++ b/internal/table/plugins.go
@@ -0,0 +1,53 @@
+package table
+
+import "github.com/ferro-labs/gateway-cli/internal/api"
+
+// A plugin's fail policy is vocabulary the operator reads, so it is spelled
+// once. "open" and "closed" answer what happens to a request when the plugin
+// itself breaks: a logging or metrics plugin fails open and the request
+// proceeds, while a guardrail, auth or rate limiter fails closed and the
+// request does not.
+const (
+ FailsOpen = "open"
+ FailsClosed = "closed"
+)
+
+// PluginPolicy is what this build's catalog says about one configured plugin.
+// Fails is "-" when the catalog does not name the plugin at all — an unknown
+// policy, which is a different claim from "closed".
+type PluginPolicy struct {
+ Summary string
+ Fails string
+}
+
+// PluginCatalog answers what a build ships, indexed by plugin name.
+type PluginCatalog map[string]PluginPolicy
+
+// NewPluginCatalog indexes GET /admin/plugins/catalog.
+//
+// Only the catalog knows whether a plugin fails open, so both the scriptable
+// table and the console must derive the FAILS cell from it. They each used to
+// carry their own copy of this lookup and their own open/closed constants,
+// which let one report a policy the other did not — for the same plugin, on
+// the same gateway, in the same release.
+func NewPluginCatalog(builtins []api.BuiltinPlugin) PluginCatalog {
+ c := make(PluginCatalog, len(builtins))
+ for _, b := range builtins {
+ fails := FailsClosed
+ if b.FailsOpen {
+ fails = FailsOpen
+ }
+ c[b.Name] = PluginPolicy{Summary: b.Summary, Fails: fails}
+ }
+ return c
+}
+
+// For resolves a configured plugin against the catalog. A plugin the build
+// does not ship — registered out of tree — reports an unknown policy rather
+// than being assumed to fail closed.
+func (c PluginCatalog) For(name string) PluginPolicy {
+ if p, ok := c[name]; ok {
+ return p
+ }
+ return PluginPolicy{Fails: "-"}
+}
diff --git a/internal/table/providers.go b/internal/table/providers.go
new file mode 100644
index 0000000..8998ed6
--- /dev/null
+++ b/internal/table/providers.go
@@ -0,0 +1,94 @@
+package table
+
+import (
+ "strconv"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+)
+
+// ProviderHeaders and ProviderRows are the provider listing's shape, and
+// MergeProviders is the reading they render, held in one place because two
+// callers print it: `ferro providers` and the console's providers verb. They
+// each carried their own copy of the merge, under near-identical comments
+// claiming they did the same thing, and they did not: the console iterated
+// /admin/health's list alone, so a provider /health reported and /admin/health
+// did not was a row in the pipe and no row on screen — two answers for one
+// gateway at one instant. The union here loses nothing, and is now the only one.
+//
+// This is the shape models.go and plugins.go already established: the wire
+// types are internal/api's, the presentation of them is here, and neither
+// caller owns it.
+var ProviderHeaders = []string{"PROVIDER", "STATUS", "CIRCUIT", "MODELS", "MESSAGE"}
+
+// ProviderRow is the merged view: no single endpoint serves all five columns.
+// /health is unauthenticated and carries the circuit; /admin/health needs a
+// credential and carries the status, the model count and the failure message.
+//
+// The json tags are `ferro providers --format json|yaml`'s output contract and
+// are frozen — which is why the whole type moved here rather than the console
+// growing a second one beside it. A dash is a rendering of absence, so it is
+// applied in ProviderRows and never stored: these fields stay literally what
+// the gateway said, empty string included.
+type ProviderRow struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Circuit string `json:"circuit"`
+ Models int `json:"models"`
+ Message string `json:"message,omitempty"`
+}
+
+// MergeProviders unions the two listings, ah being nil when no credential
+// worked — unauthenticated is a smaller answer, not an error.
+//
+// /admin/health's reading wins where both endpoints name a provider: it is the
+// only one carrying a message, and its status vocabulary is the richer one.
+// /health's circuit is grafted on because /admin/health does not report it.
+// Providers only /health knows are appended rather than dropped: /admin/health
+// listing fewer of them is an omission by that endpoint, not a provider that
+// stopped existing.
+func MergeProviders(h *api.HealthReport, ah *api.AdminHealth) []ProviderRow {
+ circuits := make(map[string]string, len(h.Providers))
+ for _, p := range h.Providers {
+ circuits[p.Name] = p.Circuit
+ }
+
+ // No credential is just an empty authenticated listing, so both loops run
+ // either way and the union has one implementation rather than two.
+ var admin []api.AdminProviderHealth
+ if ah != nil {
+ admin = ah.Providers
+ }
+ rows := make([]ProviderRow, 0, max(len(admin), len(h.Providers)))
+ seen := make(map[string]struct{}, len(admin))
+ for _, p := range admin {
+ seen[p.Name] = struct{}{}
+ rows = append(rows, ProviderRow{
+ Name: p.Name, Status: p.Status, Circuit: circuits[p.Name],
+ Models: p.Models, Message: p.Message,
+ })
+ }
+ for _, p := range h.Providers {
+ if _, ok := seen[p.Name]; ok {
+ continue
+ }
+ rows = append(rows, ProviderRow{
+ Name: p.Name, Status: p.Status, Circuit: p.Circuit, Models: p.Models,
+ })
+ }
+ return rows
+}
+
+// ProviderRows renders merged rows in ProviderHeaders' order. Every cell that
+// can be absent goes through OrDash: an empty one collapses the column for
+// anything splitting this table on whitespace, and reads as a blank status
+// rather than as its absence. MODELS is the exception — a provider routing
+// zero models is a reading the gateway gave, so it prints 0.
+func ProviderRows(rows []ProviderRow) [][]string {
+ cells := make([][]string, 0, len(rows))
+ for _, r := range rows {
+ cells = append(cells, []string{
+ r.Name, OrDash(r.Status), OrDash(r.Circuit), strconv.Itoa(r.Models), OrDash(r.Message),
+ })
+ }
+ return cells
+}
diff --git a/internal/table/providers_test.go b/internal/table/providers_test.go
new file mode 100644
index 0000000..072851a
--- /dev/null
+++ b/internal/table/providers_test.go
@@ -0,0 +1,96 @@
+package table
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+)
+
+// The listing the merge exists for: /health names two providers, /admin/health
+// knows about fewer of them, and neither endpoint alone fills the five columns.
+var providerHealth = &api.HealthReport{Providers: []api.ProviderHealth{
+ {Name: "openai", Status: "available", Circuit: "closed", Models: 1104},
+ {Name: "anthropic", Status: "available", Circuit: "half_open", Models: 412},
+}}
+
+// The console used to iterate /admin/health's list alone, so a provider that
+// endpoint omitted disappeared from the screen while the pipe still printed
+// it. Every case below asserts the whole row set, not a count: the bug was a
+// missing row, and a length check would have missed which one.
+func TestMergeProvidersUnionsBothListings(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ admin *api.AdminHealth
+ want []ProviderRow
+ }{
+ {
+ // Unauthenticated is a smaller answer, not an error: /health alone
+ // still names every provider and its circuit, and no row carries a
+ // message because only /admin/health serves one.
+ name: "no credential leaves /health answering alone",
+ admin: nil,
+ want: []ProviderRow{
+ {Name: "openai", Status: "available", Circuit: "closed", Models: 1104},
+ {Name: "anthropic", Status: "available", Circuit: "half_open", Models: 412},
+ },
+ },
+ {
+ // Where both endpoints know a provider the authenticated reading
+ // wins — it is the one with a message — and /health's circuit is
+ // grafted on because /admin/health does not report it.
+ name: "admin's status, message and count win, /health's circuit is grafted on",
+ admin: &api.AdminHealth{Providers: []api.AdminProviderHealth{
+ {Name: "openai", Status: "healthy", Models: 1104},
+ {Name: "anthropic", Status: "degraded", Models: 3, Message: "rate limited upstream"},
+ }},
+ want: []ProviderRow{
+ {Name: "openai", Status: "healthy", Circuit: "closed", Models: 1104},
+ {Name: "anthropic", Status: "degraded", Circuit: "half_open", Models: 3,
+ Message: "rate limited upstream"},
+ },
+ },
+ {
+ // The divergence itself: one row from /admin/health, one that only
+ // /health knows. Dropping the second is what the console did.
+ name: "a provider only /health reports still gets a row",
+ admin: &api.AdminHealth{Providers: []api.AdminProviderHealth{
+ {Name: "openai", Status: "healthy", Models: 1104},
+ }},
+ want: []ProviderRow{
+ {Name: "openai", Status: "healthy", Circuit: "closed", Models: 1104},
+ {Name: "anthropic", Status: "available", Circuit: "half_open", Models: 412},
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := MergeProviders(providerHealth, tc.admin); !slices.Equal(got, tc.want) {
+ t.Fatalf("merge lost or reshaped a row:\n got %+v\nwant %+v", got, tc.want)
+ }
+ })
+ }
+}
+
+// A dash is how absence renders: an empty cell collapses the column for
+// anything splitting this table on whitespace, and reads as a blank status
+// rather than as no status at all. MODELS is deliberately not dashed — zero
+// models is a reading the gateway gave, not a value it withheld.
+func TestProviderRowsDashEveryAbsentCell(t *testing.T) {
+ got := ProviderRows([]ProviderRow{{Name: "openai"}, {Name: "anthropic", Status: "healthy",
+ Circuit: "closed", Models: 412, Message: "recovering"}})
+ want := [][]string{
+ {"openai", "-", "-", "0", "-"},
+ {"anthropic", "healthy", "closed", "412", "recovering"},
+ }
+ if len(got) != len(want) {
+ t.Fatalf("want %d rows, got %d (%v)", len(want), len(got), got)
+ }
+ for i := range want {
+ if !slices.Equal(got[i], want[i]) {
+ t.Fatalf("row %d: got %q, want %q", i, got[i], want[i])
+ }
+ }
+ if len(want[0]) != len(ProviderHeaders) {
+ t.Fatalf("a row must have one cell per header: %d cells, %d headers", len(want[0]), len(ProviderHeaders))
+ }
+}
diff --git a/internal/table/sanitize.go b/internal/table/sanitize.go
new file mode 100644
index 0000000..205be84
--- /dev/null
+++ b/internal/table/sanitize.go
@@ -0,0 +1,39 @@
+package table
+
+import "strings"
+
+// A gateway-provided string — a provider name, a plugin summary, an upstream
+// error message, a model's answer — is data reaching a terminal, never layout
+// and never terminal control. A tab or newline injects a column, a row, or an
+// extra line into a pane that owes an exact line count; ESC starts a sequence
+// this tool has no business forwarding from an upstream provider to whatever
+// is reading stdout. Every surface that renders one of those strings collapses
+// them here, so the vocabulary cannot drift between surfaces.
+//
+// The whole control range goes, not an enumerated handful. Listing the four
+// characters that had actually caused a bug (tab, CR, LF, ESC) left BEL ringing
+// the terminal and NUL reaching it, and the next escape hatch would have been
+// found the same way — by someone hitting it. A terminal reads more than ESC as
+// control: C0 and DEL, and C1 0x80–0x9f, where 0x9b is a single-byte CSI that
+// opens a sequence exactly as ESC[ does.
+func sanitize(s string, keepLF bool) string {
+ return strings.Map(func(r rune) rune {
+ if r == '\n' && keepLF {
+ return r
+ }
+ if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) {
+ return ' '
+ }
+ return r
+ }, s)
+}
+
+// SanitizeCell returns s with every control character collapsed to a single
+// space, LF included. For a value that occupies one cell of a table or one
+// line of a pane, where a line break would break the caller's line count.
+func SanitizeCell(s string) string { return sanitize(s, false) }
+
+// SanitizeText returns s with every control character collapsed to a single
+// space except LF, which is kept: for a multi-line body whose line breaks are
+// its own (a streamed answer), where the caller splits on \n itself.
+func SanitizeText(s string) string { return sanitize(s, true) }
diff --git a/internal/table/table.go b/internal/table/table.go
new file mode 100644
index 0000000..bb979b6
--- /dev/null
+++ b/internal/table/table.go
@@ -0,0 +1,61 @@
+// Package table lays out headers and rows as an aligned, tab-padded table and
+// renders the handful of "-"-for-missing-value cells that both cli/internal/tui
+// and cli/internal/command print. It exists to break an import cycle:
+// internal/command hands bare `ferro` off to internal/tui (see
+// command/root.go's REPL wiring), so internal/tui can never import
+// internal/command back — yet a verb read in the console and the same verb
+// read through a pipe must render identically. This package imports neither
+// of those two, and both call it instead of keeping their own copy.
+package table
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "strings"
+ "text/tabwriter"
+)
+
+func normalizeCells(cells []string) []string {
+ out := make([]string, len(cells))
+ for i, c := range cells {
+ out[i] = SanitizeCell(c)
+ }
+ return out
+}
+
+// Write renders headers and rows as a plain, alignment-padded table to w: a
+// header line, a dashed rule the width of each heading, then one line per
+// row. No ANSI, no box drawing — this is the layout `ferro ` pipes to
+// stdout, as likely to be read by awk as by a person.
+func Write(w io.Writer, headers []string, rows [][]string) {
+ headers = normalizeCells(headers)
+ tw := tabwriter.NewWriter(w, 0, 0, 3, ' ', 0)
+ _, _ = fmt.Fprintln(tw, strings.Join(headers, "\t"))
+
+ dashes := make([]string, len(headers))
+ for i, h := range headers {
+ dashes[i] = strings.Repeat("-", len(h))
+ }
+ _, _ = fmt.Fprintln(tw, strings.Join(dashes, "\t"))
+
+ for _, r := range rows {
+ _, _ = fmt.Fprintln(tw, strings.Join(normalizeCells(r), "\t"))
+ }
+ _ = tw.Flush()
+}
+
+// Rows renders headers and rows through the identical layout Write uses, but
+// returns one string per output line — header, dashed rule, then one per data
+// row — with trailing padding trimmed. It exists for a caller with no
+// io.Writer of its own to hand tabwriter: the console transcript, which wraps
+// each line as its own row and dims the header and its rule.
+func Rows(headers []string, rows [][]string) []string {
+ var buf bytes.Buffer
+ Write(&buf, headers, rows)
+ lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
+ for i, l := range lines {
+ lines[i] = strings.TrimRight(l, " ")
+ }
+ return lines
+}
diff --git a/internal/table/table_test.go b/internal/table/table_test.go
new file mode 100644
index 0000000..7186f04
--- /dev/null
+++ b/internal/table/table_test.go
@@ -0,0 +1,189 @@
+package table
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestWriteRendersHeaderRuleAndRows(t *testing.T) {
+ var buf bytes.Buffer
+ Write(&buf, []string{"NAME", "STATE"}, [][]string{{"openai", "closed"}})
+
+ lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
+ if len(lines) != 3 {
+ t.Fatalf("want header + rule + 1 row, got %d lines:\n%s", len(lines), buf.String())
+ }
+ if !strings.HasPrefix(lines[0], "NAME") {
+ t.Fatalf("header line: %q", lines[0])
+ }
+ if !strings.HasPrefix(lines[1], "----") {
+ t.Fatalf("rule must be dashes matching header width, got %q", lines[1])
+ }
+ if !strings.Contains(lines[2], "openai") {
+ t.Fatalf("row line: %q", lines[2])
+ }
+}
+
+// Rows must be Write's output split into lines, not a second layout — that is
+// the whole point of routing both callers through one implementation.
+func TestRowsMatchesWrite(t *testing.T) {
+ headers := []string{"NAME", "READY"}
+ cells := [][]string{{"filesystem", "yes"}, {"a-much-longer-server-name", "no"}}
+
+ var buf bytes.Buffer
+ Write(&buf, headers, cells)
+ want := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
+ for i := range want {
+ want[i] = strings.TrimRight(want[i], " ")
+ }
+
+ got := Rows(headers, cells)
+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
+ t.Fatalf("Rows drifted from Write:\ngot: %q\nwant: %q", got, want)
+ }
+}
+
+// A gateway-provided cell — a provider name, a plugin summary, an upstream
+// error message — is attacker-influenced data reaching a terminal. A tab or
+// newline must not inject a column or row, and an ESC must not reach the
+// terminal as a live escape sequence.
+func TestWriteNormalizesControlCharactersInCells(t *testing.T) {
+ var buf bytes.Buffer
+ Write(&buf, []string{"NAME"}, [][]string{{"evil\tname\r\ninjected\x1b[2Jrow"}})
+
+ lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
+ if len(lines) != 3 {
+ t.Fatalf("control characters in a cell must not inject rows, got %d lines:\n%q", len(lines), buf.String())
+ }
+ if strings.ContainsAny(lines[2], "\t\r\n\x1b") {
+ t.Fatalf("row line still carries a raw control character: %q", lines[2])
+ }
+
+ rows := Rows([]string{"NAME"}, [][]string{{"evil\tname\r\ninjected\x1b[2Jrow"}})
+ if len(rows) != 3 {
+ t.Fatalf("Rows must not split an injected newline into a separate transcript record, got %d rows: %q", len(rows), rows)
+ }
+}
+
+func TestOrDash(t *testing.T) {
+ for _, tc := range []struct{ in, want string }{
+ {"", "-"},
+ {"anthropic", "anthropic"},
+ } {
+ if got := OrDash(tc.in); got != tc.want {
+ t.Errorf("OrDash(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestCountOrDashDistinguishesNilFromZero(t *testing.T) {
+ zero := 0
+ five := 5
+ for _, tc := range []struct {
+ name string
+ in *int
+ want string
+ }{
+ {"nil means never asked", nil, "-"},
+ {"zero is a real answer", &zero, "0"},
+ {"a positive count", &five, "5"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := CountOrDash(tc.in); got != tc.want {
+ t.Errorf("CountOrDash(%v) = %q, want %q", tc.in, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestPositiveOrDashTreatsZeroAsAbsent(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ in int
+ want string
+ }{
+ {"zero is not a real context window", 0, "-"},
+ {"negative is not a real context window", -1, "-"},
+ {"a real context window", 200000, "200000"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := PositiveOrDash(tc.in); got != tc.want {
+ t.Errorf("PositiveOrDash(%d) = %q, want %q", tc.in, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestBoolYN(t *testing.T) {
+ if BoolYN(true) != "yes" || BoolYN(false) != "no" {
+ t.Fatalf("BoolYN(true)=%q BoolYN(false)=%q", BoolYN(true), BoolYN(false))
+ }
+}
+
+func TestFmtTimeRendersLocalRFC3339(t *testing.T) {
+ if got := FmtTime(time.Time{}); got != "-" {
+ t.Fatalf("zero time must render as \"-\", got %q", got)
+ }
+ utc, err := time.Parse(time.RFC3339, "2026-08-08T09:00:00Z")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Pin the zone and assert a literal. Computing the expectation as
+ // utc.Local().Format(...) restates the implementation, and on a UTC
+ // machine — which CI is — it collapses to the same string however
+ // FmtTime handles zones, so the conversion would go untested precisely
+ // where it runs. FixedZone rather than LoadLocation because the test
+ // matrix includes Windows, where the tz database may not be present.
+ orig := time.Local
+ time.Local = time.FixedZone("IST", 5*60*60+30*60)
+ t.Cleanup(func() { time.Local = orig })
+
+ if got, want := FmtTime(utc), "2026-08-08T14:30:00+05:30"; got != want {
+ t.Fatalf("FmtTime must render in local time: got %q, want %q", got, want)
+ }
+}
+
+func TestFmtTimePtr(t *testing.T) {
+ if got := FmtTimePtr(nil); got != "-" {
+ t.Fatalf("nil pointer must render as \"-\", got %q", got)
+ }
+ utc, err := time.Parse(time.RFC3339, "2026-08-08T09:00:00Z")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := FmtTimePtr(&utc), FmtTime(utc); got != want {
+ t.Fatalf("FmtTimePtr(&t) = %q, want %q", got, want)
+ }
+}
+
+// SanitizeCell and SanitizeText are the one vocabulary every surface that
+// renders a gateway string shares. They differ on LF alone: a line break is
+// injected layout in a table cell and content in a streamed body.
+func TestSanitizeCellAndText(t *testing.T) {
+ for _, tc := range []struct {
+ name, in, cell, text string
+ }{
+ {"a clean value is untouched", "anthropic", "anthropic", "anthropic"},
+ {"tab", "a\tb", "a b", "a b"},
+ {"carriage return", "a\rb", "a b", "a b"},
+ {"newline is layout in a cell, content in a body", "a\nb", "a b", "a\nb"},
+ {"the ESC that starts a colour sequence", "a\x1b[31mb", "a [31mb", "a [31mb"},
+ {"the ESC that starts an OSC title write", "a\x1b]0;pwned", "a ]0;pwned", "a ]0;pwned"},
+ {"BEL, which rings the terminal on its own", "a\x07b", "a b", "a b"},
+ {"NUL", "a\x00b", "a b", "a b"},
+ {"DEL", "a\x7fb", "a b", "a b"},
+ {"the C1 CSI, which opens a sequence without an ESC", "a\u009b31mb", "a 31mb", "a 31mb"},
+ {"a multi-byte rune is not a control character", "日本語", "日本語", "日本語"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := SanitizeCell(tc.in); got != tc.cell {
+ t.Errorf("SanitizeCell(%q) = %q, want %q", tc.in, got, tc.cell)
+ }
+ if got := SanitizeText(tc.in); got != tc.text {
+ t.Errorf("SanitizeText(%q) = %q, want %q", tc.in, got, tc.text)
+ }
+ })
+ }
+}
diff --git a/internal/tui/app.go b/internal/tui/app.go
new file mode 100644
index 0000000..675f681
--- /dev/null
+++ b/internal/tui/app.go
@@ -0,0 +1,922 @@
+// Package tui is ferro's full-screen operations console.
+//
+// One root model (App) owns navigation, polling and layout; each screen is a
+// plain struct with pure update/view funcs, so only this file touches the
+// Bubble Tea interface and a v2 API surprise is contained to one adapter.
+//
+// Two rules make the whole package testable and honest:
+//
+// - Update delegates to the unexported update, which mutates the App and
+// returns a command. Tests call it directly — no terminal, no program loop.
+// - Nothing here performs I/O from a view func. Every request lives in a
+// tea.Cmd in msgs.go and comes back as a typed message.
+//
+// Measurements are in terminal cells (lipgloss.Width), never bytes: a frame
+// padded with len() breaks the first time a CJK model name or a styled glyph
+// arrives.
+package tui
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/spf13/cobra"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/config"
+ "github.com/ferro-labs/gateway-cli/internal/tui/theme"
+ "github.com/ferro-labs/gateway-cli/internal/version"
+)
+
+// Screen is the pane the main frame is currently showing.
+type Screen int
+
+// The screens the console can show. Each owns a pane; the shell owns the
+// header, rail, composer, and polling.
+const (
+ ScreenHome Screen = iota
+ ScreenLogs
+ ScreenKeys
+ ScreenPlayground
+)
+
+// Responsive breakpoints and the fixed rail geometry.
+// Never render a border that cannot close within the measured width.
+const (
+ wideMin = 110 // mark + left rail + panels
+ mediumMin = 80 // compact brand + STATUS frame
+ railWidth = 34
+ railGap = 2
+ markGap = 3 // between the mark panel and the text stack beside it
+
+ // minSplitWidth is the narrowest main frame that can carry the
+ // TARGETS│TRAFFIC split header with both titles and a closing rule.
+ minSplitWidth = 30
+
+ // providerRows caps the rail's provider list; the remainder collapses
+ // into a "+N more" line rather than pushing SERVICES off screen.
+ providerRows = 6
+
+ // unknownCount marks a rail counter this gateway does not serve (404/501)
+ // or would not answer without a credential. It renders as a dash, never 0.
+ unknownCount = -1
+
+ // emDash is the one honest answer for a value that does not exist on the
+ // wire. There is no gateway version over HTTP, so the header prints this.
+ emDash = "—"
+)
+
+// The connection states the header reads. The first two are the gateway's own
+// words, matched against what /health reported; the third is ours, for a
+// gateway that reported nothing at all.
+const (
+ stateConnected = "connected"
+ stateDegraded = "degraded"
+ stateUnreachable = "unreachable"
+)
+
+// The provider states that count as working. Anything else earns the warning
+// glyph, so a status this console has never heard of is shown as a concern
+// rather than silently as health.
+const (
+ statusHealthy = "healthy"
+ statusAvailable = "available"
+)
+
+// RailData is the left rail's four sources, already reduced to what it draws.
+// A count of unknownCount means "not served", which is not the same as zero.
+type RailData struct {
+ // Providers is the last GOOD list, not this round's. fetchRail carries the
+ // previous snapshot forward, so a 401 or a failed admin-health round leaves
+ // what was already on screen there rather than blanking the rail; AuthError
+ // is what says this round could not confirm it. It is nil only before the
+ // first successful fetch, or on a gateway that reported none.
+ Providers []api.AdminProviderHealth
+ MCPReady, MCPTotal int
+ PluginsActive int
+ Sessions int
+ AuditToday int
+ AuthError bool
+}
+
+// App is the root model. Every field the screens read lives here; the polling
+// bookkeeping below the line stays unexported.
+type App struct {
+ Client *api.Client
+ Profile config.Resolved
+ Theme theme.Theme
+
+ Width, Height int
+ Screen Screen
+
+ // Composer is the console's only navigation surface; Transcript is what
+ // the home screen shows. Both are values: the App owns them outright.
+ Composer Composer
+ Transcript Transcript
+
+ // The three screens below Home. Each is a value the App owns outright and
+ // each carries its own generation counter, so a message from a screen the
+ // operator has left is dropped rather than applied — the same contract
+ // pollGen is reserved to give the shell's own polls.
+ Logs logsScreen
+ Keys keysScreen
+ Play playScreen
+
+ // Modal is the console's one overlay. It renders in place of the pane and
+ // takes every key while it is open, which is what makes a typed
+ // confirmation a confirmation rather than a race with the command line.
+ Modal *Modal
+
+ // Status is the last GOOD report. A failed poll sets StatusErr and leaves
+ // this alone, so one dropped refresh never blanks the header — it ages it.
+ Status *api.StatusReport
+ StatusErr error
+ StatusAt time.Time
+
+ Traffic *api.LogStats // nil → the TRAFFIC panel renders "—"
+ TrafficErr error
+
+ Rail RailData
+ RailErr error
+
+ // pollGen is the generation every shell poll is issued under and matched
+ // against on arrival, so a slow in-flight response cannot overwrite state
+ // fetched against a newer connection. It is RESERVED: nothing bumps it and
+ // it is always zero, because v0.1 has neither a reconnect nor a profile
+ // switch to bump it for. The guards below are the seam either would land
+ // on; the screens' own gen counters are the live ones.
+ pollGen int
+
+ statusEvery, trafficEvery time.Duration
+}
+
+var _ tea.Model = (*App)(nil)
+
+func newApp(client *api.Client, profile config.Resolved, mode theme.Mode) *App {
+ return &App{
+ Client: client,
+ Profile: profile,
+ Theme: theme.New(mode),
+ // Replaced by the first WindowSizeMsg; a sane default keeps the very
+ // first frame from drawing at 0 cells.
+ Width: 100,
+ Height: 30,
+ Screen: ScreenHome,
+ Rail: RailData{PluginsActive: unknownCount, Sessions: unknownCount, AuditToday: unknownCount},
+ Composer: newComposer(Verbs(nil)),
+ statusEvery: statusPoll,
+ trafficEvery: trafficPoll,
+ }
+}
+
+// Run starts the console. The caller has already established that stdout is a
+// terminal — a bare `ferro` on a pipe must never draw this.
+//
+// root is the caller's own cobra tree, and the composer's vocabulary is walked
+// out of it: the console completes exactly what the CLI can run. It is passed
+// in rather than built here because internal/command is what hands bare `ferro`
+// to this function, so importing it back would be a cycle.
+func Run(client *api.Client, profile config.Resolved, mode theme.Mode, root *cobra.Command) error {
+ a := newApp(client, profile, mode)
+ a.Composer = newComposer(Verbs(root))
+ _, err := tea.NewProgram(a).Run()
+ return err
+}
+
+// Init starts the polling loops. Part of tea.Model.
+func (a *App) Init() tea.Cmd {
+ return tea.Batch(
+ fetchStatus(a.Client, a.pollGen),
+ fetchRail(a.Client, a.pollGen, a.Rail),
+ fetchTraffic(a.Client, a.pollGen),
+ tickStatus(a.statusEvery),
+ tickTraffic(a.trafficEvery),
+ )
+}
+
+// Update delegates to update, which mutates a and returns a command. Tests
+// drive that directly, with no terminal. Part of tea.Model.
+func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.update(msg) }
+
+// View is the only adapter that knows about tea.View. Everything else — every
+// screen, every test — deals in strings.
+func (a *App) View() tea.View {
+ v := tea.NewView(a.render())
+ v.AltScreen = true
+ return v
+}
+
+func (a *App) update(msg tea.Msg) tea.Cmd {
+ switch m := msg.(type) {
+ case tea.WindowSizeMsg:
+ a.Width, a.Height = m.Width, m.Height
+ // The playground lays its markdown out ahead of the view, so it is the
+ // one screen that has to be told the pane changed size.
+ a.Play.resize(a.paneWidth())
+ return nil
+
+ case tea.KeyPressMsg:
+ key := m.String()
+ if key == keyCtrlC {
+ a.Play.leave()
+ a.Logs.leave()
+ a.Keys.leave()
+ return tea.Quit
+ }
+ if a.Modal != nil {
+ // An open modal owns the keyboard outright. A confirmation whose
+ // keys also reach the command line is not a confirmation.
+ return a.Modal.handle(key, a)
+ }
+ prev := a.Screen
+ if a.screenKey(key) {
+ return nil
+ }
+ // The composer owns every other key, esc included: in reverse search
+ // esc abandons the search, and only outside it does esc mean "back".
+ return a.left(prev, a.Composer.handle(key, a))
+
+ case runCmdMsg:
+ prev := a.Screen
+ return a.left(prev, a.route(m.raw))
+
+ case keysListMsg, modalResultMsg:
+ return a.Keys.update(a, msg)
+
+ case logsBatchMsg, logsNamesMsg, tickLogsMsg:
+ return a.Logs.update(a, msg)
+
+ case playModelsMsg, chatOpenMsg, chatDeltaMsg, chatUsageMsg, chatEndMsg, chatMetaMsg, flushTickMsg:
+ return a.Play.update(a, msg)
+
+ case verbRowsMsg:
+ a.Transcript.Push(m.rows...)
+ if m.err != nil {
+ // The gateway's own words, not a paraphrase: an api.Error already
+ // says what failed and often what to do about it.
+ a.Transcript.Push(TranscriptRow{Glyph: kindBad, Text: m.err.Error()})
+ }
+ a.Transcript.Push(TranscriptRow{
+ Text: fmt.Sprintf("Completed in %dms", m.took.Milliseconds()),
+ Dim: true,
+ })
+ return nil
+
+ case statusMsg:
+ if m.gen != a.pollGen {
+ return nil
+ }
+ if m.err != nil {
+ a.StatusErr = m.err
+ a.statusEvery = backoff(a.statusEvery)
+ // api.Status returns a report alongside its error so the caller can
+ // still name the URL it tried. Adopt it only when nothing better is
+ // held — a good report is never replaced by a failure's.
+ if a.Status == nil && m.report != nil {
+ a.Status, a.StatusAt = m.report, time.Now()
+ }
+ return nil
+ }
+ a.Status, a.StatusErr, a.StatusAt = m.report, nil, time.Now()
+ a.statusEvery = statusPoll
+ return nil
+
+ case trafficMsg:
+ if m.gen != a.pollGen {
+ return nil
+ }
+ if m.err != nil {
+ a.TrafficErr = m.err
+ a.trafficEvery = backoff(a.trafficEvery)
+ return nil
+ }
+ a.Traffic, a.TrafficErr = m.stats, nil
+ a.trafficEvery = trafficPoll
+ return nil
+
+ case railMsg:
+ if m.gen != a.pollGen {
+ return nil
+ }
+ a.Rail, a.RailErr = m.data, m.err
+ return nil
+
+ case tickStatusMsg:
+ return tea.Batch(fetchStatus(a.Client, a.pollGen), fetchRail(a.Client, a.pollGen, a.Rail), tickStatus(a.statusEvery))
+
+ case tickTrafficMsg:
+ return tea.Batch(fetchTraffic(a.Client, a.pollGen), tickTraffic(a.trafficEvery))
+ }
+ return nil
+}
+
+// backoff doubles a poll interval, capped, so an unreachable gateway is probed
+// less often the longer it stays down. A success resets it at the call site.
+func backoff(d time.Duration) time.Duration {
+ if d *= 2; d > maxPoll {
+ return maxPoll
+ }
+ return d
+}
+
+// ---------------------------------------------------------------- rendering
+
+// render composes the whole screen. It is a pure function of App state: no
+// network, no clock beyond the stale-age readout.
+func (a *App) render() string {
+ wide := a.Width >= wideMin
+ medium := !wide && a.Width >= mediumMin
+
+ var header string
+ if wide && a.Screen == ScreenHome {
+ header = a.viewBrandBlock()
+ } else {
+ header = a.compactBrand()
+ }
+
+ mainW := a.Width
+ if wide {
+ mainW = a.Width - railWidth - railGap
+ }
+
+ // Line budget: header + blank + body + composer + hints(1). The composer
+ // is measured rather than assumed: it grows by two lines when it has
+ // suggestions to show, and the pane must give those lines up so the header
+ // does not scroll off the top of the screen.
+ composer := a.composerView()
+ fixed := lipgloss.Height(header) + 2 + lipgloss.Height(composer)
+ overhead := 2 // a plain frame's top and bottom borders
+ if wide || medium {
+ overhead = 7 // top + 3 stat rows + divider + pane title + bottom
+ }
+ if medium {
+ fixed += 6 // the STATUS frame and its trailing newline
+ }
+ rows := max(a.Height-fixed-overhead, 1)
+
+ main := a.mainFrame(mainW, wide || medium, rows)
+
+ var b strings.Builder
+ b.WriteString(header)
+ b.WriteString("\n\n")
+ switch {
+ case wide:
+ rail := lipgloss.JoinVertical(lipgloss.Left,
+ a.Theme.Frame("CONNECTED PROVIDERS", a.viewProviders(), railWidth),
+ a.Theme.Frame("SERVICES", a.viewServices(), railWidth))
+ b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, rail, strings.Repeat(" ", railGap), main))
+ case medium:
+ b.WriteString(a.Theme.Frame("STATUS", a.viewCompactStatus(), a.Width))
+ b.WriteString("\n")
+ b.WriteString(main)
+ default:
+ b.WriteString(main)
+ }
+ b.WriteString("\n")
+ b.WriteString(composer)
+ b.WriteString("\n")
+ b.WriteString(a.viewHints())
+ return b.String()
+}
+
+// viewBrandBlock is the wide home header: the split-F panel, the text stack,
+// and the connection readout flush against the right edge. Identity reads
+// left, live state reads right, and the two never compete for the same line.
+// Every value in it is real — see stateBlock and originLine.
+func (a *App) viewBrandBlock() string {
+ mark := theme.Mark(a.Theme)
+ state := a.stateBlock()
+ stateW := 0
+ for _, l := range state {
+ stateW = max(stateW, lipgloss.Width(l))
+ }
+ // The three columns are measured to total exactly a.Width, so the readout
+ // lands on the last cell of the screen however long the URL is; the stack
+ // is the column that gives, because it is the one that can truncate
+ // without losing a fact.
+ stackW := max(a.Width-lipgloss.Width(mark)-markGap-stateW, 10)
+ stack := clampLines([]string{
+ a.brand() + " " + a.Theme.Dim.Render(version.Version),
+ a.Theme.Border.Render(a.rule(10)),
+ a.Theme.Text.Render("AI Gateway"),
+ a.Theme.Dim.Render(a.originLine()),
+ }, stackW)
+ for i, l := range stack {
+ stack[i] = padRight(l, stackW)
+ }
+ // One blank row first: the readout sits against the stack's body rather
+ // than level with the wordmark, which owns the top line alone.
+ col := make([]string, 0, 1+len(state))
+ col = append(col, "")
+ for _, l := range state {
+ col = append(col, padLeft(l, stateW))
+ }
+ // Centred: the panel stands one row taller than the text at each end, so
+ // the stack sits inside its span rather than hanging off its top border.
+ return lipgloss.JoinHorizontal(lipgloss.Center,
+ mark, strings.Repeat(" ", markGap),
+ strings.Join(stack, "\n"), strings.Join(col, "\n"))
+}
+
+// compactBrand is the one-line header every other width and screen gets: the
+// artwork collapses before any operational data does.
+func (a *App) compactBrand() string {
+ line := a.brand() + " " + a.Theme.Dim.Render(version.Version) + " " + a.stateLine()
+ return ansi.Truncate(line, a.Width, "")
+}
+
+// brand is the wordmark. Bold is still an ANSI escape, so it is gated on the
+// same Color switch NO_COLOR and a non-TTY stdout turn off.
+func (a *App) brand() string {
+ return a.Theme.Bright.Bold(a.Theme.Mode.Color).Render("Ferro Labs")
+}
+
+// stateGlyph is the connection state as glyph, label and colour, taken from
+// the report's own state. Both headers read it, so the compact line and the
+// wide block can never disagree about what the gateway is doing.
+func (a *App) stateGlyph() (string, string, lipgloss.Style) {
+ g := a.Theme.Mode.Glyphs()
+ if a.Status == nil {
+ if a.StatusErr != nil {
+ return g.Bad, stateUnreachable, a.Theme.Bad
+ }
+ return g.None, "connecting", a.Theme.Dim
+ }
+ switch a.Status.State {
+ case stateConnected:
+ return g.Dot, stateConnected, a.Theme.OK
+ case stateDegraded:
+ return g.Warn, stateDegraded, a.Theme.Warn
+ }
+ return g.Bad, a.Status.State, a.Theme.Bad
+}
+
+// stateBlock is the wide header's right-hand readout: state, origin, round
+// trip — one fact per line so the eye lands on the state before the URL. Same
+// data stateLine carries, laid out for a column instead of a line.
+func (a *App) stateBlock() []string {
+ glyph, label, style := a.stateGlyph()
+ origin := a.url()
+ if a.Profile.ProfileName != "" {
+ origin = a.Profile.ProfileName + " · " + origin
+ }
+ lines := []string{style.Render(glyph + " " + label), a.Theme.Dim.Render(origin)}
+ // A failed poll takes the third line: how old the data is matters more
+ // than how fast the request that fetched it was.
+ switch {
+ case a.StatusErr != nil && !a.StatusAt.IsZero():
+ lines = append(lines, a.Theme.Dim.Render(fmt.Sprintf("stale %ds", int(time.Since(a.StatusAt).Seconds()))))
+ case a.StatusErr != nil:
+ lines = append(lines, a.Theme.Dim.Render("stale"))
+ case a.Status != nil && a.Status.LatencyMs > 0:
+ lines = append(lines, a.Theme.Dim.Render(fmt.Sprintf("%dms round trip", a.Status.LatencyMs)))
+ }
+ return lines
+}
+
+// stateLine is the one-line connection readout: glyph and colour from the
+// report's own state, then profile, URL and the client-measured round trip.
+// When the last poll failed the line keeps its data and gains a staleness age.
+func (a *App) stateLine() string {
+ glyph, label, style := a.stateGlyph()
+ parts := []string{}
+ if a.Profile.ProfileName != "" {
+ parts = append(parts, a.Profile.ProfileName)
+ }
+ parts = append(parts, a.url())
+ if a.Status != nil && a.Status.LatencyMs > 0 {
+ parts = append(parts, fmt.Sprintf("%dms", a.Status.LatencyMs))
+ }
+ if a.StatusErr != nil && !a.StatusAt.IsZero() {
+ parts = append(parts, fmt.Sprintf("stale %ds", int(time.Since(a.StatusAt).Seconds())))
+ } else if a.StatusErr != nil {
+ parts = append(parts, "stale")
+ }
+ return style.Render(glyph+" "+label) + a.Theme.Dim.Render(" · "+strings.Join(parts, " · "))
+}
+
+// originLine is the counts row. The gateway serves no version over HTTP, so
+// that segment is a dash — never a guess.
+func (a *App) originLine() string {
+ parts := []string{"gateway " + a.dash()}
+ if a.Status != nil {
+ // A gateway that did not report targets gets a dash. /readyz answers a
+ // 503 carrying only {status, reason}, and "0 targets" there would read
+ // as "none configured" during exactly the outage this header is read in.
+ if t := a.Status.Targets; t != nil {
+ parts = append(parts, fmt.Sprintf("%d targets", t.Total))
+ } else {
+ parts = append(parts, a.dash()+" targets")
+ }
+ if n := a.Status.Models; n != nil {
+ parts = append(parts, comma(*n)+" models")
+ }
+ }
+ return strings.Join(parts, " · ")
+}
+
+func (a *App) url() string {
+ if a.Status != nil && a.Status.URL != "" {
+ return a.Status.URL
+ }
+ return a.Profile.URL
+}
+
+// ------------------------------------------------------------------- panels
+
+// mainFrame draws the console's main frame. With panels it carries the split
+// header layout that theme.Frame cannot express:
+//
+// ┌─ TARGETS ──────────┬─ TRAFFIC ──────────┐
+// │ Healthy 7 │ RPS 159.0 │
+// ├────────────────────┴────────────────────┤
+// │ COMMAND OUTPUT │
+// └─────────────────────────────────────────┘
+//
+// This is the one frame drawn outside the theme; everything else goes through
+// it. Below minSplitWidth the split cannot close, so the pane falls back to a
+// plain titled frame.
+func (a *App) mainFrame(w int, panels bool, rows int) string {
+ if !panels || w < minSplitWidth {
+ return a.Theme.Frame(a.paneTitle(), a.paneView(w-4, rows), w)
+ }
+ b := a.boxChars()
+ bd := a.Theme.Border
+ lw := (w - 3) / 2
+ rw := w - 3 - lw
+
+ out := make([]string, 0, rows+7)
+ out = append(out, bd.Render(b.tl+b.h)+" "+a.Theme.Dim.Render("TARGETS")+" "+
+ bd.Render(strings.Repeat(b.h, lw-10)+b.tj+b.h)+" "+a.Theme.Dim.Render("TRAFFIC")+" "+
+ bd.Render(strings.Repeat(b.h, rw-10)+b.tr))
+
+ targets, traffic := a.viewTargets(), a.viewTraffic()
+ edge := bd.Render(b.v)
+ for i := range 3 {
+ out = append(out, edge+" "+padRight(targets[i], lw-2)+" "+edge+" "+padRight(traffic[i], rw-2)+" "+edge)
+ }
+ out = append(out, bd.Render(b.lj+strings.Repeat(b.h, lw)+b.bj+strings.Repeat(b.h, rw)+b.rj))
+
+ body := append([]string{a.Theme.Bright.Render(a.paneTitle())}, strings.Split(a.paneView(w-4, rows), "\n")...)
+ for _, line := range body {
+ out = append(out, edge+" "+padRight(line, w-4)+" "+edge)
+ }
+ out = append(out, bd.Render(b.bl+strings.Repeat(b.h, w-2)+b.br))
+ return strings.Join(out, "\n")
+}
+
+// viewTargets is the TARGETS panel: routable targets, and the two circuit
+// states that explain the difference. No report → dashes, never zeros.
+func (a *App) viewTargets() [3]string {
+ if a.Status == nil {
+ d := a.dash()
+ return [3]string{
+ padRight("Healthy", 14) + d,
+ padRight("Degraded", 14) + d,
+ padRight("Open circuit", 14) + d,
+ }
+ }
+ warn := func(n int, style lipgloss.Style) string {
+ if n > 0 {
+ return style.Render(strconv.Itoa(n))
+ }
+ return strconv.Itoa(n)
+ }
+ // Circuits are reported by /health, which answers even when /readyz is a
+ // bare 503 — so they stay real while the target counts go absent.
+ healthy := a.Theme.Dim.Render(a.dash())
+ if t := a.Status.Targets; t != nil {
+ healthy = a.Theme.OK.Render(strconv.Itoa(t.Routable))
+ }
+ return [3]string{
+ padRight("Healthy", 14) + healthy,
+ padRight("Degraded", 14) + warn(a.Status.Circuits.HalfOpen, a.Theme.Warn),
+ padRight("Open circuit", 14) + warn(a.Status.Circuits.Open, a.Theme.Bad),
+ }
+}
+
+// viewTraffic derives the panel from one /admin/logs/stats window. Every
+// unknown renders as a dash: a gateway with no log store answers 501, which is
+// an absence of data, not a zero and not an error state.
+func (a *App) viewTraffic() [3]string {
+ rps, p95, errRate := a.dash(), a.dash(), a.dash()
+ if t := a.Traffic; t != nil {
+ rps = fmt.Sprintf("%.1f", float64(t.Summary.TotalEntries)/trafficWindow.Seconds())
+ if t.LatencyMs != nil {
+ p95 = fmt.Sprintf("%.0fms", t.LatencyMs.P95)
+ }
+ if t.Summary.TotalEntries > 0 {
+ errRate = fmt.Sprintf("%.2f%%", 100*float64(t.Summary.ErrorEntries)/float64(t.Summary.TotalEntries))
+ }
+ }
+ // 7 cells, not 5: a p95 of 1284ms truncated to "1284m" does not read as a
+ // clipped number, it reads as minutes.
+ return [3]string{
+ padRight("RPS", 14) + padLeft(rps, 7),
+ padRight("P95", 14) + padLeft(p95, 7),
+ padRight("Errors", 14) + padLeft(errRate, 7),
+ }
+}
+
+// --------------------------------------------------------------------- rail
+
+// viewProviders is the CONNECTED PROVIDERS frame body. There is no per-provider
+// latency on the wire, so the right column is the model count the gateway
+// actually reports.
+func (a *App) viewProviders() string {
+ inner := railWidth - 4
+ g := a.Theme.Mode.Glyphs()
+ if a.Rail.AuthError {
+ return a.Theme.Dim.Render(padRight("auth required "+a.dash()+" set FERRO_API_KEY", inner))
+ }
+ if len(a.Rail.Providers) == 0 {
+ return a.Theme.Dim.Render(padRight("no providers reported", inner))
+ }
+ shown := min(len(a.Rail.Providers), providerRows)
+ lines := make([]string, 0, shown+1)
+ for _, p := range a.Rail.Providers[:shown] {
+ glyph := a.Theme.OK.Render(g.Dot)
+ if p.Status != statusHealthy && p.Status != statusAvailable {
+ glyph = a.Theme.Warn.Render(g.Warn)
+ }
+ lines = append(lines, gapPad(inner, glyph+" "+p.Name, a.Theme.Dim.Render(comma(p.Models)+" models")))
+ }
+ if rest := len(a.Rail.Providers) - shown; rest > 0 {
+ lines = append(lines, a.Theme.Dim.Render(fmt.Sprintf("+%d more", rest)))
+ }
+ return strings.Join(lines, "\n")
+}
+
+// viewServices is the SERVICES frame body: the four readiness counters the
+// mock keeps permanently on screen.
+func (a *App) viewServices() string {
+ mcp := a.dash()
+ style := a.Theme.Text
+ if a.Rail.MCPTotal > 0 {
+ mcp = fmt.Sprintf("%d/%d ready", a.Rail.MCPReady, a.Rail.MCPTotal)
+ if a.Rail.MCPReady < a.Rail.MCPTotal {
+ style = a.Theme.Warn
+ } else {
+ style = a.Theme.OK
+ }
+ }
+ // Label column 16 + the longest value ("N events today") is exactly the
+ // rail's 30 inner cells; widening it truncates the audit row.
+ return strings.Join([]string{
+ padRight("MCP servers", 16) + style.Render(mcp),
+ padRight("Plugins", 16) + a.count(a.Rail.PluginsActive, "active", a.Theme.OK),
+ padRight("Sessions", 16) + a.count(a.Rail.Sessions, "operators", a.Theme.Text),
+ padRight("Audit", 16) + a.count(a.Rail.AuditToday, "events today", a.Theme.Dim),
+ }, "\n")
+}
+
+// count renders a rail counter, or a dash when the gateway does not serve it.
+func (a *App) count(n int, unit string, style lipgloss.Style) string {
+ if n == unknownCount {
+ return a.Theme.Dim.Render(a.dash())
+ }
+ return style.Render(fmt.Sprintf("%d %s", n, unit))
+}
+
+// viewCompactStatus is the medium-width collapse of the rail plus the header
+// details the one-line brand cannot carry.
+func (a *App) viewCompactStatus() string {
+ // "gateway " is the header's phrasing; here the row label already
+ // says Gateway, so the dash stands alone rather than repeating the word.
+ gateway, targets, providers := a.dash(), a.dash(), a.dash()
+ if a.Status != nil {
+ if a.Status.LatencyMs > 0 {
+ gateway += fmt.Sprintf(" · %dms", a.Status.LatencyMs)
+ }
+ routable := a.dash() + "/" + a.dash()
+ if t := a.Status.Targets; t != nil {
+ routable = fmt.Sprintf("%d/%d", t.Routable, t.Total)
+ }
+ targets = fmt.Sprintf("%s routable · %d degraded · %d open",
+ routable, a.Status.Circuits.HalfOpen, a.Status.Circuits.Open)
+ // A count the gateway supplied is shown as given, zero included; one it
+ // never supplied leaves the dash these start as.
+ if n := a.Status.Providers; n != nil {
+ providers = strconv.Itoa(*n)
+ }
+ if n := a.Status.Models; n != nil {
+ providers += " · " + comma(*n) + " models"
+ }
+ }
+ services := fmt.Sprintf("MCP %s · plugins %s · sessions %s · audit %s",
+ a.mcpRatio(), plainCount(a.Rail.PluginsActive, a.dash()),
+ plainCount(a.Rail.Sessions, a.dash()), plainCount(a.Rail.AuditToday, a.dash()))
+ if a.Rail.AuthError {
+ services = "auth required " + a.dash() + " set FERRO_API_KEY"
+ }
+ inner := a.Width - 4
+ return strings.Join(clampLines([]string{
+ padRight("Gateway", 12) + a.Theme.Dim.Render(gateway),
+ padRight("Targets", 12) + a.Theme.Text.Render(targets),
+ padRight("Providers", 12) + a.Theme.Text.Render(providers),
+ padRight("Services", 12) + a.Theme.Dim.Render(services),
+ }, inner), "\n")
+}
+
+func (a *App) mcpRatio() string {
+ if a.Rail.MCPTotal == 0 {
+ return a.dash()
+ }
+ return fmt.Sprintf("%d/%d", a.Rail.MCPReady, a.Rail.MCPTotal)
+}
+
+func plainCount(n int, dash string) string {
+ if n == unknownCount {
+ return dash
+ }
+ return strconv.Itoa(n)
+}
+
+// --------------------------------------------------------- pane + composer
+
+func (a *App) paneTitle() string {
+ switch a.Screen {
+ case ScreenLogs:
+ return "LOGS"
+ case ScreenKeys:
+ return "KEYS"
+ case ScreenPlayground:
+ return "PLAYGROUND"
+ default:
+ return "COMMAND OUTPUT"
+ }
+}
+
+// paneView dispatches to the current screen's body. Every branch returns
+// exactly rows lines, at most w cells wide, so the composer stays anchored to
+// the bottom of the screen whatever is on show above it.
+//
+// A modal renders IN PLACE of the pane rather than over it. There is nothing
+// to dim behind an overlay in a terminal, and the pane's exact-line contract is
+// the one thing an overlay would have to break.
+func (a *App) paneView(w, rows int) string {
+ if a.Modal != nil {
+ return a.Modal.view(a, w, rows)
+ }
+ switch a.Screen {
+ case ScreenLogs:
+ return a.Logs.view(a, w, rows)
+ case ScreenKeys:
+ return a.Keys.view(a, w, rows)
+ case ScreenPlayground:
+ return a.Play.view(a, w, rows)
+ default:
+ return a.homeView(w, rows)
+ }
+}
+
+// paneWidth is the width paneView will next be called with, derived exactly as
+// render() derives it. A screen that lays content out ahead of the view — the
+// playground wraps markdown when a delta batch flushes, not per frame — needs
+// the number outside a view func.
+func (a *App) paneWidth() int {
+ w := a.Width
+ if w >= wideMin {
+ w -= railWidth + railGap
+ }
+ return max(w-4, 1)
+}
+
+// screenKey offers a key to the pane before the composer sees it and reports
+// whether the pane took it.
+//
+// The pane is only offered a key while the command line is EMPTY. The composer
+// is the console's only navigation surface, so typing `keys` from the logs
+// screen has to outrank moving a cursor — which is also why the selection keys
+// are ↑/↓ and not j/k: `k` is the first letter of a verb, and a screen that
+// swallowed it would make that verb untypeable from this pane.
+func (a *App) screenKey(key string) bool {
+ if a.Composer.searchMode || a.Composer.Input != "" {
+ return false
+ }
+ switch a.Screen {
+ case ScreenLogs:
+ return a.Logs.handleKey(key)
+ case ScreenKeys:
+ return a.Keys.handleKey(key)
+ }
+ return false
+}
+
+// left cancels the screen the shell has just moved off. Every path that can
+// change a.Screen funnels through here — the router, and the composer's own
+// esc — so a tail keeps ticking and a stream keeps streaming for exactly as
+// long as its pane is on show, and not one message longer.
+func (a *App) left(prev Screen, cmd tea.Cmd) tea.Cmd {
+ if a.Screen == prev {
+ return cmd
+ }
+ switch prev {
+ case ScreenLogs:
+ a.Logs.leave()
+ case ScreenKeys:
+ a.Keys.leave()
+ case ScreenPlayground:
+ a.Play.leave()
+ }
+ return cmd
+}
+
+func (a *App) composerView() string { return a.Composer.view(a) }
+
+func (a *App) viewHints() string {
+ left, right := "↵ run tab complete ↑ history ctrl+r search", "? help ctrl+c quit"
+ if a.Theme.Mode.ASCII {
+ left = "enter run tab complete up history ctrl+r search"
+ }
+ return gapPad(a.Width, " "+a.Theme.Dim.Render(left), a.Theme.Dim.Render(right))
+}
+
+// ------------------------------------------------------------------ helpers
+
+// boxChars mirrors the theme's border vocabulary with the two junctions the
+// split header needs; theme.Frame draws no junctions, so it exports none.
+type boxChars struct{ tl, tr, bl, br, h, v, tj, bj, lj, rj string }
+
+func (a *App) boxChars() boxChars {
+ if a.Theme.Mode.ASCII {
+ return boxChars{"+", "+", "+", "+", "-", "|", "+", "+", "+", "+"}
+ }
+ return boxChars{"┌", "┐", "└", "┘", "─", "│", "┬", "┴", "├", "┤"}
+}
+
+func (a *App) dash() string {
+ if a.Theme.Mode.ASCII {
+ return "-"
+ }
+ return emDash
+}
+
+func (a *App) rule(n int) string {
+ if a.Theme.Mode.ASCII {
+ return strings.Repeat("-", n)
+ }
+ return strings.Repeat("─", n)
+}
+
+// padRight pads or truncates to exactly w cells. ANSI-aware in both directions:
+// measuring ignores escape sequences and truncation keeps them intact.
+func padRight(s string, w int) string {
+ if w <= 0 {
+ return ""
+ }
+ s = ansi.Truncate(s, w, "")
+ if p := w - lipgloss.Width(s); p > 0 {
+ s += strings.Repeat(" ", p)
+ }
+ return s
+}
+
+func padLeft(s string, w int) string {
+ if w <= 0 {
+ return ""
+ }
+ s = ansi.Truncate(s, w, "")
+ if p := w - lipgloss.Width(s); p > 0 {
+ s = strings.Repeat(" ", p) + s
+ }
+ return s
+}
+
+// gapPad lays left and right out at the two ends of exactly w cells, giving up
+// the left side first when they cannot both fit.
+func gapPad(w int, left, right string) string {
+ if w <= 0 {
+ return ""
+ }
+ right = ansi.Truncate(right, w, "")
+ rw := lipgloss.Width(right)
+ left = ansi.Truncate(left, max(w-rw-1, 0), "")
+ return left + strings.Repeat(" ", max(w-lipgloss.Width(left)-rw, 0)) + right
+}
+
+func clampLines(lines []string, w int) []string {
+ out := make([]string, len(lines))
+ for i, l := range lines {
+ out[i] = ansi.Truncate(l, w, "")
+ }
+ return out
+}
+
+// comma groups thousands. 2500 models reads as a quantity; 2500 reads as an id.
+func comma(n int) string {
+ s := strconv.Itoa(n)
+ sign := ""
+ if strings.HasPrefix(s, "-") {
+ sign, s = "-", s[1:]
+ }
+ for i := len(s) - 3; i > 0; i -= 3 {
+ s = s[:i] + "," + s[i:]
+ }
+ return sign + s
+}
diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go
new file mode 100644
index 0000000..c1a56bf
--- /dev/null
+++ b/internal/tui/app_test.go
@@ -0,0 +1,550 @@
+package tui
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/config"
+ "github.com/ferro-labs/gateway-cli/internal/tui/theme"
+)
+
+// testApp builds a fully-populated shell with no client: every test here is
+// pure MVU — no terminal, no program loop, no network.
+func testApp(w, h int) *App {
+ a := newApp(nil, config.Resolved{ProfileName: "local", URL: "http://localhost:8080"}, theme.Mode{})
+ a.Width, a.Height = w, h
+ a.Status = &api.StatusReport{
+ State: "connected",
+ URL: "http://localhost:8080",
+ LatencyMs: 23,
+ Providers: api.Count(8),
+ Models: api.Count(2500),
+ Targets: &api.TargetSummary{Total: 8, Routable: 7},
+ Circuits: api.CircuitSummary{Open: 0, HalfOpen: 1},
+ MCP: &api.MCPSummary{Ready: 3, Total: 4},
+ Auth: "admin",
+ }
+ a.StatusAt = time.Now()
+ a.Traffic = testStats(47700, 200, 916)
+ a.Rail = RailData{
+ Providers: []api.AdminProviderHealth{
+ {Name: "openai", Status: "healthy", Models: 1104},
+ {Name: "anthropic", Status: "healthy", Models: 412},
+ {Name: "gemini", Status: "healthy", Models: 96},
+ {Name: "groq", Status: "healthy", Models: 40},
+ {Name: "mistral", Status: "healthy", Models: 32},
+ {Name: "cohere", Status: "healthy", Models: 18},
+ {Name: "deepseek", Status: "healthy", Models: 9},
+ {Name: "xai", Status: "healthy", Models: 6},
+ },
+ MCPReady: 3, MCPTotal: 4,
+ PluginsActive: 6, Sessions: 2, AuditToday: 4,
+ }
+ return a
+}
+
+func testStats(total, errs int, p95 float64) *api.LogStats {
+ s := &api.LogStats{}
+ s.Summary.TotalEntries = total
+ s.Summary.ErrorEntries = errs
+ s.LatencyMs = &api.Percentiles{P50: 210, P95: p95, P99: 1800, Count: total}
+ return s
+}
+
+func TestBreakpointComposition(t *testing.T) {
+ a := testApp(126, 40)
+ view := a.render()
+ for _, s := range []string{"CONNECTED PROVIDERS", "SERVICES", "TARGETS", "TRAFFIC", "COMMAND"} {
+ if !strings.Contains(view, s) {
+ t.Fatalf("wide view missing %q:\n%s", s, view)
+ }
+ }
+
+ a.Width = 96
+ if v := a.render(); strings.Contains(v, "CONNECTED PROVIDERS") || !strings.Contains(v, "STATUS") {
+ t.Fatalf("medium must collapse rail into STATUS frame:\n%s", v)
+ }
+
+ a.Width = 72
+ if v := a.render(); strings.Contains(v, "STATUS") || !strings.Contains(v, "COMMAND") {
+ t.Fatalf("narrow is pane + composer only:\n%s", v)
+ }
+}
+
+// TestEveryViewLineFitsWidth is the box-drawing guard: a single line wider than
+// the terminal wraps and destroys every frame below it.
+func TestEveryViewLineFitsWidth(t *testing.T) {
+ for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} {
+ for _, w := range []int{72, 96, 126} {
+ a := testApp(w, 40)
+ a.Theme = theme.New(mode)
+ for i, line := range strings.Split(a.render(), "\n") {
+ if lw := lipgloss.Width(line); lw > w {
+ t.Fatalf("mode=%+v w=%d line %d overflows: %d cells %q", mode, w, i, lw, line)
+ }
+ }
+ }
+ }
+}
+
+// The same guard, with each screen and a modal on show: the box drawing has to
+// survive what the panes put inside it, not just an empty transcript.
+func TestEveryViewLineFitsWidthOnEveryScreen(t *testing.T) {
+ now := time.Now()
+ for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} {
+ for _, w := range []int{72, 96, 126} {
+ a := composerApp()
+ a.Theme, a.Width = theme.New(mode), w
+
+ a.route("logs --since 15m --model claude-sonnet-4-6")
+ a.update(logsBatchMsg{gen: a.Logs.gen, rows: []api.LogEntry{
+ logRow("tr_00000001", now), logRow("tr_00000002", now.Add(time.Second)),
+ }})
+ a.Logs.sel, a.Logs.detail = 0, true
+
+ a.update(keysListMsg{gen: a.Keys.gen, keys: []api.Key{
+ {ID: "key_01", Key: "fgw_abcd…wxyz", Name: "ops-laptop", Scopes: []string{"admin"}, Active: true},
+ }})
+ a.Keys.sel, a.Keys.detail = 0, true
+
+ a.route("playground")
+ a.Play.submit(a, "a prompt long enough to wrap at every width under test")
+ a.update(chatDeltaMsg{gen: a.Play.gen, text: strings.Repeat("streamed words ", 12)})
+ a.update(flushTickMsg{gen: a.Play.gen})
+ a.update(chatEndMsg{gen: a.Play.gen, done: true})
+ a.update(chatMetaMsg{gen: a.Play.gen, row: &api.LogEntry{Provider: "anthropic", CostUSD: f64(0.0031)}})
+
+ for _, screen := range []Screen{ScreenHome, ScreenLogs, ScreenKeys, ScreenPlayground} {
+ a.Screen = screen
+ for _, modal := range []*Modal{nil, {
+ Title: "Rotate ops-laptop?",
+ Body: "Blast radius: every client holding the current secret fails immediately\nuntil it is redeployed with the new one.",
+ Rows: []ModalKV{{K: "id", V: "key_01"}, {K: "last used", V: "3 minutes ago · 41,000 requests", Warn: true}},
+ Input: true,
+ Placeholder: `type "ops-laptop" to confirm`,
+ Confirm: "ops-laptop",
+ OKLabel: "rotate",
+ Danger: true,
+ }} {
+ a.Modal = modal
+ for i, line := range strings.Split(a.render(), "\n") {
+ if lw := lipgloss.Width(line); lw > w {
+ t.Fatalf("mode=%+v w=%d screen=%v modal=%t line %d overflows: %d cells %q",
+ mode, w, screen, modal != nil, i, lw, line)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+func TestNarrowUnderMinimumStillRenders(t *testing.T) {
+ a := testApp(20, 12)
+ for i, line := range strings.Split(a.render(), "\n") {
+ if lw := lipgloss.Width(line); lw > 20 {
+ t.Fatalf("line %d overflows at w=20: %d cells %q", i, lw, line)
+ }
+ }
+}
+
+func TestFailedPollKeepsLastGoodStatus(t *testing.T) {
+ a := testApp(126, 40)
+ a.Status, a.StatusErr = nil, nil
+
+ good := &api.StatusReport{State: "connected", Providers: api.Count(3)}
+ a.update(statusMsg{gen: 0, report: good})
+ a.update(statusMsg{gen: 0, err: errors.New("boom")})
+
+ if a.Status != good || a.StatusErr == nil {
+ t.Fatalf("stale data must persist with its age; errors annotate, never erase (status=%v err=%v)", a.Status, a.StatusErr)
+ }
+ if !strings.Contains(a.render(), "stale") {
+ t.Fatal("a stale header must say so")
+ }
+}
+
+func TestStalePollGenerationDropped(t *testing.T) {
+ a := testApp(126, 40)
+ a.Status = nil
+ a.pollGen = 1
+ a.update(statusMsg{gen: 0, report: &api.StatusReport{State: "connected"}})
+ if a.Status != nil {
+ t.Fatal("results from a previous generation must be dropped")
+ }
+ a.update(trafficMsg{gen: 0, stats: testStats(10, 0, 5)})
+ a.update(railMsg{gen: 0, data: RailData{PluginsActive: 99}})
+ if a.Traffic == nil || a.Traffic.Summary.TotalEntries == 10 {
+ t.Fatal("stale trafficMsg must be dropped too")
+ }
+ if a.Rail.PluginsActive == 99 {
+ t.Fatal("stale railMsg must be dropped too")
+ }
+}
+
+// There is no gateway version over HTTP. The header must say so, not guess.
+func TestHeaderNeverInventsAGatewayVersion(t *testing.T) {
+ v := testApp(126, 40).render()
+ if !strings.Contains(v, "gateway "+emDash) {
+ t.Fatalf("header must render `gateway %s`:\n%s", emDash, v)
+ }
+ if m := regexp.MustCompile(`gateway v?\d`).FindString(v); m != "" {
+ t.Fatalf("header fabricated a gateway version: %q", m)
+ }
+}
+
+func TestNilTrafficRendersDash(t *testing.T) {
+ a := testApp(126, 40)
+ a.Traffic = nil
+ v := a.render()
+ for _, label := range []string{"RPS", "P95", "Errors"} {
+ row := findRow(v, label)
+ if !strings.Contains(row, emDash) {
+ t.Fatalf("unknown %s must render %s, got %q", label, emDash, row)
+ }
+ for _, bad := range []string{"0.0", "NaN", "0ms", "0.00%"} {
+ if strings.Contains(row, bad) {
+ t.Fatalf("unknown %s must not render %q: %q", label, bad, row)
+ }
+ }
+ }
+
+ // A measured window with no requests still has no percentiles.
+ a.Traffic = &api.LogStats{}
+ v = a.render()
+ if !strings.Contains(findRow(v, "P95"), emDash) {
+ t.Fatalf("nil LatencyMs must render %s: %q", emDash, findRow(v, "P95"))
+ }
+ if strings.Contains(findRow(v, "Errors"), "NaN") {
+ t.Fatalf("0/0 errors must not divide: %q", findRow(v, "Errors"))
+ }
+}
+
+// A gateway that answers /readyz with a bare 503 reports no targets at all.
+// Rendering that as 0 would say "none are configured" during exactly the
+// outage the header is read in — and dereferencing it would take the console
+// down instead.
+func TestAbsentTargetsRenderDashNotZero(t *testing.T) {
+ for _, w := range []int{72, 96, 126} {
+ a := testApp(w, 40)
+ a.Status.State, a.Status.Targets = "unreachable", nil
+ v := a.render()
+ for _, bad := range []string{"0 targets", "0/0"} {
+ if strings.Contains(v, bad) {
+ t.Fatalf("w=%d: unreported targets must never render %q:\n%s", w, bad, v)
+ }
+ }
+ if w < mediumMin {
+ continue // narrow drops both the counts header and the panels
+ }
+ if row := findRow(v, "Healthy"); !strings.Contains(row, a.dash()) {
+ t.Fatalf("w=%d: unreported targets must render a dash: %q", w, row)
+ }
+ }
+
+ // The wide header is the one place the total is spelled out in words.
+ a := testApp(126, 40)
+ a.Status.Targets = nil
+ if v := a.render(); !strings.Contains(v, a.dash()+" targets") {
+ t.Fatalf("the counts row must say %s targets, not invent one:\n%s", a.dash(), v)
+ }
+}
+
+func TestCompactStatusDoesNotInventZeroMeasurements(t *testing.T) {
+ a := testApp(96, 30)
+ a.Status = &api.StatusReport{State: "connected", URL: "http://localhost:8080"}
+ view := a.viewCompactStatus()
+ gatewayRow := findRow(view, "Gateway")
+ providersRow := findRow(view, "Providers")
+ // A bare \b0\b, not strings.Contains: the row label or padding could
+ // legitimately carry a "0" elsewhere, and that must not fail this check —
+ // only an invented zero measurement should.
+ if !strings.Contains(gatewayRow, a.dash()) || strings.Contains(gatewayRow, "0ms") ||
+ !strings.Contains(providersRow, a.dash()) || regexp.MustCompile(`\b0\b`).MatchString(providersRow) {
+ t.Fatalf("unreported latency and provider counts must render as dashes:\n%s", view)
+ }
+}
+
+func TestTrafficDerivedFromLogStats(t *testing.T) {
+ v := testApp(126, 40).render()
+ for label, want := range map[string]string{
+ "RPS": "159.0", // 47700 entries / 300s
+ "P95": "916ms",
+ "Errors": "0.42%", // 200 / 47700
+ } {
+ if row := findRow(v, label); !strings.Contains(row, want) {
+ t.Fatalf("%s row must contain %q, got %q", label, want, row)
+ }
+ }
+}
+
+func TestHeaderRendersRealStatusOnly(t *testing.T) {
+ v := testApp(126, 40).render()
+ for _, want := range []string{
+ "Ferro Labs",
+ "AI Gateway",
+ "connected",
+ "local",
+ "http://localhost:8080",
+ "23ms",
+ "8 targets",
+ "2,500 models",
+ } {
+ if !strings.Contains(v, want) {
+ t.Fatalf("header missing %q:\n%s", want, v)
+ }
+ }
+}
+
+// The header's three columns are measured, not spaced by eye: the readout has
+// to land on the last cell of the screen at any width, or it drifts away from
+// the right edge the moment a URL or a profile name changes length.
+func TestHeaderReadoutIsFlushRight(t *testing.T) {
+ for _, w := range []int{110, 126, 200} {
+ a := testApp(w, 40)
+ for i, line := range strings.Split(a.viewBrandBlock(), "\n") {
+ if got := lipgloss.Width(line); got != w {
+ t.Fatalf("width %d: header row %d is %d cells, must fill exactly %d:\n%q", w, i, got, w, line)
+ }
+ }
+ want := "23ms round trip"
+ row := findRow(a.viewBrandBlock(), want)
+ if !strings.HasSuffix(row, want) {
+ t.Fatalf("width %d: readout must sit flush right, got %q", w, row)
+ }
+ }
+}
+
+func TestUnreachableStateGetsBadGlyph(t *testing.T) {
+ a := testApp(126, 40)
+ a.Status = &api.StatusReport{State: "unreachable", URL: "http://localhost:8080"}
+ g := a.Theme.Mode.Glyphs()
+ if v := a.render(); !strings.Contains(v, g.Bad+" unreachable") {
+ t.Fatalf("unreachable must carry the bad glyph:\n%s", v)
+ }
+ a.Status.State = "degraded"
+ if v := a.render(); !strings.Contains(v, g.Warn+" degraded") {
+ t.Fatalf("degraded must carry the warn glyph:\n%s", v)
+ }
+}
+
+func TestRailReportsMissingCredential(t *testing.T) {
+ a := testApp(126, 40)
+ a.Rail = RailData{AuthError: true, PluginsActive: -1, Sessions: -1, AuditToday: -1}
+ v := a.render()
+ if !strings.Contains(v, "auth required") {
+ t.Fatalf("unauthorized rail must say so:\n%s", v)
+ }
+ if !strings.Contains(findRow(v, "Plugins"), emDash) {
+ t.Fatalf("unknown service counts render %s: %q", emDash, findRow(v, "Plugins"))
+ }
+}
+
+func TestWindowSizeAndQuitKeys(t *testing.T) {
+ a := testApp(80, 24)
+ a.update(tea.WindowSizeMsg{Width: 126, Height: 40})
+ if a.Width != 126 || a.Height != 40 {
+ t.Fatalf("resize ignored: %dx%d", a.Width, a.Height)
+ }
+ if cmd := a.update(tea.KeyPressMsg{Mod: tea.ModCtrl, Code: 'c'}); cmd == nil {
+ t.Fatal("ctrl+c must return a quit command")
+ }
+ a.Screen = ScreenLogs
+ a.update(tea.KeyPressMsg{Code: tea.KeyEscape})
+ if a.Screen != ScreenHome {
+ t.Fatal("esc must return to home")
+ }
+}
+
+func TestPollBackoffDoublesAndResets(t *testing.T) {
+ a := testApp(126, 40)
+ a.update(statusMsg{err: errors.New("boom")})
+ a.update(statusMsg{err: errors.New("boom")})
+ if a.statusEvery != 4*statusPoll {
+ t.Fatalf("two failures must back off to %v, got %v", 4*statusPoll, a.statusEvery)
+ }
+ for range 10 {
+ a.update(statusMsg{err: errors.New("boom")})
+ }
+ if a.statusEvery != maxPoll {
+ t.Fatalf("backoff must cap at %v, got %v", maxPoll, a.statusEvery)
+ }
+ a.update(statusMsg{report: &api.StatusReport{State: "connected"}})
+ if a.statusEvery != statusPoll {
+ t.Fatalf("a good poll must reset the cadence, got %v", a.statusEvery)
+ }
+}
+
+func TestCommaGrouping(t *testing.T) {
+ for in, want := range map[int]string{0: "0", 12: "12", 999: "999", 1000: "1,000", 2500: "2,500", 1234567: "1,234,567"} {
+ if got := comma(in); got != want {
+ t.Fatalf("comma(%d) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+// findRow returns the first rendered line mentioning label, for asserting on
+// one panel row without pinning the whole layout.
+func findRow(view, label string) string {
+ for _, line := range strings.Split(view, "\n") {
+ if strings.Contains(line, label) {
+ return line
+ }
+ }
+ return ""
+}
+
+// railStub answers the four sources fetchRail polls. Any handler left nil
+// answers 500, so a test only wires the endpoints its scenario cares about.
+func railStub(health, plugins, sessions, audit http.HandlerFunc) *httptest.Server {
+ mux := http.NewServeMux()
+ fail := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) }
+ for path, h := range map[string]http.HandlerFunc{
+ "/admin/health": health, "/admin/plugins": plugins,
+ "/admin/sessions": sessions, "/admin/audit": audit,
+ } {
+ if h == nil {
+ h = fail
+ }
+ mux.HandleFunc(path, h)
+ }
+ return httptest.NewServer(mux)
+}
+
+// A gateway poll fans out to four independent sources, and one of them can
+// fail while the other three answer fine. fetchRail must not let that one
+// failure blank the whole rail: a source that errors THIS round keeps prev's
+// last good value for it, while a source that succeeds still refreshes.
+func TestFetchRailKeepsLastGoodDataWhenOneSourceFails(t *testing.T) {
+ srv := railStub(nil, // /admin/health fails — left unwired, answers 500
+ func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `[{"name":"rate-limit","type":"ratelimit","enabled":true}]`)
+ },
+ func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"data":[{"id":"s1"}]}`)
+ },
+ func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"data":[],"summary":{"total_entries":3,"returned_entries":0}}`)
+ })
+ t.Cleanup(srv.Close)
+ c, err := api.New(srv.URL, "k")
+ if err != nil {
+ t.Fatalf("client: %v", err)
+ }
+
+ prev := RailData{
+ Providers: []api.AdminProviderHealth{{Name: "anthropic", Status: "healthy", Models: 412}},
+ MCPReady: 2, MCPTotal: 2,
+ }
+ msg, ok := fetchRail(c, 0, prev)().(railMsg)
+ if !ok {
+ t.Fatal("fetchRail must answer a railMsg")
+ }
+
+ if msg.err == nil {
+ t.Fatal("the failing source must still be reported")
+ }
+ if len(msg.data.Providers) != 1 || msg.data.Providers[0].Name != "anthropic" {
+ t.Fatalf("a failed health poll must keep the last known providers, got %+v", msg.data.Providers)
+ }
+ if msg.data.MCPTotal != 2 || msg.data.MCPReady != 2 {
+ t.Fatalf("a failed health poll must keep the last known MCP counts, got %d/%d ready", msg.data.MCPReady, msg.data.MCPTotal)
+ }
+ if msg.data.PluginsActive != 1 {
+ t.Fatalf("a source that succeeded this round must still refresh, got plugins=%d", msg.data.PluginsActive)
+ }
+ if msg.data.Sessions != 1 {
+ t.Fatalf("a source that succeeded this round must still refresh, got sessions=%d", msg.data.Sessions)
+ }
+ if msg.data.AuditToday != 3 {
+ t.Fatalf("a source that succeeded this round must still refresh, got audit=%d", msg.data.AuditToday)
+ }
+}
+
+// AuthError is a verdict about the round just polled, not a sticky flag: a
+// credential fixed after a 401 must clear it, or the rail would keep telling
+// the operator to re-authenticate after they already have.
+func TestFetchRailClearsAuthErrorOnceTheCredentialWorks(t *testing.T) {
+ answer := func(body string) http.HandlerFunc {
+ return func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, body) }
+ }
+ srv := railStub(
+ answer(`{"status":"ok","providers":[]}`),
+ answer(`[]`),
+ answer(`{"data":[]}`),
+ answer(`{"data":[],"summary":{"total_entries":0,"returned_entries":0}}`),
+ )
+ t.Cleanup(srv.Close)
+ c, err := api.New(srv.URL, "k")
+ if err != nil {
+ t.Fatalf("client: %v", err)
+ }
+
+ msg, ok := fetchRail(c, 0, RailData{AuthError: true})().(railMsg)
+ if !ok {
+ t.Fatal("fetchRail must answer a railMsg")
+ }
+ if msg.data.AuthError {
+ t.Fatal("a since-fixed credential must clear AuthError, not carry it forward forever")
+ }
+}
+
+// Every counter fetchRail derives by walking a list must be recomputed from
+// the round it is walking, not added to the round before it. d starts as prev
+// so a failed source keeps its last good value — which turns any bare ++ into
+// a running total. Feeding a poll's own answer back in as prev is the only
+// shape that catches it: one poll in isolation always looks right.
+func TestFetchRailCountersDoNotAccumulateAcrossPolls(t *testing.T) {
+ srv := railStub(
+ func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"status":"healthy","providers":[{"name":"openai","status":"healthy","models":7}],`+
+ `"mcp_servers":[{"name":"fs","ready":true},{"name":"search","ready":false}]}`)
+ },
+ func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `[{"name":"rate-limit","type":"ratelimit","enabled":true}]`)
+ },
+ func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"data":[{"id":"s1"}]}`)
+ },
+ func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"data":[],"summary":{"total_entries":3,"returned_entries":0}}`)
+ })
+ t.Cleanup(srv.Close)
+ c, err := api.New(srv.URL, "k")
+ if err != nil {
+ t.Fatalf("client: %v", err)
+ }
+
+ first, ok := fetchRail(c, 0, RailData{})().(railMsg)
+ if !ok {
+ t.Fatal("fetchRail must answer a railMsg")
+ }
+ if first.data.MCPTotal != 2 || first.data.MCPReady != 1 {
+ t.Fatalf("first poll must count the served list: total=%d ready=%d",
+ first.data.MCPTotal, first.data.MCPReady)
+ }
+
+ // The gateway has not changed, so neither may the counts.
+ second, ok := fetchRail(c, 0, first.data)().(railMsg)
+ if !ok {
+ t.Fatal("fetchRail must answer a railMsg")
+ }
+ if second.data.MCPTotal != 2 || second.data.MCPReady != 1 {
+ t.Fatalf("an unchanged gateway must report unchanged counts, got total=%d ready=%d",
+ second.data.MCPTotal, second.data.MCPReady)
+ }
+ if second.data.PluginsActive != first.data.PluginsActive {
+ t.Fatalf("plugin count drifted across polls: %d then %d",
+ first.data.PluginsActive, second.data.PluginsActive)
+ }
+}
diff --git a/internal/tui/composer.go b/internal/tui/composer.go
new file mode 100644
index 0000000..e4001b3
--- /dev/null
+++ b/internal/tui/composer.go
@@ -0,0 +1,389 @@
+package tui
+
+import (
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/spf13/cobra"
+)
+
+// The composer is the console's only navigation surface: there is no menu and
+// no key-per-screen. Everything an operator can do here is a verb they could
+// also have typed at a shell, which is why the vocabulary is walked out of the
+// real cobra tree rather than listed a second time in this file.
+
+const (
+ // historyMax is the recall depth. A ring, not a log: the composer is a
+ // navigation aid, and a shell already keeps the durable history.
+ historyMax = 30
+
+ // suggestMax caps the suggestion row at what fits on one line without
+ // pushing the pane around.
+ suggestMax = 6
+
+ // cobraCompletion is the shell-completion command cobra generates. The
+ // console skips it for the same reason it skips help: it cannot install a
+ // script into the shell that launched it.
+ cobraCompletion = "completion"
+)
+
+// The console's keyboard vocabulary, spelled exactly as tea.KeyPressMsg.String()
+// spells it. The composer, the modal and every screen's handleKey are keyed on
+// these strings rather than on tea key types — that is what makes them testable
+// — so a mistyped literal is a binding that silently never fires and no test
+// that drives the same typo can see it. Naming them once turns that into a
+// compile error. The test suites deliberately keep typing the raw strings: they
+// are the independent check that these values are what the terminal sends.
+const (
+ keyEnter = "enter"
+ keyEsc = "esc"
+ keyUp = "up"
+ keyDown = "down"
+ keyLeft = "left"
+ keyRight = "right"
+ keyTab = "tab"
+ keySpace = "space"
+ keyBackspace = "backspace"
+ keyCtrlC = "ctrl+c"
+ keyCtrlR = "ctrl+r"
+ keyCtrlU = "ctrl+u"
+)
+
+// tuiVerbs exist only in the console — there is no `ferro clear` to script.
+var tuiVerbs = []string{verbPlayground, verbModel, verbClear, verbHelp}
+
+// Composer is the command line: input, completion, bounded history, and
+// reverse search. It holds no styles; every rendering decision reads the App's
+// theme at draw time, so a mode switch needs no rebuild.
+type Composer struct {
+ Input string
+ History []string // newest first
+
+ verbs []string
+
+ // histIdx is -1 for the live input, which live holds while a walk is in
+ // progress. Losing a half-typed line to an accidental ↑ is the one thing
+ // history recall must never do.
+ histIdx int
+ live string
+
+ searchMode bool
+ searchTerm string
+}
+
+func newComposer(verbs []string) Composer {
+ return Composer{verbs: verbs, histIdx: -1}
+}
+
+// Verbs is the console's vocabulary: every path in the cobra tree, plus the
+// three verbs that exist only here, plus a slash alias per top-level verb.
+//
+// It takes the root rather than building one so internal/tui imports no CLI
+// package: internal/command is what hands bare `ferro` to Run, and an import
+// back the other way would be a cycle.
+func Verbs(root *cobra.Command) []string {
+ verbs := []string{}
+ var walk func(prefix string, cmd *cobra.Command)
+ walk = func(prefix string, cmd *cobra.Command) {
+ for _, sub := range cmd.Commands() {
+ name := sub.Name()
+ // help and completion are shell plumbing, not operations: the
+ // console prints its own verb tree and cannot install a completion
+ // script into the shell that launched it.
+ if name == verbHelp || name == cobraCompletion || sub.Hidden {
+ continue
+ }
+ path := strings.TrimSpace(prefix + " " + name)
+ verbs = append(verbs, path)
+ walk(path, sub)
+ }
+ }
+ if root != nil {
+ walk("", root)
+ }
+ verbs = append(verbs, tuiVerbs...)
+
+ // Slash aliases are top-level only: /keys is an action, "/keys create" is
+ // not a spelling anyone types.
+ aliases := make([]string, 0, len(verbs))
+ for _, v := range verbs {
+ if !strings.Contains(v, " ") {
+ aliases = append(aliases, "/"+v)
+ }
+ }
+ return append(verbs, aliases...)
+}
+
+// handle is keyed on the key's string form, which is what makes the composer
+// testable: constructing tea.KeyPressMsg values is awkward and the mapping from
+// one to the other is untested glue either way.
+func (c *Composer) handle(key string, a *App) tea.Cmd {
+ if c.searchMode {
+ return c.handleSearch(key)
+ }
+ switch key {
+ case keyEnter:
+ return c.submit(a)
+ case keyTab:
+ if m := c.match(); m != "" {
+ c.Input = m
+ }
+ return nil
+ case keyUp:
+ c.walk(1)
+ return nil
+ case keyDown:
+ c.walk(-1)
+ return nil
+ case keyCtrlR:
+ c.searchMode, c.searchTerm = true, ""
+ return nil
+ case keyEsc:
+ a.Screen = ScreenHome
+ return nil
+ case keyBackspace:
+ c.Input = trimLastRune(c.Input)
+ return nil
+ case keyCtrlU:
+ c.Input = ""
+ return nil
+ case keySpace:
+ c.Input += " "
+ return nil
+ case "?":
+ // Only on an empty line: mid-line it is a character, and `logs ?` must
+ // not silently become the help screen.
+ if strings.TrimSpace(c.Input) == "" {
+ return emit(runCmdMsg{raw: verbHelp})
+ }
+ }
+ if r, ok := printable(key); ok {
+ c.Input += string(r)
+ }
+ return nil
+}
+
+func (c *Composer) handleSearch(key string) tea.Cmd {
+ switch key {
+ case keyEnter:
+ if m := c.searchMatch(); m != "" {
+ c.Input = m
+ }
+ c.searchMode, c.searchTerm = false, ""
+ return nil
+ case keyEsc, keyCtrlC:
+ // Abandoning a search leaves the line exactly as it was found.
+ c.searchMode, c.searchTerm = false, ""
+ return nil
+ case keyBackspace:
+ c.searchTerm = trimLastRune(c.searchTerm)
+ return nil
+ case keySpace:
+ c.searchTerm += " "
+ return nil
+ }
+ if r, ok := printable(key); ok {
+ c.searchTerm += string(r)
+ }
+ return nil
+}
+
+// submit runs the line. A blank line is a no-op rather than an error: pressing
+// enter on an empty composer is how people check that the console is alive.
+func (c *Composer) submit(a *App) tea.Cmd {
+ input := strings.TrimSpace(c.Input)
+ // EVERY leading slash goes, not one. The sanitisation below reads the head
+ // of raw to decide what may be retained, so a line that keeps a slash on its
+ // head names no verb, matches nothing, and is recorded whole — which for
+ // //chat is the prompt, in full, in history and reverse search.
+ raw := strings.TrimLeft(input, "/")
+ if raw == "" {
+ return nil
+ }
+ // Prompts can contain credentials or customer data. Keep command recall, but
+ // never retain prompt content in history or reverse search.
+ record := a.Screen != ScreenPlayground
+ if a.Screen == ScreenPlayground && strings.HasPrefix(input, "/") {
+ fields := strings.Fields(raw)
+ record = len(fields) > 0 && knownVerb(c.verbs, strings.ToLower(fields[0]))
+ }
+ if record {
+ history := raw
+ fields := strings.Fields(raw)
+ if len(fields) > 1 {
+ verb := strings.ToLower(fields[0])
+ if verb == verbChat || verb == verbPlayground {
+ history = verb
+ }
+ }
+ c.record(history)
+ }
+ c.Input, c.histIdx, c.live = "", -1, ""
+ return emit(runCmdMsg{raw: raw})
+}
+
+// record pushes onto the ring, newest first. Consecutive duplicates collapse:
+// running `status` four times while watching a rollout should not cost four
+// entries of recall.
+func (c *Composer) record(raw string) {
+ if len(c.History) > 0 && c.History[0] == raw {
+ return
+ }
+ next := make([]string, 0, min(len(c.History)+1, historyMax))
+ next = append(next, raw)
+ next = append(next, c.History...)
+ if len(next) > historyMax {
+ next = next[:historyMax]
+ }
+ c.History = next
+}
+
+// walk moves d steps toward older history (+1) or back toward the live input
+// (-1), stopping at both ends rather than wrapping.
+func (c *Composer) walk(d int) {
+ i := c.histIdx + d
+ if i < -1 || i >= len(c.History) {
+ return
+ }
+ if c.histIdx == -1 {
+ c.live = c.Input
+ }
+ c.histIdx = i
+ if i == -1 {
+ c.Input = c.live
+ return
+ }
+ c.Input = c.History[i]
+}
+
+// query is the completion prefix: trimmed, and with the slash alias folded
+// away so /log and log complete identically.
+func (c *Composer) query() string {
+ return strings.TrimPrefix(strings.TrimSpace(c.Input), "/")
+}
+
+// match is the verb tab completes to, and the one the ghost hint names.
+func (c *Composer) match() string {
+ q := c.query()
+ if q == "" {
+ return ""
+ }
+ for _, v := range c.verbs {
+ if v != q && strings.HasPrefix(v, q) {
+ return v
+ }
+ }
+ return ""
+}
+
+// ghost is the first matching verb, or "" when the line already names one.
+func (c *Composer) ghost() string { return c.match() }
+
+func (c *Composer) suggestions() []string {
+ q := c.query()
+ if q == "" {
+ return nil
+ }
+ out := make([]string, 0, suggestMax)
+ for _, v := range c.verbs {
+ if v == q || !strings.HasPrefix(v, q) {
+ continue
+ }
+ out = append(out, v)
+ if len(out) == suggestMax {
+ break
+ }
+ }
+ return out
+}
+
+func (c *Composer) searchMatch() string {
+ if c.searchTerm == "" {
+ return ""
+ }
+ for _, h := range c.History {
+ if strings.Contains(h, c.searchTerm) {
+ return h
+ }
+ }
+ return ""
+}
+
+// view draws the framed command line. Its height is 3 lines, or 5 when there
+// are suggestions to show; render() measures it rather than assuming, so the
+// pane above gives up the two lines instead of the header scrolling away.
+func (c *Composer) view(a *App) string {
+ inner := a.Width - 4
+ g := a.Theme.Mode.Glyphs()
+ prompt := a.Theme.Accent.Bold(a.Theme.Mode.Color).Render(g.Prompt)
+
+ var left, right string
+ switch {
+ case c.searchMode:
+ left = prompt + " " + a.Theme.Dim.Render("search:") + " " +
+ a.Theme.Bright.Render(c.searchTerm) + c.cursor(a)
+ if m := c.searchMatch(); m != "" {
+ right = a.Theme.Dim.Render(a.arrow() + " " + m)
+ } else if c.searchTerm != "" {
+ right = a.Theme.Dim.Render("no match")
+ }
+ case c.Input == "":
+ left = prompt + " " + c.cursor(a) +
+ a.Theme.Dim.Render(" Type a command, / for actions, or ? for help")
+ default:
+ left = prompt + " " + a.Theme.Bright.Render(c.Input) + c.cursor(a)
+ if m := c.ghost(); m != "" {
+ right = a.Theme.Faint.Render("tab " + a.arrow() + " " + m)
+ }
+ }
+
+ lines := []string{gapPad(inner, left, right)}
+ if s := c.suggestions(); len(s) > 0 {
+ lines = append(lines,
+ a.Theme.Hairline.Render(a.rule(max(inner, 0))),
+ a.Theme.Dim.Render(padRight(strings.Join(s, " "), inner)))
+ }
+ return a.Theme.Frame("COMMAND", strings.Join(lines, "\n"), a.Width)
+}
+
+// cursor is drawn rather than left to the terminal: the console renders one
+// frame at a time and a hardware cursor would sit wherever the last write did.
+func (c *Composer) cursor(a *App) string {
+ if a.Theme.Mode.ASCII {
+ return a.Theme.Accent.Render("_")
+ }
+ return a.Theme.Accent.Render("▌")
+}
+
+// ------------------------------------------------------------------ helpers
+
+func emit(msg tea.Msg) tea.Cmd { return func() tea.Msg { return msg } }
+
+// arrow is the "leads to" glyph the ghost hint and search preview use.
+func (a *App) arrow() string {
+ if a.Theme.Mode.ASCII {
+ return "->"
+ }
+ return "→"
+}
+
+// printable reports whether a key string is a single printable rune — the
+// only keys the composer types into the line. Named keys ("enter", "ctrl+r")
+// are multi-rune and fall through.
+func printable(key string) (rune, bool) {
+ r, size := utf8.DecodeRuneInString(key)
+ if size != len(key) || r == utf8.RuneError || !unicode.IsPrint(r) {
+ return 0, false
+ }
+ return r, true
+}
+
+func trimLastRune(s string) string {
+ if s == "" {
+ return ""
+ }
+ _, size := utf8.DecodeLastRuneInString(s)
+ return s[:len(s)-size]
+}
diff --git a/internal/tui/composer_test.go b/internal/tui/composer_test.go
new file mode 100644
index 0000000..289beef
--- /dev/null
+++ b/internal/tui/composer_test.go
@@ -0,0 +1,352 @@
+package tui
+
+import (
+ "slices"
+ "strings"
+ "testing"
+
+ "charm.land/lipgloss/v2"
+ "github.com/spf13/cobra"
+
+ "github.com/ferro-labs/gateway-cli/internal/tui/theme"
+)
+
+// fakeRoot mirrors the shape of the real cobra tree — a couple of leaf verbs, a
+// parent with children, and the two commands the walk must skip. The real tree
+// is asserted against in verbs_cobra_test.go, which lives in the external test
+// package so internal/tui never imports internal/command.
+func fakeRoot() *cobra.Command {
+ root := &cobra.Command{Use: "ferro"}
+ keys := &cobra.Command{Use: "keys"}
+ keys.AddCommand(&cobra.Command{Use: "create"}, &cobra.Command{Use: "rotate"}, &cobra.Command{Use: "revoke"})
+ logs := &cobra.Command{Use: "logs"}
+ logs.AddCommand(&cobra.Command{Use: "tail"}, &cobra.Command{Use: "stats"})
+ root.AddCommand(
+ &cobra.Command{Use: "status"},
+ &cobra.Command{Use: "providers"},
+ keys, logs,
+ &cobra.Command{Use: "completion"},
+ &cobra.Command{Use: "help"},
+ )
+ return root
+}
+
+// composerApp is an App whose composer speaks the fake tree's vocabulary.
+func composerApp() *App {
+ a := testApp(126, 40)
+ a.Composer = newComposer(Verbs(fakeRoot()))
+ return a
+}
+
+// run drives one key and returns the message its command produced, or nil.
+func run(t *testing.T, a *App, key string) any {
+ t.Helper()
+ cmd := a.Composer.handle(key, a)
+ if cmd == nil {
+ return nil
+ }
+ return cmd()
+}
+
+func typeIn(a *App, s string) {
+ for _, r := range s {
+ key := string(r)
+ if r == ' ' {
+ key = "space"
+ }
+ a.Composer.handle(key, a)
+ }
+}
+
+func TestComposerEnterEmitsAndRecords(t *testing.T) {
+ a := composerApp()
+ typeIn(a, "status")
+
+ msg, ok := run(t, a, "enter").(runCmdMsg)
+ if !ok || msg.raw != "status" {
+ t.Fatalf("enter must emit runCmdMsg{status}, got %#v", msg)
+ }
+ if a.Composer.Input != "" {
+ t.Fatalf("enter must clear the input, got %q", a.Composer.Input)
+ }
+ if len(a.Composer.History) != 1 || a.Composer.History[0] != "status" {
+ t.Fatalf("enter must record history, got %v", a.Composer.History)
+ }
+}
+
+func TestComposerDoesNotRetainPlaygroundPrompts(t *testing.T) {
+ a := composerApp()
+ a.Screen = ScreenPlayground
+ typeIn(a, "SENTINEL_SECRET_DO_NOT_STORE")
+
+ msg, ok := run(t, a, "enter").(runCmdMsg)
+ if !ok || msg.raw != "SENTINEL_SECRET_DO_NOT_STORE" {
+ t.Fatalf("the prompt must still be submitted, got %#v", msg)
+ }
+ if len(a.Composer.History) != 0 {
+ t.Fatalf("playground prompts must not enter command history: %v", a.Composer.History)
+ }
+}
+
+func TestComposerDoesNotRetainUnknownSlashPrompts(t *testing.T) {
+ a := composerApp()
+ a.Screen = ScreenPlayground
+ a.Composer.Input = "/customer-secret please analyze"
+ msg, ok := run(t, a, "enter").(runCmdMsg)
+ if !ok || msg.raw != "customer-secret please analyze" {
+ t.Fatalf("unknown slash input remains a prompt: %#v", msg)
+ }
+ if len(a.Composer.History) != 0 {
+ t.Fatalf("unknown slash prompts can carry private data and must not be retained: %v", a.Composer.History)
+ }
+
+ a.Composer.Input = "/model gpt-4o"
+ run(t, a, "enter")
+ if len(a.Composer.History) != 1 || a.Composer.History[0] != "model gpt-4o" {
+ t.Fatalf("known slash actions should remain recallable: %v", a.Composer.History)
+ }
+}
+
+func TestComposerSanitizesInlineChatHistory(t *testing.T) {
+ for _, input := range []string{
+ "chat SENTINEL_SECRET_DO_NOT_STORE",
+ "CHAT SENTINEL_SECRET_DO_NOT_STORE",
+ "PLAYGROUND SENTINEL_SECRET_DO_NOT_STORE",
+ "//chat SENTINEL_SECRET_DO_NOT_STORE",
+ } {
+ t.Run(input, func(t *testing.T) {
+ a := composerApp()
+ a.Composer.Input = input
+ run(t, a, "enter")
+ if got := a.Composer.History; len(got) != 1 ||
+ (got[0] != "chat" && got[0] != "playground") {
+ t.Fatalf("history may retain the verb but not its prompt: %v", got)
+ }
+ if strings.Contains(strings.Join(a.Composer.History, " "), "SENTINEL") {
+ t.Fatalf("history retained prompt content: %v", a.Composer.History)
+ }
+ })
+ }
+}
+
+func TestComposerEmptyEnterIsNoop(t *testing.T) {
+ a := composerApp()
+ typeIn(a, " ")
+ if cmd := a.Composer.handle("enter", a); cmd != nil {
+ t.Fatal("a blank line must not run anything")
+ }
+ if len(a.Composer.History) != 0 {
+ t.Fatalf("a blank line must not enter history, got %v", a.Composer.History)
+ }
+}
+
+func TestComposerTabCompletesPrefix(t *testing.T) {
+ a := composerApp()
+ typeIn(a, "sta")
+ run(t, a, "tab")
+ if a.Composer.Input != "status" {
+ t.Fatalf("tab must complete to the first matching verb, got %q", a.Composer.Input)
+ }
+
+ // A prefix nothing matches leaves the line exactly as typed.
+ a.Composer.Input = "zzz"
+ run(t, a, "tab")
+ if a.Composer.Input != "zzz" {
+ t.Fatalf("tab with no match must not rewrite the line, got %q", a.Composer.Input)
+ }
+}
+
+func TestComposerHistoryWalk(t *testing.T) {
+ a := composerApp()
+ typeIn(a, "status")
+ run(t, a, "enter")
+ typeIn(a, "providers")
+ run(t, a, "enter")
+
+ // The live input must survive a walk into history and come back intact.
+ typeIn(a, "half typed")
+
+ run(t, a, "up")
+ if a.Composer.Input != "providers" {
+ t.Fatalf("first up must recall the newest command, got %q", a.Composer.Input)
+ }
+ run(t, a, "up")
+ if a.Composer.Input != "status" {
+ t.Fatalf("second up must recall the older command, got %q", a.Composer.Input)
+ }
+ run(t, a, "up")
+ if a.Composer.Input != "status" {
+ t.Fatalf("up past the oldest entry must stay put, got %q", a.Composer.Input)
+ }
+ run(t, a, "down")
+ if a.Composer.Input != "providers" {
+ t.Fatalf("down must walk back toward the live input, got %q", a.Composer.Input)
+ }
+ run(t, a, "down")
+ if a.Composer.Input != "half typed" {
+ t.Fatalf("down off the end must restore the live input, got %q", a.Composer.Input)
+ }
+}
+
+func TestComposerHistoryBounded(t *testing.T) {
+ a := composerApp()
+ for i := range 35 {
+ a.Composer.Input = string(rune('a'+i%26)) + strings.Repeat("x", i)
+ run(t, a, "enter")
+ }
+ if len(a.Composer.History) != historyMax {
+ t.Fatalf("history must be bounded at %d, got %d", historyMax, len(a.Composer.History))
+ }
+ if a.Composer.History[0] != "i"+strings.Repeat("x", 34) {
+ t.Fatalf("history must be newest first, got %q", a.Composer.History[0])
+ }
+}
+
+func TestComposerHistoryDedupesConsecutive(t *testing.T) {
+ a := composerApp()
+ for range 3 {
+ a.Composer.Input = "status"
+ run(t, a, "enter")
+ }
+ a.Composer.Input = "providers"
+ run(t, a, "enter")
+ a.Composer.Input = "status"
+ run(t, a, "enter")
+
+ if got := a.Composer.History; len(got) != 3 {
+ t.Fatalf("consecutive duplicates must collapse, got %v", got)
+ }
+}
+
+func TestComposerReverseSearch(t *testing.T) {
+ a := composerApp()
+ typeIn(a, "status")
+ run(t, a, "enter")
+ typeIn(a, "providers")
+ run(t, a, "enter")
+
+ run(t, a, "ctrl+r")
+ if !a.Composer.searchMode {
+ t.Fatal("ctrl+r must enter reverse search")
+ }
+ typeIn(a, "sta")
+ if got := a.Composer.searchMatch(); got != "status" {
+ t.Fatalf("search must match history, got %q", got)
+ }
+ if v := a.Composer.view(a); !strings.Contains(v, "status") {
+ t.Fatalf("search mode must show the match:\n%s", v)
+ }
+ run(t, a, "enter")
+ if a.Composer.searchMode || a.Composer.Input != "status" {
+ t.Fatalf("enter must accept the match and leave search (mode=%v input=%q)", a.Composer.searchMode, a.Composer.Input)
+ }
+
+ // Esc abandons the search without touching the line.
+ a.Composer.Input = "kept"
+ run(t, a, "ctrl+r")
+ typeIn(a, "pro")
+ run(t, a, "esc")
+ if a.Composer.searchMode || a.Composer.Input != "kept" {
+ t.Fatalf("esc must abandon search intact (mode=%v input=%q)", a.Composer.searchMode, a.Composer.Input)
+ }
+}
+
+func TestComposerSlashAlias(t *testing.T) {
+ a := composerApp()
+ typeIn(a, "/logs")
+ msg, ok := run(t, a, "enter").(runCmdMsg)
+ if !ok || msg.raw != "logs" {
+ t.Fatalf("a leading / must be stripped, got %#v", msg)
+ }
+}
+
+func TestComposerQuestionMarkOnEmptyInputAsksForHelp(t *testing.T) {
+ a := composerApp()
+ msg, ok := run(t, a, "?").(runCmdMsg)
+ if !ok || msg.raw != "help" {
+ t.Fatalf("? on an empty line must run help, got %#v", msg)
+ }
+ if a.Composer.Input != "" {
+ t.Fatalf("? must not be typed into the line, got %q", a.Composer.Input)
+ }
+
+ // With a line in progress it is just a character.
+ typeIn(a, "logs ?")
+ if a.Composer.Input != "logs ?" {
+ t.Fatalf("? mid-line is a character, got %q", a.Composer.Input)
+ }
+}
+
+func TestComposerBackspaceAndEscape(t *testing.T) {
+ a := composerApp()
+ typeIn(a, "abc")
+ run(t, a, "backspace")
+ if a.Composer.Input != "ab" {
+ t.Fatalf("backspace must delete one rune, got %q", a.Composer.Input)
+ }
+ a.Screen = ScreenLogs
+ run(t, a, "esc")
+ if a.Screen != ScreenHome {
+ t.Fatal("esc outside search must return to home")
+ }
+}
+
+func TestComposerGhostAndSuggestions(t *testing.T) {
+ a := composerApp()
+ typeIn(a, "k")
+ if got := a.Composer.ghost(); got != "keys" {
+ t.Fatalf("ghost must name the first matching verb, got %q", got)
+ }
+ if s := a.Composer.suggestions(); len(s) == 0 || !slices.Contains(s, "keys create") {
+ t.Fatalf("suggestions must include the subcommands, got %v", s)
+ }
+ if s := a.Composer.suggestions(); len(s) > suggestMax {
+ t.Fatalf("suggestions are capped at %d, got %d", suggestMax, len(s))
+ }
+ v := a.Composer.view(a)
+ if !strings.Contains(v, "tab") || !strings.Contains(v, "keys") {
+ t.Fatalf("the composer must render the ghost hint:\n%s", v)
+ }
+
+ a.Composer.Input = ""
+ if a.Composer.ghost() != "" || len(a.Composer.suggestions()) != 0 {
+ t.Fatal("an empty line offers no ghost and no suggestions")
+ }
+}
+
+func TestVerbsWalksTheTree(t *testing.T) {
+ v := Verbs(fakeRoot())
+ for _, want := range []string{"status", "keys", "keys create", "logs tail", "playground", "clear", "help", "/providers"} {
+ if !slices.Contains(v, want) {
+ t.Fatalf("Verbs must contain %q, got %v", want, v)
+ }
+ }
+ for _, unwanted := range []string{"completion", "/keys create"} {
+ if slices.Contains(v, unwanted) {
+ t.Fatalf("Verbs must not contain %q, got %v", unwanted, v)
+ }
+ }
+ // A nil tree still yields the TUI-only vocabulary rather than panicking.
+ if got := Verbs(nil); !slices.Contains(got, "clear") {
+ t.Fatalf("Verbs(nil) must still carry the TUI verbs, got %v", got)
+ }
+}
+
+// The composer sits inside a frame; a line wider than the terminal wraps and
+// destroys every frame above it.
+func TestComposerViewFitsEveryWidth(t *testing.T) {
+ for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} {
+ for _, w := range []int{40, 72, 96, 126} {
+ a := composerApp()
+ a.Theme, a.Width = theme.New(mode), w
+ typeIn(a, "k")
+ for _, view := range []string{a.Composer.view(a), a.render()} {
+ for i, line := range strings.Split(view, "\n") {
+ if lw := lipgloss.Width(line); lw > w {
+ t.Fatalf("mode=%+v w=%d line %d overflows: %d cells %q", mode, w, i, lw, line)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/internal/tui/home.go b/internal/tui/home.go
new file mode 100644
index 0000000..982ca90
--- /dev/null
+++ b/internal/tui/home.go
@@ -0,0 +1,370 @@
+package tui
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/table"
+ "github.com/ferro-labs/gateway-cli/internal/version"
+)
+
+// This file is the home screen and the command router — the two halves of
+// "typing a verb does something". The router is the console's whole navigation
+// model, so it reads as one table on purpose.
+//
+// Nothing here performs I/O. A verb that needs the gateway returns the tea.Cmd
+// from msgs.go and its rows arrive later as a verbRowsMsg.
+
+// The verbs more than one place has to agree on. A fetched verb is named twice
+// — route() dispatches it and verbRows() answers it — and a spelling that
+// matches in one but not the other is an "unknown command" or a "has no
+// fetcher" that neither file can see on its own. The console-only verbs are
+// named for the same reason: the composer offers them (tuiVerbs), the router
+// runs them, and the playground has to recognise them to tell a command from a
+// prompt. `services` is handled in exactly one place and stays a literal there;
+// there is nothing for it to drift from.
+const (
+ verbHelp = "help"
+ verbClear = "clear"
+ verbVersion = "version"
+ verbModel = "model"
+ verbChat = "chat"
+ verbPlayground = "playground"
+
+ verbStatus = "status"
+ verbModels = "models"
+ verbProviders = "providers"
+ verbMCP = "mcp"
+ verbPlugins = "plugins"
+ verbSessions = "sessions"
+ verbAudit = "audit"
+
+ verbLogs = "logs"
+ verbKeys = "keys"
+)
+
+// Column headings more than one table draws. The same fact is spelled the same
+// way everywhere — NAME is the object's own name, STATE the condition, PROVIDER
+// the target that served it — so an operator who has read one table can read the
+// next. A heading only one table has stays a literal at its call site.
+const (
+ colName = "NAME"
+ colState = "STATE"
+ colProvider = "PROVIDER"
+)
+
+// homeView is the transcript, padded to exactly rows lines.
+func (a *App) homeView(w, rows int) string {
+ if a.Transcript.Len() == 0 {
+ return fill([]string{"", a.Theme.Dim.Render("No output yet. Type a command below, or ? for help.")}, w, rows)
+ }
+ return a.Transcript.view(a.Theme, w, rows)
+}
+
+// route is the dispatch table. Every branch either renders into the transcript
+// or switches screens; none of them touches the network directly.
+func (a *App) route(raw string) tea.Cmd {
+ line := strings.TrimSpace(raw)
+ if line == "" {
+ return nil
+ }
+ // A slash action is the same verb: /logs and logs are one command. Only the
+ // verb is case-folded — an argument like --model Claude-* is a glob, and
+ // lowercasing it would quietly change which rows match.
+ line = strings.TrimPrefix(line, "/")
+ fields := strings.Fields(line)
+ if len(fields) == 0 {
+ return nil
+ }
+ head := strings.ToLower(fields[0])
+ rest := strings.Join(fields[1:], " ")
+
+ // The playground's seam. On that screen a line that does not name a verb is
+ // a prompt, not a mistyped command — and the test cannot key on the slash,
+ // which the composer strips before this function is ever reached. It sits
+ // here, ahead of the echo, because a prompt is not a command and must not be
+ // written into the transcript as one. `line` is still exactly what was
+ // typed, case and all, which is what a prompt needs.
+ if a.Screen == ScreenPlayground {
+ if cmd, claimed := a.Play.route(a, head, rest, line); claimed {
+ return cmd
+ }
+ }
+
+ line = strings.TrimSpace(head + " " + rest)
+
+ if head == verbClear {
+ a.Transcript.Reset()
+ a.Screen = ScreenHome
+ return nil
+ }
+
+ echo := line
+ if rest != "" && (head == verbChat || head == verbPlayground) {
+ echo = head
+ }
+ a.Transcript.Push(TranscriptRow{Glyph: kindCmd, Text: "ferro " + echo})
+
+ switch head {
+ case verbHelp, "?":
+ a.Screen = ScreenHome
+ a.Transcript.Push(helpRows(a.Composer.verbs)...)
+ return nil
+
+ case verbVersion:
+ a.Screen = ScreenHome
+ a.Transcript.Push(TranscriptRow{Glyph: kindOK, Text: "ferro " + version.String()})
+ return nil
+
+ // Screens own their own data. The router only moves to them; Tasks 14–16
+ // wire the fetch, so starting one here would be output with nowhere to go.
+ case verbLogs:
+ return a.Logs.start(a, rest)
+ case verbKeys:
+ return a.Keys.enter(a, rest)
+ case verbPlayground, verbChat:
+ return a.Play.enter(a, rest)
+ case "services":
+ a.Screen = ScreenHome
+ for _, line := range strings.Split(a.viewServices(), "\n") {
+ a.Transcript.Push(TranscriptRow{Text: line})
+ }
+ return nil
+
+ case verbStatus, verbModels, verbProviders, verbMCP, verbPlugins, verbSessions, verbAudit:
+ a.Screen = ScreenHome
+ return verbFetch(a.Client, head)
+ }
+
+ a.Screen = ScreenHome
+ a.Transcript.Push(TranscriptRow{
+ Glyph: kindBad,
+ Text: fmt.Sprintf("unknown command %q — press ? for the verb tree", head),
+ })
+ return nil
+}
+
+// helpRows prints the verb tree from the composer's own vocabulary, which came
+// from the cobra tree. Hand-listing it here is how a help screen starts naming
+// verbs that no longer exist.
+func helpRows(verbs []string) []TranscriptRow {
+ var top, nested, slash []string
+ for _, v := range verbs {
+ switch {
+ case strings.HasPrefix(v, "/"):
+ slash = append(slash, v)
+ case strings.Contains(v, " "):
+ nested = append(nested, v)
+ default:
+ top = append(top, v)
+ }
+ }
+ rows := []TranscriptRow{{Glyph: kindNote, Text: strings.Join(top, " ")}}
+ if len(nested) > 0 {
+ rows = append(rows, TranscriptRow{Text: strings.Join(nested, " "), Dim: true})
+ }
+ return append(rows,
+ TranscriptRow{Text: "slash actions: " + strings.Join(slash, " "), Dim: true},
+ TranscriptRow{Text: "every verb has a scriptable twin — run it with --format json for a pipe", Dim: true},
+ )
+}
+
+// ------------------------------------------------------------ verb rendering
+
+// verbRows fetches one verb and renders it as transcript rows. It is called
+// from the tea.Cmd in msgs.go, never from a view func.
+func verbRows(ctx context.Context, c *api.Client, verb string) ([]TranscriptRow, error) {
+ switch verb {
+ case verbStatus:
+ // Status returns a report even when it fails, naming the URL it tried;
+ // rendering it is what makes state:"unreachable" visible.
+ r, err := c.Status(ctx)
+ if r == nil {
+ return nil, err
+ }
+ mcp := "-"
+ if r.MCP != nil {
+ mcp = fmt.Sprintf("%d/%d", r.MCP.Ready, r.MCP.Total)
+ }
+ // Targets is nil when the gateway did not report them — a 503 /readyz
+ // carrying only {status, reason}. "0/0" there would claim none are
+ // configured when the truth is that they are dead.
+ targets := "-"
+ if t := r.Targets; t != nil {
+ targets = fmt.Sprintf("%d/%d", t.Routable, t.Total)
+ }
+ return tableRows(
+ []string{colState, "URL", "LATENCY", "TARGETS", "PROVIDERS", "MODELS", "MCP", "AUTH"},
+ [][]string{{
+ r.State, r.URL, fmt.Sprintf("%dms", r.LatencyMs), targets,
+ table.CountOrDash(r.Providers), table.CountOrDash(r.Models), mcp, table.OrDash(r.Auth),
+ }}), err
+
+ case verbModels:
+ models, err := c.Models(ctx)
+ if err != nil {
+ return nil, err
+ }
+ cells := table.ModelRows(models)
+ return orEmpty(tableRows(table.ModelHeaders, cells),
+ len(cells), "this gateway routes no models"), nil
+
+ case verbProviders:
+ health, _, err := c.Health(ctx)
+ if err != nil {
+ return nil, err
+ }
+ // Best effort: an unusable credential costs two columns, not the verb.
+ admin, adminErr := c.AdminHealth(ctx)
+ if adminErr != nil {
+ admin = nil
+ }
+ // The merge, the five columns and the dash-for-absent rendering are
+ // internal/table's: `ferro providers` renders this same listing and the
+ // two used to disagree about a provider /admin/health omits.
+ cells := table.ProviderRows(table.MergeProviders(health, admin))
+ rows := tableRows(table.ProviderHeaders, cells)
+ if adminErr != nil {
+ // Name what actually happened. A refused credential and a timed-out
+ // or broken /admin/health cost the same two columns, but only one of
+ // them is fixed by looking at a credential.
+ why := "admin health unavailable"
+ if api.IsUnauthorized(adminErr) {
+ why = "no admin credential accepted"
+ }
+ rows = append(rows, TranscriptRow{Glyph: kindWarn,
+ Text: why + " — status and message come from /health only"})
+ }
+ return orEmpty(rows, len(cells), "no providers reported"), nil
+
+ case verbMCP:
+ servers, err := mcpServers(ctx, c)
+ if err != nil {
+ return nil, err
+ }
+ cells := make([][]string, 0, len(servers))
+ for _, s := range servers {
+ cells = append(cells, []string{s.Name, table.BoolYN(s.Ready), table.BoolYN(s.Required), table.OrDash(s.LastError)})
+ }
+ return orEmpty(tableRows([]string{colName, "READY", "REQUIRED", "LAST ERROR"}, cells),
+ len(cells), "this gateway has no MCP servers configured"), nil
+
+ case verbPlugins:
+ configured, err := c.Plugins(ctx)
+ if err != nil {
+ return nil, err
+ }
+ catalog, err := c.PluginCatalog(ctx)
+ if err != nil {
+ // A build without the catalog route still lists what it runs; it
+ // just cannot say what fails open.
+ if !api.IsNotSupported(err) {
+ return nil, err
+ }
+ catalog = nil
+ }
+ cells := pluginCells(configured, catalog)
+ return orEmpty(tableRows([]string{colName, "TYPE", "ENABLED", "FAILS", "SUMMARY"}, cells),
+ len(cells), "this gateway has no plugins configured"), nil
+
+ case verbSessions:
+ sessions, err := c.Sessions(ctx)
+ if err != nil {
+ return nil, err
+ }
+ cells := make([][]string, 0, len(sessions))
+ for _, s := range sessions {
+ cells = append(cells, []string{
+ s.Subject, strings.Join(s.Scopes, ","),
+ table.FmtTime(s.CreatedAt), table.FmtTimePtr(s.LastSeenAt), table.FmtTime(s.ExpiresAt),
+ })
+ }
+ return orEmpty(tableRows([]string{"SUBJECT", "SCOPES", "CREATED", "LAST SEEN", "EXPIRES"}, cells),
+ len(cells), "no active dashboard sessions"), nil
+
+ case verbAudit:
+ page, err := c.Audit(ctx, api.AuditQuery{Limit: auditRows})
+ if err != nil {
+ return nil, err
+ }
+ cells := make([][]string, 0, len(page.Data))
+ for _, e := range page.Data {
+ cells = append(cells, []string{
+ table.FmtTime(e.OccurredAt), e.Action, table.OrDash(e.Actor), e.Outcome, table.OrDash(e.TargetID),
+ })
+ }
+ return orEmpty(tableRows([]string{"TIME", "ACTION", "ACTOR", "OUTCOME", "TARGET"}, cells),
+ len(cells), "no audit entries in range"), nil
+ }
+ return nil, fmt.Errorf("verb %q has no fetcher", verb)
+}
+
+// mcpServers prefers /admin/health: it alone carries last_error, which /readyz
+// withholds because it is unauthenticated and the reason can quote a URL.
+func mcpServers(ctx context.Context, c *api.Client) ([]api.MCPServer, error) {
+ if ah, err := c.AdminHealth(ctx); err == nil {
+ return ah.MCPServers, nil
+ }
+ ready, _, err := c.Ready(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return ready.MCPServers, nil
+}
+
+// The two failure policies a catalog entry reports. Which one a plugin has is
+// the single most consequential thing on the plugins table — it is the
+// difference between a broken guardrail stopping traffic and waving it through
+// — so the column says it in the catalog's own words.
+// pluginCells merges what this gateway has configured with what this build
+// ships — only the catalog knows whether a plugin fails open, and the
+// scriptable table derives that cell from the same place.
+func pluginCells(configured []api.PluginInfo, catalog []api.BuiltinPlugin) [][]string {
+ shipped := table.NewPluginCatalog(catalog)
+ cells := make([][]string, 0, len(configured))
+ for _, p := range configured {
+ policy := shipped.For(p.Name)
+ cells = append(cells, []string{
+ p.Name, p.Type, table.BoolYN(p.Enabled), policy.Fails, table.OrDash(policy.Summary),
+ })
+ }
+ return cells
+}
+
+// ------------------------------------------------------------------ helpers
+
+// tableRows renders headers and cells through internal/table's shared layout
+// — the same one Printer.Table (internal/command/output.go) calls — so a verb
+// read in the console and the same verb read through a pipe align identically.
+// This is the only thing left here: the TranscriptRow wrapping, dimming the
+// header and its rule because they are furniture, leaving the data alone.
+func tableRows(headers []string, cells [][]string) []TranscriptRow {
+ lines := table.Rows(headers, cells)
+ rows := make([]TranscriptRow, 0, len(lines))
+ for i, line := range lines {
+ rows = append(rows, TranscriptRow{Text: line, Dim: i < 2})
+ }
+ return rows
+}
+
+// orEmpty replaces a table with nothing in it by a row that says so. An empty
+// gateway and a broken query look identical otherwise.
+func orEmpty(rows []TranscriptRow, n int, empty string) []TranscriptRow {
+ if n > 0 {
+ return rows
+ }
+ return []TranscriptRow{{Text: empty, Dim: true}}
+}
+
+// fill pads a body to exactly rows lines, truncating each to w cells.
+func fill(lines []string, w, rows int) string {
+ out := append(make([]string, 0, rows), clampLines(lines, w)...)
+ for len(out) < rows {
+ out = append(out, "")
+ }
+ return strings.Join(out[:max(rows, 0)], "\n")
+}
diff --git a/internal/tui/home_test.go b/internal/tui/home_test.go
new file mode 100644
index 0000000..21d8b65
--- /dev/null
+++ b/internal/tui/home_test.go
@@ -0,0 +1,472 @@
+package tui
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "charm.land/lipgloss/v2"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/table"
+ "github.com/ferro-labs/gateway-cli/internal/tui/theme"
+)
+
+// TableRows hands tableRows to the external test package. The layout is held
+// equal to internal/command's Printer.Table there (verbs_cobra_test.go),
+// because that assertion needs to import internal/command and this package
+// never may.
+var TableRows = tableRows
+
+// transcriptText flattens the transcript for assertions that do not care where
+// a row's text sits on screen.
+func transcriptText(a *App) string {
+ var b strings.Builder
+ for _, r := range a.Transcript.rows {
+ b.WriteString(r.Glyph + " " + r.Text + "\n")
+ }
+ return b.String()
+}
+
+func lastRow(a *App) TranscriptRow {
+ if n := len(a.Transcript.rows); n > 0 {
+ return a.Transcript.rows[n-1]
+ }
+ return TranscriptRow{}
+}
+
+func TestRouteEchoesEveryCommand(t *testing.T) {
+ a := composerApp()
+ a.route("status")
+ if got := a.Transcript.rows[0]; got.Glyph != "$" || got.Text != "ferro status" {
+ t.Fatalf("every command must be echoed as it would be run, got %#v", got)
+ }
+}
+
+func TestRouteUnknownVerb(t *testing.T) {
+ a := composerApp()
+ if cmd := a.route("frobnicate --hard"); cmd != nil {
+ t.Fatal("an unknown verb must not reach the network")
+ }
+ row := lastRow(a)
+ if row.Glyph != "bad" || !strings.Contains(row.Text, `"frobnicate"`) {
+ t.Fatalf("an unknown verb must be named in a red row, got %#v", row)
+ }
+ if !strings.Contains(row.Text, "?") {
+ t.Fatalf("the refusal must point at the verb tree, got %q", row.Text)
+ }
+ if a.Screen != ScreenHome {
+ t.Fatal("an unknown verb leaves the operator on the transcript")
+ }
+}
+
+func TestRouteHelpListsVerbs(t *testing.T) {
+ for _, raw := range []string{"help", "?"} {
+ a := composerApp()
+ a.route(raw)
+ text := transcriptText(a)
+ for _, want := range []string{"status", "logs", "playground", "keys", "keys create", "/providers"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("%q must list %q:\n%s", raw, want, text)
+ }
+ }
+ if !strings.Contains(text, "--format json") {
+ t.Fatalf("help must point at the scriptable twin:\n%s", text)
+ }
+ }
+}
+
+func TestRouteStatusEchoesAndFetches(t *testing.T) {
+ a := composerApp()
+ cmd := a.route("status")
+ if cmd == nil {
+ t.Fatal("status must return a fetch command")
+ }
+ if !strings.Contains(transcriptText(a), "ferro status") {
+ t.Fatalf("status must echo its scriptable form:\n%s", transcriptText(a))
+ }
+ if a.Screen != ScreenHome {
+ t.Fatal("status renders into the transcript, so it must land on home")
+ }
+ // The command must run without a client rather than panicking on nil.
+ msg, ok := cmd().(verbRowsMsg)
+ if !ok || msg.err == nil {
+ t.Fatalf("a verb fetched with no connection must report the failure, got %#v", msg)
+ }
+}
+
+func TestRouteSwitchesScreens(t *testing.T) {
+ for raw, want := range map[string]Screen{
+ "logs --model claude-*": ScreenLogs,
+ "logs": ScreenLogs,
+ "keys": ScreenKeys,
+ "keys create": ScreenKeys,
+ "playground": ScreenPlayground,
+ "chat": ScreenPlayground,
+ } {
+ a := composerApp()
+ a.route(raw)
+ if a.Screen != want {
+ t.Fatalf("%q must switch to screen %v, got %v", raw, want, a.Screen)
+ }
+ }
+}
+
+func TestRouteClearResetsTranscriptAndScreen(t *testing.T) {
+ a := composerApp()
+ a.route("status")
+ a.Screen = ScreenLogs
+ a.route("clear")
+ if a.Transcript.Len() != 0 {
+ t.Fatalf("clear must empty the transcript, got %d rows", a.Transcript.Len())
+ }
+ if a.Screen != ScreenHome {
+ t.Fatal("clear returns to the transcript")
+ }
+}
+
+func TestRouteStripsSlashAndIgnoresVerbCase(t *testing.T) {
+ a := composerApp()
+ a.route("/STATUS")
+ if !strings.Contains(transcriptText(a), "ferro status") {
+ t.Fatalf("a slash action is the same verb, lowercased:\n%s", transcriptText(a))
+ }
+ a = composerApp()
+ a.route(" ")
+ if a.Transcript.Len() != 0 {
+ t.Fatal("a blank line is not a command")
+ }
+ a.route("/")
+ if a.Transcript.Len() != 0 {
+ t.Fatal("a bare slash is not a command")
+ }
+}
+
+func TestInlineChatPromptIsNotEchoedToTranscript(t *testing.T) {
+ a := composerApp()
+ a.route("chat SENTINEL_SECRET_DO_NOT_STORE")
+ if strings.Contains(transcriptText(a), "SENTINEL_SECRET_DO_NOT_STORE") {
+ t.Fatalf("chat prompt leaked into transcript:\n%s", transcriptText(a))
+ }
+}
+
+// Arguments keep their case: a --model filter is a glob, not a verb.
+func TestRouteKeepsArgumentCase(t *testing.T) {
+ a := composerApp()
+ a.route("logs --model Claude-Sonnet")
+ if !strings.Contains(transcriptText(a), "Claude-Sonnet") {
+ t.Fatalf("arguments must survive verb normalisation:\n%s", transcriptText(a))
+ }
+}
+
+func TestVerbRowsLandInTranscript(t *testing.T) {
+ a := composerApp()
+ a.update(verbRowsMsg{
+ verb: "status",
+ rows: []TranscriptRow{{Glyph: "ok", Text: "STATE URL"}, {Glyph: "ok", Text: "connected http://x"}},
+ took: 41 * time.Millisecond,
+ })
+ text := transcriptText(a)
+ if !strings.Contains(text, "connected http://x") {
+ t.Fatalf("fetched rows must land in the transcript:\n%s", text)
+ }
+ if !strings.Contains(text, "Completed in 41ms") {
+ t.Fatalf("every async verb must report its duration:\n%s", text)
+ }
+ if !lastRow(a).Dim {
+ t.Fatal("the duration row is dim furniture, not output")
+ }
+}
+
+func TestVerbFailureAppendsARedRow(t *testing.T) {
+ a := composerApp()
+ err := &api.Error{Status: 401, Message: "missing credential"}
+ a.update(verbRowsMsg{verb: "keys", err: err, took: time.Millisecond})
+
+ var found bool
+ for _, r := range a.Transcript.rows {
+ if r.Glyph == "bad" && strings.Contains(r.Text, err.Error()) {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("a failed verb must carry the gateway's own error text:\n%s", transcriptText(a))
+ }
+}
+
+// The pane is the shell's anchor: exactly rows lines on every screen, so the
+// composer never moves.
+func TestPaneIsExactlyRowsLinesOnEveryScreen(t *testing.T) {
+ a := composerApp()
+ a.route("status")
+ for _, screen := range []Screen{ScreenHome, ScreenLogs, ScreenKeys, ScreenPlayground} {
+ a.Screen = screen
+ for _, rows := range []int{1, 5, 18} {
+ body := a.paneView(60, rows)
+ if got := len(strings.Split(body, "\n")); got != rows {
+ t.Fatalf("screen %v pane at rows=%d returned %d lines", screen, rows, got)
+ }
+ for i, line := range strings.Split(body, "\n") {
+ if lw := lipgloss.Width(line); lw > 60 {
+ t.Fatalf("screen %v pane line %d overflows: %d cells %q", screen, i, lw, line)
+ }
+ }
+ }
+ }
+}
+
+// Every screen now says something. An empty pane would read as "this gateway
+// has nothing to show", which is a claim none of them can support before their
+// first fetch lands.
+func TestEveryScreenSaysSomethingBeforeItsFirstFetch(t *testing.T) {
+ a := composerApp() // no client
+ for raw, want := range map[string]string{
+ // With nothing to ask, the pane names the reason rather than looking
+ // like a gateway with no logs and no keys.
+ "logs": errNoConnection.Error(),
+ "keys": errNoConnection.Error(),
+ // The playground needs no fetch to accept a prompt; the failure
+ // surfaces on the turn, where it can be read.
+ "playground": "Type a prompt",
+ } {
+ a.route(raw)
+ if body := a.paneView(70, 8); !strings.Contains(body, want) {
+ t.Fatalf("%q must say %q rather than render blank:\n%s", raw, want, body)
+ }
+ }
+
+ // And a real, genuinely empty listing says THAT, which is a different fact.
+ a = composerApp()
+ a.route("logs")
+ a.Logs.lastErr = nil
+ if body := a.paneView(70, 8); !strings.Contains(body, "no rows") {
+ t.Fatalf("an empty log window must say so:\n%s", body)
+ }
+ a.route("keys")
+ a.Keys.err = nil
+ if body := a.paneView(70, 8); !strings.Contains(body, "no API keys") {
+ t.Fatalf("a gateway with no keys must say so:\n%s", body)
+ }
+}
+
+// The whole shell, with a transcript in it, still fits every width and mode.
+func TestRenderWithTranscriptFitsEveryWidth(t *testing.T) {
+ for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} {
+ for _, w := range []int{72, 96, 126} {
+ a := composerApp()
+ a.Theme, a.Width = theme.New(mode), w
+ a.route("help")
+ a.update(verbRowsMsg{verb: "status", rows: []TranscriptRow{
+ {Glyph: "ok", Text: strings.Repeat("a very wide row ", 20)},
+ }, took: 12 * time.Millisecond})
+ for i, line := range strings.Split(a.render(), "\n") {
+ if lw := lipgloss.Width(line); lw > w {
+ t.Fatalf("mode=%+v w=%d line %d overflows: %d cells %q", mode, w, i, lw, line)
+ }
+ }
+ }
+ }
+}
+
+// stubGateway serves just enough of the admin API for the transcript verbs.
+func stubGateway(t *testing.T) *api.Client {
+ t.Helper()
+ mux := http.NewServeMux()
+ body := func(path, json string) {
+ mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, json)
+ })
+ }
+ body("/health", `{"status":"ok","providers":[
+ {"name":"openai","status":"healthy","circuit":"closed","models":1104},
+ {"name":"anthropic","status":"degraded","circuit":"half_open","models":412}]}`)
+ body("/readyz", `{"status":"ready","targets":[{"name":"openai","routable":true}],
+ "mcp_servers":[{"name":"filesystem","ready":true,"required":false}]}`)
+ body("/admin/health", `{"status":"ok","scopes":["admin"],"providers":[
+ {"name":"openai","status":"healthy","models":1104},
+ {"name":"anthropic","status":"degraded","models":412,"message":"rate limited upstream"}],
+ "mcp_servers":[{"name":"filesystem","ready":true,"required":true},
+ {"name":"search","ready":false,"required":false,"last_error":"handshake timeout"}]}`)
+ body("/v1/models", `{"object":"list","data":[
+ {"id":"claude-sonnet-4-6","owned_by":"anthropic","mode":"chat","context_window":200000,
+ "capabilities":["chat","tools"],"status":"available"}]}`)
+ body("/admin/plugins", `[{"name":"rate-limit","type":"ratelimit","enabled":true},
+ {"name":"request-logger","type":"logging","enabled":false}]`)
+ body("/admin/plugins/catalog", `{"data":[{"name":"rate-limit","type":"ratelimit","summary":"caps request rate","fails_open":false},
+ {"name":"request-logger","type":"logging","summary":"records requests","fails_open":true}]}`)
+ body("/admin/sessions", `{"data":[{"id":"s1","subject":"alice","scopes":["admin"],
+ "created_at":"2026-08-08T09:00:00Z","expires_at":"2026-08-09T09:00:00Z"}]}`)
+ body("/admin/audit", `{"data":[{"occurred_at":"2026-08-08T09:00:00Z","action":"key.create",
+ "actor":"alice","outcome":"ok","target_id":"ak_1"}],"summary":{"total_entries":1,"returned_entries":1}}`)
+
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ c, err := api.New(srv.URL, "test-key")
+ if err != nil {
+ t.Fatalf("client: %v", err)
+ }
+ return c
+}
+
+// mustLocalRFC3339 renders the UTC wire timestamp the sessions and audit
+// fixtures carry (stubGateway) the way verbRows actually renders it: through
+// fmtTime, in the reader's local time. Asserting this rather than a literal
+// RFC3339 string keeps the test honest about what the column shows without
+// hardcoding a timezone-dependent value — the repo convention is to assert
+// UTC for persisted/admin timestamps, but this cell is deliberately local.
+func mustLocalRFC3339(t *testing.T, utc string) string {
+ t.Helper()
+ parsed, err := time.Parse(time.RFC3339, utc)
+ if err != nil {
+ t.Fatalf("bad fixture timestamp %q: %v", utc, err)
+ }
+ return parsed.Local().Format(time.RFC3339)
+}
+
+// The transcript's verbs are the scriptable verbs: same sources, same columns,
+// same alignment. This is the test that fails when one of them drifts.
+func TestVerbRowsRenderEveryTranscriptVerb(t *testing.T) {
+ c := stubGateway(t)
+ // Both fixtures (stubGateway) carry this same UTC instant as their
+ // administrative timestamp; verbRows renders it through fmtTime, which
+ // converts to local time (home.go). Without this, a regression in that
+ // conversion — or in which field feeds the column — passes silently.
+ localCreatedAt := mustLocalRFC3339(t, "2026-08-08T09:00:00Z")
+ for verb, want := range map[string][]string{
+ // One provider's circuit is half-open, so the report is degraded — the
+ // derivation lives in api.Status and the transcript must not soften it.
+ "status": {"STATE", "URL", "LATENCY", "degraded", "1/1", "admin"},
+ "models": {"ID", "OWNED BY", "claude-sonnet-4-6", "anthropic", "200000"},
+ "providers": {"PROVIDER", "CIRCUIT", "openai", "closed", "half_open", "rate limited upstream"},
+ "mcp": {"NAME", "READY", "REQUIRED", "filesystem", "yes", "handshake timeout"},
+ "plugins": {"NAME", "FAILS", "rate-limit", "closed", "request-logger", "open"},
+ "sessions": {"SUBJECT", "SCOPES", "alice", "admin", localCreatedAt},
+ "audit": {"ACTION", "OUTCOME", "key.create", "ok", "ak_1", localCreatedAt},
+ } {
+ rows, err := verbRows(t.Context(), c, verb)
+ if err != nil {
+ t.Fatalf("%s: %v", verb, err)
+ }
+ var text strings.Builder
+ for _, r := range rows {
+ text.WriteString(r.Text + "\n")
+ }
+ for _, w := range want {
+ if !strings.Contains(text.String(), w) {
+ t.Fatalf("%s output missing %q:\n%s", verb, w, text.String())
+ }
+ }
+ // The header and its rule are furniture, so they are dim; the data is
+ // the output and must not be.
+ if len(rows) < 3 || !rows[0].Dim || !rows[1].Dim || rows[2].Dim {
+ t.Fatalf("%s: header must be dim and data must not be: %#v", verb, rows[:min(3, len(rows))])
+ }
+ }
+}
+
+// The console and `ferro providers` read the same two endpoints, and they used
+// to merge them differently: the console iterated /admin/health's list alone,
+// so /health naming openai and anthropic while /admin/health named only openai
+// printed two rows through a pipe and one on screen. That is the fixture below.
+//
+// The expectation is table.ProviderRows' own output rather than a table typed
+// out here, because a hand-written one drifts the moment either side changes a
+// column — and then the guard against divergence is itself divergent.
+func TestProviderRowsAreTheScriptableVerbsRows(t *testing.T) {
+ h := &api.HealthReport{Status: "ok", Providers: []api.ProviderHealth{
+ {Name: "openai", Status: "available", Circuit: "closed", Models: 3},
+ {Name: "anthropic", Status: "available", Circuit: "open", Models: 412},
+ }}
+ ah := &api.AdminHealth{Status: "degraded", Providers: []api.AdminProviderHealth{
+ {Name: "openai", Status: "degraded", Models: 3, Message: "rate limited upstream"},
+ }}
+
+ mux := http.NewServeMux()
+ serve := func(path string, v any) {
+ mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(v)
+ })
+ }
+ serve("/health", h)
+ serve("/admin/health", ah)
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ c, err := api.New(srv.URL, "test-key")
+ if err != nil {
+ t.Fatalf("client: %v", err)
+ }
+
+ rows, err := verbRows(t.Context(), c, verbProviders)
+ if err != nil {
+ t.Fatalf("providers: %v", err)
+ }
+ want := tableRows(table.ProviderHeaders, table.ProviderRows(table.MergeProviders(h, ah)))
+ if !slices.Equal(rows, want) {
+ t.Fatalf("the console must render the rows `ferro providers` renders:\n got %#v\nwant %#v", rows, want)
+ }
+ // The union is the point: anthropic is in /health only, and three rows
+ // (header, rule, one provider) would be the console dropping it again.
+ if len(rows) != 4 {
+ t.Fatalf("want header, rule and both providers, got %d rows: %#v", len(rows), rows)
+ }
+}
+
+// A gateway that does not serve an endpoint must surface its own words, not a
+// paraphrase and not an empty table.
+func TestVerbRowsSurfaceGatewayErrors(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = io.WriteString(w, `{"error":{"message":"missing credential","code":"unauthorized"}}`)
+ }))
+ t.Cleanup(srv.Close)
+ c, err := api.New(srv.URL, "")
+ if err != nil {
+ t.Fatalf("client: %v", err)
+ }
+ if _, err := verbRows(t.Context(), c, "sessions"); err == nil ||
+ !strings.Contains(err.Error(), "missing credential") {
+ t.Fatalf("the gateway's own error must survive, got %v", err)
+ }
+ if _, err := verbRows(t.Context(), c, "frobnicate"); err == nil {
+ t.Fatal("a verb with no fetcher must not report success")
+ }
+}
+
+// An empty listing and a broken query look identical unless the empty one says
+// so in words.
+func TestVerbRowsNameAnEmptyListing(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/admin/sessions", func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"data":[]}`)
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ c, err := api.New(srv.URL, "k")
+ if err != nil {
+ t.Fatalf("client: %v", err)
+ }
+
+ rows, err := verbRows(t.Context(), c, "sessions")
+ if err != nil {
+ t.Fatalf("an empty listing is not an error: %v", err)
+ }
+ if len(rows) != 1 || !strings.Contains(rows[0].Text, "no active dashboard sessions") {
+ t.Fatalf("an empty listing must say so, got %#v", rows)
+ }
+}
+
+// The runCmdMsg arm is the composer's only route into the router.
+func TestRunCmdMsgReachesTheRouter(t *testing.T) {
+ a := composerApp()
+ a.update(runCmdMsg{raw: "frobnicate"})
+ if !strings.Contains(transcriptText(a), "frobnicate") {
+ t.Fatalf("runCmdMsg must be dispatched:\n%s", transcriptText(a))
+ }
+}
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
new file mode 100644
index 0000000..ae9b45d
--- /dev/null
+++ b/internal/tui/keys.go
@@ -0,0 +1,721 @@
+package tui
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/table"
+)
+
+// The keys screen is the console's only destructive surface, and every choice
+// here follows from that:
+//
+// - Rotate and revoke are gated on typing the key's exact name. The wording
+// of each gate is the approved blast-radius copy, because what an operator
+// needs at that moment is not "are you sure" but "here is what breaks".
+// - A secret exists in exactly one place: the modal that was handed it. It is
+// never pushed to the transcript, never recorded in history, and is gone
+// from the process the moment that modal closes. What the transcript gets
+// instead names the key and its scope, so the action is on the record and
+// the credential is not.
+// - The table shows the wire's masked `key`, never a full secret — reads are
+// masked head8…tail4 by the gateway and this screen does no unmasking.
+//
+// There is no state field on the wire; api.KeyState derives revoked/expired/
+// active from revoked_at, expires_at and active. Nothing here re-derives it.
+
+// defaultExpiry is the wizard's prefilled ceiling. An unexpiring admin key is
+// a decision, so it costs a deliberate edit rather than a default.
+const defaultExpiry = "720h"
+
+// The actions this screen can be asked to perform. One string travels a long
+// way — from the subcommand the operator typed, through pendingVerb, into
+// modalResultMsg.kind in msgs.go, and back out to the arm that decides whether
+// a secret modal opens — and every hop is a plain string comparison. Naming
+// them keeps the three switches on one vocabulary. The alternative spellings
+// (`new`, `delete`) stay literals where they are accepted: they are input the
+// operator may type, not values this package passes around.
+const (
+ actionGet = "get"
+ actionCreate = "create"
+ actionRotate = "rotate"
+ actionRevoke = "revoke"
+)
+
+// The labels the modals share. A field named one thing in the create wizard
+// and another in the detail strip reads as two different fields.
+const (
+ kvName = "name"
+ kvScope = "scope"
+ kvLastUsed = "last used"
+
+ labelNext = "next"
+)
+
+// The create wizard's three step titles. One prefix, three steps: the title is
+// the only thing telling an operator where they are in the chain.
+const (
+ titleCreateName = "Create API key · name"
+ titleCreateScope = "Create API key · scope"
+ titleCreateExpiry = "Create API key · expiry"
+)
+
+// keysScreen is the API keys pane: a table, an optional detail strip, and the
+// modal flows that mutate it.
+type keysScreen struct {
+ keys []api.Key
+ sel int
+ detail bool
+ err error
+
+ // gen invalidates in-flight work from a visit the operator has left or a
+ // command that has been superseded, the same contract the shell's pollGen
+ // has.
+ gen int
+
+ pendingVerb string
+ pendingArg string
+
+ draft keyDraft
+}
+
+// keyDraft is the create wizard's accumulated answers. It holds no secret:
+// the secret does not exist until the gateway answers, and then it lives only
+// in the modal built from that answer.
+type keyDraft struct {
+ name, scope string
+ expires *time.Time
+}
+
+// ------------------------------------------------------------------ lifecycle
+
+// enter opens the pane and dispatches the subcommand the operator typed.
+// `keys` alone lists; `keys create|rotate|revoke` opens a flow.
+func (s *keysScreen) enter(a *App, rest string) tea.Cmd {
+ if a.Screen != ScreenKeys {
+ s.detail = false
+ }
+ s.gen++
+ s.pendingVerb, s.pendingArg = "", ""
+ a.Screen = ScreenKeys
+ cmd := s.fetch(a)
+
+ f := strings.Fields(rest)
+ if len(f) == 0 {
+ return cmd
+ }
+ arg := strings.Join(f[1:], " ")
+ switch strings.ToLower(f[0]) {
+ case "list":
+ case actionGet:
+ s.pendingVerb, s.pendingArg = actionGet, arg
+ case actionCreate, "new":
+ s.openCreate(a)
+ case actionRotate:
+ s.pendingVerb, s.pendingArg = actionRotate, arg
+ case actionRevoke, "delete":
+ s.pendingVerb, s.pendingArg = actionRevoke, arg
+ default:
+ a.Screen = ScreenHome
+ a.Transcript.Push(TranscriptRow{Glyph: kindBad,
+ Text: fmt.Sprintf("keys: unknown action %q — list, get, create, rotate or revoke", f[0])})
+ return nil
+ }
+ return cmd
+}
+
+// leave drops whatever the visit had in flight. There is no stream and no tick
+// here, so bumping the generation is the whole of it — a list fetch that lands
+// after the operator moved on must not repopulate a pane they are not looking
+// at, and a modal must not outlive its screen.
+func (s *keysScreen) leave() { s.gen++ }
+
+// refresh re-reads the list. It is the only way rows change: this screen never
+// patches a row locally from a mutation's response, because the gateway is the
+// authority on a key's state and a local guess is how a revoked key keeps
+// rendering as active.
+func (s *keysScreen) refresh(a *App) tea.Cmd {
+ s.gen++
+ return s.fetch(a)
+}
+
+func (s *keysScreen) fetch(a *App) tea.Cmd {
+ if a.Client == nil {
+ s.err = errNoConnection
+ return nil
+ }
+ return fetchKeys(a.Client, s.gen)
+}
+
+func (s *keysScreen) update(a *App, msg tea.Msg) tea.Cmd {
+ switch m := msg.(type) {
+ case keysListMsg:
+ if m.gen != s.gen {
+ return nil
+ }
+ s.err = m.err
+ if m.err == nil {
+ s.keys = m.keys
+ s.sel = clampIdx(s.sel, len(s.keys))
+ return s.runPending(a)
+ }
+ s.pendingVerb, s.pendingArg = "", ""
+ return nil
+
+ case modalResultMsg:
+ if m.gen != s.gen {
+ return nil
+ }
+ return s.result(a, m)
+ }
+ return nil
+}
+
+func (s *keysScreen) runPending(a *App) tea.Cmd {
+ verb, arg := s.pendingVerb, s.pendingArg
+ s.pendingVerb, s.pendingArg = "", ""
+ switch verb {
+ case actionGet:
+ if s.selectNamed(arg) {
+ return nil
+ }
+ a.Screen = ScreenHome
+ a.Transcript.Push(TranscriptRow{Glyph: kindBad, Text: fmt.Sprintf("keys get: no key named %q", arg)})
+ case actionRotate:
+ s.open(a, verb, arg, s.openRotate)
+ case actionRevoke:
+ s.open(a, verb, arg, s.openRevoke)
+ }
+ return nil
+}
+
+// key returns the selected row.
+func (s *keysScreen) key() (api.Key, bool) {
+ if s.sel < 0 || s.sel >= len(s.keys) {
+ return api.Key{}, false
+ }
+ return s.keys[s.sel], true
+}
+
+func (s *keysScreen) selectNamed(arg string) bool {
+ for i, k := range s.keys {
+ if k.Name == arg || k.ID == arg {
+ s.sel, s.detail = i, true
+ return true
+ }
+ }
+ return false
+}
+
+// handleKey is the pane's own keyboard. It claims a key only when the composer
+// is empty, so typing a verb from this screen always beats moving a cursor.
+func (s *keysScreen) handleKey(key string) bool {
+ switch key {
+ case keyUp:
+ s.sel = clampIdx(s.sel-1, len(s.keys))
+ case keyDown:
+ s.sel = clampIdx(s.sel+1, len(s.keys))
+ case keyEnter:
+ s.detail = s.sel >= 0 && s.sel < len(s.keys)
+ case keyEsc:
+ if !s.detail {
+ return false // esc means "back" once there is no strip to close
+ }
+ s.detail = false
+ default:
+ return false
+ }
+ return true
+}
+
+// -------------------------------------------------------------------- flows
+
+// open resolves the key a destructive verb names — by name, by id, or by the
+// current selection — and refuses rather than guessing when it names none.
+func (s *keysScreen) open(a *App, verb, arg string, f func(*App, api.Key)) {
+ if arg != "" {
+ for _, k := range s.keys {
+ if k.Name == arg || k.ID == arg {
+ f(a, k)
+ return
+ }
+ }
+ a.Screen = ScreenHome
+ a.Transcript.Push(TranscriptRow{Glyph: kindBad,
+ Text: fmt.Sprintf("keys %s: no key named %q", verb, arg)})
+ return
+ }
+ k, ok := s.key()
+ if !ok {
+ a.Screen = ScreenHome
+ a.Transcript.Push(TranscriptRow{Glyph: kindBad,
+ Text: fmt.Sprintf("keys %s: no key selected — run keys, pick a row with ↑/↓, then keys %s", verb, verb)})
+ return
+ }
+ f(a, k)
+}
+
+// openCreate is step 1 of the wizard. Each step's OnConfirm builds the next,
+// so the flow is a chain of modals rather than a step counter to keep in sync.
+func (s *keysScreen) openCreate(a *App) {
+ s.draft = keyDraft{scope: api.ScopeReadOnly}
+ a.Modal = &Modal{
+ Title: titleCreateName,
+ Body: "Names are how log rows resolve after a key is revoked.",
+ Input: true,
+ Placeholder: "prod-ingest",
+ OKLabel: labelNext,
+ OnConfirm: func(a *App, v string) tea.Cmd {
+ if v == "" {
+ s.openCreate(a) // a nameless key is the one thing this step exists to prevent
+ return nil
+ }
+ s.draft.name = v
+ s.openScope(a)
+ return nil
+ },
+ }
+}
+
+func (s *keysScreen) openScope(a *App) {
+ a.Modal = &Modal{
+ Title: titleCreateScope,
+ Body: "Scope is always sent explicitly, so the key carries the scope you\n" +
+ "chose rather than the gateway's default.",
+ Rows: []ModalKV{{K: kvName, V: s.draft.name, Bold: true}},
+ Options: []string{api.ScopeReadOnly, api.ScopeAdmin},
+ OptionLabel: kvScope,
+ OKLabel: labelNext,
+ OnConfirm: func(a *App, scope string) tea.Cmd {
+ s.draft.scope = scope
+ s.openExpiry(a, "")
+ return nil
+ },
+ }
+}
+
+func (s *keysScreen) openExpiry(a *App, errMsg string) {
+ a.Modal = &Modal{
+ Title: titleCreateExpiry,
+ Body: "Default 720h. An unexpiring admin key needs a written exception.",
+ Rows: []ModalKV{
+ {K: kvName, V: s.draft.name, Bold: true},
+ {K: kvScope, V: s.draft.scope},
+ },
+ Input: true,
+ Value: defaultExpiry,
+ Placeholder: "720h · empty for no expiry",
+ Err: errMsg,
+ OKLabel: actionCreate,
+ OnConfirm: func(a *App, v string) tea.Cmd {
+ exp, err := parseExpiry(v)
+ if err != nil {
+ s.openExpiry(a, err.Error())
+ return nil
+ }
+ s.draft.expires = exp
+ return createKey(a.Client, s.gen, api.KeyCreateRequest{
+ Name: s.draft.name,
+ Scopes: []string{s.draft.scope},
+ ExpiresAt: exp,
+ })
+ },
+ }
+}
+
+func (s *keysScreen) openRotate(a *App, k api.Key) {
+ a.Modal = &Modal{
+ Title: "Rotate " + k.Name + "?",
+ Body: "Blast radius: every client holding the current secret fails immediately\n" +
+ "until it is redeployed with the new one.",
+ Rows: []ModalKV{
+ {K: "id", V: k.ID},
+ {K: kvScope, V: strings.Join(k.Scopes, ",")},
+ {K: kvLastUsed, V: lastUsed(k), Warn: true},
+ },
+ Input: true,
+ Placeholder: `type "` + k.Name + `" to confirm`,
+ Confirm: k.Name,
+ OKLabel: actionRotate,
+ Danger: true,
+ OnConfirm: func(a *App, _ string) tea.Cmd {
+ return rotateKey(a.Client, s.gen, k.ID)
+ },
+ }
+}
+
+func (s *keysScreen) openRevoke(a *App, k api.Key) {
+ a.Modal = &Modal{
+ Title: "Revoke " + k.Name + "?",
+ Body: "Revocation is immediate and cannot be undone. Existing log rows keep\n" +
+ "their api_key_id and will render with a revoked badge.",
+ Rows: []ModalKV{
+ {K: "id", V: k.ID},
+ {K: kvScope, V: strings.Join(k.Scopes, ",")},
+ },
+ Input: true,
+ Placeholder: `type "` + k.Name + `" to confirm`,
+ Confirm: k.Name,
+ OKLabel: actionRevoke,
+ Danger: true,
+ OnConfirm: func(a *App, _ string) tea.Cmd {
+ return revokeKey(a.Client, s.gen, k.ID, k.Name, strings.Join(k.Scopes, ","))
+ },
+ }
+}
+
+// result turns a finished mutation into the next thing on screen. A failure is
+// pushed to the transcript in the gateway's own words and the operator is
+// returned to it, because a red row on a pane nobody is looking at is a
+// failure that happened silently.
+func (s *keysScreen) result(a *App, m modalResultMsg) tea.Cmd {
+ if m.err != nil {
+ s.err = m.err
+ a.Screen = ScreenHome
+ a.Transcript.Push(TranscriptRow{Glyph: kindBad, Text: "keys " + m.kind + ": " + m.err.Error()})
+ return nil
+ }
+ switch m.kind {
+ case actionRevoke:
+ a.Transcript.Push(TranscriptRow{Glyph: kindOK, Text: "revoked " + m.name + " (" + m.scope + ")"})
+ return s.refresh(a)
+ case actionCreate, actionRotate:
+ s.openSecret(a, m.kind, m.key)
+ return nil
+ }
+ return nil
+}
+
+// openSecret is the one place a secret is ever rendered. Confirm and cancel run
+// the same closure: leaving by esc still records what happened and still drops
+// the secret, because esc is a real way out and not an error path.
+func (s *keysScreen) openSecret(a *App, kind string, k *api.Key) {
+ if k == nil {
+ s.err = fmt.Errorf("keys %s returned no key", kind)
+ a.Screen = ScreenHome
+ a.Transcript.Push(TranscriptRow{Glyph: kindBad, Text: s.err.Error()})
+ return
+ }
+ scope := strings.Join(k.Scopes, ",")
+ // The list is re-read rather than patched from this response: it carries a
+ // secret we are about to forget on purpose, and the gateway is the
+ // authority on what the key now looks like.
+ done := func(a *App) tea.Cmd {
+ a.Transcript.Push(TranscriptRow{Glyph: kindOK, Text: summarize(kind, k, scope)})
+ a.Screen = ScreenKeys
+ return s.refresh(a)
+ }
+
+ if kind == actionRotate {
+ a.Modal = &Modal{
+ Title: "Rotated " + k.Name,
+ Body: "The previous secret stopped authenticating immediately.",
+ Rows: []ModalKV{
+ {K: "new secret", V: k.Key, Accent: true, Bold: true},
+ {K: "id", V: k.ID},
+ {K: "blast radius", V: "every client using the old secret", Warn: true},
+ },
+ OKLabel: "done",
+ OnConfirm: func(a *App, _ string) tea.Cmd { return done(a) },
+ OnCancel: done,
+ }
+ return
+ }
+
+ a.Modal = &Modal{
+ Title: "Key created",
+ Body: "Shown exactly once. Copy it now — the gateway stores only a hash.",
+ Rows: []ModalKV{
+ {K: kvName, V: k.Name, Bold: true},
+ {K: "secret", V: k.Key, Accent: true, Bold: true},
+ {K: "copy", V: "it will not be shown again"},
+ },
+ OKLabel: "done",
+ OnConfirm: func(a *App, _ string) tea.Cmd { return done(a) },
+ OnCancel: done,
+ }
+}
+
+// summarize is the transcript row a finished flow leaves behind. It names the
+// key, its scope and its ceiling — everything except the credential.
+func summarize(kind string, k *api.Key, scope string) string {
+ verb := map[string]string{actionCreate: "created", actionRotate: "rotated"}[kind]
+ out := verb + " " + k.Name + " (" + scope
+ if k.ExpiresAt != nil {
+ out += ", expires " + k.ExpiresAt.Local().Format(time.DateOnly)
+ } else if kind == actionCreate {
+ out += ", no expiry"
+ }
+ return out + ")"
+}
+
+// --------------------------------------------------------------------- view
+
+// keyColumns is the table, widest-useful first. NAME flexes because it is the
+// column an operator reads down; everything else is a fixed field.
+func keyColumns() []column {
+ return []column{
+ {head: colName, w: 18, flex: true},
+ {head: "KEY", w: 17},
+ {head: "SCOPE", w: 10},
+ {head: "EXPIRES", w: 10},
+ {head: colState, w: 7},
+ }
+}
+
+func (s *keysScreen) view(a *App, w, rows int) string {
+ th := a.Theme
+ lines := []string{s.subLine(a, w)}
+
+ if s.err != nil {
+ return fill(append(lines, "", th.Bad.Render("keys: "+s.err.Error())), w, rows)
+ }
+
+ var strip []string
+ if s.detail {
+ if k, ok := s.key(); ok {
+ strip = detailStrip(a, "Key "+k.Name, keyDetail(a, k), w)
+ }
+ }
+
+ // The table gets whatever the sub-line and the strip leave. It never
+ // scrolls off its own header: the header is dropped last, not first.
+ body := max(rows-len(lines)-len(strip), 0)
+ cols := layout(keyColumns(), w)
+ if body > 0 {
+ lines = append(lines, th.Dim.Render(headerLine(cols)))
+ body--
+ }
+ if len(s.keys) == 0 {
+ lines = append(lines, th.Dim.Render("this gateway has no API keys"))
+ }
+ for _, i := range window(len(s.keys), s.sel, body) {
+ lines = append(lines, s.row(a, cols, i, w))
+ }
+ return fill(append(padTo(lines, rows-len(strip)), strip...), w, rows)
+}
+
+func (s *keysScreen) subLine(a *App, w int) string {
+ th := a.Theme
+ left := th.Dim.Render("keys create · keys rotate · keys revoke")
+ right := th.Dim.Render(fmt.Sprintf("%d keys", len(s.keys)))
+ if len(s.keys) == 0 {
+ right = ""
+ }
+ return gapPad(w, " "+left, right)
+}
+
+func (s *keysScreen) row(a *App, cols []column, i, w int) string {
+ th, k := a.Theme, s.keys[i]
+ state := api.KeyState(k)
+
+ stateStyle := th.OK
+ switch state {
+ case api.KeyStateRevoked:
+ stateStyle = th.Bad
+ case api.KeyStateExpired:
+ stateStyle = th.Warn
+ }
+ expires := "never"
+ if k.ExpiresAt != nil {
+ expires = k.ExpiresAt.Local().Format(time.DateOnly)
+ }
+
+ line := rowLine(cols, []cell{
+ {v: k.Name, style: th.Bright},
+ {v: k.Key, style: th.Dim},
+ {v: strings.Join(k.Scopes, ","), style: th.Text},
+ {v: expires, style: th.Dim},
+ {v: state, style: stateStyle},
+ })
+ if i == s.sel {
+ return th.Selected.Render(padRight(line, w))
+ }
+ return line
+}
+
+// keyDetail is the strip's rows. Every value is from the wire; the actions row
+// names the verbs rather than binding a key to them, because the composer is
+// the console's only action surface.
+func keyDetail(_ *App, k api.Key) []ModalKV {
+ state := api.KeyState(k)
+ row := ModalKV{K: "state", V: state}
+ switch state {
+ case api.KeyStateRevoked, api.KeyStateExpired:
+ row.Warn = true
+ }
+ return []ModalKV{
+ {K: "id", V: k.ID, Bold: true},
+ {K: "key", V: k.Key},
+ {K: kvScope, V: strings.Join(k.Scopes, ",")},
+ {K: "created", V: table.FmtTime(k.CreatedAt)},
+ {K: kvLastUsed, V: lastUsed(k)},
+ {K: "expires", V: table.FmtTimePtr(k.ExpiresAt)},
+ row,
+ {K: "actions", V: "keys rotate · keys revoke", Accent: true},
+ }
+}
+
+func lastUsed(k api.Key) string {
+ if k.LastUsedAt == nil {
+ return "never"
+ }
+ return fmt.Sprintf("%s · %s requests", table.FmtTime(*k.LastUsedAt), comma(int(k.UsageCount)))
+}
+
+// ------------------------------------------------------------------ helpers
+
+// parseExpiry reads the wizard's ceiling. Empty is a real answer — no expiry —
+// so it is not an error; anything else must be a duration Go can parse.
+func parseExpiry(v string) (*time.Time, error) {
+ v = strings.TrimSpace(v)
+ if v == "" {
+ return nil, nil
+ }
+ d, err := time.ParseDuration(v)
+ if err != nil {
+ return nil, fmt.Errorf("%q is not a duration — try 720h, 30m, or empty for no expiry", v)
+ }
+ if d <= 0 {
+ return nil, fmt.Errorf("%q expires in the past", v)
+ }
+ t := time.Now().Add(d).UTC()
+ return &t, nil
+}
+
+func clampIdx(i, n int) int {
+ if n == 0 {
+ return -1
+ }
+ return min(max(i, 0), n-1)
+}
+
+// ---------------------------------------------------- shared table furniture
+//
+// Used by this screen and by logs.go. Both draw a fixed-width table into a
+// pane that owes an exact line count, so the one thing neither may do is wrap:
+// a folded line breaks the count and every frame below it. Columns are
+// therefore dropped, right to left, until the row fits.
+
+type column struct {
+ head string
+ w int
+ flex bool // absorbs the width the fixed columns leave
+ right bool // numerics align right
+}
+
+type cell struct {
+ v string
+ style lipgloss.Style
+}
+
+// layout returns the columns that fit w cells, with the flex column resized to
+// whatever is left. It keeps at least the first column, which is the one that
+// identifies the row.
+func layout(cols []column, w int) []column {
+ const gap = 1
+ fits := func(c []column) int {
+ total := 0
+ for i, col := range c {
+ total += col.w
+ if i > 0 {
+ total += gap
+ }
+ }
+ return total
+ }
+ out := append([]column(nil), cols...)
+ for len(out) > 1 && fits(out) > w {
+ out = out[:len(out)-1]
+ }
+ if slack := w - fits(out); slack != 0 {
+ for i := range out {
+ if out[i].flex {
+ out[i].w = max(out[i].w+slack, 4)
+ break
+ }
+ }
+ }
+ return out
+}
+
+func headerLine(cols []column) string {
+ parts := make([]string, 0, len(cols))
+ for _, c := range cols {
+ if c.right {
+ parts = append(parts, padLeft(c.head, c.w))
+ continue
+ }
+ parts = append(parts, padRight(c.head, c.w))
+ }
+ return strings.Join(parts, " ")
+}
+
+func rowLine(cols []column, cells []cell) string {
+ parts := make([]string, 0, len(cols))
+ for i, c := range cols {
+ if i >= len(cells) {
+ break
+ }
+ v := table.SanitizeCell(cells[i].v)
+ if c.right {
+ v = padLeft(v, c.w)
+ } else {
+ v = padRight(v, c.w)
+ }
+ parts = append(parts, cells[i].style.Render(v))
+ }
+ return strings.Join(parts, " ")
+}
+
+// window returns the indices of the n rows to draw, scrolled so the selection
+// stays on screen. A table that cannot show its selection is a table whose
+// cursor keys do nothing visible.
+func window(total, sel, n int) []int {
+ if n <= 0 || total == 0 {
+ return nil
+ }
+ start := 0
+ if sel >= n {
+ start = sel - n + 1
+ }
+ if end := min(start+n, total); end-start > 0 {
+ out := make([]int, 0, end-start)
+ for i := start; i < end; i++ {
+ out = append(out, i)
+ }
+ return out
+ }
+ return nil
+}
+
+// detailStrip is the bottom-anchored inspector both tables share.
+func detailStrip(a *App, title string, rows []ModalKV, w int) []string {
+ th := a.Theme
+ out := append(make([]string, 0, 2+len(rows)),
+ th.Border.Render(a.rule(max(w, 0))),
+ gapPad(w, th.Bright.Bold(th.Mode.Color).Render(table.SanitizeCell(title)), th.Dim.Render("esc close")),
+ )
+ for _, r := range rows {
+ out = append(out, th.Dim.Render(padRight(table.SanitizeCell(r.K), modalKeyCol))+
+ kvStyle(th, r).Render(table.SanitizeCell(r.V)))
+ }
+ return clampLines(out, w)
+}
+
+// padTo pads or trims to exactly n lines, so a view can reserve the space a
+// bottom-anchored strip will occupy before it appends one.
+func padTo(lines []string, n int) []string {
+ if n <= 0 {
+ return nil
+ }
+ for len(lines) < n {
+ lines = append(lines, "")
+ }
+ return lines[:n]
+}
diff --git a/internal/tui/keys_test.go b/internal/tui/keys_test.go
new file mode 100644
index 0000000..2b5af4d
--- /dev/null
+++ b/internal/tui/keys_test.go
@@ -0,0 +1,414 @@
+package tui
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/fixture"
+ "github.com/ferro-labs/gateway-cli/internal/tui/theme"
+)
+
+// fixtureApp is a shell wired to the stateful fake: create/rotate/revoke
+// actually mutate a key store, so the wizard is exercised end to end rather
+// than against a canned response that can never disagree with it.
+func fixtureApp(t *testing.T) *App {
+ t.Helper()
+ srv := httptest.NewServer(fixture.Handler(fixture.Default()))
+ t.Cleanup(srv.Close)
+ c, err := api.New(srv.URL, "fgw_test")
+ if err != nil {
+ t.Fatalf("client: %v", err)
+ }
+ a := composerApp()
+ a.Client = c
+ return a
+}
+
+// runCmd runs one command and applies its message, failing rather than
+// returning nil so a broken flow names the step it stopped at.
+func runCmd(t *testing.T, a *App, cmd tea.Cmd, what string) tea.Msg {
+ t.Helper()
+ if cmd == nil {
+ t.Fatalf("%s returned no command", what)
+ }
+ msg := cmd()
+ a.update(msg)
+ return msg
+}
+
+func loadKeys(t *testing.T, a *App) {
+ t.Helper()
+ runCmd(t, a, a.route("keys"), "keys")
+ if a.Keys.err != nil {
+ t.Fatalf("key list failed: %v", a.Keys.err)
+ }
+ if len(a.Keys.keys) == 0 {
+ t.Fatal("the fixture seeds keys; an empty list means the fetch did not land")
+ }
+ if a.Screen != ScreenKeys {
+ t.Fatalf("keys must switch to the keys pane, got %v", a.Screen)
+ }
+}
+
+func keyNamed(a *App, name string) (api.Key, bool) {
+ for _, k := range a.Keys.keys {
+ if k.Name == name {
+ return k, true
+ }
+ }
+ return api.Key{}, false
+}
+
+func TestKeysTableUsesMaskedKey(t *testing.T) {
+ a := fixtureApp(t)
+ loadKeys(t, a)
+ view := a.paneView(120, 18)
+
+ for _, k := range a.Keys.keys {
+ if !strings.Contains(view, k.Name) {
+ t.Fatalf("key %q missing from the table:\n%s", k.Name, view)
+ }
+ }
+ // The wire's `key` field is masked head8…tail4 on every read; the table
+ // shows that, never a full secret and never a bare row id in its place.
+ masked := a.Keys.keys[0].Key
+ if masked == "" {
+ t.Fatal("the fixture serves a masked key on reads")
+ }
+ if !strings.Contains(view, masked) {
+ t.Fatalf("the table must show the masked key %q:\n%s", masked, view)
+ }
+}
+
+func TestKeysTableRendersEveryDerivedState(t *testing.T) {
+ a := fixtureApp(t)
+ loadKeys(t, a)
+ view := a.paneView(120, 18)
+ // There is no state field on the wire — api.KeyState derives it.
+ seen := map[string]bool{}
+ for _, k := range a.Keys.keys {
+ seen[api.KeyState(k)] = true
+ }
+ for _, want := range []string{"active", "revoked", "expired"} {
+ if !seen[want] {
+ continue // the fixture may not seed every state
+ }
+ if !strings.Contains(view, want) {
+ t.Fatalf("state %q must appear in the table:\n%s", want, view)
+ }
+ }
+}
+
+func TestNewKeysCommandIgnoresEarlierListResult(t *testing.T) {
+ a := composerApp()
+ a.route("keys list")
+ oldGen := a.Keys.gen
+
+ a.route("keys rotate target")
+ newGen := a.Keys.gen
+ if newGen == oldGen {
+ t.Fatal("a newer keys command must invalidate the earlier list request")
+ }
+
+ a.update(keysListMsg{gen: oldGen, keys: []api.Key{{ID: "old", Name: "target"}}})
+ if a.Modal != nil {
+ t.Fatal("an earlier list result must not open the newer command's modal")
+ }
+ if a.Keys.pendingVerb != "rotate" || a.Keys.pendingArg != "target" {
+ t.Fatalf("stale result cleared the newer pending command: %q %q",
+ a.Keys.pendingVerb, a.Keys.pendingArg)
+ }
+
+ a.update(keysListMsg{gen: newGen, keys: []api.Key{{ID: "current", Name: "target"}}})
+ if a.Modal == nil || a.Modal.Title != "Rotate target?" {
+ t.Fatalf("the current list result must resume the pending command, got %+v", a.Modal)
+ }
+}
+
+func TestWizardHappyPath(t *testing.T) {
+ a := fixtureApp(t)
+ loadKeys(t, a)
+
+ a.route("keys create")
+ if a.Modal == nil {
+ t.Fatal("keys create must open the wizard")
+ }
+ if a.Modal.Title != "Create API key · name" {
+ t.Fatalf("step 1 title drifted from the expected wording: %q", a.Modal.Title)
+ }
+ if !strings.Contains(a.Modal.Body, "Names are how log rows resolve after a key is revoked.") {
+ t.Fatalf("step 1 body drifted from the expected wording: %q", a.Modal.Body)
+ }
+ typeModal(a, a.Modal, "ci-readonly")
+ a.Modal.handle("enter", a)
+
+ if a.Modal == nil || !strings.Contains(a.Modal.Title, "scope") {
+ t.Fatalf("step 2 must be the scope step, got %+v", a.Modal)
+ }
+ if !strings.Contains(a.Modal.Body, "Scope is always sent explicitly") {
+ t.Fatalf("step 2 body drifted from the expected wording: %q", a.Modal.Body)
+ }
+ // The gateway defaults an omitted scope to read_only. This panel told the
+ // operator it was granted admin, which is the opposite.
+ if strings.Contains(a.Modal.Body, "granted admin") {
+ t.Fatalf("step 2 must not restate the old backwards claim: %q", a.Modal.Body)
+ }
+ if a.Modal.Option() != "read_only" {
+ t.Fatalf("the wizard defaults to read_only, got %q", a.Modal.Option())
+ }
+ a.Modal.handle("space", a) // → admin
+ if a.Modal.Option() != "admin" {
+ t.Fatalf("space must toggle the scope, got %q", a.Modal.Option())
+ }
+ a.Modal.handle("enter", a)
+
+ if a.Modal == nil || !strings.Contains(a.Modal.Title, "expiry") {
+ t.Fatalf("step 3 must be the expiry step, got %+v", a.Modal)
+ }
+ if !strings.Contains(a.Modal.Body, "Default 720h. An unexpiring admin key needs a written exception.") {
+ t.Fatalf("step 3 body drifted from the expected wording: %q", a.Modal.Body)
+ }
+ if a.Modal.Value != "720h" {
+ t.Fatalf("the expiry step is prefilled with 720h, got %q", a.Modal.Value)
+ }
+
+ before := time.Now()
+ msg := runCmd(t, a, a.Modal.handle("enter", a), "create")
+ res, ok := msg.(modalResultMsg)
+ if !ok || res.err != nil {
+ t.Fatalf("create must reach the gateway, got %#v", msg)
+ }
+ if res.key == nil || res.key.Key == "" {
+ t.Fatal("create returns the full secret exactly once")
+ }
+ secret := res.key.Key
+
+ if a.Modal == nil || a.Modal.Title != "Key created" {
+ t.Fatalf("a created key must land in its own modal, got %+v", a.Modal)
+ }
+ if !strings.Contains(a.Modal.Body, "Shown exactly once. Copy it now — the gateway stores only a hash.") {
+ t.Fatalf("the secret step body drifted from the expected wording: %q", a.Modal.Body)
+ }
+ if !strings.Contains(a.paneView(110, 18), secret) {
+ t.Fatalf("the secret must be visible in its modal:\n%s", a.paneView(110, 18))
+ }
+
+ // Closing it refreshes the list, which is where the request's own values
+ // come back: the scope the toggle chose and the expiry it parsed.
+ runCmd(t, a, a.Modal.handle("enter", a), "close secret modal")
+ created, found := keyNamed(a, "ci-readonly")
+ if !found {
+ t.Fatalf("the created key must appear after the refresh: %+v", a.Keys.keys)
+ }
+ if len(created.Scopes) != 1 || created.Scopes[0] != "admin" {
+ t.Fatalf("the toggled scope must be what was sent, got %v", created.Scopes)
+ }
+ if created.ExpiresAt == nil {
+ t.Fatal("720h must have been sent as an expiry")
+ }
+ if d := created.ExpiresAt.Sub(before); d < 719*time.Hour || d > 721*time.Hour {
+ t.Fatalf("expiry must be ~720h out, got %v", d)
+ }
+}
+
+func TestWizardSecretNeverInTranscriptOrHistory(t *testing.T) {
+ a := fixtureApp(t)
+ loadKeys(t, a)
+
+ a.route("keys create")
+ typeModal(a, a.Modal, "ci-readonly")
+ a.Modal.handle("enter", a)
+ a.Modal.handle("enter", a) // keep read_only
+ msg := runCmd(t, a, a.Modal.handle("enter", a), "create")
+ secret := msg.(modalResultMsg).key.Key
+ if secret == "" {
+ t.Fatal("nothing to assert about if no secret was issued")
+ }
+ runCmd(t, a, a.Modal.handle("enter", a), "close secret modal")
+
+ if strings.Contains(transcriptText(a), secret) {
+ t.Fatalf("the secret must never enter the transcript:\n%s", transcriptText(a))
+ }
+ for _, h := range a.Composer.History {
+ if strings.Contains(h, secret) {
+ t.Fatalf("the secret must never enter the command history: %q", h)
+ }
+ }
+ if strings.Contains(a.render(), secret) {
+ t.Fatal("the secret must be gone from the screen once its modal closes")
+ }
+ // What it does leave behind is the key and its scope, so the operation is
+ // on the record without the credential being on it.
+ if !strings.Contains(transcriptText(a), "ci-readonly") ||
+ !strings.Contains(transcriptText(a), "read_only") {
+ t.Fatalf("a completed create must name the key and its scope:\n%s", transcriptText(a))
+ }
+}
+
+func TestRotateFlowSecretOnce(t *testing.T) {
+ a := fixtureApp(t)
+ loadKeys(t, a)
+ a.Keys.sel = 0
+ name := a.Keys.keys[0].Name
+
+ runCmd(t, a, a.route("keys rotate"), "refresh before rotate")
+ if a.Modal == nil {
+ t.Fatal("keys rotate must open a confirmation")
+ }
+ if !strings.Contains(a.Modal.Body,
+ "Blast radius: every client holding the current secret fails immediately") {
+ t.Fatalf("rotate body drifted from the expected wording: %q", a.Modal.Body)
+ }
+ if a.Modal.Placeholder != `type "`+name+`" to confirm` {
+ t.Fatalf("rotate placeholder drifted: %q", a.Modal.Placeholder)
+ }
+ if cmd := a.Modal.handle("enter", a); cmd != nil {
+ t.Fatal("rotate must not fire before the key's name is typed")
+ }
+ if a.Modal == nil {
+ t.Fatal("an unconfirmed destructive modal stays open")
+ }
+
+ typeModal(a, a.Modal, name)
+ msg := runCmd(t, a, a.Modal.handle("enter", a), "rotate")
+ res, ok := msg.(modalResultMsg)
+ if !ok || res.err != nil {
+ t.Fatalf("rotate must reach the gateway, got %#v", msg)
+ }
+ secret := res.key.Key
+ if secret == "" {
+ t.Fatal("rotate returns the new secret exactly once")
+ }
+ if !strings.Contains(a.paneView(110, 18), secret) {
+ t.Fatal("the rotated secret must be visible in its modal")
+ }
+
+ runCmd(t, a, a.Modal.handle("enter", a), "close rotate secret modal")
+ if strings.Contains(transcriptText(a), secret) {
+ t.Fatalf("the rotated secret must never enter the transcript:\n%s", transcriptText(a))
+ }
+ if strings.Contains(a.render(), secret) {
+ t.Fatal("the rotated secret must be gone once its modal closes")
+ }
+ if k, _ := keyNamed(a, name); k.RotatedAt == nil {
+ t.Fatal("the refreshed row must show the rotation")
+ }
+}
+
+func TestRevokeUpdatesStateColumn(t *testing.T) {
+ a := fixtureApp(t)
+ loadKeys(t, a)
+
+ // Pick a key that is currently active, so "revoked" is a change.
+ idx := -1
+ for i, k := range a.Keys.keys {
+ if api.KeyState(k) == "active" {
+ idx = i
+ break
+ }
+ }
+ if idx < 0 {
+ t.Fatal("the fixture seeds at least one active key")
+ }
+ a.Keys.sel = idx
+ name := a.Keys.keys[idx].Name
+
+ runCmd(t, a, a.route("keys revoke"), "refresh before revoke")
+ if a.Modal == nil {
+ t.Fatal("keys revoke must open a confirmation")
+ }
+ if !strings.Contains(a.Modal.Body, "Revocation is immediate and cannot be undone.") ||
+ !strings.Contains(a.Modal.Body, "revoked badge.") {
+ t.Fatalf("revoke body drifted from the expected wording: %q", a.Modal.Body)
+ }
+ typeModal(a, a.Modal, "not-the-name")
+ if cmd := a.Modal.handle("enter", a); cmd != nil {
+ t.Fatal("a wrong phrase must not revoke")
+ }
+ a.Modal.handle("ctrl+u", a)
+ typeModal(a, a.Modal, name)
+
+ runCmd(t, a, a.Modal.handle("enter", a), "revoke")
+ // The result arm refreshes the list; run that fetch too.
+ runCmd(t, a, a.Keys.refresh(a), "refresh")
+
+ k, found := keyNamed(a, name)
+ if !found {
+ t.Fatalf("a revoked key keeps its row: %+v", a.Keys.keys)
+ }
+ if got := api.KeyState(k); got != "revoked" {
+ t.Fatalf("state column must read revoked after a revoke, got %q", got)
+ }
+ if !strings.Contains(a.paneView(120, 18), "revoked") {
+ t.Fatalf("the table must render the new state:\n%s", a.paneView(120, 18))
+ }
+ if !strings.Contains(transcriptText(a), name) {
+ t.Fatalf("a completed revoke must name the key:\n%s", transcriptText(a))
+ }
+}
+
+func TestKeysDestructiveVerbsNeedASelection(t *testing.T) {
+ a := fixtureApp(t)
+ loadKeys(t, a)
+ a.Keys.keys, a.Keys.sel = nil, -1
+ for _, verb := range []string{"keys rotate", "keys revoke"} {
+ a.route(verb)
+ a.update(keysListMsg{gen: a.Keys.gen, keys: nil})
+ if a.Modal != nil {
+ t.Fatalf("%q with nothing selected must not open a destructive modal", verb)
+ }
+ if lastRow(a).Glyph != "bad" {
+ t.Fatalf("%q with nothing selected must say so: %#v", verb, lastRow(a))
+ }
+ }
+}
+
+func TestKeysScreenKeepsThePaneContract(t *testing.T) {
+ a := fixtureApp(t)
+ loadKeys(t, a)
+ a.Keys.sel, a.Keys.detail = 1, true
+ for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} {
+ a.Theme = theme.New(mode)
+ for _, rows := range []int{1, 4, 9, 20} {
+ for _, w := range []int{30, 60, 92, 126} {
+ body := a.paneView(w, rows)
+ if got := len(strings.Split(body, "\n")); got != rows {
+ t.Fatalf("mode=%+v w=%d rows=%d returned %d lines", mode, w, rows, got)
+ }
+ for i, line := range strings.Split(body, "\n") {
+ if lw := lipgloss.Width(line); lw > w {
+ t.Fatalf("mode=%+v w=%d line %d overflows: %d cells %q", mode, w, i, lw, line)
+ }
+ }
+ }
+ }
+ }
+}
+
+// The keys pane owes the same exact line count, and a key name is gateway data
+// like any other: it is echoed into the table and into the detail strip.
+func TestKeysPaneKeepsItsLineCountWithControlCharactersInGatewayData(t *testing.T) {
+ a := composerApp()
+ a.Screen = ScreenKeys
+ a.Keys.keys = []api.Key{{
+ ID: "key_01", Name: "prod\ningest", Key: "fgw_abcd…9f21",
+ Scopes: []string{"admin\x1b[31m"}, Active: true, CreatedAt: time.Now(),
+ }}
+ a.Keys.sel, a.Keys.detail = 0, true
+
+ for _, rows := range []int{4, 9, 20} {
+ out := a.Keys.view(a, 100, rows)
+ if got := strings.Count(out, "\n") + 1; got != rows {
+ t.Fatalf("view(rows=%d) returned %d lines:\n%q", rows, got, out)
+ }
+ if strings.Contains(out, "\x1b") {
+ t.Fatalf("view(rows=%d) forwarded an escape sequence:\n%q", rows, out)
+ }
+ }
+}
diff --git a/internal/tui/logs.go b/internal/tui/logs.go
new file mode 100644
index 0000000..df2d1d0
--- /dev/null
+++ b/internal/tui/logs.go
@@ -0,0 +1,499 @@
+package tui
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/table"
+)
+
+// The logs screen is a live tail, and everything here is shaped by the one
+// rule a tail must obey: a poll that fails does not erase what is already on
+// screen. It keeps the rows, says how old they are, and probes less often the
+// longer the gateway stays unreachable. Blanking the table because one request
+// timed out would claim the traffic stopped, which is the opposite of what
+// happened.
+//
+// The three measurements are pointers on the wire because the gateway sends
+// null for "not measured" — a request that failed before reaching a provider,
+// or one served by a provider with no price in the catalog. They render as a
+// dash. Collapsing null into 0 would draw a real zero-cost, zero-latency
+// request, which is a claim no row here can support.
+
+const (
+ // logsRing is the display ring. A console that tails for a day must not
+ // grow without limit, and 200 rows is more than the tallest pane can show.
+ logsRing = 200
+
+ // logsPoll is the tail cadence. A failure doubles it up to maxPoll and the
+ // first success resets it.
+ logsPoll = 2 * time.Second
+
+ // logsPage is the default page size per poll.
+ logsPage = 100
+
+ // traceCell is the trace-id column: "tr_" plus eight bytes of the id, which
+ // is what /admin/logs and the playground's attribution both key on.
+ traceCell = 11
+)
+
+// keyRef is what a log row's api_key_id resolves to. Revoked is carried
+// separately because a key revoked SINCE it served the traffic still names the
+// rows it served — that is the whole reason keys have names.
+type keyRef struct {
+ Name string
+ Revoked bool
+}
+
+// logsScreen is the request-log pane: a filtered tail, a selection, and a
+// detail strip.
+type logsScreen struct {
+ rows []api.LogEntry // newest first, bounded at logsRing
+ sel int // -1 when nothing is selected
+ detail bool
+ follower *api.Follower
+ filters api.LogsQuery
+ // raw is the filter arguments exactly as typed. The sub-line renders it
+ // rather than reconstructing one from the parsed query, so what is on
+ // screen is a line the operator could run again — "--since 15m", not the
+ // "14m59s" a round trip through time.Duration turns it into.
+ raw string
+ tailing bool
+
+ // keyNames comes from one Keys() read at start. A caller without the
+ // credential for it gets an empty map and raw ids, which is strictly better
+ // than refusing to show the logs they can read.
+ keyNames map[string]keyRef
+
+ lastErr error
+ staleAt time.Time // when the data went stale; zero while polls succeed
+ backoff time.Duration
+ // truncated records that the LAST poll hit the follower's page bound. The
+ // rows it returned are real, so this is a readout beside them and not an
+ // error: what is missing is the oldest end of that one window.
+ truncated bool
+
+ gen int
+}
+
+// ------------------------------------------------------------------ lifecycle
+
+// start parses the filter arguments and opens the tail. A bad argument never
+// enters the screen: the refusal belongs in the transcript, which is on the
+// home pane, and switching away from it would hide the reason.
+func (s *logsScreen) start(a *App, args string) tea.Cmd {
+ q, err := parseLogFilters(args)
+ if err != nil {
+ a.Screen = ScreenHome
+ a.Transcript.Push(TranscriptRow{Glyph: kindBad, Text: err.Error()})
+ return nil
+ }
+
+ *s = logsScreen{gen: s.gen + 1, filters: q, raw: strings.TrimSpace(args),
+ sel: -1, backoff: logsPoll, keyNames: map[string]keyRef{}}
+ a.Screen = ScreenLogs
+ if a.Client == nil {
+ s.lastErr = errNoConnection
+ return nil
+ }
+ s.tailing = true
+ s.follower = api.NewFollower(a.Client, q)
+ // No tick is armed here: the first poll arms the second. See rearm.
+ return tea.Batch(fetchLogKeyNames(a.Client, s.gen), pollLogs(s.follower, s.gen))
+}
+
+// leave stops the tail. The generation bump is what actually stops it: a tick
+// already in flight carries the old one and schedules nothing when it lands.
+func (s *logsScreen) leave() {
+ s.gen++
+ s.tailing = false
+ s.follower = nil
+}
+
+func (s *logsScreen) update(a *App, msg tea.Msg) tea.Cmd {
+ switch m := msg.(type) {
+ case tickLogsMsg:
+ // Three independent reasons to stop: a newer visit, a pane that is no
+ // longer on show, and a tail that was never started.
+ if m.gen != s.gen || a.Screen != ScreenLogs || !s.tailing || s.follower == nil {
+ return nil
+ }
+ return pollLogs(s.follower, s.gen)
+
+ case logsBatchMsg:
+ if m.gen != s.gen {
+ return nil
+ }
+ // api.ErrFollowTruncated is the one error that arrives WITH rows: the
+ // poll succeeded, the cursor moved past what it read, and what was lost
+ // is the oldest end of that single window. Treating it as a failed poll
+ // would discard rows the gateway did return and back the tail off
+ // against a gateway whose only problem is that it is busy.
+ if truncated := errors.Is(m.err, api.ErrFollowTruncated); m.err == nil || truncated {
+ s.lastErr, s.staleAt, s.backoff = nil, time.Time{}, logsPoll
+ s.truncated = truncated
+ s.prepend(m.rows)
+ return s.rearm()
+ }
+ s.lastErr = m.err
+ if api.IsNotSupported(m.err) {
+ // A gateway with no request-log store answers 501. That is an
+ // absent feature, not a failed poll: it will not become a 200,
+ // so the tail stops instead of backing off against it forever.
+ s.tailing = false
+ return nil
+ }
+ if s.staleAt.IsZero() {
+ s.staleAt = time.Now()
+ }
+ s.backoff = backoff(s.backoff)
+ return s.rearm()
+
+ case logsNamesMsg:
+ if m.gen != s.gen {
+ return nil
+ }
+ s.keyNames = m.names
+ return nil
+ }
+ return nil
+}
+
+// rearm schedules the next poll, and it is called from exactly one place: the
+// arm that handles a finished one. That is what serialises the tail.
+//
+// api.Follower is one-per-tail-loop state — its cursor, its dedupe map and its
+// row slice are unsynchronised — so two polls in flight against the same
+// follower race, and a gateway whose /admin/logs takes longer than the poll
+// interval is all it takes to have two. Arming the next tick only once the
+// previous poll's rows have landed makes that unreachable. The generation
+// counter cannot do this job: it discards a stale result after the fact, which
+// is a different thing from never running two polls at once.
+func (s *logsScreen) rearm() tea.Cmd {
+ if !s.tailing || s.follower == nil {
+ return nil
+ }
+ return tickLogs(s.backoff, s.gen)
+}
+
+// prepend puts a poll's ascending rows at the head, newest first, and trims to
+// the ring. The selection follows its row rather than its index, so a batch
+// arriving under the cursor does not silently re-point the detail strip at a
+// different request.
+func (s *logsScreen) prepend(rows []api.LogEntry) {
+ if len(rows) == 0 {
+ return
+ }
+ selected, hadSelection := s.row()
+
+ next := make([]api.LogEntry, 0, min(len(rows)+len(s.rows), logsRing))
+ for i := len(rows) - 1; i >= 0; i-- {
+ next = append(next, rows[i])
+ }
+ next = append(next, s.rows...)
+ if len(next) > logsRing {
+ next = next[:logsRing]
+ }
+ s.rows = next
+
+ if !hadSelection {
+ return
+ }
+ for i, r := range s.rows {
+ if sameRow(r, selected) {
+ s.sel = i
+ return
+ }
+ }
+ s.sel, s.detail = -1, false
+}
+
+// sameRow is api.dedupeKey's identity, spelled locally because the wire's
+// notion of one row belongs to the API layer. A trace has one row per pipeline
+// stage, so the trace id alone matches several rows: following it would land
+// the cursor on a different stage of the same request, which is the exact
+// silent re-pointing prepend exists to prevent.
+func sameRow(a, b api.LogEntry) bool {
+ return a.TraceID == b.TraceID && a.Stage == b.Stage && a.CreatedAt.Equal(b.CreatedAt)
+}
+
+func (s *logsScreen) row() (api.LogEntry, bool) {
+ if s.sel < 0 || s.sel >= len(s.rows) {
+ return api.LogEntry{}, false
+ }
+ return s.rows[s.sel], true
+}
+
+// handleKey is the pane's own keyboard; see App.screenKey for why it is only
+// ever offered a key while the command line is empty.
+func (s *logsScreen) handleKey(key string) bool {
+ switch key {
+ case keyUp:
+ s.sel = clampIdx(s.sel-1, len(s.rows))
+ case keyDown:
+ s.sel = clampIdx(s.sel+1, len(s.rows))
+ case keyEnter:
+ s.detail = s.sel >= 0 && s.sel < len(s.rows)
+ case keyEsc:
+ if !s.detail {
+ return false
+ }
+ s.detail = false
+ default:
+ return false
+ }
+ return true
+}
+
+// -------------------------------------------------------------------- filters
+
+// logFilterHelp is the vocabulary a refusal names. It is one string so the
+// parser and its error message cannot drift apart.
+const logFilterHelp = "filters are --since --model --provider --stage --key --limit"
+
+// parseLogFilters walks `--flag value` pairs. An unknown flag is refused rather
+// than ignored: a filter silently dropped shows a wider log than was asked for,
+// and the operator has no way to tell.
+func parseLogFilters(args string) (api.LogsQuery, error) {
+ q := api.LogsQuery{Limit: logsPage}
+ f := strings.Fields(args)
+ for i := 0; i < len(f); i++ {
+ name := f[i]
+ if !strings.HasPrefix(name, "--") {
+ return q, fmt.Errorf("logs: unexpected argument %q — %s", name, logFilterHelp)
+ }
+ if i+1 >= len(f) {
+ return q, fmt.Errorf("logs: %s needs a value — %s", name, logFilterHelp)
+ }
+ v := f[i+1]
+ i++
+ switch name {
+ case "--since":
+ t, err := api.ParseSince(v)
+ if err != nil {
+ return q, fmt.Errorf("logs: %w — %s", err, logFilterHelp)
+ }
+ q.Since = t
+ case "--model":
+ q.Model = v
+ case "--provider":
+ q.Provider = v
+ case "--stage":
+ q.Stage = v
+ case "--key":
+ // "none" is the gateway's own sentinel for rows with no credential
+ // and is passed through untouched.
+ q.APIKeyID = v
+ case "--limit":
+ n, err := strconv.Atoi(v)
+ if err != nil || n <= 0 {
+ return q, fmt.Errorf("logs: --limit %q is not a positive count — %s", v, logFilterHelp)
+ }
+ q.Limit = n
+ default:
+ return q, fmt.Errorf("logs: unknown flag %q — %s", name, logFilterHelp)
+ }
+ }
+ return q, nil
+}
+
+// filterLabel renders the active filters the way they were typed, so what is
+// on screen is a line the operator could run again.
+func (s *logsScreen) filterLabel() string {
+ if s.raw == "" {
+ return "no filter · terminal rows only"
+ }
+ return strings.Join(strings.Fields(s.raw), " ")
+}
+
+// --------------------------------------------------------------------- view
+
+// logColumns is the table. MODEL flexes because it is the only column whose
+// content has no bound; everything else is a field of known shape.
+func logColumns() []column {
+ return []column{
+ {head: "TIME", w: 8},
+ {head: "TRACE", w: traceCell},
+ {head: colProvider, w: 10},
+ {head: "MODEL", w: 14, flex: true},
+ {head: "STAGE", w: 13},
+ {head: "MS", w: 6, right: true},
+ {head: "COST", w: 8, right: true},
+ }
+}
+
+func (s *logsScreen) view(a *App, w, rows int) string {
+ th := a.Theme
+ lines := []string{s.subLine(a, w)}
+
+ // A 501 falls through to the empty note, which names the missing store. The
+ // gateway's raw words are right for a failure and wrong for an absence.
+ if s.lastErr != nil && len(s.rows) == 0 && !api.IsNotSupported(s.lastErr) {
+ return fill(append(lines, "", th.Bad.Render("logs: "+s.lastErr.Error())), w, rows)
+ }
+
+ var strip []string
+ if s.detail {
+ if r, ok := s.row(); ok {
+ strip = detailStrip(a, "Request "+r.TraceID, s.rowDetail(a, r), w)
+ }
+ }
+
+ body := max(rows-len(lines)-len(strip), 0)
+ cols := layout(logColumns(), w)
+ if body > 0 {
+ lines = append(lines, th.Dim.Render(headerLine(cols)))
+ body--
+ }
+ if len(s.rows) == 0 {
+ lines = append(lines, th.Dim.Render(s.emptyNote()))
+ }
+ for _, i := range window(len(s.rows), s.sel, body) {
+ lines = append(lines, s.line(a, cols, i, w))
+ }
+ return fill(append(padTo(lines, rows-len(strip)), strip...), w, rows)
+}
+
+// emptyNote distinguishes "nothing has arrived yet" from "this gateway keeps no
+// log": a tail against a gateway with no store would otherwise look identical
+// to a quiet one.
+func (s *logsScreen) emptyNote() string {
+ switch {
+ case s.lastErr != nil && api.IsNotSupported(s.lastErr):
+ return "this gateway has no request-log store configured"
+ case s.tailing:
+ return "no rows yet — the tail is live and will fill as traffic arrives"
+ default:
+ return "no rows in range"
+ }
+}
+
+// subLine carries the active filters on the left and the tail's own health on
+// the right. Staleness is a readout, not an error: the rows below it are real,
+// they are just older than they look.
+func (s *logsScreen) subLine(a *App, w int) string {
+ th := a.Theme
+ right := ""
+ switch {
+ case !s.staleAt.IsZero():
+ right = th.Warn.Render(fmt.Sprintf("stale %ds · retrying in %s",
+ int(time.Since(s.staleAt).Seconds()), s.backoff))
+ case s.truncated:
+ // The rows below are real; the gateway was simply busy enough that one
+ // poll could not reach the far end of its window. Saying which end was
+ // cut is what stops the table reading as the whole range.
+ right = th.Warn.Render("tailing · older rows in that window skipped")
+ case s.tailing:
+ right = th.Accent.Render("tailing…")
+ if th.Mode.ASCII {
+ right = th.Accent.Render("tailing...")
+ }
+ }
+ return gapPad(w, " "+th.Dim.Render(s.filterLabel()), right)
+}
+
+func (s *logsScreen) line(a *App, cols []column, i, w int) string {
+ th, r := a.Theme, s.rows[i]
+
+ stage, stageStyle := r.Stage, th.OK
+ if r.ErrorMessage != "" {
+ stageStyle = th.Bad
+ }
+ line := rowLine(cols, []cell{
+ {v: r.CreatedAt.Local().Format(time.TimeOnly), style: th.Dim},
+ {v: shortTrace(r.TraceID), style: th.Accent},
+ {v: table.OrDash(r.Provider), style: th.Text},
+ {v: table.OrDash(r.Model), style: th.Bright},
+ {v: table.OrDash(stage), style: stageStyle},
+ {v: msCell(r.DurationMs), style: th.Text},
+ {v: costCell(r.CostUSD), style: th.Dim},
+ })
+ if i == s.sel {
+ return th.Selected.Render(padRight(line, w))
+ }
+ return line
+}
+
+// rowDetail is the strip. It carries the full trace id (the table's is cut),
+// both measurements, and the credential resolved to a name — which is the one
+// thing the table cannot show and the reason a row is opened at all.
+func (s *logsScreen) rowDetail(_ *App, r api.LogEntry) []ModalKV {
+ name, revoked := s.resolveKey(r.APIKeyID)
+ out := []ModalKV{
+ {K: "trace", V: r.TraceID, Bold: true},
+ {K: "provider", V: table.OrDash(r.Provider)},
+ {K: "model", V: table.OrDash(r.Model)},
+ {K: "stage", V: table.OrDash(r.Stage)},
+ {K: "duration", V: msUnit(r.DurationMs)},
+ {K: "ttft", V: msUnit(r.TTFTMs)},
+ {K: "tokens", V: fmt.Sprintf("%d in / %d out", r.PromptTokens, r.CompletionTokens)},
+ {K: "cost", V: costCell(r.CostUSD)},
+ {K: "api key", V: name, Warn: revoked},
+ }
+ if r.ErrorMessage != "" {
+ out = append(out, ModalKV{K: "error", V: r.ErrorMessage, Warn: true})
+ }
+ return out
+}
+
+// resolveKey turns a row's api_key_id into something an operator can act on.
+// Three cases, all real: the master credential carries a synthetic id with no
+// key row behind it, a named key may have been revoked since it served the
+// traffic, and a deleted key keeps its rows but loses its name.
+func (s *logsScreen) resolveKey(id string) (string, bool) {
+ switch {
+ case id == "":
+ return "none", false
+ case strings.HasPrefix(id, "master-key:"):
+ return "master key", false
+ }
+ if ref, ok := s.keyNames[id]; ok && ref.Name != "" {
+ if ref.Revoked {
+ return ref.Name + " [revoked]", true
+ }
+ return ref.Name, false
+ }
+ return id, false
+}
+
+// ------------------------------------------------------------------ helpers
+
+// shortTrace cuts a trace id to the table's column without inventing a shape:
+// it keeps the head, which is what /admin/logs?trace= matches on.
+func shortTrace(id string) string {
+ if id == "" {
+ return "-"
+ }
+ if len(id) <= traceCell {
+ return id
+ }
+ return id[:traceCell]
+}
+
+// msCell and costCell render an absent measurement as a dash, never as a zero.
+func msCell(v *float64) string {
+ if v == nil {
+ return "-"
+ }
+ return strconv.FormatFloat(*v, 'f', 0, 64)
+}
+
+func msUnit(v *float64) string {
+ if v == nil {
+ return "-"
+ }
+ return msCell(v) + "ms"
+}
+
+func costCell(v *float64) string {
+ if v == nil {
+ return "-"
+ }
+ return "$" + strconv.FormatFloat(*v, 'f', 4, 64)
+}
diff --git a/internal/tui/logs_test.go b/internal/tui/logs_test.go
new file mode 100644
index 0000000..ceb2aca
--- /dev/null
+++ b/internal/tui/logs_test.go
@@ -0,0 +1,498 @@
+package tui
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "charm.land/lipgloss/v2"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/tui/theme"
+)
+
+func f64(v float64) *float64 { return &v }
+
+// logRow builds one entry with everything measured, so a test that wants an
+// absent measurement removes exactly the field it is about.
+func logRow(trace string, at time.Time) api.LogEntry {
+ return api.LogEntry{
+ TraceID: trace, Stage: "response", Model: "claude-sonnet-4-6",
+ Provider: "anthropic", APIKeyID: "ak_1",
+ PromptTokens: 412, CompletionTokens: 189, TotalTokens: 601,
+ CreatedAt: at, DurationMs: f64(934), TTFTMs: f64(210), CostUSD: f64(0.0031),
+ }
+}
+
+func TestLogsStartParsesFilters(t *testing.T) {
+ a := composerApp()
+ before := time.Now()
+ a.route("logs --since 15m --model claude-* --provider anthropic --stage response --key none --limit 25")
+
+ if a.Screen != ScreenLogs {
+ t.Fatalf("a valid filter set enters the screen, got %v", a.Screen)
+ }
+ q := a.Logs.filters
+ if q.Model != "claude-*" {
+ t.Fatalf("--model must keep its case and its glob, got %q", q.Model)
+ }
+ if q.Provider != "anthropic" || q.Stage != "response" {
+ t.Fatalf("provider/stage filters dropped: %+v", q)
+ }
+ if q.APIKeyID != "none" {
+ t.Fatalf(`--key none is the gateway's own sentinel and passes through, got %q`, q.APIKeyID)
+ }
+ if q.Limit != 25 {
+ t.Fatalf("--limit dropped, got %d", q.Limit)
+ }
+ if d := before.Sub(q.Since); d < 14*time.Minute || d > 16*time.Minute {
+ t.Fatalf("--since 15m must be 15m into the past, got %v before now", d)
+ }
+ if !strings.Contains(a.paneView(120, 10), "--model claude-*") {
+ t.Fatalf("the active filters must be on screen:\n%s", a.paneView(120, 10))
+ }
+}
+
+func TestLogsStartRejectsUnknownFlag(t *testing.T) {
+ for _, args := range []string{"--frobnicate x", "--since", "--limit banana", "claude-*"} {
+ a := composerApp()
+ if cmd := a.route("logs " + args); cmd != nil {
+ t.Fatalf("%q must not reach the network", args)
+ }
+ if a.Screen != ScreenHome {
+ t.Fatalf("%q must leave the operator on the transcript, got %v", args, a.Screen)
+ }
+ row := lastRow(a)
+ if row.Glyph != "bad" {
+ t.Fatalf("%q must be refused in a red row, got %#v", args, row)
+ }
+ if !strings.Contains(row.Text, "--since") {
+ t.Fatalf("a refusal must name the filters that do exist: %q", row.Text)
+ }
+ }
+}
+
+func TestLogsBatchPrependsBounded(t *testing.T) {
+ a := composerApp()
+ a.route("logs")
+ now := time.Now()
+
+ seed := make([]api.LogEntry, 0, 195)
+ for i := range 195 {
+ seed = append(seed, logRow(fmt.Sprintf("tr_seed%03d", i), now.Add(time.Duration(i)*time.Second)))
+ }
+ a.update(logsBatchMsg{gen: a.Logs.gen, rows: seed})
+
+ batch := make([]api.LogEntry, 0, 10)
+ for i := range 10 {
+ batch = append(batch, logRow(fmt.Sprintf("tr_new%03d", i), now.Add(time.Duration(200+i)*time.Second)))
+ }
+ a.update(logsBatchMsg{gen: a.Logs.gen, rows: batch})
+
+ if got := len(a.Logs.rows); got != logsRing {
+ t.Fatalf("the ring is bounded at %d, got %d", logsRing, got)
+ }
+ // Newest first: the last row of the newest ascending batch leads.
+ if a.Logs.rows[0].TraceID != "tr_new009" {
+ t.Fatalf("rows must be newest first, got %q", a.Logs.rows[0].TraceID)
+ }
+ if a.Logs.rows[9].TraceID != "tr_new000" {
+ t.Fatalf("a batch must keep its own order when prepended, got %q", a.Logs.rows[9].TraceID)
+ }
+}
+
+func TestLogsSelectionAndDetail(t *testing.T) {
+ a := composerApp()
+ a.route("logs")
+ now := time.Now()
+ rows := []api.LogEntry{logRow("tr_aaaaaaaa", now), logRow("tr_bbbbbbbb", now.Add(time.Second))}
+ rows[0].APIKeyID = "ak_revoked"
+ rows[1].APIKeyID = "master-key:0f21"
+ a.update(logsBatchMsg{gen: a.Logs.gen, rows: rows})
+ a.update(logsNamesMsg{gen: a.Logs.gen, names: map[string]keyRef{
+ "ak_revoked": {Name: "laptop-old", Revoked: true},
+ }})
+
+ if !a.screenKey("down") {
+ t.Fatal("the logs pane must take ↓ while the command line is empty")
+ }
+ if a.Logs.sel != 0 {
+ t.Fatalf("↓ with nothing selected takes the newest row, got %d", a.Logs.sel)
+ }
+ a.screenKey("down")
+ if a.Logs.sel != 1 {
+ t.Fatalf("↓ must move the selection, got %d", a.Logs.sel)
+ }
+ a.screenKey("up")
+ if a.Logs.sel != 0 {
+ t.Fatalf("↑ must move it back, got %d", a.Logs.sel)
+ }
+
+ // Row 0 is the newest, which is tr_bbbbbbbb with the master credential.
+ a.screenKey("enter")
+ view := a.paneView(120, 20)
+ if !strings.Contains(view, "tr_bbbbbbbb") {
+ t.Fatalf("the strip must carry the full trace id:\n%s", view)
+ }
+ if !strings.Contains(view, "master key") {
+ t.Fatalf("a master-key:* id must resolve to `master key`:\n%s", view)
+ }
+
+ a.screenKey("down")
+ a.screenKey("enter")
+ view = a.paneView(120, 20)
+ if !strings.Contains(view, "laptop-old") {
+ t.Fatalf("a resolvable api_key_id must render as its name:\n%s", view)
+ }
+ if !strings.Contains(view, "[revoked]") {
+ t.Fatalf("a key revoked since it served the traffic must carry the badge:\n%s", view)
+ }
+
+ if !a.screenKey("esc") {
+ t.Fatal("esc must close the strip before it means back")
+ }
+ if a.Logs.detail {
+ t.Fatal("esc must close the detail strip")
+ }
+ if a.screenKey("esc") {
+ t.Fatal("with no strip open, esc belongs to the composer")
+ }
+}
+
+// An unresolvable id is rendered raw. A deleted key keeps its log rows, and
+// that is exactly the case this question is most often asked about.
+func TestLogsUnresolvableKeyRendersRawID(t *testing.T) {
+ a := composerApp()
+ a.route("logs")
+ row := logRow("tr_cccccccc", time.Now())
+ row.APIKeyID = "ak_deleted"
+ a.update(logsBatchMsg{gen: a.Logs.gen, rows: []api.LogEntry{row}})
+ a.Logs.sel, a.Logs.detail = 0, true
+ if v := a.paneView(120, 20); !strings.Contains(v, "ak_deleted") {
+ t.Fatalf("an id no key store can name must render as itself:\n%s", v)
+ }
+}
+
+func TestLogsPollFailureKeepsRowsShowsStale(t *testing.T) {
+ a := composerApp()
+ a.route("logs")
+ a.update(logsBatchMsg{gen: a.Logs.gen, rows: []api.LogEntry{logRow("tr_keepme0", time.Now())}})
+
+ base := a.Logs.backoff
+ a.update(logsBatchMsg{gen: a.Logs.gen, err: errors.New("connection refused")})
+ if len(a.Logs.rows) != 1 {
+ t.Fatal("a failed poll must never blank the table")
+ }
+ if a.Logs.backoff != 2*base {
+ t.Fatalf("a failure must double the cadence, got %v", a.Logs.backoff)
+ }
+ if a.Logs.staleAt.IsZero() {
+ t.Fatal("a failed poll must record when the data went stale")
+ }
+ view := a.paneView(120, 12)
+ if !strings.Contains(view, "tr_keepme0") {
+ t.Fatalf("the rows already shown must survive:\n%s", view)
+ }
+ if !strings.Contains(view, "stale") || !strings.Contains(view, "retrying") {
+ t.Fatalf("stale data must say so and say it is still trying:\n%s", view)
+ }
+
+ for range 10 {
+ a.update(logsBatchMsg{gen: a.Logs.gen, err: errors.New("still down")})
+ }
+ if a.Logs.backoff != maxPoll {
+ t.Fatalf("backoff must cap at %v, got %v", maxPoll, a.Logs.backoff)
+ }
+
+ a.update(logsBatchMsg{gen: a.Logs.gen, rows: []api.LogEntry{logRow("tr_backnow", time.Now())}})
+ if a.Logs.backoff != logsPoll || !a.Logs.staleAt.IsZero() || a.Logs.lastErr != nil {
+ t.Fatalf("one good poll clears the staleness: backoff=%v stale=%v err=%v",
+ a.Logs.backoff, a.Logs.staleAt, a.Logs.lastErr)
+ }
+}
+
+func TestLogsNullsRenderDash(t *testing.T) {
+ a := composerApp()
+ a.route("logs")
+ row := logRow("tr_nulls000", time.Now())
+ row.DurationMs, row.TTFTMs, row.CostUSD = nil, nil, nil
+ a.update(logsBatchMsg{gen: a.Logs.gen, rows: []api.LogEntry{row}})
+ a.Logs.sel, a.Logs.detail = 0, true
+
+ view := a.paneView(126, 20)
+ line := findRow(view, "tr_nulls")
+ if line == "" {
+ t.Fatalf("the row must render:\n%s", view)
+ }
+ for _, bad := range []string{"0.0000", "$0.00", " 0 ", "0ms"} {
+ if strings.Contains(line, bad) {
+ t.Fatalf("an unmeasured value must never render as %q: %q", bad, line)
+ }
+ }
+ if !strings.Contains(line, "-") {
+ t.Fatalf("an unmeasured value renders as a dash: %q", line)
+ }
+ for _, label := range []string{"duration", "ttft", "cost"} {
+ if r := findRow(view, label); !strings.Contains(r, "-") {
+ t.Fatalf("strip row %q must render a dash for an absent measurement: %q", label, r)
+ }
+ }
+}
+
+func TestLogsTickIgnoredAfterLeave(t *testing.T) {
+ a := fixtureApp(t)
+ a.route("logs")
+ gen := a.Logs.gen
+ if !a.Logs.tailing {
+ t.Fatal("entering the logs screen starts the tail")
+ }
+
+ // esc is how an operator leaves, and it goes through the composer.
+ a.update(runCmdMsg{raw: "help"})
+ if a.Screen != ScreenHome {
+ t.Fatalf("help must leave the logs screen, got %v", a.Screen)
+ }
+ if a.Logs.tailing {
+ t.Fatal("leaving the screen must stop the tail")
+ }
+ if cmd := a.update(tickLogsMsg{gen: gen}); cmd != nil {
+ t.Fatal("a tick from a screen the operator has left must schedule nothing")
+ }
+ if cmd := a.update(tickLogsMsg{gen: a.Logs.gen}); cmd != nil {
+ t.Fatal("a tick while the pane is off screen must schedule nothing either")
+ }
+ // A batch that lands late must not repopulate a pane nobody is watching.
+ a.update(logsBatchMsg{gen: gen, rows: []api.LogEntry{logRow("tr_late0000", time.Now())}})
+ if len(a.Logs.rows) != 0 {
+ t.Fatalf("a late batch must be dropped, got %d rows", len(a.Logs.rows))
+ }
+}
+
+// A gateway with no request-log store answers 501 to every poll. That is an
+// absent feature, not an outage: the pane names it, and the tail stops rather
+// than backing off against something that will never succeed.
+func TestLogsNoStoreStopsTailingAndSaysWhy(t *testing.T) {
+ a := fixtureApp(t)
+ a.route("logs")
+ a.update(logsBatchMsg{gen: a.Logs.gen, err: &api.Error{
+ Status: 501, Code: "not_supported", Message: "no request log store configured",
+ }})
+
+ if a.Logs.tailing {
+ t.Fatal("a 501 will not become a 200 — the tail must stop")
+ }
+ if !a.Logs.staleAt.IsZero() || a.Logs.backoff != logsPoll {
+ t.Fatalf("an absent feature is not stale data: stale=%v backoff=%v", a.Logs.staleAt, a.Logs.backoff)
+ }
+ view := a.paneView(110, 10)
+ if !strings.Contains(view, "no request-log store configured") {
+ t.Fatalf("the pane must name the missing store:\n%s", view)
+ }
+ for _, bad := range []string{"stale", "retrying", "501"} {
+ if strings.Contains(view, bad) {
+ t.Fatalf("an absent store must not read as a failure (%q):\n%s", bad, view)
+ }
+ }
+}
+
+func TestLogsNoConnectionSaysSoWithoutTailing(t *testing.T) {
+ a := composerApp() // no client
+ a.route("logs")
+ if a.Logs.tailing {
+ t.Fatal("there is nothing to tail without a connection")
+ }
+ if v := a.paneView(110, 10); !strings.Contains(v, errNoConnection.Error()) {
+ t.Fatalf("the pane must say why it is empty:\n%s", v)
+ }
+}
+
+func TestLogsSelectionClearsWhenItsRowIsEvicted(t *testing.T) {
+ var s logsScreen
+ base := time.Now()
+ for i := range logsRing {
+ s.rows = append(s.rows, logRow(fmt.Sprintf("tr_%08d", i), base.Add(-time.Duration(i)*time.Second)))
+ }
+ s.sel, s.detail = len(s.rows)-1, true
+ s.prepend([]api.LogEntry{logRow("tr_new", base.Add(time.Second))})
+ if s.sel != -1 || s.detail {
+ t.Fatalf("evicted selection must close its detail strip: sel=%d detail=%v", s.sel, s.detail)
+ }
+}
+
+func TestLogsScreenKeepsThePaneContract(t *testing.T) {
+ a := composerApp()
+ a.route("logs --since 15m --model claude-sonnet-4-6")
+ now := time.Now()
+ rows := make([]api.LogEntry, 0, 12)
+ for i := range 12 {
+ r := logRow(fmt.Sprintf("tr_%08d", i), now.Add(time.Duration(i)*time.Second))
+ if i%3 == 0 {
+ r.ErrorMessage = "upstream error from provider anthropic"
+ r.Stage = "on_error"
+ }
+ rows = append(rows, r)
+ }
+ a.update(logsBatchMsg{gen: a.Logs.gen, rows: rows})
+ a.Logs.sel, a.Logs.detail = 4, true
+
+ for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} {
+ a.Theme = theme.New(mode)
+ for _, rowsN := range []int{1, 4, 9, 20} {
+ for _, w := range []int{30, 60, 92, 126} {
+ body := a.paneView(w, rowsN)
+ if got := len(strings.Split(body, "\n")); got != rowsN {
+ t.Fatalf("mode=%+v w=%d rows=%d returned %d lines", mode, w, rowsN, got)
+ }
+ for i, line := range strings.Split(body, "\n") {
+ if lw := lipgloss.Width(line); lw > w {
+ t.Fatalf("mode=%+v w=%d line %d overflows: %d cells %q", mode, w, i, lw, line)
+ }
+ }
+ }
+ }
+ }
+}
+
+// api.Follower is one-per-tail-loop state — its cursor, dedupe map and row
+// slice are unsynchronised — so the tail must never have two polls in flight.
+// The next tick is armed by the arm that handles a finished poll, and nowhere
+// else: a gateway whose /admin/logs takes longer than the interval is all it
+// would take to overlap two otherwise.
+func TestLogsPollsAreSerialised(t *testing.T) {
+ a := fixtureApp(t)
+ a.route("logs")
+
+ // A tick asks for ONE poll and schedules nothing. If it also armed the next
+ // tick, this command would answer with a batch instead of a poll result.
+ cmd := a.update(tickLogsMsg{gen: a.Logs.gen})
+ if cmd == nil {
+ t.Fatal("a tick on a live tail must poll")
+ }
+ if msg := cmd(); !isLogsBatch(msg) {
+ t.Fatalf("a tick must produce exactly one poll, got %T", msg)
+ }
+
+ // The finished poll is what arms the next tick — on both of its outcomes.
+ if cmd = a.update(logsBatchMsg{gen: a.Logs.gen,
+ rows: []api.LogEntry{logRow("tr_serial01", time.Now())}}); cmd == nil {
+ t.Fatal("a finished poll must arm the next tick")
+ }
+ // Read the armed command on the failure path, where the cadence is ours to
+ // shrink: a good poll resets it to logsPoll and running that would sleep.
+ a.Logs.backoff = time.Millisecond
+ cmd = a.update(logsBatchMsg{gen: a.Logs.gen, err: errors.New("connection refused")})
+ if cmd == nil {
+ t.Fatal("a failed poll must arm the retry")
+ }
+ if msg := cmd(); !isTickLogs(msg) {
+ t.Fatalf("a finished poll arms a tick, not another poll, got %T", msg)
+ }
+}
+
+func isLogsBatch(msg any) bool { _, ok := msg.(logsBatchMsg); return ok }
+func isTickLogs(msg any) bool { _, ok := msg.(tickLogsMsg); return ok }
+
+// A tail that has stopped arms nothing, or the loop outlives the screen.
+func TestLogsStoppedTailArmsNothing(t *testing.T) {
+ a := fixtureApp(t)
+ a.route("logs")
+ if cmd := a.update(logsBatchMsg{gen: a.Logs.gen, err: &api.Error{
+ Status: 501, Code: "not_supported", Message: "no request log store configured",
+ }}); cmd != nil {
+ t.Fatal("a 501 stops the tail — it must not arm another poll")
+ }
+}
+
+// ErrFollowTruncated arrives WITH rows: the poll worked and the cursor moved,
+// and what was lost is the oldest end of that one window. It is a readout
+// beside real rows, not a failure — backing off or dropping the rows would
+// punish a gateway whose only problem is that it is busy.
+func TestLogsTruncatedWindowKeepsRowsAndSaysSo(t *testing.T) {
+ a := fixtureApp(t)
+ a.route("logs")
+ a.update(logsBatchMsg{
+ gen: a.Logs.gen,
+ rows: []api.LogEntry{logRow("tr_trunc001", time.Now())},
+ err: api.ErrFollowTruncated,
+ })
+
+ if len(a.Logs.rows) != 1 {
+ t.Fatalf("the rows a truncated poll did return are real, got %d", len(a.Logs.rows))
+ }
+ if a.Logs.lastErr != nil || !a.Logs.staleAt.IsZero() || a.Logs.backoff != logsPoll {
+ t.Fatalf("a truncated window is not a failed poll: err=%v stale=%v backoff=%v",
+ a.Logs.lastErr, a.Logs.staleAt, a.Logs.backoff)
+ }
+ if !a.Logs.tailing {
+ t.Fatal("a truncated window must not stop the tail")
+ }
+ if view := a.paneView(110, 10); !strings.Contains(view, "older rows") {
+ t.Fatalf("the pane must say which end of the window was cut:\n%s", view)
+ }
+
+ // The next clean poll takes the notice back down.
+ a.update(logsBatchMsg{gen: a.Logs.gen, rows: []api.LogEntry{logRow("tr_trunc002", time.Now())}})
+ if a.Logs.truncated {
+ t.Fatal("a clean poll clears the truncation notice")
+ }
+}
+
+// Every pane owes exactly `rows` lines — that contract is what anchors the
+// composer to the bottom of the screen. Gateway data reaches both the table
+// (provider, model, stage) and the detail strip (error_message), so a newline
+// in either would add a line to the frame and shift everything below it.
+func TestLogsPaneKeepsItsLineCountWithControlCharactersInGatewayData(t *testing.T) {
+ a := composerApp()
+ a.route("logs")
+ r := logRow("tr_control0", time.Now())
+ r.Provider = "anth\nropic"
+ r.ErrorMessage = "upstream\nrefused \x1b[31mhard\x1b[0m"
+ a.update(logsBatchMsg{gen: a.Logs.gen, rows: []api.LogEntry{r}})
+ a.Logs.sel, a.Logs.detail = 0, true
+
+ for _, rows := range []int{4, 9, 20} {
+ out := a.Logs.view(a, 100, rows)
+ if got := strings.Count(out, "\n") + 1; got != rows {
+ t.Fatalf("view(rows=%d) returned %d lines:\n%q", rows, got, out)
+ }
+ if strings.Contains(out, "\x1b") {
+ t.Fatalf("view(rows=%d) forwarded an escape sequence:\n%q", rows, out)
+ }
+ }
+}
+
+// api.dedupeKey identifies a log row as trace + stage + created_at, because one
+// trace has one row per pipeline stage. Following the trace id alone lands the
+// cursor on whichever row of that trace arrives first, which is the silent
+// re-pointing of the detail strip prepend exists to prevent.
+func TestLogsSelectionFollowsItsRowNotItsTrace(t *testing.T) {
+ now := time.Now()
+ for _, tc := range []struct {
+ name string
+ mutate func(*api.LogEntry)
+ }{
+ {"another stage of the same trace", func(e *api.LogEntry) { e.Stage = "on_error" }},
+ {"the same trace and stage seen again later", func(e *api.LogEntry) { e.CreatedAt = now.Add(time.Second) }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var s logsScreen
+ selected := logRow("tr_onetrace", now)
+ s.rows = []api.LogEntry{selected}
+ s.sel, s.detail = 0, true
+
+ arriving := logRow("tr_onetrace", now)
+ tc.mutate(&arriving)
+ s.prepend([]api.LogEntry{arriving})
+
+ if s.sel != 1 {
+ t.Fatalf("the selection must follow its own row to index 1, got %d", s.sel)
+ }
+ got, ok := s.row()
+ if !ok || got.Stage != selected.Stage || !got.CreatedAt.Equal(selected.CreatedAt) {
+ t.Fatalf("the strip is describing a different row now: %+v", got)
+ }
+ })
+ }
+}
diff --git a/internal/tui/modal.go b/internal/tui/modal.go
new file mode 100644
index 0000000..4d2f50c
--- /dev/null
+++ b/internal/tui/modal.go
@@ -0,0 +1,291 @@
+package tui
+
+import (
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+
+ "github.com/ferro-labs/gateway-cli/internal/tui/theme"
+)
+
+// This file is the console's one overlay. It renders in place of the pane
+// rather than over it: a terminal has no transparency to dim with, and a modal
+// drawn on top of live content is a modal whose scrim is a lie about what is
+// underneath it.
+//
+// Two things here are load-bearing rather than cosmetic:
+//
+// - Confirm gates the OK arm on typing an exact phrase. A destructive
+// operation confirmed by one keypress is not confirmed, it is acknowledged.
+// - Nothing in this file writes to the transcript or the composer history.
+// A secret shown in a modal is shown here and nowhere else, which is what
+// makes "shown exactly once" true rather than aspirational.
+
+// modalKeyCol is the label column of a key/value row, wide enough for the
+// longest label the flows use ("blast radius").
+const modalKeyCol = 14
+
+// ModalKV is one key/value row of a modal card. The three flags are the only
+// emphasis a modal can apply — a row is never styled by matching on its text.
+type ModalKV struct {
+ K, V string
+ Accent bool
+ Warn bool
+ Bold bool
+}
+
+// Modal is a titled card that owns the keyboard while it is open.
+//
+// A modal has at most one editing surface: Input (a one-line editor) or
+// Options (a left/right toggle). OnConfirm receives the typed value, which for
+// a gated modal is exactly Confirm — the caller does not have to re-check it.
+type Modal struct {
+ Title, Body string
+ Rows []ModalKV
+
+ Input bool
+ Placeholder string
+ Value string
+
+ Options []string
+ Opt int
+ // OptionLabel names the toggle row. Empty falls back to "option".
+ OptionLabel string
+
+ // Err is a refusal to accept what was typed — a duration that does not
+ // parse. It renders under the input rather than replacing the body, so the
+ // instruction that was misread stays on screen next to the mistake.
+ Err string
+
+ OKLabel string
+ Danger bool
+
+ // Confirm is the phrase Value must equal before OK is enabled. Empty means
+ // enter confirms immediately.
+ Confirm string
+
+ OnConfirm func(a *App, value string) tea.Cmd
+ // OnCancel runs on esc. A flow that must finish something on the way out —
+ // recording that a secret was shown and closed, refreshing the list behind
+ // it — hangs it here, because esc is a real way to leave and not an error
+ // path.
+ OnCancel func(a *App) tea.Cmd
+}
+
+// Option is the currently selected toggle value, or "" when there is no toggle.
+func (m *Modal) Option() string {
+ if len(m.Options) == 0 {
+ return ""
+ }
+ return m.Options[m.Opt%len(m.Options)]
+}
+
+// collected is what this modal gathered: the selected option for a toggle, the
+// typed value otherwise. OnConfirm reads one field rather than branching on
+// which editing surface it was given.
+func (m *Modal) collected() string {
+ if len(m.Options) > 0 {
+ return m.Option()
+ }
+ return strings.TrimSpace(m.Value)
+}
+
+// ready reports whether enter may confirm.
+func (m *Modal) ready() bool {
+ return m.Confirm == "" || strings.TrimSpace(m.Value) == m.Confirm
+}
+
+// handle is keyed on the key's string form, exactly as the composer is: the
+// mapping from a tea.KeyPressMsg to that string is untested glue either way,
+// and keeping it out of here is what makes a confirmation flow testable.
+func (m *Modal) handle(key string, a *App) tea.Cmd {
+ switch key {
+ case keyEsc:
+ a.Modal = nil
+ if m.OnCancel != nil {
+ return m.OnCancel(a)
+ }
+ return nil
+
+ case keyEnter:
+ if !m.ready() {
+ return nil
+ }
+ a.Modal = nil
+ if m.OnConfirm != nil {
+ return m.OnConfirm(a, m.collected())
+ }
+ return nil
+
+ case keyLeft:
+ m.cycle(-1)
+ return nil
+
+ case keyRight:
+ m.cycle(1)
+ return nil
+
+ case keySpace:
+ if len(m.Options) > 0 {
+ m.cycle(1)
+ return nil
+ }
+ if m.Input {
+ m.Value += " "
+ }
+ return nil
+
+ case keyBackspace:
+ if m.Input {
+ m.Value, m.Err = trimLastRune(m.Value), ""
+ }
+ return nil
+
+ case keyCtrlU:
+ if m.Input {
+ m.Value, m.Err = "", ""
+ }
+ return nil
+ }
+
+ if r, ok := printable(key); ok && m.Input {
+ m.Value, m.Err = m.Value+string(r), ""
+ }
+ return nil
+}
+
+func (m *Modal) cycle(d int) {
+ if n := len(m.Options); n > 0 {
+ m.Opt = ((m.Opt+d)%n + n) % n
+ }
+}
+
+// view draws the card into the pane's exact-line contract: rows lines, none
+// wider than w. Below the width or height a border can close in, the card
+// degrades to its bare content rather than emitting a frame with a hole in it.
+func (m *Modal) view(a *App, w, rows int) string {
+ if rows < 4 || w < 14 {
+ return fill(m.fit(a, w, rows), w, rows)
+ }
+ return a.Theme.FrameRound(fill(m.fit(a, w-4, rows-2), w-4, rows-2), w)
+}
+
+// fit drops the blank separators while the card is taller than the budget.
+// fill truncates from the tail, and the tail is the OK arm and the "type the
+// name to enable" gate hint — the one control a destructive modal cannot
+// render without — so those blanks have to go before fill ever sees them.
+func (m *Modal) fit(a *App, w, rows int) []string {
+ out := m.lines(a, w)
+ for i := 0; i < len(out) && len(out) > rows; {
+ if out[i] == "" {
+ out = append(out[:i], out[i+1:]...)
+ continue
+ }
+ i++
+ }
+ return out
+}
+
+// lines is the card's content, in order: title, body, rows, editor, actions.
+// Blank separators are dropped as the pane gets shorter, so a 4-line modal
+// still shows its title and its OK arm.
+func (m *Modal) lines(a *App, w int) []string {
+ th := a.Theme
+ out := []string{th.Bright.Bold(th.Mode.Color).Render(m.Title)}
+ for _, line := range strings.Split(m.Body, "\n") {
+ if line == "" && m.Body == "" {
+ continue
+ }
+ out = append(out, th.Dim.Render(line))
+ }
+
+ if len(m.Rows) > 0 {
+ out = append(out, "")
+ for _, r := range m.Rows {
+ out = append(out, th.Dim.Render(padRight(r.K, modalKeyCol))+kvStyle(th, r).Render(r.V))
+ }
+ }
+
+ switch {
+ case m.Input:
+ g := th.Mode.Glyphs()
+ field := th.Accent.Bold(th.Mode.Color).Render(g.Prompt) + " "
+ if m.Value == "" {
+ field += a.Composer.cursor(a) + " " + th.Dim.Render(m.Placeholder)
+ } else {
+ field += th.Bright.Render(m.Value) + a.Composer.cursor(a)
+ }
+ out = append(out, "", field)
+ case len(m.Options) > 0:
+ label := m.OptionLabel
+ if label == "" {
+ label = "option"
+ }
+ out = append(out, "", th.Dim.Render(padRight(label, modalKeyCol))+m.options(th))
+ }
+
+ if m.Err != "" {
+ out = append(out, th.Bad.Render(m.Err))
+ }
+ return append(out, "", m.actions(a, w))
+}
+
+// kvStyle resolves a key/value row's emphasis. Shared with the detail strips,
+// which draw the same row shape outside a modal.
+func kvStyle(th theme.Theme, r ModalKV) lipgloss.Style {
+ s := th.Text
+ switch {
+ case r.Accent:
+ s = th.Accent
+ case r.Warn:
+ s = th.Warn
+ case r.Bold:
+ s = th.Bright
+ }
+ if r.Bold {
+ s = s.Bold(th.Mode.Color)
+ }
+ return s
+}
+
+// options draws the toggle. The selected value carries the accent AND the
+// brackets: colour never carries state alone, here or anywhere else.
+func (m *Modal) options(th theme.Theme) string {
+ parts := make([]string, 0, len(m.Options))
+ for i, o := range m.Options {
+ if i == m.Opt {
+ parts = append(parts, th.Accent.Bold(th.Mode.Color).Render("["+o+"]"))
+ continue
+ }
+ parts = append(parts, th.Dim.Render(" "+o+" "))
+ }
+ return strings.Join(parts, " ") + th.Faint.Render(" space toggles")
+}
+
+// actions is the footer: cancel on the left, OK on the right. A gated OK that
+// is not yet armed renders faint and says what is missing, so the arm reads as
+// disabled rather than broken.
+func (m *Modal) actions(a *App, w int) string {
+ th := a.Theme
+ label := m.OKLabel
+ if label == "" {
+ label = "ok"
+ }
+ if len(m.Options) > 0 {
+ label = m.Option() + " " + a.arrow() + " " + label
+ }
+
+ style := th.Accent
+ if m.Danger {
+ style = th.Bad
+ }
+ right := style.Bold(th.Mode.Color).Render("[ "+label+" ]") + th.Dim.Render(" ↵")
+ if a.Theme.Mode.ASCII {
+ right = style.Bold(th.Mode.Color).Render("[ "+label+" ]") + th.Dim.Render(" enter")
+ }
+ if !m.ready() {
+ right = th.Faint.Render("[ " + label + " ] type the name to enable")
+ }
+ return gapPad(w, th.Dim.Render("esc cancel"), right)
+}
diff --git a/internal/tui/modal_test.go b/internal/tui/modal_test.go
new file mode 100644
index 0000000..157d32c
--- /dev/null
+++ b/internal/tui/modal_test.go
@@ -0,0 +1,200 @@
+package tui
+
+import (
+ "strings"
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+
+ "github.com/ferro-labs/gateway-cli/internal/tui/theme"
+)
+
+// typeModal types a literal string into the open modal, mapping the space bar
+// to the key name the runtime sends. It is the modal's twin of typeIn.
+func typeModal(a *App, m *Modal, s string) {
+ for _, r := range s {
+ key := string(r)
+ if r == ' ' {
+ key = "space"
+ }
+ m.handle(key, a)
+ }
+}
+
+func TestModalEscCancels(t *testing.T) {
+ a := composerApp()
+ confirmed := false
+ a.Modal = &Modal{
+ Title: "Revoke ci-pipeline?",
+ Input: true,
+ OnConfirm: func(*App, string) tea.Cmd { confirmed = true; return nil },
+ }
+ a.update(tea.KeyPressMsg{Code: tea.KeyEscape})
+ if a.Modal != nil {
+ t.Fatal("esc must close the modal")
+ }
+ if confirmed {
+ t.Fatal("esc must never confirm")
+ }
+}
+
+func TestModalEscRunsCancelCallback(t *testing.T) {
+ a := composerApp()
+ cancelled := false
+ a.Modal = &Modal{OnCancel: func(*App) tea.Cmd {
+ return func() tea.Msg {
+ cancelled = true
+ return nil
+ }
+ }}
+ cmd := a.Modal.handle("esc", a)
+ if cmd == nil {
+ t.Fatal("cancel callback command was dropped")
+ }
+ _ = cmd()
+ if !cancelled || a.Modal != nil {
+ t.Fatal("esc must close the modal and run OnCancel")
+ }
+}
+
+func TestModalUsesConfiguredOptionLabel(t *testing.T) {
+ a := composerApp()
+ a.Modal = &Modal{Options: []string{"one", "two"}, OptionLabel: "mode"}
+ if view := a.paneView(80, 12); !strings.Contains(view, "mode") || strings.Contains(view, "scope") {
+ t.Fatalf("option label must be generic and caller-defined:\n%s", view)
+ }
+}
+
+// An open modal owns the keyboard. A typed confirmation that leaks its keys
+// into the composer is a confirmation racing a command line.
+func TestModalTakesEveryKeyFromTheComposer(t *testing.T) {
+ a := composerApp()
+ a.Modal = &Modal{Input: true}
+ a.update(tea.KeyPressMsg{Code: 'x'})
+ if a.Composer.Input != "" {
+ t.Fatalf("the composer must not see a key while a modal is open, got %q", a.Composer.Input)
+ }
+ if a.Modal.Value != "x" {
+ t.Fatalf("the modal input must receive the key, got %q", a.Modal.Value)
+ }
+}
+
+func TestModalTypedConfirmGate(t *testing.T) {
+ a := composerApp()
+ calls, got := 0, ""
+ m := &Modal{
+ Title: "Revoke ci-pipeline?", Input: true, Confirm: "ci-pipeline",
+ OnConfirm: func(_ *App, v string) tea.Cmd { calls, got = calls+1, v; return nil },
+ }
+ a.Modal = m
+
+ m.handle("enter", a)
+ typeModal(a, m, "ci-pipe")
+ m.handle("enter", a)
+ if calls != 0 {
+ t.Fatalf("a partial phrase must not confirm (%d calls)", calls)
+ }
+ if a.Modal == nil {
+ t.Fatal("a gated modal stays open until the phrase matches")
+ }
+
+ typeModal(a, m, "line")
+ m.handle("enter", a)
+ if calls != 1 || got != "ci-pipeline" {
+ t.Fatalf("the exact phrase must confirm once with the typed value, got %d calls %q", calls, got)
+ }
+ if a.Modal != nil {
+ t.Fatal("a confirmed modal closes")
+ }
+}
+
+func TestModalOptionsToggle(t *testing.T) {
+ a := composerApp()
+ m := &Modal{Options: []string{"read_only", "admin"}}
+ a.Modal = m
+ if m.Option() != "read_only" {
+ t.Fatalf("the first option is the default, got %q", m.Option())
+ }
+ m.handle("space", a)
+ if m.Option() != "admin" {
+ t.Fatalf("space must advance the toggle, got %q", m.Option())
+ }
+ m.handle("right", a)
+ if m.Option() != "read_only" {
+ t.Fatalf("the toggle wraps, got %q", m.Option())
+ }
+ m.handle("left", a)
+ if m.Option() != "admin" {
+ t.Fatalf("left steps back, got %q", m.Option())
+ }
+}
+
+// The modal renders in place of the pane, so it owes the pane's contract:
+// exactly rows lines, none wider than the frame it sits in.
+func TestModalViewKeepsThePaneContract(t *testing.T) {
+ for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} {
+ a := composerApp()
+ a.Theme = theme.New(mode)
+ a.Modal = &Modal{
+ Title: "Create API key · name",
+ Body: "Names are how log rows resolve after a key is revoked.",
+ Rows: []ModalKV{{K: "name", V: "ci-readonly", Bold: true}, {K: "scope", V: "read_only", Accent: true}},
+ Input: true,
+ Placeholder: "prod-ingest",
+ OKLabel: "next",
+ Danger: true,
+ }
+ for _, rows := range []int{1, 3, 6, 14, 22} {
+ for _, w := range []int{30, 60, 92, 126} {
+ body := a.paneView(w, rows)
+ if got := len(strings.Split(body, "\n")); got != rows {
+ t.Fatalf("mode=%+v w=%d rows=%d: modal returned %d lines", mode, w, rows, got)
+ }
+ for i, line := range strings.Split(body, "\n") {
+ if lw := lipgloss.Width(line); lw > w {
+ t.Fatalf("mode=%+v w=%d line %d overflows: %d cells %q", mode, w, i, lw, line)
+ }
+ }
+ }
+ }
+ }
+}
+
+// fill truncates from the tail, and the tail is the OK arm and the "type the
+// name to enable" gate hint. Before dropping blank separators first, a short
+// pane lost those before it lost anything cosmetic, leaving a gated
+// destructive modal with no visible confirmation control.
+func TestModalViewKeepsActionsWhenPaneIsShort(t *testing.T) {
+ a := composerApp()
+ a.Modal = &Modal{
+ Title: "Revoke ci-pipeline?",
+ Body: "Every client holding this key fails immediately.",
+ Rows: []ModalKV{{K: "name", V: "ci-pipeline", Bold: true}},
+ Input: true,
+ Confirm: "ci-pipeline",
+ OKLabel: "revoke",
+ Danger: true,
+ }
+ view := a.paneView(60, 7)
+ if !strings.Contains(view, "revoke") || !strings.Contains(view, "type the name to enable") {
+ t.Fatalf("a short pane must drop blank separators before the OK arm, got:\n%s", view)
+ }
+}
+
+// A modal body can carry intentional line breaks; rendering must preserve them
+// instead of collapsing the wording into one run-on line.
+func TestModalRendersEveryBodyLine(t *testing.T) {
+ a := composerApp()
+ a.Modal = &Modal{
+ Title: "Rotate ops-laptop?",
+ Body: "Blast radius: every client holding the current secret fails immediately\nuntil it is redeployed with the new one.",
+ OKLabel: "rotate",
+ }
+ view := a.paneView(110, 16)
+ for _, want := range []string{"Blast radius", "until it is redeployed with the new one."} {
+ if !strings.Contains(view, want) {
+ t.Fatalf("modal body lost %q:\n%s", want, view)
+ }
+ }
+}
diff --git a/internal/tui/msgs.go b/internal/tui/msgs.go
new file mode 100644
index 0000000..2a9b65b
--- /dev/null
+++ b/internal/tui/msgs.go
@@ -0,0 +1,544 @@
+package tui
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+)
+
+// This file is the shell's message vocabulary and the only place network I/O
+// is started. A screen never calls the client from its view func: it returns a
+// tea.Cmd built here, and the result arrives as one of the typed messages
+// below. Screen-specific message types live alongside the shared poll results.
+//
+// Every poll result carries the generation it was requested under. The App
+// drops a message whose gen no longer matches, so a slow in-flight response
+// cannot overwrite state fetched against a newer profile.
+
+// statusMsg is one /health + /readyz + /admin/health round trip. report and err
+// are both meaningful: a failed poll annotates the header, it never blanks it.
+type statusMsg struct {
+ gen int
+ report *api.StatusReport
+ err error
+}
+
+// trafficMsg carries the 5-minute log-stats window the TRAFFIC panel derives
+// RPS, P95 and the error rate from. A gateway with no log store answers 501,
+// which is not an error state — stats is simply nil and the panel renders "—".
+type trafficMsg struct {
+ gen int
+ stats *api.LogStats
+ err error
+}
+
+// railMsg is the fan-in of the four rail sources (admin health, plugins,
+// sessions, audit). Partial failure is normal and already encoded in RailData:
+// AuthError for a 401, a -1 count for an endpoint this gateway does not serve.
+type railMsg struct {
+ gen int
+ data RailData
+ err error
+}
+
+// runCmdMsg is a composer submission. The shell forwards the raw line to the
+// router in home.go, which is the only place a verb is interpreted.
+type runCmdMsg struct{ raw string }
+
+// verbRowsMsg is one finished transcript verb. rows and err are both
+// meaningful: `status` renders a report even when the call failed, naming the
+// URL it tried. took is measured around the request so the transcript can end
+// with a duration the operator can trust.
+type verbRowsMsg struct {
+ verb string
+ rows []TranscriptRow
+ err error
+ took time.Duration
+}
+
+// The tick messages drive the two poll cadences. They carry no payload: the
+// handler re-reads the current generation when it issues the next fetch.
+type (
+ tickStatusMsg struct{}
+ tickTrafficMsg struct{}
+)
+
+// errNoConnection is what a screen reports when it was opened with no client
+// to ask. It is a state, not a failed request: nothing was attempted.
+var errNoConnection = errors.New("no gateway connection configured")
+
+// keysListMsg is one GET /admin/keys. Every row's `key` is masked by the
+// gateway; nothing in this package ever holds an unmasked one from a read.
+type keysListMsg struct {
+ gen int
+ keys []api.Key
+ err error
+}
+
+// modalResultMsg is one finished key mutation.
+//
+// key carries the FULL secret for create and rotate — the only two responses
+// that ever will. It goes straight into the modal that shows it once and is
+// dropped when that modal closes. It is never pushed to the transcript, never
+// recorded in history, and never logged. revoke has no row to answer with, so
+// it carries the name and scope it was run against instead.
+type modalResultMsg struct {
+ gen int
+ kind string // create | rotate | revoke
+ key *api.Key
+ name, scope string
+ err error
+}
+
+// playModelsMsg is the playground's completion source and its default model.
+// A failure is not surfaced: the pane still works, the gateway is simply the
+// only authority on which names it accepts.
+type playModelsMsg struct {
+ gen int
+ models []string
+ err error
+}
+
+// chatOpenMsg is a stream that has begun. A non-2xx arriving BEFORE the stream
+// starts is an error here; once streaming has begun the status is already 200
+// and every failure arrives on the channel instead.
+type chatOpenMsg struct {
+ gen int
+ stream *api.ChatStream
+ err error
+}
+
+// The three in-stream messages. One event becomes one message and the handler
+// re-issues the reader, which is the whole pump.
+type (
+ chatDeltaMsg struct {
+ gen int
+ text string
+ }
+ chatUsageMsg struct {
+ gen int
+ usage *api.Usage
+ }
+ // chatEndMsg is terminal, and its three shapes are distinct: an error
+ // frame, a [DONE], or a channel that closed with neither — which is a
+ // truncated answer and must not be mistaken for a clean finish.
+ chatEndMsg struct {
+ gen int
+ err *api.Error
+ done bool
+ }
+ // chatMetaMsg carries the request-log row that names the provider and the
+ // cost, PLUS the turn, usage and elapsed time the fetch was issued for.
+ // Those three are pinned by fetchAttribution's caller at issue time and
+ // travel on the message rather than being resolved against screen state at
+ // arrival, because a second turn's fetch can be issued before the first
+ // turn's has landed (see playScreen.metaCmd) — carrying them here is what
+ // lets the handler match each arriving message back to its own turn. row
+ // is nil for every "cannot know" case and the meta line drops the segments
+ // it cannot support.
+ chatMetaMsg struct {
+ gen int
+ turn int
+ usage *api.Usage
+ took time.Duration
+ row *api.LogEntry
+ }
+ flushTickMsg struct{ gen int }
+)
+
+func tickFlush(gen int) tea.Cmd {
+ return tea.Tick(flushEvery, func(time.Time) tea.Msg { return flushTickMsg{gen: gen} })
+}
+
+// fetchChatModels lists what this gateway advertises. Its failure is swallowed
+// into an empty list: a playground that refuses to open because /v1/models was
+// unreachable is worse than one that cannot pre-validate a model name.
+func fetchChatModels(c *api.Client, gen int) tea.Cmd {
+ return func() tea.Msg {
+ if c == nil {
+ return playModelsMsg{gen: gen, err: errNoConnection}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
+ defer cancel()
+ models, err := c.Models(ctx)
+ if err != nil {
+ return playModelsMsg{gen: gen, err: err}
+ }
+ ids := make([]string, 0, len(models))
+ for _, m := range models {
+ ids = append(ids, m.ID)
+ }
+ return playModelsMsg{gen: gen, models: ids}
+ }
+}
+
+// openChat starts one streamed completion. The context is deliberately NOT
+// bounded by fetchTimeout: a long answer is not a stuck request, and the
+// gateway already applies its own idle bound. ChatStream.Cancel is what ends it.
+func openChat(ctx context.Context, c *api.Client, gen int, req api.ChatRequest) tea.Cmd {
+ return func() tea.Msg {
+ if c == nil {
+ return chatOpenMsg{gen: gen, err: errNoConnection}
+ }
+ st, err := c.StreamChat(ctx, req)
+ return chatOpenMsg{gen: gen, stream: st, err: err}
+ }
+}
+
+// readStream turns the next event into a message. Err and Done are both
+// terminal and mutually exclusive — a stream that fails mid-flight ends with an
+// error frame and NO [DONE] — so this reads until the channel closes and treats
+// a bare close as its own terminal case rather than waiting for a Done that is
+// never coming.
+func readStream(gen int, ev <-chan api.StreamEvent) tea.Cmd {
+ return func() tea.Msg {
+ e, ok := <-ev
+ switch {
+ case !ok:
+ return chatEndMsg{gen: gen}
+ case e.Err != nil:
+ return chatEndMsg{gen: gen, err: e.Err}
+ case e.Done:
+ return chatEndMsg{gen: gen, done: true}
+ case e.Chunk != nil && e.Chunk.Usage != nil:
+ return chatUsageMsg{gen: gen, usage: e.Chunk.Usage}
+ }
+ var b strings.Builder
+ if e.Chunk != nil {
+ for _, c := range e.Chunk.Choices {
+ b.WriteString(c.Delta.Content)
+ }
+ }
+ return chatDeltaMsg{gen: gen, text: b.String()}
+ }
+}
+
+// fetchAttribution resolves which provider served a streamed answer and what it
+// cost. The log row is written after the stream ends, hence the one retry.
+// Every failure answers a nil row: attribution is a detail beside an answer the
+// operator already has, and failing over its absence would turn a cosmetic gap
+// into a broken chat.
+//
+// turn, usage and took are the caller's pinned facts about the turn this fetch
+// was issued for (see playScreen.metaCmd) — they are echoed back on every
+// returned chatMetaMsg, success or failure alike, so the handler can match the
+// message to its turn without consulting screen state that a second, later
+// fetch may have since moved on from.
+func fetchAttribution(c *api.Client, gen, turn int, usage *api.Usage, took time.Duration, traceID string, since time.Time) tea.Cmd {
+ return func() tea.Msg {
+ miss := chatMetaMsg{gen: gen, turn: turn, usage: usage, took: took}
+ if c == nil || traceID == "" {
+ return miss
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
+ defer cancel()
+ for attempt := range 2 {
+ if attempt > 0 {
+ select {
+ case <-ctx.Done():
+ return miss
+ case <-time.After(attributionRetry):
+ }
+ }
+ row, err := c.TraceAttribution(ctx, traceID, since)
+ if err != nil {
+ return miss
+ }
+ if row != nil {
+ miss.row = row
+ return miss
+ }
+ }
+ return miss
+ }
+}
+
+// logsBatchMsg is one tail poll. rows and err are both meaningful: a failure
+// annotates the table, it never empties it.
+type logsBatchMsg struct {
+ gen int
+ rows []api.LogEntry
+ err error
+}
+
+// logsNamesMsg resolves api_key_id → name for the detail strip. Its failure is
+// swallowed into an empty map on purpose: a caller who can read the logs but
+// not the key store still gets the logs, with raw ids.
+type logsNamesMsg struct {
+ gen int
+ names map[string]keyRef
+}
+
+// tickLogsMsg drives the tail. It carries the generation it was scheduled
+// under, so a tick outliving its screen schedules nothing.
+type tickLogsMsg struct{ gen int }
+
+func tickLogs(d time.Duration, gen int) tea.Cmd {
+ return tea.Tick(d, func(time.Time) tea.Msg { return tickLogsMsg{gen: gen} })
+}
+
+func pollLogs(f *api.Follower, gen int) tea.Cmd {
+ return func() tea.Msg {
+ ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
+ defer cancel()
+ rows, err := f.Poll(ctx)
+ return logsBatchMsg{gen: gen, rows: rows, err: err}
+ }
+}
+
+// fetchLogKeyNames reads the key store once per tail so the detail strip can
+// name a credential. A key revoked since it served the traffic still names its
+// rows — that is what key names are for — so the revoked flag travels with it.
+func fetchLogKeyNames(c *api.Client, gen int) tea.Cmd {
+ return func() tea.Msg {
+ names := map[string]keyRef{}
+ if c == nil {
+ return logsNamesMsg{gen: gen, names: names}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
+ defer cancel()
+ keys, err := c.Keys(ctx)
+ if err != nil {
+ return logsNamesMsg{gen: gen, names: names} // raw ids beat no logs
+ }
+ for _, k := range keys {
+ names[k.ID] = keyRef{Name: k.Name, Revoked: api.KeyState(k) == api.KeyStateRevoked}
+ }
+ return logsNamesMsg{gen: gen, names: names}
+ }
+}
+
+func fetchKeys(c *api.Client, gen int) tea.Cmd {
+ return func() tea.Msg {
+ if c == nil {
+ return keysListMsg{gen: gen, err: errNoConnection}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
+ defer cancel()
+ keys, err := c.Keys(ctx)
+ return keysListMsg{gen: gen, keys: keys, err: err}
+ }
+}
+
+func createKey(c *api.Client, gen int, req api.KeyCreateRequest) tea.Cmd {
+ return func() tea.Msg {
+ if c == nil {
+ return modalResultMsg{gen: gen, kind: actionCreate, err: errNoConnection}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
+ defer cancel()
+ k, err := c.CreateKey(ctx, req)
+ return modalResultMsg{gen: gen, kind: actionCreate, key: k, err: err}
+ }
+}
+
+func rotateKey(c *api.Client, gen int, id string) tea.Cmd {
+ return func() tea.Msg {
+ if c == nil {
+ return modalResultMsg{gen: gen, kind: actionRotate, err: errNoConnection}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
+ defer cancel()
+ k, err := c.RotateKey(ctx, id)
+ return modalResultMsg{gen: gen, kind: actionRotate, key: k, err: err}
+ }
+}
+
+func revokeKey(c *api.Client, gen int, id, name, scope string) tea.Cmd {
+ return func() tea.Msg {
+ if c == nil {
+ return modalResultMsg{gen: gen, kind: actionRevoke, err: errNoConnection}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
+ defer cancel()
+ // RevokeKey answers with no row: the gateway confirms {"status":
+ // "revoked"} and the refreshed listing is what shows the new state.
+ err := c.RevokeKey(ctx, id)
+ return modalResultMsg{gen: gen, kind: actionRevoke, name: name, scope: scope, err: err}
+ }
+}
+
+// Poll cadences and the failure backoff ceiling. A failing poll doubles its
+// interval up to maxPoll and resets on the first success, so an unreachable
+// gateway is not hammered every 5s while its stale-data age keeps climbing.
+const (
+ statusPoll = 5 * time.Second
+ trafficPoll = 10 * time.Second
+ maxPoll = 30 * time.Second
+
+ // fetchTimeout bounds one poll. It is longer than the client's own 15s
+ // request timeout would need to be for a single call because a rail fetch
+ // makes four.
+ fetchTimeout = 20 * time.Second
+
+ // trafficWindow is the log-stats window; RPS divides by its seconds.
+ trafficWindow = 5 * time.Minute
+
+ // auditRows is one transcript page of the audit trail. The gateway caps at
+ // 200; the transcript ring is 60, so asking for more only drops rows.
+ auditRows = 50
+)
+
+func fetchStatus(c *api.Client, gen int) tea.Cmd {
+ return func() tea.Msg {
+ if c == nil {
+ return statusMsg{gen: gen, err: errNoConnection}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
+ defer cancel()
+ r, err := c.Status(ctx)
+ return statusMsg{gen: gen, report: r, err: err}
+ }
+}
+
+func fetchTraffic(c *api.Client, gen int) tea.Cmd {
+ return func() tea.Msg {
+ if c == nil {
+ return trafficMsg{gen: gen, err: errNoConnection}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
+ defer cancel()
+ s, err := c.LogStats(ctx, api.LogsQuery{Since: time.Now().Add(-trafficWindow)})
+ if api.IsNotSupported(err) {
+ // No log store configured. Not an error state — just no numbers.
+ return trafficMsg{gen: gen}
+ }
+ return trafficMsg{gen: gen, stats: s, err: err}
+ }
+}
+
+// fetchRail collects the left rail in one command. Each source degrades on its
+// own: an unsupported endpoint leaves its count at -1 ("—") and a credential
+// problem sets AuthError, because the rail is exactly where an operator should
+// see that ferro is connected but not authorized.
+//
+// prev is the rail's last good snapshot. Each of the four sources overwrites
+// only ITS OWN slice/counters when it succeeds; a source that fails this round
+// leaves prev's values for it untouched rather than zeroing them, so one
+// flaky source no longer blanks CONNECTED PROVIDERS or MCP just because the
+// other three answered fine. The caller still learns the round had a problem
+// via railMsg.err — this only stops a transient failure from being drawn as
+// "nothing to report" instead of "the last good answer, aging".
+func fetchRail(c *api.Client, gen int, prev RailData) tea.Cmd {
+ return func() tea.Msg {
+ d := prev
+ // AuthError is a verdict about THIS round, not a running tally: it is
+ // re-earned every poll, or a since-fixed 401 would stay stuck on
+ // screen after the credential is corrected.
+ d.AuthError = false
+ if c == nil {
+ return railMsg{gen: gen, data: d, err: errNoConnection}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
+ defer cancel()
+ var firstErr error
+ // note reports whether this source failed. A 404/501 is not a failure —
+ // it is a gateway that does not serve that endpoint, which leaves the
+ // counter at unknownCount and renders as a dash.
+ note := func(err error) bool {
+ if err == nil {
+ return false
+ }
+ if api.IsUnauthorized(err) {
+ d.AuthError = true
+ }
+ if firstErr == nil && !api.IsNotSupported(err) {
+ firstErr = err
+ }
+ return true
+ }
+
+ type result struct {
+ kind string
+ health *api.AdminHealth
+ plugins []api.PluginInfo
+ sessions []api.Session
+ audit *api.AuditPage
+ err error
+ }
+ results := make(chan result, 4)
+ go func() {
+ value, err := c.AdminHealth(ctx)
+ results <- result{kind: "health", health: value, err: err}
+ }()
+ go func() {
+ value, err := c.Plugins(ctx)
+ results <- result{kind: verbPlugins, plugins: value, err: err}
+ }()
+ go func() {
+ value, err := c.Sessions(ctx)
+ results <- result{kind: verbSessions, sessions: value, err: err}
+ }()
+ since := time.Now()
+ since = time.Date(since.Year(), since.Month(), since.Day(), 0, 0, 0, 0, since.Location())
+ go func() {
+ value, err := c.Audit(ctx, api.AuditQuery{Since: since, Limit: 1})
+ results <- result{kind: verbAudit, audit: value, err: err}
+ }()
+
+ for range 4 {
+ r := <-results
+ if note(r.err) {
+ continue
+ }
+ switch r.kind {
+ case "health":
+ d.Providers = r.health.Providers
+ // Recount from this round's body instead of adding to the last
+ // one's. d starts as prev so a failed source keeps its previous
+ // value, which makes every ++ here a running total unless the
+ // counter is cleared first — two good polls would report twice
+ // the MCP servers that exist. Providers above overwrites, and
+ // PluginsActive below clears for this same reason.
+ d.MCPTotal, d.MCPReady = 0, 0
+ for _, s := range r.health.MCPServers {
+ d.MCPTotal++
+ if s.Ready {
+ d.MCPReady++
+ }
+ }
+ case verbPlugins:
+ d.PluginsActive = 0
+ for _, p := range r.plugins {
+ if p.Enabled {
+ d.PluginsActive++
+ }
+ }
+ case verbSessions:
+ d.Sessions = len(r.sessions)
+ case verbAudit:
+ d.AuditToday = r.audit.Summary.TotalEntries
+ }
+ }
+ return railMsg{gen: gen, data: d, err: firstErr}
+ }
+}
+
+// verbFetch runs one transcript verb off the render path. Timing lives here,
+// where the clock is; formatting the duration and the failure into rows is the
+// update arm's job, which keeps it pure and testable.
+func verbFetch(c *api.Client, verb string) tea.Cmd {
+ return func() tea.Msg {
+ start := time.Now()
+ if c == nil {
+ return verbRowsMsg{verb: verb, err: errNoConnection, took: time.Since(start)}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
+ defer cancel()
+ rows, err := verbRows(ctx, c, verb)
+ return verbRowsMsg{verb: verb, rows: rows, err: err, took: time.Since(start)}
+ }
+}
+
+func tickStatus(d time.Duration) tea.Cmd {
+ return tea.Tick(d, func(time.Time) tea.Msg { return tickStatusMsg{} })
+}
+
+func tickTraffic(d time.Duration) tea.Cmd {
+ return tea.Tick(d, func(time.Time) tea.Msg { return tickTrafficMsg{} })
+}
diff --git a/internal/tui/panetitle_probe_test.go b/internal/tui/panetitle_probe_test.go
new file mode 100644
index 0000000..d275741
--- /dev/null
+++ b/internal/tui/panetitle_probe_test.go
@@ -0,0 +1,41 @@
+package tui
+
+import (
+ "strings"
+ "testing"
+)
+
+// A demo recording showed "COMMAND OUTPUT" rendered twice in the pane after
+// navigating to another screen and back. mainFrame prepends the title and
+// homeView returns only the transcript, so the pane title must appear exactly
+// once in a render — on Home, and after any round trip through another screen.
+func TestPaneTitleRendersExactlyOnce(t *testing.T) {
+ a := testApp(126, 34)
+ a.Transcript.Push(TranscriptRow{Glyph: "$", Text: "ferro status"})
+
+ count := func(where string) int {
+ n := strings.Count(a.render(), "COMMAND OUTPUT")
+ t.Logf("%s: %d occurrence(s)", where, n)
+ return n
+ }
+
+ if got := count("home, first render"); got != 1 {
+ t.Fatalf("pane title must render once on Home, got %d", got)
+ }
+
+ // Round trip: Home -> playground -> Home, which is what the recording did.
+ a.Screen = ScreenPlayground
+ _ = a.render()
+ a.Screen = ScreenHome
+ if got := count("home, after a playground round trip"); got != 1 {
+ t.Fatalf("pane title duplicated after returning to Home: %d occurrences", got)
+ }
+
+ // And through the logs screen, the other transition the demo makes.
+ a.Screen = ScreenLogs
+ _ = a.render()
+ a.Screen = ScreenHome
+ if got := count("home, after a logs round trip"); got != 1 {
+ t.Fatalf("pane title duplicated after returning from logs: %d occurrences", got)
+ }
+}
diff --git a/internal/tui/playground.go b/internal/tui/playground.go
new file mode 100644
index 0000000..1233957
--- /dev/null
+++ b/internal/tui/playground.go
@@ -0,0 +1,679 @@
+package tui
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/table"
+)
+
+// The playground is a streamed chat with the route metadata a gateway operator
+// actually needs beside the answer. Three things here are not obvious:
+//
+// - Deltas are BATCHED. Each one appends to a buffer and a tick moves the
+// buffer into the turn every flushEvery, which is where the single markdown
+// re-render happens. Rendering per token turns a 400-token answer into 400
+// full re-layouts and the terminal into a slideshow.
+// - A streamed body names no provider and carries no price. Both come from
+// the request log, matched on the response's X-Request-ID — so the meta
+// line is assembled from two sources and every segment it cannot support is
+// dropped. An invented provider is worse than a shorter line.
+// - Answers are rendered as predictable, word-wrapped plain text. The
+// renderer remains replaceable without changing the stream state machine.
+
+const (
+ // flushEvery is the re-render cadence. Fast enough to read as streaming,
+ // slow enough that one render covers many tokens.
+ flushEvery = 80 * time.Millisecond
+
+ // attributionRetry is the single re-ask. The log row is written after the
+ // stream ends, so the first look can legitimately be too early.
+ attributionRetry = 500 * time.Millisecond
+
+ // attributionSlack widens the log window backwards from the moment the
+ // request started. created_at is stamped with the GATEWAY's clock and the
+ // cursor with ours, so a gateway running even slightly behind writes a row
+ // that looks older than the request that produced it — and attribution then
+ // fails permanently and silently for that deployment. The window is only a
+ // prefilter: the row is still matched on its exact trace id, which is
+ // unique, so widening it cannot select the wrong request.
+ attributionSlack = 2 * time.Minute
+
+ // modelSuggestions is how many near misses a rejected /model names.
+ modelSuggestions = 5
+
+ // playTurnMax bounds both rendered transcript work and the conversation
+ // retained in memory. It is intentionally a turn count, not a byte count.
+ playTurnMax = 60
+)
+
+// The two sides of the conversation. Who is compared against these all over
+// this file — to find the turn being written into, to decide a label's style,
+// to map a turn onto an API role — so the strings are named rather than
+// repeated: a misspelt one reads as a third speaker nothing ever matches.
+const (
+ whoYou = "you"
+ whoGateway = "gateway"
+)
+
+// chatTurn is one side of the conversation. lines is the rendered body, cached
+// at flush time: the view is called once per message and must not re-run a
+// markdown pass to answer.
+type chatTurn struct {
+ Who string // whoYou | whoGateway
+ Text string
+ Meta string
+ Bad bool
+ Note bool
+
+ // id names this turn for as long as it exists. Attribution arrives after
+ // the turn it describes has stopped being the last one, and appendTurns
+ // trims from the head, so a position is not a name.
+ id int
+
+ lines []string
+}
+
+// playScreen is the chat pane.
+type playScreen struct {
+ turns []chatTurn
+ lastID int // last id handed out by appendTurns
+ pending strings.Builder
+
+ // queued holds a `chat ` prompt while model discovery is in
+ // flight. submit snapshots s.model into the request, so submitting before
+ // the first playModelsMsg lands sends an empty model.
+ queued string
+
+ stream *api.ChatStream
+ openCancel context.CancelFunc
+ requestID string
+ started time.Time
+ took time.Duration
+ usage *api.Usage
+ busy bool
+
+ model string
+ models []string
+
+ width int
+ // renderer lays an answer out. It is a field so another renderer can be
+ // introduced independently, and so tests can count layout passes.
+ renderer func(text string, w int) string
+
+ gen int
+}
+
+// ------------------------------------------------------------------ lifecycle
+
+func (s *playScreen) enter(a *App, rest string) tea.Cmd {
+ if a.Screen != ScreenPlayground {
+ s.gen++
+ }
+ a.Screen = ScreenPlayground
+ s.resize(a.paneWidth())
+
+ var cmds []tea.Cmd
+ discovering := len(s.models) == 0 && a.Client != nil
+ if discovering {
+ cmds = append(cmds, fetchChatModels(a.Client, s.gen))
+ }
+ // `chat ` is one line that opens the pane and asks: the scriptable
+ // verb takes a prompt, so the console's twin of it must too.
+ //
+ // It waits for discovery when discovery is running. submit copies s.model
+ // into the request there and then, and until playModelsMsg picks a default
+ // that field is empty — so the first console chat would be sent naming no
+ // model at all and refused by a gateway that would have served it.
+ if rest != "" {
+ if discovering {
+ s.queued = rest
+ } else {
+ cmds = append(cmds, s.submit(a, rest))
+ }
+ }
+ if len(cmds) == 0 {
+ return nil
+ }
+ return tea.Batch(cmds...)
+}
+
+// leave cancels the stream and invalidates the visit. Cancel is what releases
+// the reader goroutine and the connection, and it is safe to call more than
+// once — so it is called here whether or not the turn finished cleanly.
+func (s *playScreen) leave() {
+ s.gen++
+ s.cancel()
+ s.busy = false
+ s.queued = ""
+ s.pending.Reset()
+}
+
+func (s *playScreen) cancel() {
+ if s.openCancel != nil {
+ s.openCancel()
+ s.openCancel = nil
+ }
+ if s.stream != nil {
+ s.stream.Cancel()
+ s.stream = nil
+ }
+}
+
+// resize re-wraps every turn. The markdown pass runs off the render path, so a
+// width change is the only other thing that can invalidate it.
+func (s *playScreen) resize(w int) {
+ if w <= 0 || w == s.width {
+ return
+ }
+ s.width = w
+ if s.renderer == nil {
+ return
+ }
+ for i := range s.turns {
+ s.turns[i].lines = s.wrap(s.turns[i].Text)
+ }
+}
+
+// --------------------------------------------------------------------- input
+
+// route is the playground's half of the command/prompt decision.
+//
+// It reports whether the playground claimed the line. The check cannot key on a
+// leading slash: the composer strips it before route() is reached, so `/status`
+// and `status` are the same string by then. What is asked instead is whether the
+// head names a verb — the console's vocabulary is the cobra tree's, so "does
+// this name something ferro can run" is a question with an exact answer.
+func (s *playScreen) route(a *App, head, rest, line string) (tea.Cmd, bool) {
+ switch head {
+ case verbModel:
+ s.setModel(a, rest)
+ return nil, true
+ case verbClear:
+ s.reset()
+ return nil, true
+ }
+ if knownVerb(a.Composer.verbs, head) {
+ return nil, false
+ }
+ return s.submit(a, line), true
+}
+
+// knownVerb reports whether head names something the router can run.
+func knownVerb(verbs []string, head string) bool {
+ switch head {
+ case verbHelp, "?", verbClear, verbVersion:
+ return true
+ }
+ for _, v := range verbs {
+ if v == head || strings.HasPrefix(v, head+" ") {
+ return true
+ }
+ }
+ return false
+}
+
+// setModel switches the model the next turn is sent to, refusing anything the
+// gateway does not advertise. A model chosen here and refused by the gateway
+// would fail one turn later with an error naming neither.
+func (s *playScreen) setModel(a *App, id string) {
+ id = strings.TrimSpace(id)
+ if id == "" {
+ s.note(a, "model is "+s.model, false)
+ return
+ }
+ for _, m := range s.models {
+ if m == id {
+ s.model = id
+ s.note(a, "model is "+id, false)
+ return
+ }
+ }
+ if len(s.models) == 0 {
+ // Nothing to check against is not the same as a bad name; adopt it and
+ // let the gateway be the authority, which it is either way.
+ s.model = id
+ s.note(a, "model is "+id+" (this gateway advertised no model list)", false)
+ return
+ }
+ s.note(a, fmt.Sprintf("no model named %q — try %s", id, strings.Join(s.nearest(id), ", ")), true)
+}
+
+// nearest offers the models closest to what was typed: shared prefix first,
+// then the head of the list, so a refusal always names something runnable.
+func (s *playScreen) nearest(id string) []string {
+ // Three RUNES, not three bytes. id is whatever the operator typed, so a
+ // byte slice cuts a non-ASCII name at an arbitrary point: for a CJK name
+ // three bytes is ONE character, which matches far more than the near miss
+ // this reads as, and for other scripts it lands mid-rune and the comparison
+ // becomes a byte accident. Hoisted out of the loop while it is here.
+ head := []rune(id)
+ prefix := string(head[:min(len(head), 3)])
+ var out []string
+ for _, m := range s.models {
+ if strings.HasPrefix(m, prefix) {
+ out = append(out, m)
+ }
+ }
+ if len(out) == 0 {
+ out = s.models
+ }
+ return out[:min(len(out), modelSuggestions)]
+}
+
+// note is how the playground answers a command: as a turn in the conversation,
+// because that is where the operator is looking. The transcript is on another
+// pane and a red row pushed there would not be seen.
+func (s *playScreen) note(_ *App, text string, bad bool) {
+ s.appendTurns(chatTurn{Who: whoGateway, Text: text, Bad: bad, Note: true, lines: []string{text}})
+}
+
+func (s *playScreen) appendTurns(turns ...chatTurn) {
+ for i := range turns {
+ s.lastID++
+ turns[i].id = s.lastID
+ }
+ s.turns = append(s.turns, turns...)
+ if len(s.turns) <= playTurnMax {
+ return
+ }
+ s.turns = s.turns[len(s.turns)-playTurnMax:]
+ // A chat answer belongs with the user prompt immediately before it. If a
+ // one-turn note pushed only that prompt out, discard the orphaned answer.
+ for i, turn := range s.turns {
+ if turn.Note {
+ continue
+ }
+ if turn.Who == whoGateway {
+ s.turns = append(s.turns[:i], s.turns[i+1:]...)
+ }
+ break
+ }
+}
+
+func (s *playScreen) reset() {
+ s.cancel()
+ s.gen++
+ s.turns, s.usage, s.busy, s.requestID = nil, nil, false, ""
+ s.queued = ""
+ s.pending.Reset()
+}
+
+// submit opens a turn. The operator's line lands immediately and an empty
+// gateway turn is opened beside it, so the answer streams into a slot that is
+// already on screen rather than appearing all at once at the end.
+func (s *playScreen) submit(a *App, prompt string) tea.Cmd {
+ prompt = strings.TrimSpace(prompt)
+ if prompt == "" {
+ return nil
+ }
+ if s.busy {
+ s.note(a, "a turn is still streaming — wait for it, or run clear", true)
+ return nil
+ }
+ if s.renderer == nil {
+ s.renderer = wrapPlain
+ }
+ if s.width <= 0 {
+ s.width = a.paneWidth()
+ }
+
+ s.appendTurns(
+ chatTurn{Who: whoYou, Text: prompt, lines: s.wrap(prompt)},
+ chatTurn{Who: whoGateway})
+ s.usage, s.busy, s.started, s.requestID = nil, true, time.Now(), ""
+ s.pending.Reset()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ s.openCancel = cancel
+ return openChat(ctx, a.Client, s.gen, api.ChatRequest{
+ Model: s.model,
+ Messages: s.history(),
+ })
+}
+
+// history is the conversation as the gateway sees it. Failed turns are left
+// out: a turn that ended in a stream error has no answer, and sending its
+// partial text back as though the model had said it is a fabrication.
+func (s *playScreen) history() []api.ChatMessage {
+ out := make([]api.ChatMessage, 0, len(s.turns))
+ for _, t := range s.turns {
+ if t.Text == "" || t.Bad || t.Note {
+ continue
+ }
+ role := "assistant"
+ if t.Who == whoYou {
+ role = "user"
+ }
+ out = append(out, api.ChatMessage{Role: role, Content: t.Text})
+ }
+ return out
+}
+
+// -------------------------------------------------------------------- update
+
+func (s *playScreen) update(a *App, msg tea.Msg) tea.Cmd {
+ switch m := msg.(type) {
+ case playModelsMsg:
+ if m.gen != s.gen {
+ return nil
+ }
+ if m.err == nil {
+ s.models = m.models
+ if s.model == "" && len(s.models) > 0 {
+ s.model = s.models[0]
+ }
+ }
+ // A queued prompt is released either way. A failed listing leaves the
+ // gateway as the only authority on which names it accepts, which it is
+ // regardless — refusing to ask would be worse than asking and being
+ // told why.
+ if q := s.queued; q != "" {
+ s.queued = ""
+ return s.submit(a, q)
+ }
+ return nil
+
+ case chatOpenMsg:
+ if m.gen != s.gen {
+ // A stream opened for a visit the operator has left still owns a
+ // connection and a goroutine. Cancel it rather than leak it.
+ if m.stream != nil {
+ m.stream.Cancel()
+ }
+ return nil
+ }
+ if m.err != nil {
+ return s.fail(a, "gateway: "+m.err.Error())
+ }
+ if m.stream == nil {
+ return s.fail(a, "gateway: stream opened without a response")
+ }
+ s.stream, s.requestID = m.stream, m.stream.RequestID
+ return tea.Batch(readStream(s.gen, m.stream.Events), tickFlush(s.gen))
+
+ case chatDeltaMsg:
+ if m.gen != s.gen || !s.busy {
+ return nil
+ }
+ s.pending.WriteString(m.text)
+ return s.next()
+
+ case chatUsageMsg:
+ if m.gen != s.gen || !s.busy {
+ return nil
+ }
+ s.usage = m.usage
+ return s.next()
+
+ case chatEndMsg:
+ if m.gen != s.gen || !s.busy {
+ return nil
+ }
+ s.flush(a)
+ s.took = time.Since(s.started)
+ s.cancel()
+ s.busy = false
+ switch {
+ case m.err != nil:
+ s.markFailed(streamFailure(m.err))
+ return nil
+ case !m.done:
+ // The channel closed with neither a terminator nor an error. The
+ // answer is truncated and saying so is the whole job.
+ s.markFailed("gateway: the stream ended with no terminator — the answer above is truncated")
+ return nil
+ }
+ return s.metaCmd(a)
+
+ case chatMetaMsg:
+ if m.gen != s.gen {
+ return nil
+ }
+ for i := range s.turns {
+ if s.turns[i].id == m.turn {
+ s.turns[i].Meta = metaLine(m.usage, m.row, m.took)
+ break
+ }
+ }
+ return nil
+
+ case flushTickMsg:
+ if m.gen != s.gen {
+ return nil
+ }
+ s.flush(a)
+ if !s.busy {
+ return nil
+ }
+ return tickFlush(s.gen)
+ }
+ return nil
+}
+
+// next keeps the reader pumping. One event per command, re-issued until the
+// stream ends — the standard Bubble Tea channel pump.
+func (s *playScreen) next() tea.Cmd {
+ if s.stream == nil {
+ return nil
+ }
+ return readStream(s.gen, s.stream.Events)
+}
+
+// answer is the index of the turn currently being written into.
+func (s *playScreen) answer() int {
+ for i := len(s.turns) - 1; i >= 0; i-- {
+ if s.turns[i].Who == whoGateway && !s.turns[i].Note {
+ return i
+ }
+ }
+ return -1
+}
+
+// flush moves the batched deltas into the turn and re-renders it ONCE.
+func (s *playScreen) flush(_ *App) {
+ if s.pending.Len() == 0 {
+ return
+ }
+ text := s.pending.String()
+ s.pending.Reset()
+ i := s.answer()
+ if i < 0 {
+ return
+ }
+ if s.renderer == nil {
+ s.renderer = wrapPlain
+ }
+ s.turns[i].Text += text
+ s.turns[i].lines = s.wrap(s.turns[i].Text)
+}
+
+// markFailed turns the open answer into a visible failure, keeping whatever
+// text arrived before it broke.
+func (s *playScreen) markFailed(text string) {
+ i := s.answer()
+ if i < 0 {
+ return
+ }
+ s.turns[i].Bad = true
+ if s.turns[i].Text != "" {
+ text = s.turns[i].Text + "\n" + text
+ }
+ s.turns[i].Text = text
+ s.turns[i].lines = s.wrap(text)
+}
+
+func (s *playScreen) fail(_ *App, text string) tea.Cmd {
+ s.busy = false
+ s.cancel()
+ s.markFailed(text)
+ return nil
+}
+
+// metaCmd resolves the route metadata a streamed body cannot carry. It reads
+// the turn and the two measurements the answer will be labelled with HERE,
+// because everything it reads is about to become the PREVIOUS turn's, and
+// hands them to fetchAttribution to carry on the returned command's eventual
+// message rather than pinning them in screen state.
+//
+// That distinction matters because chatEndMsg clears busy before this
+// command's result lands, so the operator can submit and finish a SECOND turn
+// while the first turn's fetch is still in flight — two fetches really can be
+// outstanding at once. A single screen-level slot would let the second turn's
+// metaCmd overwrite the turn id, usage and duration the first turn's fetch was
+// issued for, so its answer — arriving later — would be applied to the wrong
+// turn. Carrying the values on the message and matching on turn id when it
+// arrives keeps the two independent regardless of arrival order.
+func (s *playScreen) metaCmd(a *App) tea.Cmd {
+ var turn int
+ if i := s.answer(); i >= 0 {
+ turn = s.turns[i].id
+ }
+ return fetchAttribution(a.Client, s.gen, turn, s.usage, s.took, s.requestID, s.started.Add(-attributionSlack))
+}
+
+// streamFailure is the operator-facing wording for each stream error code. It
+// matches `ferro chat`'s, so the same failure reads the same in both.
+func streamFailure(e *api.Error) string {
+ switch e.Code {
+ case api.CodeStreamTimeout:
+ // Neither "gateway:" nor a hand-written 2m: the idle timer is ferro's
+ // own (api.DefaultStreamIdleTimeout), so the gateway is not who gave
+ // up, and a duration typed out here drifts the moment the constant
+ // moves. `ferro chat` says the same thing from the same constant.
+ return fmt.Sprintf("stream idled out — the gateway sent no events for %s, so ferro closed the connection",
+ api.DefaultStreamIdleTimeout)
+ case api.CodeStreamIncomplete:
+ return "gateway: stream ended mid-answer with no terminator — the answer above is truncated"
+ }
+ if e.Message == "" {
+ return "gateway: stream failed: " + e.Error()
+ }
+ return "gateway: " + e.Message
+}
+
+// metaLine assembles the route readout from the two sources that have it: the
+// usage chunk the stream sent, and the request-log row the response's
+// X-Request-ID matched. Every segment whose fact is missing is DROPPED — a
+// gateway with no log store gets a shorter line, never a guessed provider and
+// never a $0.00 that was really "unpriced".
+func metaLine(u *api.Usage, row *api.LogEntry, took time.Duration) string {
+ var parts []string
+ if row != nil && row.Provider != "" {
+ parts = append(parts, row.Provider+" → served")
+ }
+ parts = append(parts, fmt.Sprintf("%.2fs", took.Seconds()))
+ if u != nil {
+ parts = append(parts, fmt.Sprintf("%d in / %d out", u.PromptTokens, u.CompletionTokens))
+ }
+ if row != nil && row.CostUSD != nil {
+ parts = append(parts, costCell(row.CostUSD))
+ }
+ return strings.Join(parts, " · ")
+}
+
+// --------------------------------------------------------------------- view
+
+func (s *playScreen) view(a *App, w, rows int) string {
+ th := a.Theme
+ // One sub-line plus at most rows-1 body lines: the tail below is cut to
+ // exactly that before it is appended.
+ out := append(make([]string, 0, rows), s.subLine(a, w))
+
+ body := make([]string, 0, rows)
+ for i, t := range s.turns {
+ if i > 0 {
+ body = append(body, "")
+ }
+ label := th.Accent.Bold(th.Mode.Color).Render(t.Who)
+ if t.Who != whoYou {
+ label = th.Bright.Bold(th.Mode.Color).Render(t.Who)
+ }
+ body = append(body, label)
+
+ lines := t.lines
+ if len(lines) == 0 && t.Text != "" {
+ lines = strings.Split(t.Text, "\n")
+ }
+ for _, line := range lines {
+ if t.Bad {
+ line = th.Bad.Render(line)
+ }
+ body = append(body, line)
+ }
+ if t.Meta != "" {
+ body = append(body,
+ th.Hairline.Render(a.rule(min(lipgloss.Width(t.Meta), w))),
+ th.Dim.Render(t.Meta))
+ }
+ }
+ if len(s.turns) == 0 {
+ body = append(body, "", th.Dim.Render("Type a prompt to start. /model switches models, clear resets."))
+ }
+
+ // The transcript of a conversation is read from the bottom.
+ return fill(append(out, tail(body, max(rows-len(out), 0))...), w, rows)
+}
+
+func (s *playScreen) subLine(a *App, w int) string {
+ th := a.Theme
+ model := s.model
+ if model == "" {
+ model = "gateway default"
+ }
+ right := ""
+ if s.busy {
+ right = th.Accent.Render("streaming…")
+ if th.Mode.ASCII {
+ right = th.Accent.Render("streaming...")
+ }
+ }
+ return gapPad(w, " "+th.Dim.Render("/model "+model+" · clear resets · esc home"), right)
+}
+
+// tail keeps the last n lines. A chat scrolls up, so what falls off the top is
+// what the operator has already read.
+func tail(lines []string, n int) []string {
+ if n <= 0 {
+ return nil
+ }
+ if len(lines) <= n {
+ return lines
+ }
+ return lines[len(lines)-n:]
+}
+
+// ---------------------------------------------------------------- rendering
+
+func (s *playScreen) wrap(text string) []string {
+ if text == "" {
+ return nil
+ }
+ // The one chokepoint where model-supplied text becomes rendered lines:
+ // submit, flush and markFailed all arrive here, so this is where an answer
+ // stops being able to drive the terminal. wrapPlain is deliberately
+ // ANSI-AWARE and would carry a \x1b[2J or an OSC title change straight into
+ // the frame, breaking both the pane's exact-line contract and the rule that
+ // this tool forwards no terminal control from an upstream provider. LF
+ // survives — an answer's own line breaks are meaningful and the split below
+ // is what honours them. Text itself is left untouched: it is what history()
+ // sends back to the gateway as the conversation.
+ text = table.SanitizeText(text)
+ w := max(s.width, 8)
+ out := text
+ if s.renderer != nil {
+ out = s.renderer(text, w)
+ }
+ return strings.Split(strings.TrimRight(out, "\n"), "\n")
+}
+
+// wrapPlain folds an answer to the pane width. It is ANSI- and grapheme-aware,
+// so a wrapped line still measures what it draws.
+func wrapPlain(text string, w int) string { return ansi.Wrap(text, max(w, 8), "") }
diff --git a/internal/tui/playground_test.go b/internal/tui/playground_test.go
new file mode 100644
index 0000000..85ded65
--- /dev/null
+++ b/internal/tui/playground_test.go
@@ -0,0 +1,680 @@
+package tui
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "charm.land/lipgloss/v2"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+ "github.com/ferro-labs/gateway-cli/internal/tui/theme"
+)
+
+// stubStream is a ChatStream whose events the test writes itself. Cancel is
+// counted rather than asserted once, because leaving a screen and finishing a
+// turn can both reach it and both must be safe.
+func stubStream(events ...api.StreamEvent) (*api.ChatStream, *atomic.Int32) {
+ ch := make(chan api.StreamEvent, len(events)+1)
+ for _, e := range events {
+ ch <- e
+ }
+ close(ch)
+ var cancels atomic.Int32
+ return &api.ChatStream{
+ RequestID: "tr_stub0001",
+ Events: ch,
+ Cancel: func() { cancels.Add(1) },
+ }, &cancels
+}
+
+func delta(text string) api.StreamEvent {
+ return api.StreamEvent{Chunk: &api.ChatChunk{
+ Choices: []api.StreamChoice{{Delta: api.MessageDelta{Content: text}}},
+ }}
+}
+
+func usageEvent(in, out int) api.StreamEvent {
+ return api.StreamEvent{Chunk: &api.ChatChunk{
+ Usage: &api.Usage{PromptTokens: in, CompletionTokens: out, TotalTokens: in + out},
+ }}
+}
+
+// pump drains a stream into the app exactly as the reader command does.
+func pump(a *App, st *api.ChatStream) {
+ for range 64 {
+ msg := readStream(a.Play.gen, st.Events)()
+ a.update(msg)
+ if _, done := msg.(chatEndMsg); done {
+ return
+ }
+ }
+}
+
+func lastTurn(a *App) chatTurn {
+ if n := len(a.Play.turns); n > 0 {
+ return a.Play.turns[n-1]
+ }
+ return chatTurn{}
+}
+
+func TestSubmitAppendsTurnsAndStreams(t *testing.T) {
+ a := composerApp()
+ a.route("playground")
+ a.Play.submit(a, "which target serves sonnet?")
+
+ if len(a.Play.turns) != 2 {
+ t.Fatalf("a submission opens the operator's turn and the answer's, got %d", len(a.Play.turns))
+ }
+ if a.Play.turns[0].Who != "you" || a.Play.turns[0].Text != "which target serves sonnet?" {
+ t.Fatalf("the prompt must be echoed verbatim, got %#v", a.Play.turns[0])
+ }
+ if !a.Play.busy {
+ t.Fatal("a submitted turn is busy until the stream ends")
+ }
+
+ st, cancels := stubStream(delta("Ferro "), delta("routed "), delta("this."),
+ usageEvent(318, 142), api.StreamEvent{Done: true})
+ a.update(chatOpenMsg{gen: a.Play.gen, stream: st})
+ pump(a, st)
+
+ if a.Play.busy {
+ t.Fatal("[DONE] ends the turn")
+ }
+ if got := lastTurn(a).Text; got != "Ferro routed this." {
+ t.Fatalf("every delta must land in the turn, got %q", got)
+ }
+ if a.Play.usage == nil || a.Play.usage.PromptTokens != 318 {
+ t.Fatalf("the usage chunk must be kept, got %+v", a.Play.usage)
+ }
+ if cancels.Load() == 0 {
+ t.Fatal("Cancel releases the reader and the connection even after a clean Done")
+ }
+ if v := a.paneView(120, 20); !strings.Contains(v, "Ferro routed this.") {
+ t.Fatalf("the answer must be on screen:\n%s", v)
+ }
+}
+
+func TestLeavingPlaygroundCancelsStreamBeforeHeaders(t *testing.T) {
+ requestStarted := make(chan struct{})
+ releaseServer := make(chan struct{})
+ // sync.Once, not a bare close: a second request reaching this handler —
+ // a client retry, or a later test change probing the same server — must
+ // not panic the whole test binary on a double close.
+ var once sync.Once
+ srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
+ once.Do(func() { close(requestStarted) })
+ <-releaseServer
+ }))
+ defer func() {
+ close(releaseServer)
+ srv.Close()
+ }()
+ c, err := api.New(srv.URL, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := composerApp()
+ a.Client = c
+ a.route("playground")
+ cmd := a.Play.submit(a, "hello")
+ done := make(chan struct{})
+ go func() {
+ _ = cmd()
+ close(done)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ select {
+ case <-requestStarted:
+ case <-ctx.Done():
+ t.Fatal("the stream-opening request never reached the server")
+ }
+ a.Play.leave()
+ select {
+ case <-done:
+ case <-ctx.Done():
+ t.Fatal("the stream-opening command did not return after cancellation")
+ }
+}
+
+// The whole point of the flush tick: one markdown render per batch, never one
+// per token.
+func TestFlushCadenceBatchesDeltas(t *testing.T) {
+ a := composerApp()
+ a.route("playground")
+
+ var renders atomic.Int32
+ a.Play.renderer = func(text string, _ int) string {
+ renders.Add(1)
+ return text
+ }
+ a.Play.submit(a, "hello")
+ renders.Store(0)
+
+ for range 10 {
+ a.update(chatDeltaMsg{gen: a.Play.gen, text: "word "})
+ }
+ if n := renders.Load(); n != 0 {
+ t.Fatalf("a delta must not re-render; %d renders before the flush", n)
+ }
+ if lastTurn(a).Text != "" {
+ t.Fatal("deltas are batched, not written straight into the turn")
+ }
+
+ a.update(flushTickMsg{gen: a.Play.gen})
+ if n := renders.Load(); n != 1 {
+ t.Fatalf("one flush is one render, got %d", n)
+ }
+ if got := lastTurn(a).Text; got != strings.Repeat("word ", 10) {
+ t.Fatalf("the flush must move every batched delta, got %q", got)
+ }
+}
+
+func TestMetaLineFromUsageAndAttribution(t *testing.T) {
+ u := &api.Usage{PromptTokens: 318, CompletionTokens: 142, TotalTokens: 460}
+ row := &api.LogEntry{Provider: "anthropic", CostUSD: f64(0.0031)}
+
+ got := metaLine(u, row, 930*time.Millisecond)
+ for _, want := range []string{"anthropic", "served", "0.93s", "318 in / 142 out", "$0.0031"} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("meta line missing %q: %q", want, got)
+ }
+ }
+}
+
+// A gateway with no log store answers 501, which TraceAttribution reports as
+// (nil, nil). The line must lose the segments it cannot support and keep the
+// ones it measured itself — never invent a provider or a cost.
+func TestMetaWithoutLogStore(t *testing.T) {
+ u := &api.Usage{PromptTokens: 318, CompletionTokens: 142}
+ got := metaLine(u, nil, 930*time.Millisecond)
+ if !strings.Contains(got, "318 in / 142 out") || !strings.Contains(got, "0.93s") {
+ t.Fatalf("what was measured must survive: %q", got)
+ }
+ for _, bad := range []string{"served", "$", "→"} {
+ if strings.Contains(got, bad) {
+ t.Fatalf("an unattributed turn must not carry %q: %q", bad, got)
+ }
+ }
+
+ // And with no usage chunk either, the elapsed time is all there is.
+ if got := metaLine(nil, nil, time.Second); got != "1.00s" {
+ t.Fatalf("a bare turn reports only what it timed, got %q", got)
+ }
+}
+
+func TestSlashModelValidates(t *testing.T) {
+ a := composerApp()
+ a.route("playground")
+ a.update(playModelsMsg{gen: a.Play.gen, models: []string{
+ "claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5", "gpt-4o-mini", "gpt-4o",
+ }})
+ if a.Play.model != "claude-sonnet-4-6" {
+ t.Fatalf("the first advertised model is the default, got %q", a.Play.model)
+ }
+
+ a.update(runCmdMsg{raw: "model gpt-4o"})
+ if a.Play.model != "gpt-4o" {
+ t.Fatalf("/model must switch to an advertised model, got %q", a.Play.model)
+ }
+ if a.Screen != ScreenPlayground {
+ t.Fatal("/model is a playground action, not a command that leaves it")
+ }
+
+ a.update(runCmdMsg{raw: "model claude-nope"})
+ if a.Play.model != "gpt-4o" {
+ t.Fatalf("an unknown model must not be adopted, got %q", a.Play.model)
+ }
+ turn := lastTurn(a)
+ if !turn.Bad {
+ t.Fatalf("an unknown model must be refused visibly, got %#v", turn)
+ }
+ if !strings.Contains(turn.Text, "claude-sonnet-4-6") {
+ t.Fatalf("a refusal must suggest the models that do exist: %q", turn.Text)
+ }
+ if strings.Contains(transcriptText(a), "ferro model") {
+ t.Fatalf("a playground action is not a shell verb and must not be echoed:\n%s", transcriptText(a))
+ }
+}
+
+func TestPlaygroundNotesNeverEnterModelHistoryOrReceiveDeltas(t *testing.T) {
+ a := composerApp()
+ a.route("playground")
+ a.Play.renderer = wrapPlain
+ a.Play.width = 80
+ a.Play.appendTurns(
+ chatTurn{Who: "you", Text: "hello"},
+ chatTurn{Who: "gateway", Text: "answer"},
+ )
+ a.Play.note(a, "model is gpt-4o", false)
+
+ history := a.Play.history()
+ if len(history) != 2 || history[0].Content != "hello" || history[1].Content != "answer" {
+ t.Fatalf("console notes must not be sent as assistant messages: %+v", history)
+ }
+ a.Play.pending.WriteString(" delta")
+ a.Play.flush(a)
+ if lastTurn(a).Text != "model is gpt-4o" || a.Play.turns[1].Text != "answer delta" {
+ t.Fatalf("stream deltas must target the chat answer, not the note: %+v", a.Play.turns)
+ }
+}
+
+func TestPlaygroundTranscriptIsBounded(t *testing.T) {
+ s := playScreen{}
+ s.appendTurns(
+ chatTurn{Who: "you", Text: "old prompt"}, chatTurn{Who: "gateway", Text: "old answer"},
+ chatTurn{Who: "you", Text: "kept prompt"}, chatTurn{Who: "gateway", Text: "kept answer"},
+ )
+ for i := 0; i < playTurnMax-3; i++ {
+ s.appendTurns(chatTurn{Who: "gateway", Text: "note", Note: true})
+ }
+ if len(s.turns) > playTurnMax || len(s.turns) < 2 || s.turns[0].Who != "you" || s.turns[1].Who != "gateway" {
+ t.Fatalf("retention must remove orphaned answers and preserve pairing: %+v", s.turns[:min(3, len(s.turns))])
+ }
+}
+
+func TestNilOpenedStreamFailsVisibly(t *testing.T) {
+ a := composerApp()
+ a.route("playground")
+ a.Play.submit(a, "hello")
+ a.update(chatOpenMsg{gen: a.Play.gen})
+ if a.Play.busy || !lastTurn(a).Bad || !strings.Contains(lastTurn(a).Text, "without a response") {
+ t.Fatalf("nil stream must become a visible failed turn: %#v", lastTurn(a))
+ }
+}
+
+func TestLeaveCancelsStream(t *testing.T) {
+ a := composerApp()
+ a.route("playground")
+ a.Play.submit(a, "hello")
+ st, cancels := stubStream(delta("one "), delta("two "))
+ a.update(chatOpenMsg{gen: a.Play.gen, stream: st})
+ gen := a.Play.gen
+
+ a.update(chatDeltaMsg{gen: gen, text: "one "})
+ a.update(runCmdMsg{raw: "help"}) // leaves the screen
+
+ if cancels.Load() == 0 {
+ t.Fatal("leaving the playground must cancel the stream in flight")
+ }
+ if a.Play.busy {
+ t.Fatal("a cancelled turn is not busy")
+ }
+ before := lastTurn(a).Text
+ a.update(chatDeltaMsg{gen: gen, text: "two "})
+ a.update(chatUsageMsg{gen: gen, usage: &api.Usage{PromptTokens: 9}})
+ a.update(chatEndMsg{gen: gen, done: true})
+ if lastTurn(a).Text != before {
+ t.Fatalf("a delta from a screen the operator has left must be dropped, got %q", lastTurn(a).Text)
+ }
+ if a.Play.usage != nil {
+ t.Fatal("a late usage chunk must be dropped too")
+ }
+}
+
+// Every stream failure ends as a turn an operator can see. stream_incomplete
+// is the one most easily lost: it is ferro's own synthesis for a connection
+// that ended with neither a terminator nor an explanation.
+func TestStreamErrorRendersRedTurn(t *testing.T) {
+ for _, tc := range []struct {
+ code, message, want string
+ }{
+ {api.CodeStreamError, "upstream error from provider anthropic", "upstream error from provider anthropic"},
+ {api.CodeStreamTimeout, "idle bound elapsed", "stream idled out"},
+ {api.CodeStreamIncomplete, "", "terminator"},
+ } {
+ a := composerApp()
+ a.route("playground")
+ a.Play.submit(a, "hello")
+ st, cancels := stubStream(delta("partial "),
+ api.StreamEvent{Err: &api.Error{Status: 200, Code: tc.code, Message: tc.message}})
+ a.update(chatOpenMsg{gen: a.Play.gen, stream: st})
+ pump(a, st)
+
+ turn := lastTurn(a)
+ if !turn.Bad {
+ t.Fatalf("%s must render as a failed turn, got %#v", tc.code, turn)
+ }
+ if !strings.Contains(turn.Text, tc.want) {
+ t.Fatalf("%s must say %q, got %q", tc.code, tc.want, turn.Text)
+ }
+ if a.Play.busy {
+ t.Fatalf("%s ends the turn", tc.code)
+ }
+ if cancels.Load() == 0 {
+ t.Fatalf("%s must still release the stream", tc.code)
+ }
+ if v := a.paneView(110, 20); !strings.Contains(v, tc.want) {
+ t.Fatalf("%s must be visible on screen:\n%s", tc.code, v)
+ }
+ }
+}
+
+// A channel that closes with neither Done nor an error frame is the same
+// truncation, arriving a different way.
+func TestSilentStreamCloseEndsTheTurn(t *testing.T) {
+ a := composerApp()
+ a.route("playground")
+ a.Play.submit(a, "hello")
+ st, _ := stubStream(delta("partial "))
+ a.update(chatOpenMsg{gen: a.Play.gen, stream: st})
+ pump(a, st)
+
+ if a.Play.busy {
+ t.Fatal("a closed channel ends the turn")
+ }
+ if turn := lastTurn(a); !turn.Bad || !strings.Contains(turn.Text, "partial") {
+ t.Fatalf("a truncated answer keeps what arrived and says it was cut: %#v", turn)
+ }
+}
+
+// The seam the plan got wrong: the composer strips the leading slash before
+// route() sees the line, so "is this a command" cannot key on it.
+func TestPlaygroundRoutesVerbsAndPromptsApart(t *testing.T) {
+ a := composerApp()
+ a.route("playground")
+
+ a.update(runCmdMsg{raw: "status"})
+ if a.Screen != ScreenHome {
+ t.Fatal("a line naming a verb is a command, even on the playground")
+ }
+ if !strings.Contains(transcriptText(a), "ferro status") {
+ t.Fatalf("a verb must still be echoed:\n%s", transcriptText(a))
+ }
+
+ a.route("playground")
+ a.update(runCmdMsg{raw: "which target is serving Sonnet right now?"})
+ if a.Screen != ScreenPlayground {
+ t.Fatal("a prompt keeps the operator in the playground")
+ }
+ if got := a.Play.turns[0].Text; got != "which target is serving Sonnet right now?" {
+ t.Fatalf("a prompt must reach the model with its case intact, got %q", got)
+ }
+ if strings.Contains(transcriptText(a), "which target") {
+ t.Fatalf("a prompt is not a command and must not be echoed as one:\n%s", transcriptText(a))
+ }
+}
+
+func TestPlaygroundClearResetsTurnsNotTranscript(t *testing.T) {
+ a := composerApp()
+ a.route("status")
+ a.route("playground")
+ a.Play.submit(a, "hello")
+ st, cancels := stubStream(delta("hi"))
+ a.update(chatOpenMsg{gen: a.Play.gen, stream: st})
+
+ a.update(runCmdMsg{raw: "clear"})
+ if len(a.Play.turns) != 0 {
+ t.Fatalf("/clear empties the conversation, got %d turns", len(a.Play.turns))
+ }
+ if cancels.Load() == 0 {
+ t.Fatal("/clear must cancel a stream it is discarding the answer of")
+ }
+ if a.Screen != ScreenPlayground {
+ t.Fatal("/clear stays in the playground")
+ }
+ if a.Transcript.Len() == 0 {
+ t.Fatal("/clear on the playground is not the transcript's clear")
+ }
+}
+
+// End to end against the fake: real SSE framing, real decode, real attribution.
+func TestPlaygroundStreamsAgainstTheFixture(t *testing.T) {
+ a := fixtureApp(t)
+ a.route("playground")
+ a.Play.model = "claude-sonnet-4-6"
+
+ msg := a.Play.submit(a, "hello")()
+ open, ok := msg.(chatOpenMsg)
+ if !ok || open.err != nil {
+ t.Fatalf("opening a stream against the fake must succeed, got %#v", msg)
+ }
+ a.update(open)
+ pump(a, open.stream)
+ a.update(flushTickMsg{gen: a.Play.gen})
+
+ if got := lastTurn(a).Text; !strings.Contains(got, "Ferro routed this through the fake gateway.") {
+ t.Fatalf("the fake's whole answer must arrive, got %q", got)
+ }
+ if a.Play.usage == nil || a.Play.usage.PromptTokens != 24 {
+ t.Fatalf("the usage chunk must be decoded, got %+v", a.Play.usage)
+ }
+
+ // The end arm returns the attribution fetch; run it and apply the meta. This
+ // is the whole loop the plan calls out: a streamed body names no provider,
+ // so the answer's X-Request-ID is matched against the request log.
+ a.update(a.Play.metaCmd(a)())
+ meta := lastTurn(a).Meta
+ for _, want := range []string{"anthropic → served", "24 in / 7 out", "$0.0031"} {
+ if !strings.Contains(meta, want) {
+ t.Fatalf("the meta line must carry %q, got %q", want, meta)
+ }
+ }
+ t.Logf("meta against the fixture: %q", meta)
+}
+
+func TestPlaygroundKeepsThePaneContract(t *testing.T) {
+ a := composerApp()
+ a.route("playground")
+ a.Play.submit(a, "a fairly long prompt that will need wrapping at every width we test")
+ a.update(chatDeltaMsg{gen: a.Play.gen, text: "# Heading\n\nSome **markdown** with a list:\n\n- one\n- two\n\n```go\nfmt.Println(\"hi\")\n```\n"})
+ a.update(flushTickMsg{gen: a.Play.gen})
+ a.update(chatEndMsg{gen: a.Play.gen, done: true})
+ a.update(chatMetaMsg{gen: a.Play.gen, turn: lastTurn(a).id, row: &api.LogEntry{Provider: "anthropic", CostUSD: f64(0.0031)}})
+
+ for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} {
+ a.Theme = theme.New(mode)
+ // The renderer must be REAL: resize returns early without one, so a nil
+ // renderer would leave every turn wrapped for whatever width came
+ // before and this loop would measure a cache instead of the wrapping
+ // it exists to check.
+ a.Play.renderer = wrapPlain
+ for _, rows := range []int{1, 4, 9, 20} {
+ for _, w := range []int{30, 60, 92, 126} {
+ a.Play.width = 0 // force the re-wrap regardless of the previous width
+ a.Play.resize(w)
+ body := a.paneView(w, rows)
+ if got := len(strings.Split(body, "\n")); got != rows {
+ t.Fatalf("mode=%+v w=%d rows=%d returned %d lines", mode, w, rows, got)
+ }
+ for i, line := range strings.Split(body, "\n") {
+ if lw := lipgloss.Width(line); lw > w {
+ t.Fatalf("mode=%+v w=%d line %d overflows: %d cells %q", mode, w, i, lw, line)
+ }
+ }
+ }
+ }
+ }
+}
+
+// Attribution is resolved from the request log AFTER the stream ends, with one
+// retry 500ms later, so the operator can easily submit the next prompt inside
+// that window. Everything the meta line is built from — which turn is last, the
+// usage chunk, the elapsed time — is about to describe a different turn by the
+// time the answer lands, so all of it is pinned when the fetch is ISSUED.
+func TestAttributionStaysOnTheTurnThatEarnedIt(t *testing.T) {
+ a := composerApp()
+ a.route("playground")
+
+ a.Play.submit(a, "which target serves sonnet?")
+ a.update(chatUsageMsg{gen: a.Play.gen, usage: &api.Usage{PromptTokens: 24, CompletionTokens: 7}})
+ a.update(chatEndMsg{gen: a.Play.gen, done: true}) // issues the attribution fetch
+ turn := lastTurn(a).id
+
+ // The operator does not wait for it, and the second prompt resets both the
+ // usage and the answer position the old code resolved against.
+ a.Play.submit(a, "and haiku?")
+ a.update(chatMetaMsg{gen: a.Play.gen, turn: turn, usage: &api.Usage{PromptTokens: 24, CompletionTokens: 7},
+ row: &api.LogEntry{Provider: "anthropic", CostUSD: f64(0.0031)}})
+
+ if n := len(a.Play.turns); n != 4 {
+ t.Fatalf("two prompts and two answers, got %d turns", n)
+ }
+ first := a.Play.turns[1].Meta
+ for _, want := range []string{"anthropic", "24 in / 7 out", "$0.0031"} {
+ if !strings.Contains(first, want) {
+ t.Fatalf("the answered turn must keep its own attribution %q, got %q", want, first)
+ }
+ }
+ if got := lastTurn(a).Meta; got != "" {
+ t.Fatalf("the turn still streaming has earned no attribution, got %q", got)
+ }
+}
+
+// Two turns' attribution fetches CAN be outstanding at the same time: chatEndMsg
+// clears busy before its fetch lands, so nothing stops the operator from
+// submitting and finishing a second turn before the first turn's fetch has
+// answered. Both fetches are issued here before either's chatMetaMsg is
+// delivered, delivered out of submission order, and each must still land on
+// the turn that earned it.
+func TestOverlappingAttributionsResolveToTheirOwnTurn(t *testing.T) {
+ a := composerApp()
+ a.route("playground")
+
+ a.Play.submit(a, "which target serves sonnet?")
+ a.update(chatUsageMsg{gen: a.Play.gen, usage: &api.Usage{PromptTokens: 24, CompletionTokens: 7}})
+ fetch1 := a.update(chatEndMsg{gen: a.Play.gen, done: true}) // turn 1's fetch is now in flight
+ turn1 := lastTurn(a).id
+
+ a.Play.submit(a, "and haiku?")
+ a.update(chatUsageMsg{gen: a.Play.gen, usage: &api.Usage{PromptTokens: 11, CompletionTokens: 3}})
+ fetch2 := a.update(chatEndMsg{gen: a.Play.gen, done: true}) // turn 2 ends before turn 1's fetch has landed
+ turn2 := lastTurn(a).id
+
+ if fetch1 == nil || fetch2 == nil || turn1 == turn2 {
+ t.Fatalf("both turns must issue their own attribution fetch, got fetch1=%v fetch2=%v turn1=%d turn2=%d",
+ fetch1 != nil, fetch2 != nil, turn1, turn2)
+ }
+
+ // Turn 2's row lands first — arrival order is the opposite of submission
+ // order, and must not matter.
+ a.update(chatMetaMsg{gen: a.Play.gen, turn: turn2, usage: &api.Usage{PromptTokens: 11, CompletionTokens: 3},
+ took: 2 * time.Second, row: &api.LogEntry{Provider: "openai", CostUSD: f64(0.0009)}})
+ a.update(chatMetaMsg{gen: a.Play.gen, turn: turn1, usage: &api.Usage{PromptTokens: 24, CompletionTokens: 7},
+ took: time.Second, row: &api.LogEntry{Provider: "anthropic", CostUSD: f64(0.0031)}})
+
+ if n := len(a.Play.turns); n != 4 {
+ t.Fatalf("two prompts and two answers, got %d turns", n)
+ }
+ first, second := a.Play.turns[1].Meta, a.Play.turns[3].Meta
+ for _, want := range []string{"anthropic", "24 in / 7 out", "$0.0031"} {
+ if !strings.Contains(first, want) {
+ t.Fatalf("turn 1 must keep its own provider, usage and duration, got %q", first)
+ }
+ }
+ for _, want := range []string{"openai", "11 in / 3 out", "$0.0009"} {
+ if !strings.Contains(second, want) {
+ t.Fatalf("turn 2 must keep its own provider, usage and duration, got %q", second)
+ }
+ }
+}
+
+// `chat ` opens the pane and asks in one line, which means the ask
+// races the model listing that same command starts. submit copies s.model into
+// the request there and then, so a prompt sent before discovery lands names no
+// model at all — and a gateway that would have served it refuses instead.
+func TestInlineChatWaitsForModelDiscovery(t *testing.T) {
+ a := fixtureApp(t)
+ a.route("chat which target serves sonnet?")
+
+ if len(a.Play.turns) != 0 {
+ t.Fatalf("nothing may be sent before a model is known, got %d turns", len(a.Play.turns))
+ }
+ if a.Play.queued == "" {
+ t.Fatal("the prompt must be held, not dropped")
+ }
+
+ a.Play.update(a, playModelsMsg{gen: a.Play.gen, models: []string{"claude-sonnet-4-6", "gpt-4o"}})
+
+ if a.Play.model == "" {
+ t.Fatal("discovery must name the default before the held prompt is sent")
+ }
+ if len(a.Play.turns) != 2 || a.Play.turns[0].Text != "which target serves sonnet?" {
+ t.Fatalf("the held prompt must be sent once discovery lands, got %+v", a.Play.turns)
+ }
+ if a.Play.queued != "" {
+ t.Fatal("a released prompt must not be sent twice")
+ }
+}
+
+// A gateway that will not list its models is still the authority on which
+// names it accepts. Holding the prompt for a listing that never arrives would
+// swallow it silently, which is worse than asking and being told why.
+func TestInlineChatReleasesThePromptWhenDiscoveryFails(t *testing.T) {
+ a := fixtureApp(t)
+ a.route("chat which target serves sonnet?")
+ a.Play.update(a, playModelsMsg{gen: a.Play.gen, err: errors.New("connection refused")})
+
+ if len(a.Play.turns) != 2 {
+ t.Fatalf("a failed listing must not swallow the prompt, got %+v", a.Play.turns)
+ }
+}
+
+// A streamed answer is model-supplied text, and wrapPlain is deliberately
+// ANSI-AWARE: it preserved a \x1b[2J (clear screen) and an OSC window-title
+// change straight into the frame. That is terminal control forwarded from an
+// upstream provider into the operator's session, and a pane that no longer owes
+// exactly rows lines.
+func TestAnswerEscapesNeverReachTheFrame(t *testing.T) {
+ a := composerApp()
+ a.route("playground")
+ a.Play.renderer = wrapPlain
+ a.Play.submit(a, "hello")
+ a.update(chatDeltaMsg{gen: a.Play.gen, text: "\x1b[2Jcleared\x1b]0;pwned\x07titled\nsecond line"})
+ a.update(flushTickMsg{gen: a.Play.gen})
+
+ turn := lastTurn(a)
+ // Text is the conversation history sent back to the gateway and stays as
+ // the model wrote it. What must be inert is the RENDERED lines.
+ if !strings.Contains(turn.Text, "\x1b[2J") {
+ t.Fatalf("the turn keeps the model's own text verbatim, got %q", turn.Text)
+ }
+ if len(turn.lines) < 2 {
+ t.Fatalf("the answer's own newline must still split the body: %q", turn.lines)
+ }
+ for i, line := range turn.lines {
+ if strings.ContainsRune(line, 0x1b) {
+ t.Fatalf("rendered line %d still carries an escape: %q", i, line)
+ }
+ }
+
+ const rows = 12
+ body := a.paneView(80, rows)
+ if strings.ContainsRune(body, 0x1b) {
+ t.Fatalf("an escape from the model reached the frame: %q", body)
+ }
+ if got := len(strings.Split(body, "\n")); got != rows {
+ t.Fatalf("the pane owes exactly %d lines, got %d", rows, got)
+ }
+ for i, line := range strings.Split(body, "\n") {
+ if lw := lipgloss.Width(line); lw > 80 {
+ t.Fatalf("line %d overflows: %d cells %q", i, lw, line)
+ }
+ }
+ if !strings.Contains(body, "cleared") || !strings.Contains(body, "second line") {
+ t.Fatalf("the answer's own words must survive sanitising:\n%s", body)
+ }
+}
+
+// nearest slices what the OPERATOR typed. Three bytes is one character of a
+// CJK name — and can land mid-rune — so the near miss it offered was a byte
+// accident rather than the three-character prefix it reads as.
+func TestNearestMatchesOnRunesNotBytes(t *testing.T) {
+ s := playScreen{models: []string{"日本語-alpha", "日本語-beta", "日本-gamma", "gpt-4o"}}
+ got := s.nearest("日本語-zeta")
+ if len(got) != 2 || got[0] != "日本語-alpha" || got[1] != "日本語-beta" {
+ t.Fatalf("want the three-character near misses, got %v", got)
+ }
+ // Shorter than three runes is still its whole self, not a panic.
+ if got := s.nearest("日"); len(got) != 3 {
+ t.Fatalf("a one-character name prefixes all three, got %v", got)
+ }
+}
diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go
new file mode 100644
index 0000000..18ab8da
--- /dev/null
+++ b/internal/tui/theme/theme.go
@@ -0,0 +1,274 @@
+// Package theme is the ferro console's visual vocabulary: palette, glyph set,
+// cell-accurate frames, and the split-F mark.
+//
+// Every measurement here is in terminal cells (lipgloss.Width), never bytes and
+// never runes: CJK is double-width, combining marks are zero-width, and ANSI
+// sequences measure zero. A frame padded with len() breaks the first time real
+// output arrives.
+package theme
+
+import (
+ "strings"
+
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// Console palette. Truecolor hex; lipgloss downgrades to the
+// terminal's actual profile. Never paint a full-screen background — the
+// terminal's own background is the ground.
+const (
+ colorAccent = "#d97757"
+ colorOK = "#77ba8d"
+ colorWarn = "#e4b354"
+ colorBad = "#dc6b67"
+ colorDim = "#7f817e"
+ colorText = "#e8e5df"
+ colorBright = "#fff8ef"
+ colorFaint = "#4a4b49"
+ colorBorder = "#3a3b38"
+ colorHairline = "#262726"
+ colorSelection = "#1a1717"
+)
+
+// minFrameWidth is the narrowest frame that can still close around content:
+// two border cells, two padding cells, and something in between.
+const minFrameWidth = 8
+
+// Mode is what the terminal will accept. Color is false under NO_COLOR, a
+// non-TTY stdout, or TERM=dumb; ASCII is the --ascii glyph downgrade.
+type Mode struct {
+ Color bool
+ ASCII bool
+}
+
+// Glyphs is the whole status vocabulary. Color never carries state alone, so
+// every state has a glyph here.
+type Glyphs struct{ Dot, OK, Warn, Bad, None, Prompt string }
+
+// Glyphs returns the unicode set, or the ASCII set under --ascii.
+func (m Mode) Glyphs() Glyphs {
+ if m.ASCII {
+ return Glyphs{Dot: "[+]", OK: "[OK]", Warn: "[!]", Bad: "[X]", None: "[-]", Prompt: ">"}
+ }
+ return Glyphs{Dot: "●", OK: "✓", Warn: "!", Bad: "✗", None: "·", Prompt: "❯"}
+}
+
+// box is the border vocabulary for one frame style.
+type box struct{ tl, tr, bl, br, h, v string }
+
+func (m Mode) box(round bool) box {
+ switch {
+ case m.ASCII:
+ return box{tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|"}
+ case round:
+ return box{tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│"}
+ default:
+ return box{tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│"}
+ }
+}
+
+// Theme is the rendering surface every TUI file draws through. When
+// Mode.Color is false every style is a plain no-op, so callers never branch on
+// color themselves.
+type Theme struct {
+ Mode Mode
+ Accent, OK, Warn, Bad, Dim, Text, Bright, Faint lipgloss.Style
+ Border, Hairline lipgloss.Style
+ Selected lipgloss.Style
+}
+
+// New builds the theme for a mode. Under !m.Color every field is
+// lipgloss.NewStyle(), which renders its input unchanged.
+func New(m Mode) Theme {
+ fg := func(hex string) lipgloss.Style {
+ s := lipgloss.NewStyle()
+ if !m.Color {
+ return s
+ }
+ return s.Foreground(lipgloss.Color(hex))
+ }
+ t := Theme{
+ Mode: m,
+ Accent: fg(colorAccent),
+ OK: fg(colorOK),
+ Warn: fg(colorWarn),
+ Bad: fg(colorBad),
+ Dim: fg(colorDim),
+ Text: fg(colorText),
+ Bright: fg(colorBright),
+ Faint: fg(colorFaint),
+ Border: fg(colorBorder),
+ Hairline: fg(colorHairline),
+ Selected: lipgloss.NewStyle(),
+ }
+ if m.Color {
+ t.Selected = t.Selected.Background(lipgloss.Color(colorSelection))
+ }
+ return t
+}
+
+// Frame draws a square-corner frame exactly w cells wide, with the title
+// embedded in the top border:
+//
+// ┌─ TITLE ────────┐
+// │ body │
+// └────────────────┘
+//
+// Body lines are padded or truncated to fit. An empty title yields an
+// unbroken top border. w < minFrameWidth returns the bare body: never emit a
+// border that cannot close.
+func (t Theme) Frame(title, body string, w int) string {
+ return t.frame(title, body, w, t.Mode.box(false))
+}
+
+// FrameRound is Frame with rounded corners and no title — the composer and
+// modals.
+func (t Theme) FrameRound(body string, w int) string {
+ return t.frame("", body, w, t.Mode.box(true))
+}
+
+func (t Theme) frame(title, body string, w int, b box) string {
+ if w < minFrameWidth {
+ return body
+ }
+ lines := strings.Split(body, "\n")
+ rows := make([]string, 0, len(lines)+2)
+ rows = append(rows, t.top(b, title, w))
+ edge := t.Border.Render(b.v)
+ for _, line := range lines {
+ rows = append(rows, edge+" "+padCell(line, w-4)+" "+edge)
+ }
+ rows = append(rows, t.Border.Render(b.bl+strings.Repeat(b.h, w-2)+b.br))
+ return strings.Join(rows, "\n")
+}
+
+func (t Theme) top(b box, title string, w int) string {
+ if title == "" {
+ return t.Border.Render(b.tl + strings.Repeat(b.h, w-2) + b.tr)
+ }
+ // Cap the title so at least one trailing rule remains; w >= minFrameWidth
+ // leaves at least two cells for it.
+ title = ansi.Truncate(title, w-6, "")
+ fill := max(w-5-lipgloss.Width(title), 1)
+ return t.Border.Render(b.tl+b.h) + " " + t.Dim.Render(title) + " " +
+ t.Border.Render(strings.Repeat(b.h, fill)+b.tr)
+}
+
+// padCell pads or truncates s to exactly w terminal cells. Truncation is
+// ANSI- and grapheme-aware, so styled content keeps its escape sequences
+// intact and a clipped double-width rune still leaves the line whole.
+func padCell(s string, w int) string {
+ if w <= 0 {
+ return ""
+ }
+ s = ansi.Truncate(s, w, "")
+ if pad := w - lipgloss.Width(s); pad > 0 {
+ s += strings.Repeat(" ", pad)
+ }
+ return s
+}
+
+// markStem is the F's stem: the three rows that carry no arm are the same row.
+const markStem = " ███"
+
+// markRows is the split-F, sampled from f_logo.svg — canonical, do not redraw.
+//
+// An array, and unexported: markArt indexes row zero to measure the shared
+// margin, so a caller that emptied a slice here would panic the whole console
+// on a header render. The length is a compile-time fact instead.
+var markRows = [...]string{
+ " ████████",
+ " ██████████",
+ markStem,
+ " ██████",
+ " ████████",
+ markStem,
+ markStem,
+}
+
+// Mark is the split-F in a rounded panel, drawn two art rows to a text row.
+//
+// A terminal cell is about twice as tall as it is wide, so the art drawn one
+// row per row stands seven lines high — nine inside a panel — beside four
+// lines of header text, and reads as a billboard. Half blocks fold it: ▀ inks
+// the top half of a cell and ▄ the bottom, so each text row carries two art
+// rows and the panel lands six rows over fourteen columns, close enough to
+// square on screen. The fold is exact, not a downsample; no art row is dropped
+// or merged away.
+//
+// The panel is what keeps the mark off the screen's corner: without it the art
+// starts on the first cell of the first row with nothing around it.
+//
+// Under --ascii there are no half blocks and no panel — 2h falls below
+// minFrameWidth, so FrameRound hands back the letter the mark degrades to.
+func Mark(t Theme) string {
+ if t.Mode.ASCII {
+ return t.Accent.Render("F")
+ }
+ art := foldedArt()
+ widest := 0
+ for _, r := range art {
+ widest = max(widest, lipgloss.Width(r))
+ }
+ rows := make([]string, len(art))
+ for i, r := range art {
+ rows[i] = t.Accent.Render(r)
+ }
+ // widest + 4: the panel's two border cells and the padding cell FrameRound
+ // sets inside each of them.
+ return t.FrameRound(strings.Join(rows, "\n"), widest+4)
+}
+
+// foldedArt is the canonical rows paired off into half-block text rows.
+func foldedArt() []string {
+ art := markArt()
+ rows := make([]string, 0, (len(art)+1)/2)
+ for i := 0; i < len(art); i += 2 {
+ bottom := "" // an odd row count leaves the last text row a top half only
+ if i+1 < len(art) {
+ bottom = art[i+1]
+ }
+ rows = append(rows, fold(art[i], bottom))
+ }
+ return rows
+}
+
+// fold merges two art rows into one text row of half blocks: a cell is full
+// when both rows ink it, a top or bottom half when one does, blank when
+// neither.
+func fold(topRow, bottomRow string) string {
+ top, bottom := []rune(topRow), []rune(bottomRow)
+ out := make([]rune, 0, max(len(top), len(bottom)))
+ for i := range max(len(top), len(bottom)) {
+ inkedTop := i < len(top) && top[i] != ' '
+ inkedBottom := i < len(bottom) && bottom[i] != ' '
+ switch {
+ case inkedTop && inkedBottom:
+ out = append(out, '█')
+ case inkedTop:
+ out = append(out, '▀')
+ case inkedBottom:
+ out = append(out, '▄')
+ default:
+ out = append(out, ' ')
+ }
+ }
+ return strings.TrimRight(string(out), " ")
+}
+
+// markArt is markRows with the shared left margin the SVG sample carried
+// measured off, so the mark starts on its own first inked column. The shape is
+// untouched: every row loses the same count, so the split arms keep their
+// overhang. Byte slicing is safe because the margin is all spaces.
+func markArt() []string {
+ indent := len(markRows[0])
+ for _, r := range markRows {
+ indent = min(indent, len(r)-len(strings.TrimLeft(r, " ")))
+ }
+ out := make([]string, len(markRows))
+ for i, r := range markRows {
+ out[i] = r[indent:]
+ }
+ return out
+}
diff --git a/internal/tui/theme/theme_test.go b/internal/tui/theme/theme_test.go
new file mode 100644
index 0000000..ae1c127
--- /dev/null
+++ b/internal/tui/theme/theme_test.go
@@ -0,0 +1,117 @@
+package theme
+
+import (
+ "strings"
+ "testing"
+ "unicode"
+
+ "charm.land/lipgloss/v2"
+)
+
+func TestFrameClosesAtExactWidth(t *testing.T) {
+ th := New(Mode{}) // no color → deterministic output
+ got := th.Frame("LOGS", "hello", 20)
+ want := "" +
+ "┌─ LOGS ───────────┐\n" +
+ "│ hello │\n" +
+ "└──────────────────┘"
+ if got != want {
+ t.Fatalf("frame drift:\n%s\nwant:\n%s", got, want)
+ }
+ for i, line := range strings.Split(got, "\n") {
+ if w := lipgloss.Width(line); w != 20 {
+ t.Fatalf("line %d width %d != 20 (cell measurement)", i, w)
+ }
+ }
+}
+
+// Frame is pinned above in the default unicode box only, and that is one of
+// three border sets: Mode.box answers differently under --ascii, and FrameRound
+// is what the composer and every modal draw. All three do the same
+// cell-accurate padding, so all three are measured.
+func TestFrameVariantsCloseAtExactWidth(t *testing.T) {
+ const w = 20
+ for _, m := range []Mode{{}, {ASCII: true}} {
+ th := New(m)
+ for _, got := range []string{th.Frame("LOGS", "hello", w), th.FrameRound("hello", w)} {
+ for i, line := range strings.Split(got, "\n") {
+ if got := lipgloss.Width(line); got != w {
+ t.Fatalf("mode=%+v line %d width %d != %d: %q", m, i, got, w, line)
+ }
+ }
+ }
+ }
+}
+
+func TestFrameUnicodeContentDoesNotBreakBorders(t *testing.T) {
+ th := New(Mode{})
+ got := th.Frame("T", "métriques ✓ 日本語", 26)
+ for _, line := range strings.Split(got, "\n") {
+ if lipgloss.Width(line) != 26 {
+ t.Fatalf("unicode content broke cell math: %q", line)
+ }
+ }
+}
+
+func TestFrameTooNarrowReturnsBody(t *testing.T) {
+ th := New(Mode{})
+ if got := th.Frame("T", "x", 6); got != "x" {
+ t.Fatalf("narrow frame must degrade to bare body, got %q", got)
+ }
+}
+
+func TestGlyphsASCII(t *testing.T) {
+ g := Mode{ASCII: true}.Glyphs()
+ if g.OK != "[OK]" || g.Bad != "[X]" || g.Warn != "[!]" || g.None != "[-]" || g.Prompt != ">" {
+ t.Fatalf("ascii glyph vocabulary drifted: %+v", g)
+ }
+}
+
+func TestMarkShape(t *testing.T) {
+ if len(markRows) != 7 {
+ t.Fatal("mark is exactly 7 rows — never redraw it")
+ }
+}
+
+// The mark is folded two art rows to a text row so it stands level with the
+// four header lines beside it. The fold must lose nothing: half the height,
+// the full width, and every inked column still inked.
+func TestMarkFoldsToHalfHeight(t *testing.T) {
+ rows := foldedArt()
+ if want := (len(markRows) + 1) / 2; len(rows) != want {
+ t.Fatalf("mark folded to %d rows, want %d", len(rows), want)
+ }
+ art := markArt()
+ widest, folded := 0, 0
+ for _, r := range art {
+ widest = max(widest, lipgloss.Width(r))
+ }
+ for _, r := range rows {
+ folded = max(folded, lipgloss.Width(r))
+ }
+ if folded != widest {
+ t.Fatalf("fold is %d cells wide, art is %d — the shape lost columns", folded, widest)
+ }
+}
+
+func TestFoldPairsRowsIntoHalfBlocks(t *testing.T) {
+ for _, c := range []struct{ top, bottom, want string }{
+ {"██", "██", "██"}, // both inked → full cell
+ {"██", "", "▀▀"}, // top only → upper half
+ {"", "██", "▄▄"}, // bottom only → lower half
+ {"█ █", "██", "█▄▀"}, // mixed, decided per column
+ {"", "", ""},
+ } {
+ if got := fold(c.top, c.bottom); got != c.want {
+ t.Fatalf("fold(%q, %q) = %q, want %q", c.top, c.bottom, got, c.want)
+ }
+ }
+}
+
+func TestMarkIsASCIIInASCIIMode(t *testing.T) {
+ for _, r := range Mark(New(Mode{ASCII: true})) {
+ if r > unicode.MaxASCII {
+ t.Fatalf("ASCII mark contains %q", r)
+ }
+ }
+}
diff --git a/internal/tui/transcript.go b/internal/tui/transcript.go
new file mode 100644
index 0000000..2ad4fb2
--- /dev/null
+++ b/internal/tui/transcript.go
@@ -0,0 +1,169 @@
+package tui
+
+import (
+ "strings"
+
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+
+ "github.com/ferro-labs/gateway-cli/internal/table"
+ "github.com/ferro-labs/gateway-cli/internal/tui/theme"
+)
+
+// transcriptMax is the ring depth. Bounded because a console that runs for a
+// week must not grow without limit, and 60 rows is what the tallest pane can
+// scroll through without paging.
+const transcriptMax = 60
+
+// The row kinds. A kind is resolved by glyphCell into a styled glyph, and an
+// unrecognised one is rendered as written — which means a mistyped literal
+// draws itself into the transcript instead of failing, so the vocabulary is
+// named here and every caller spells it from this list. The empty kind ("") is
+// a row with no glyph and needs no name.
+const (
+ kindCmd = "$" // an echoed command line
+ kindOK = "ok"
+ kindWarn = "warn"
+ kindBad = "bad"
+ kindNote = "·"
+)
+
+// TranscriptRow is one line of command output. Glyph is a kind, not a
+// character: the theme resolves it, so the same row reads correctly under
+// --ascii and under NO_COLOR.
+type TranscriptRow struct {
+ Glyph string
+ Text string
+ Dim bool
+ Bold bool
+}
+
+// Transcript is the home screen's bounded output history.
+//
+// dropped is the honest half of the ring: when rows fall off the top the view
+// says so. Silent truncation reads as "this is everything", which is exactly
+// the wrong thing to believe while reading a gateway's output.
+type Transcript struct {
+ rows []TranscriptRow
+ dropped bool
+}
+
+// Push appends rows, dropping the oldest past the ring bound and recording
+// that it did so, so the view can say output was lost.
+func (tr *Transcript) Push(rows ...TranscriptRow) {
+ tr.rows = append(tr.rows, rows...)
+ if n := len(tr.rows) - transcriptMax; n > 0 {
+ tr.rows = append(tr.rows[:0], tr.rows[n:]...)
+ tr.dropped = true
+ }
+}
+
+// Reset empties the ring, drop marker included: a cleared transcript has
+// dropped nothing.
+func (tr *Transcript) Reset() { *tr = Transcript{} }
+
+// Len reports how many rows are currently retained.
+func (tr *Transcript) Len() int { return len(tr.rows) }
+
+// view renders exactly h lines at most w cells wide — the pane's contract, and
+// what keeps the composer anchored to the bottom of the screen.
+func (tr *Transcript) view(th theme.Theme, w, h int) string {
+ if h <= 0 {
+ return ""
+ }
+ rows, clipped := tr.rows, tr.dropped
+ if len(rows) > h {
+ clipped = true
+ }
+ if clipped {
+ // The marker costs a line, so the window it announces is one shorter.
+ if len(rows) > h-1 {
+ rows = rows[len(rows)-max(h-1, 0):]
+ }
+ }
+
+ lines := make([]string, 0, h)
+ if clipped {
+ lines = append(lines, dropMarker(th, w))
+ }
+ gw := glyphWidth(th)
+ for _, r := range rows {
+ lines = append(lines, r.render(th, w, gw))
+ }
+ for len(lines) < h {
+ lines = append(lines, "")
+ }
+ return strings.Join(lines[:h], "\n")
+}
+
+func (r TranscriptRow) render(th theme.Theme, w, gw int) string {
+ text := th.Text
+ switch r.Glyph {
+ case kindBad:
+ text = th.Bad
+ case kindWarn:
+ text = th.Warn
+ }
+ if r.Dim {
+ text = th.Dim
+ }
+ if r.Bold {
+ text = text.Bold(th.Mode.Color)
+ }
+ line := padRight(glyphCell(th, r.Glyph), gw) + " " + text.Render(table.SanitizeCell(r.Text))
+ // A row wider than the pane is cut, never wrapped — wrapping would break
+ // the pane's line count and every frame below it. The marker is what keeps
+ // the cut from reading as the end of the row.
+ return ansi.Truncate(line, w, cutMark(th))
+}
+
+func cutMark(th theme.Theme) string {
+ if th.Mode.ASCII {
+ return ">"
+ }
+ return "…"
+}
+
+// glyphCell resolves a row's kind into a styled glyph. An unrecognised kind is
+// rendered as written, which is how kindCmd and any future literal marker work.
+func glyphCell(th theme.Theme, kind string) string {
+ g := th.Mode.Glyphs()
+ switch kind {
+ case "":
+ return ""
+ case kindOK:
+ return th.OK.Render(g.OK)
+ case kindWarn:
+ return th.Warn.Render(g.Warn)
+ case kindBad:
+ return th.Bad.Render(g.Bad)
+ case kindNote:
+ return th.Dim.Render(g.None)
+ default:
+ return th.Dim.Render(kind)
+ }
+}
+
+// glyphWidth is the glyph column: one cell for the unicode set, four for the
+// ASCII set's widest member ("[OK]"). Measured, never assumed — and measured
+// over EVERYTHING glyphCell can put in the column, which is the four mapped
+// glyphs plus the literal kinds it renders as written. A set that widens a
+// glyph nothing measured shifts every row's text by the difference.
+func glyphWidth(th theme.Theme) int {
+ g := th.Mode.Glyphs()
+ return max(
+ lipgloss.Width(g.OK), lipgloss.Width(g.Bad),
+ lipgloss.Width(g.Warn), lipgloss.Width(g.None),
+ lipgloss.Width(kindCmd), 1)
+}
+
+func dropMarker(th theme.Theme, w int) string {
+ label := " older output dropped "
+ rule := "─"
+ if th.Mode.ASCII {
+ rule = "-"
+ }
+ fill := max((w-lipgloss.Width(label))/2, 1)
+ side := strings.Repeat(rule, fill)
+ return ansi.Truncate(th.Faint.Render(side)+th.Dim.Render(label)+th.Faint.Render(side), w, "")
+}
diff --git a/internal/tui/transcript_test.go b/internal/tui/transcript_test.go
new file mode 100644
index 0000000..2ae6d3a
--- /dev/null
+++ b/internal/tui/transcript_test.go
@@ -0,0 +1,123 @@
+package tui
+
+import (
+ "strings"
+ "testing"
+
+ "charm.land/lipgloss/v2"
+
+ "github.com/ferro-labs/gateway-cli/internal/tui/theme"
+)
+
+func TestTranscriptRingAndDropMarker(t *testing.T) {
+ var tr Transcript
+ for i := range 70 {
+ tr.Push(TranscriptRow{Text: "row " + string(rune('a'+i%26))})
+ }
+ if tr.Len() != transcriptMax {
+ t.Fatalf("the transcript is a %d-row ring, got %d", transcriptMax, tr.Len())
+ }
+ v := tr.view(theme.New(theme.Mode{}), 60, 80)
+ if !strings.Contains(v, "older output dropped") {
+ t.Fatalf("dropped rows must be announced, not silently swallowed:\n%s", v)
+ }
+}
+
+// A window narrower than the transcript hides rows too, and that is the same
+// lie: what is on screen is not everything.
+func TestTranscriptMarksAWindowThatClips(t *testing.T) {
+ var tr Transcript
+ for i := range 10 {
+ tr.Push(TranscriptRow{Text: "row " + string(rune('a'+i))})
+ }
+ th := theme.New(theme.Mode{})
+ if v := tr.view(th, 60, 10); strings.Contains(v, "older output dropped") {
+ t.Fatalf("a window that shows everything must not claim otherwise:\n%s", v)
+ }
+ v := tr.view(th, 60, 4)
+ if !strings.Contains(v, "older output dropped") {
+ t.Fatalf("a clipped window must say so:\n%s", v)
+ }
+ if !strings.Contains(v, "row j") {
+ t.Fatalf("a clipped window keeps the newest rows:\n%s", v)
+ }
+}
+
+// The pane's height is the shell's anchor for the composer: exactly h lines,
+// whatever the transcript holds.
+func TestTranscriptViewIsExactlyHLines(t *testing.T) {
+ th := theme.New(theme.Mode{})
+ for _, rows := range []int{0, 3, 40} {
+ var tr Transcript
+ for i := range rows {
+ tr.Push(TranscriptRow{Text: strings.Repeat("wide ", i)})
+ }
+ for _, h := range []int{1, 2, 8, 20} {
+ if got := len(strings.Split(tr.view(th, 50, h), "\n")); got != h {
+ t.Fatalf("view(%d rows, h=%d) returned %d lines", rows, h, got)
+ }
+ }
+ }
+ if tr := (Transcript{}); tr.view(th, 50, 0) != "" {
+ t.Fatal("a zero-height pane renders nothing")
+ }
+}
+
+func TestTranscriptGlyphColumnAndWidth(t *testing.T) {
+ for _, mode := range []theme.Mode{{}, {Color: true}, {ASCII: true}, {Color: true, ASCII: true}} {
+ th := theme.New(mode)
+ g := mode.Glyphs()
+ var tr Transcript
+ tr.Push(
+ TranscriptRow{Glyph: "$", Text: "ferro status"},
+ TranscriptRow{Glyph: "ok", Text: "gateway ready"},
+ TranscriptRow{Glyph: "bad", Text: "unknown command"},
+ TranscriptRow{Glyph: "warn", Text: "degraded"},
+ TranscriptRow{Text: strings.Repeat("long ", 40), Dim: true},
+ )
+ v := tr.view(th, 40, 6)
+ for _, want := range []string{"$", g.OK, g.Bad, g.Warn, cutMark(th)} {
+ if !strings.Contains(v, want) {
+ t.Fatalf("mode=%+v glyph %q missing:\n%s", mode, want, v)
+ }
+ }
+ for i, line := range strings.Split(v, "\n") {
+ if lw := lipgloss.Width(line); lw > 40 {
+ t.Fatalf("mode=%+v line %d overflows: %d cells %q", mode, i, lw, line)
+ }
+ }
+ }
+}
+
+func TestTranscriptResetClearsTheDropMarker(t *testing.T) {
+ var tr Transcript
+ for range 70 {
+ tr.Push(TranscriptRow{Text: "x"})
+ }
+ tr.Reset()
+ if tr.Len() != 0 {
+ t.Fatalf("reset must empty the ring, got %d", tr.Len())
+ }
+ if v := tr.view(theme.New(theme.Mode{}), 40, 5); strings.Contains(v, "older output dropped") {
+ t.Fatalf("a cleared transcript has dropped nothing:\n%s", v)
+ }
+}
+
+// The transcript renders the gateway's own words — an api.Error message reaches
+// it whole. A newline in one must not add a line to a pane that owes exactly h,
+// and an ESC in one must not reach the terminal as a live sequence.
+func TestTranscriptRowWithGatewayControlCharactersKeepsTheLineCount(t *testing.T) {
+ th := theme.New(theme.Mode{})
+ var tr Transcript
+ tr.Push(TranscriptRow{Glyph: kindBad, Text: "gateway: upstream\nrefused \x1b[2J"})
+
+ for _, h := range []int{1, 5, 12} {
+ out := tr.view(th, 60, h)
+ if got := strings.Count(out, "\n") + 1; got != h {
+ t.Fatalf("view(h=%d) returned %d lines: %q", h, got, out)
+ }
+ if strings.Contains(out, "\x1b") {
+ t.Fatalf("view(h=%d) forwarded an escape sequence: %q", h, out)
+ }
+ }
+}
diff --git a/internal/tui/verbs_cobra_test.go b/internal/tui/verbs_cobra_test.go
new file mode 100644
index 0000000..dae4a70
--- /dev/null
+++ b/internal/tui/verbs_cobra_test.go
@@ -0,0 +1,80 @@
+// This file is deliberately in the external test package: internal/command is
+// the package that hands bare `ferro` off to internal/tui, so internal/tui must
+// never import it back. An external test may, which is how the TUI's vocabulary
+// gets asserted against the real scriptable tree with no import cycle.
+package tui_test
+
+import (
+ "bytes"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/ferro-labs/gateway-cli/internal/command"
+ "github.com/ferro-labs/gateway-cli/internal/tui"
+)
+
+// The composer completes what the CLI can actually run. If a verb is added,
+// renamed or removed in internal/command, this is where the TUI finds out.
+func TestVerbsMatchTheScriptableTree(t *testing.T) {
+ v := tui.Verbs(command.NewRoot())
+ for _, want := range []string{
+ "status", "keys", "keys create", "keys rotate", "keys revoke",
+ "logs", "logs tail", "logs stats", "models", "providers",
+ "mcp", "plugins", "sessions", "audit",
+ "services", "chat", "version",
+ } {
+ if !slices.Contains(v, want) {
+ t.Fatalf("the TUI vocabulary has drifted from the cobra tree: missing %q\ngot %v", want, v)
+ }
+ }
+ if slices.Contains(v, "completion") || slices.Contains(v, "help completion") {
+ t.Fatalf("shell-plumbing commands are not TUI verbs, got %v", v)
+ }
+}
+
+// tui.TableRows and command.Printer.Table both call internal/table's shared
+// layout now (table.Rows and table.Write, the latter also underneath the
+// former), so the two sides of this comparison can no longer drift on their
+// own — the whole point of moving the layout into that leaf package. This
+// test earns its keep anyway, cheaply, as the trip-wire against the failure
+// mode that motivated the move: it fails the moment either call site stops
+// routing through internal/table and grows a layout — or a special case — of
+// its own again, which is exactly how the two copies drifted before. The
+// Dim-row assertions below are independent of the parity question and still
+// exercise the console's own furniture-vs-output contract.
+func TestConsoleTableMatchesThePipedTable(t *testing.T) {
+ headers := []string{"NAME", "READY", "REQUIRED", "LAST ERROR"}
+ cells := [][]string{
+ {"filesystem", "yes", "no", "-"},
+ {"search", "no", "yes", "dial tcp: connection refused"},
+ {"a-considerably-longer-server-name", "yes", "no", "-"},
+ }
+
+ var buf bytes.Buffer
+ (&command.Printer{Out: &buf}).Table(headers, cells)
+ piped := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
+ for i, line := range piped {
+ // The console trims the trailing padding the last column carries;
+ // nothing else about the two layouts may differ.
+ piped[i] = strings.TrimRight(line, " ")
+ }
+
+ rows := tui.TableRows(headers, cells)
+ console := make([]string, 0, len(rows))
+ for _, r := range rows {
+ console = append(console, r.Text)
+ }
+
+ if !slices.Equal(console, piped) {
+ t.Fatalf("the console table has drifted from the piped one:\nconsole: %q\npiped: %q", console, piped)
+ }
+ if len(rows) < 2 || !rows[0].Dim || !rows[1].Dim {
+ t.Fatalf("the heading and its rule are furniture and must be dimmed, got %+v", rows[:min(2, len(rows))])
+ }
+ for _, r := range rows[2:] {
+ if r.Dim {
+ t.Fatalf("a data row is the output, not furniture: %+v", r)
+ }
+ }
+}
diff --git a/internal/version/version.go b/internal/version/version.go
new file mode 100644
index 0000000..7a037b5
--- /dev/null
+++ b/internal/version/version.go
@@ -0,0 +1,83 @@
+// Package version holds build metadata injected via -ldflags, mirroring the
+// gateway's internal/version so release tooling stays uniform across the two
+// projects. What the linker did not stamp is recovered from the build info the
+// toolchain embeds anyway — see fillFromBuildInfo.
+package version
+
+import (
+ "fmt"
+ "runtime/debug"
+)
+
+// Version, Commit, and Date are overwritten at link time by the release build.
+var (
+ Version = devVersion
+ Commit = noCommit
+ Date = noDate
+)
+
+// The values a build with no -ldflags starts from. They double as the marker
+// that the linker stamped nothing, which is what lets fillFromBuildInfo tell
+// "not set" from "set to something that happens to look unhelpful".
+const (
+ devVersion = "dev"
+ noCommit = "none"
+ noDate = "unknown"
+)
+
+func init() { fillFromBuildInfo(debug.ReadBuildInfo) }
+
+// fillFromBuildInfo recovers what the linker did not set.
+//
+// `go install github.com/ferro-labs/gateway-cli/cmd/ferro@v0.1.0` is the
+// install path the README documents first, and go install applies no ldflags —
+// so without this, everyone who followed the README reports
+// "dev (commit none, built unknown)" and cannot tell a bug report apart from
+// any other. The module version is in the build info for that build, and a
+// build from a checkout carries the revision and time as vcs settings.
+//
+// A stamped field is never overwritten: what the release build says is what
+// ferro reports, so a goreleaser binary and this fallback can never disagree.
+// read is a parameter so a test can supply build info a test binary cannot have.
+func fillFromBuildInfo(read func() (*debug.BuildInfo, bool)) {
+ bi, ok := read()
+ if !ok {
+ return
+ }
+ // "(devel)" is what a local build reports for its own module; it says less
+ // than "dev" already does and would only obscure that nothing stamped this.
+ if Version == devVersion && bi.Main.Version != "" && bi.Main.Version != "(devel)" {
+ Version = bi.Main.Version
+ }
+ // Read all three before deciding: the settings arrive in no promised order,
+ // and vcs.modified changes what vcs.revision is allowed to claim.
+ var revision, when string
+ var dirty bool
+ for _, s := range bi.Settings {
+ switch s.Key {
+ case "vcs.revision":
+ revision = s.Value
+ case "vcs.time":
+ when = s.Value
+ case "vcs.modified":
+ dirty = s.Value == "true"
+ }
+ }
+ if Commit == noCommit && revision != "" {
+ if dirty {
+ // The worktree had uncommitted changes, so this commit does not
+ // describe the binary. Say so rather than name a tree nobody can
+ // check out to reproduce it.
+ revision += "-dirty"
+ }
+ Commit = revision
+ }
+ if Date == noDate && when != "" {
+ Date = when
+ }
+}
+
+// String renders the full build stamp for `ferro version`.
+func String() string {
+ return fmt.Sprintf("%s (commit %s, built %s)", Version, Commit, Date)
+}
diff --git a/internal/version/version_test.go b/internal/version/version_test.go
new file mode 100644
index 0000000..62e634f
--- /dev/null
+++ b/internal/version/version_test.go
@@ -0,0 +1,93 @@
+package version
+
+import (
+ "runtime/debug"
+ "strings"
+ "testing"
+)
+
+// reset returns the package vars to their unstamped defaults for the duration
+// of one test. They are package state written by init(), and this test binary's
+// own build info has already run through it.
+func reset(t *testing.T) {
+ t.Helper()
+ old := [3]string{Version, Commit, Date}
+ t.Cleanup(func() { Version, Commit, Date = old[0], old[1], old[2] })
+ Version, Commit, Date = devVersion, noCommit, noDate
+}
+
+func buildInfo(mainVersion string, settings ...debug.BuildSetting) func() (*debug.BuildInfo, bool) {
+ return func() (*debug.BuildInfo, bool) {
+ return &debug.BuildInfo{Main: debug.Module{Version: mainVersion}, Settings: settings}, true
+ }
+}
+
+// The release build is the one that must never be second-guessed: goreleaser
+// stamps a tag and a commit, and a binary built from a checkout also carries
+// vcs settings that disagree with them.
+func TestLdflagsValuesWin(t *testing.T) {
+ reset(t)
+ Version, Commit, Date = "1.2.3", "abc1234", "2026-08-16T00:00:00Z"
+ fillFromBuildInfo(buildInfo("v9.9.9",
+ debug.BuildSetting{Key: "vcs.revision", Value: "deadbeef"},
+ debug.BuildSetting{Key: "vcs.time", Value: "1999-01-01T00:00:00Z"}))
+ if got := String(); got != "1.2.3 (commit abc1234, built 2026-08-16T00:00:00Z)" {
+ t.Fatalf("the linker's values must survive the fallback, got %q", got)
+ }
+}
+
+// The README's primary install path: go install applies no ldflags, but the
+// module version it resolved is in the build info.
+func TestGoInstallVersionFillsTheDevDefault(t *testing.T) {
+ reset(t)
+ fillFromBuildInfo(buildInfo("v0.1.0"))
+ if Version != "v0.1.0" {
+ t.Fatalf("go install must not report %q", Version)
+ }
+ // go install builds from the module cache, which has no VCS to read.
+ if Commit != noCommit || Date != noDate {
+ t.Fatalf("nothing supplied a commit or date: %q %q", Commit, Date)
+ }
+}
+
+func TestVCSSettingsFillCommitAndDate(t *testing.T) {
+ reset(t)
+ fillFromBuildInfo(buildInfo("(devel)",
+ debug.BuildSetting{Key: "vcs.revision", Value: "deadbeef"},
+ debug.BuildSetting{Key: "vcs.time", Value: "2026-08-16T12:00:00Z"}))
+ // "(devel)" carries no more information than the default it would replace.
+ if Version != devVersion {
+ t.Fatalf("(devel) must not become the reported version: %q", Version)
+ }
+ if Commit != "deadbeef" || Date != "2026-08-16T12:00:00Z" {
+ t.Fatalf("vcs settings ignored: %q %q", Commit, Date)
+ }
+}
+
+// A commit from a dirty worktree does not describe the binary, and a bug report
+// quoting it would send somebody to the wrong tree.
+func TestDirtyWorktreeIsMarked(t *testing.T) {
+ reset(t)
+ fillFromBuildInfo(buildInfo("",
+ debug.BuildSetting{Key: "vcs.modified", Value: "true"},
+ debug.BuildSetting{Key: "vcs.revision", Value: "deadbeef"}))
+ if Commit != "deadbeef-dirty" {
+ t.Fatalf("an uncommitted build must say so: %q", Commit)
+ }
+}
+
+// `go run .` in a sandbox with no VCS and no ldflags. The stamp is useless but
+// it still has to be a line, not three gaps in a sentence.
+func TestNoLdflagsAndNoBuildInfoStillRenders(t *testing.T) {
+ reset(t)
+ fillFromBuildInfo(func() (*debug.BuildInfo, bool) { return nil, false })
+ got := String()
+ if got != "dev (commit none, built unknown)" {
+ t.Fatalf("unstamped builds have a fixed rendering, got %q", got)
+ }
+ for _, part := range strings.Fields(got) {
+ if part == "" || part == "()" {
+ t.Fatalf("no field may render empty: %q", got)
+ }
+ }
+}
diff --git a/itest/itest_test.go b/itest/itest_test.go
new file mode 100644
index 0000000..3a18769
--- /dev/null
+++ b/itest/itest_test.go
@@ -0,0 +1,489 @@
+//go:build integration
+
+// Package itest checks ferro's typed client against a running gateway.
+// A decode failure indicates that the client is incompatible with the
+// gateway response.
+//
+// Every test skips cleanly when FERRO_ITEST_URL is unset, so
+// `go test -tags integration ./itest/` is always safe to run. Boot a gateway
+// and run the suite with ./scripts/with-gateway.sh.
+package itest
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ferro-labs/gateway-cli/internal/api"
+)
+
+func client(t *testing.T) *api.Client {
+ t.Helper()
+ u := os.Getenv("FERRO_ITEST_URL")
+ if u == "" {
+ t.Skip("FERRO_ITEST_URL not set")
+ }
+ c, err := api.New(u, os.Getenv("FERRO_ITEST_KEY"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return c
+}
+
+// raw fetches a path with no decoding, so a failure can quote what the gateway
+// actually sent rather than only how the typed decode objected to it.
+func raw(t *testing.T, p string) (int, []byte) {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet,
+ strings.TrimRight(os.Getenv("FERRO_ITEST_URL"), "/")+p, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Accept", "application/json")
+ if k := os.Getenv("FERRO_ITEST_KEY"); k != "" {
+ req.Header.Set("Authorization", "Bearer "+k)
+ }
+ resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
+ if err != nil {
+ t.Fatalf("GET %s: %v", p, err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ t.Fatalf("GET %s: read body: %v", p, err)
+ }
+ return resp.StatusCode, b
+}
+
+func TestStatusAgainstRealGateway(t *testing.T) {
+ r, err := client(t).Status(context.Background())
+ if err != nil {
+ t.Fatalf("status: %v (report %+v)", err, r)
+ }
+ // Providers is deliberately NOT asserted non-zero: a gateway booted with no
+ // provider credentials reports no_providers and zero targets, which is the
+ // state this suite runs in by default.
+ if r.State == "unreachable" {
+ t.Fatalf("expected a live gateway, got %+v", r)
+ }
+ if r.URL == "" || r.State == "" {
+ t.Fatalf("status report is not usable: %+v", r)
+ }
+ // Targets is nil when /readyz answered 503 — its not_ready body carries no
+ // targets array at all. That is the default state of this harness (no
+ // provider credentials), so this is the common path here, not the edge one.
+ targets := "—"
+ if r.Targets != nil {
+ targets = fmt.Sprintf("%d/%d", r.Targets.Routable, r.Targets.Total)
+ }
+ t.Logf("status: state=%s providers=%d models=%d targets=%s auth=%q warnings=%v",
+ r.State, r.Providers, r.Models, targets, r.Auth, r.Warnings)
+}
+
+// A gateway with no routable targets must never report a target count. The
+// client cannot know one: /readyz 503s with only {status, reason}. Reporting
+// 0/0 would read as "none configured" when the truth is "none routable", and
+// this suite runs in exactly that state, so the assertion is always live.
+func TestNotReadyGatewayReportsNoTargetCount(t *testing.T) {
+ r, err := client(t).Status(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ ready, status, err := client(t).Ready(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status == http.StatusServiceUnavailable {
+ if len(ready.Targets) != 0 {
+ t.Fatalf("a not_ready body must carry no targets, got %d", len(ready.Targets))
+ }
+ if r.Targets != nil {
+ t.Fatalf("Status must leave Targets nil when the gateway reported none, got %+v", r.Targets)
+ }
+ if len(r.Warnings) == 0 {
+ t.Fatal("a not-ready gateway must explain itself in warnings")
+ }
+ }
+}
+
+func TestKeyLifecycle(t *testing.T) {
+ c := client(t)
+ if os.Getenv("FERRO_ITEST_KEY") == "" {
+ t.Skip("needs admin key")
+ }
+ ctx := context.Background()
+ exp := time.Now().Add(1 * time.Hour).UTC()
+ created, err := c.CreateKey(ctx, api.KeyCreateRequest{
+ Name: "ferro-itest", Scopes: []string{"read_only"}, ExpiresAt: &exp,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Deferred so a failure below never leaves a live credential behind.
+ defer func() { _ = c.RevokeKey(ctx, created.ID) }()
+
+ if !strings.HasPrefix(created.Key, "fgw_") || strings.Contains(created.Key, "...") {
+ t.Fatalf("create response did not contain a valid one-time secret (length=%d, masked=%v)",
+ len(created.Key), strings.Contains(created.Key, "..."))
+ }
+ if created.ID == "" || api.KeyState(*created) != "active" {
+ t.Fatalf("created key is not usable: id_present=%v state=%s", created.ID != "", api.KeyState(*created))
+ }
+
+ got, err := c.Key(ctx, created.ID)
+ if err != nil {
+ t.Fatalf("read back %s: %v", created.ID, err)
+ }
+ if got.Key == created.Key || strings.Contains(got.Key, strings.TrimPrefix(created.Key, "fgw_")) {
+ t.Fatalf("read-back leaked the full one-time secret for key id %s", created.ID)
+ }
+ if !strings.Contains(got.Key, "...") {
+ t.Fatalf("read-back must be masked for key id %s", created.ID)
+ }
+ if s := api.KeyState(*got); s != "active" {
+ t.Fatalf("state after create = %q, want active for key id %s", s, created.ID)
+ }
+
+ rotated, err := c.RotateKey(ctx, created.ID)
+ if err != nil {
+ t.Fatalf("rotate: %v", err)
+ }
+ if !strings.HasPrefix(rotated.Key, "fgw_") || strings.Contains(rotated.Key, "...") {
+ t.Fatalf("rotate response did not contain a valid one-time secret (length=%d, masked=%v)",
+ len(rotated.Key), strings.Contains(rotated.Key, "..."))
+ }
+ if rotated.Key == created.Key {
+ t.Fatal("rotate returned the previous secret")
+ }
+
+ if err := c.RevokeKey(ctx, created.ID); err != nil {
+ t.Fatalf("revoke: %v", err)
+ }
+ after, err := c.Key(ctx, created.ID)
+ if err != nil {
+ if !api.IsNotSupported(err) { // a store that deletes on revoke would 404
+ t.Fatalf("read back after revoke: %v", err)
+ }
+ return
+ }
+ if s := api.KeyState(*after); s != "revoked" {
+ t.Fatalf("state after revoke = %q, want revoked for key id %s", s, created.ID)
+ }
+}
+
+// TestAdminKeysWireShape asserts the two shapes internal/fixture hard-codes.
+// A disagreement here means the local fixture must be reconciled with the
+// running gateway before it can support other CLI tests.
+func TestAdminKeysWireShape(t *testing.T) {
+ c := client(t)
+ if os.Getenv("FERRO_ITEST_KEY") == "" {
+ t.Skip("needs admin key")
+ }
+
+ status, body := raw(t, "/admin/keys")
+ if status != http.StatusOK {
+ t.Fatalf("GET /admin/keys = %d: %s", status, body)
+ }
+ var arr []json.RawMessage
+ if err := json.Unmarshal(body, &arr); err != nil {
+ t.Fatalf("/admin/keys is not a bare array (%v).\nredacted JSON: %s", err, safeBody(body))
+ }
+
+ ctx := context.Background()
+ created, err := c.CreateKey(ctx, api.KeyCreateRequest{Name: "ferro-itest-shape", Scopes: []string{"read_only"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = c.RevokeKey(ctx, created.ID) }()
+
+ listed, err := c.Keys(ctx)
+ if err != nil {
+ t.Fatalf("list keys: %v", err)
+ }
+ var found *api.Key
+ for i := range listed {
+ if listed[i].ID == created.ID {
+ found = &listed[i]
+ break
+ }
+ }
+ if found == nil {
+ t.Fatalf("created key %s is missing from the listing (%d rows)", created.ID, len(listed))
+ }
+ if found.Key == created.Key {
+ t.Fatalf("SECRET LEAK: /admin/keys serves the full secret for %s", created.ID)
+ }
+ if !strings.Contains(found.Key, "...") {
+ t.Fatalf("listed key %s is not masked head...tail", created.ID)
+ }
+}
+
+// contract is one endpoint of the parity table.
+type contract struct {
+ path string
+ optional bool // may answer 404/501 on a build without the feature
+ call func(context.Context, *api.Client) (any, error)
+}
+
+// TestContractParity calls every endpoint ferro reads through the typed client
+// and asserts it decodes. A decode failure means internal/api's types have
+// drifted from the gateway — the whole reason this suite exists.
+func TestContractParity(t *testing.T) {
+ c := client(t)
+
+ cases := []contract{
+ {path: "/health", call: func(ctx context.Context, c *api.Client) (any, error) {
+ r, _, err := c.Health(ctx)
+ return r, err
+ }},
+ {path: "/readyz", call: func(ctx context.Context, c *api.Client) (any, error) {
+ r, _, err := c.Ready(ctx)
+ return r, err
+ }},
+ {path: "/admin/health", call: func(ctx context.Context, c *api.Client) (any, error) {
+ return c.AdminHealth(ctx)
+ }},
+ {path: "/v1/models", call: func(ctx context.Context, c *api.Client) (any, error) {
+ return c.Models(ctx)
+ }},
+ {path: "/admin/keys", call: func(ctx context.Context, c *api.Client) (any, error) {
+ return c.Keys(ctx)
+ }},
+ {path: "/admin/logs", optional: true, call: func(ctx context.Context, c *api.Client) (any, error) {
+ return c.Logs(ctx, api.LogsQuery{Limit: 5})
+ }},
+ {path: "/admin/logs/stats", optional: true, call: func(ctx context.Context, c *api.Client) (any, error) {
+ return c.LogStats(ctx, api.LogsQuery{})
+ }},
+ {path: "/admin/plugins", call: func(ctx context.Context, c *api.Client) (any, error) {
+ return c.Plugins(ctx)
+ }},
+ {path: "/admin/plugins/catalog", call: func(ctx context.Context, c *api.Client) (any, error) {
+ return c.PluginCatalog(ctx)
+ }},
+ {path: "/admin/sessions", optional: true, call: func(ctx context.Context, c *api.Client) (any, error) {
+ return c.Sessions(ctx)
+ }},
+ {path: "/admin/audit", optional: true, call: func(ctx context.Context, c *api.Client) (any, error) {
+ return c.Audit(ctx, api.AuditQuery{Limit: 5})
+ }},
+ }
+
+ authed := os.Getenv("FERRO_ITEST_KEY") != ""
+ for _, tc := range cases {
+ t.Run(tc.path, func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ // /admin/* and /v1/* sit behind the same bearer middleware, so
+ // without a credential they answer 401 — which proves nothing about
+ // the payload contract this test exists to check.
+ if !authed && tc.path != "/health" && tc.path != "/readyz" {
+ t.Skip("needs FERRO_ITEST_KEY")
+ }
+ // The wire bytes, not the round-tripped struct: a decoded struct
+ // cannot show a field internal/api silently drops, and the point of
+ // this suite is what the gateway actually sends.
+ status, body := raw(t, tc.path)
+ t.Logf("HTTP %d %s", status, safeBody(body))
+
+ _, err := tc.call(ctx, c)
+ switch {
+ case err == nil:
+ case tc.optional && api.IsNotSupported(err):
+ t.Logf("absent on this build (expected for an optional endpoint): %v", err)
+ default:
+ t.Fatalf("typed client could not read %s: %v\nHTTP %d, redacted JSON: %s",
+ tc.path, err, status, safeBody(body))
+ }
+ })
+ }
+}
+
+// TestPluginCatalogIsPopulated pins the one parity case an empty decode hides:
+// /admin/plugins/catalog is fixed for the life of the binary and never empty,
+// so a successful decode of zero rows means the envelope moved.
+func TestPluginCatalogIsPopulated(t *testing.T) {
+ c := client(t)
+ if os.Getenv("FERRO_ITEST_KEY") == "" {
+ t.Skip("needs admin key")
+ }
+ got, err := c.PluginCatalog(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) == 0 {
+ _, body := raw(t, "/admin/plugins/catalog")
+ t.Fatalf("catalog decoded to zero plugins — the {\"data\":…} envelope has moved.\nredacted JSON: %s", safeBody(body))
+ }
+ for _, p := range got {
+ if p.Name == "" || p.Type == "" {
+ t.Fatalf("catalog row is missing name/type: %+v", p)
+ }
+ }
+ t.Logf("catalog: %d built-in plugins", len(got))
+}
+
+func TestChatStreamAndAttribution(t *testing.T) {
+ model := os.Getenv("FERRO_ITEST_CHAT_MODEL")
+ if model == "" {
+ t.Skip("FERRO_ITEST_CHAT_MODEL not set (needs live provider credentials)")
+ }
+ c := client(t)
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+ defer cancel()
+ start := time.Now().Add(-2 * time.Minute)
+ st, err := c.StreamChat(ctx, api.ChatRequest{
+ Model: model,
+ Messages: []api.ChatMessage{{Role: "user", Content: "Reply with the word ok."}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer st.Cancel()
+
+ var text strings.Builder
+ var sawUsage, sawDone bool
+ for ev := range st.Events {
+ switch {
+ case ev.Err != nil:
+ t.Fatalf("stream error: %v", ev.Err)
+ case ev.Done:
+ sawDone = true
+ case ev.Chunk != nil:
+ if ev.Chunk.Usage != nil {
+ sawUsage = true
+ }
+ for _, ch := range ev.Chunk.Choices {
+ text.WriteString(ch.Delta.Content)
+ }
+ }
+ }
+ if !sawDone || !sawUsage || text.Len() == 0 {
+ t.Fatalf("done=%v usage=%v answer_bytes=%d", sawDone, sawUsage, text.Len())
+ }
+
+ probeCtx, cancelProbe := context.WithTimeout(ctx, 5*time.Second)
+ _, err = c.Logs(probeCtx, api.LogsQuery{Limit: 1})
+ cancelProbe()
+ if api.IsNotSupported(err) {
+ return
+ } else if err != nil {
+ t.Fatalf("probe request-log store: %v", err)
+ }
+ deadline := time.Now().Add(5 * time.Second)
+ for {
+ attributionCtx, cancelAttribution := context.WithTimeout(ctx, 5*time.Second)
+ entry, err := c.TraceAttribution(attributionCtx, st.RequestID, start)
+ cancelAttribution()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if entry != nil {
+ if entry.Provider == "" {
+ t.Fatalf("matched log row should attribute a provider: trace=%s", entry.TraceID)
+ }
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("request-log store is enabled but trace %s was not attributed within 5s", st.RequestID)
+ }
+ select {
+ case <-ctx.Done():
+ t.Fatal(ctx.Err())
+ case <-time.After(250 * time.Millisecond):
+ }
+ }
+}
+
+func safeBody(b []byte) string {
+ var value any
+ if json.Unmarshal(b, &value) != nil {
+ return fmt.Sprintf("<%d non-JSON bytes>", len(b))
+ }
+ redact(value)
+ b, _ = json.Marshal(value)
+ const max = 600
+ s := strings.TrimSpace(string(b))
+ if len(s) > max {
+ return s[:max] + "… (truncated)"
+ }
+ return s
+}
+
+func TestSafeBodyRedactsCompoundCredentials(t *testing.T) {
+ got := safeBody([]byte(`{
+ "access_token":"one",
+ "nested":{"refresh_token":"two","client_secret":"three"},
+ "items":[{"private_key":"four"}],
+ "apiKey":"five","X-API-Key":"six","sessionToken":"seven",
+ "credential":"eight","credentials":"nine",
+ "note":"visible"
+ }`))
+ for _, secret := range []string{"one", "two", "three", "four", "five", "six", "seven", "eight", "nine"} {
+ if strings.Contains(got, secret) {
+ t.Fatalf("safeBody leaked %q: %s", secret, got)
+ }
+ }
+ if strings.Count(got, "[REDACTED]") != 9 || !strings.Contains(got, "visible") {
+ t.Fatalf("every compound credential field must be redacted: %s", got)
+ }
+}
+
+// TestSafeBodyRedactsFgwSecretUnderAnyFieldName pins the value-based catch: a
+// live gateway can drift a master key onto a field name this vocabulary has
+// never seen, but every key this gateway issues is fgw_-prefixed regardless
+// of where it lands (newSecret, internal/fixture/keys.go).
+func TestSafeBodyRedactsFgwSecretUnderAnyFieldName(t *testing.T) {
+ got := safeBody([]byte(`{"unexpected_field":"fgw_wildcardvalue","note":"visible"}`))
+ if strings.Contains(got, "fgw_wildcardvalue") {
+ t.Fatalf("safeBody leaked a fgw_ secret under an unlisted field name: %s", got)
+ }
+ if strings.Count(got, "[REDACTED]") != 1 || !strings.Contains(got, "visible") {
+ t.Fatalf("fgw_-prefixed value must be redacted regardless of field name: %s", got)
+ }
+}
+
+func redact(value any) {
+ switch value := value.(type) {
+ case map[string]any:
+ for key, child := range value {
+ normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
+ switch normalized {
+ // credential/credentials joins this list because it is this
+ // codebase's own word for the secret (AcceptKey, defaultKey in
+ // internal/fixture/state.go) even though the wire field is
+ // spelled "key".
+ case "key", "apikey", "xapikey", "privatekey",
+ "secret", "clientsecret", "token", "accesstoken",
+ "refreshtoken", "sessiontoken", "authorization",
+ "credential", "credentials":
+ value[key] = "[REDACTED]"
+ default:
+ if s, ok := child.(string); ok && strings.HasPrefix(s, "fgw_") {
+ // Every master key this gateway issues is fgw_-prefixed
+ // (newSecret, internal/fixture/keys.go). raw() dumps a
+ // live gateway's actual bytes, not internal/api's
+ // modeled shape, so matching the value catches a secret
+ // riding under a field name this vocabulary has never
+ // seen.
+ value[key] = "[REDACTED]"
+ } else {
+ redact(child)
+ }
+ }
+ }
+ case []any:
+ for _, child := range value {
+ redact(child)
+ }
+ }
+}
diff --git a/scripts/check-module-boundary.sh b/scripts/check-module-boundary.sh
new file mode 100755
index 0000000..6308745
--- /dev/null
+++ b/scripts/check-module-boundary.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if ! grep -Fxq 'module github.com/ferro-labs/gateway-cli' go.mod; then
+ echo 'go.mod must declare module github.com/ferro-labs/gateway-cli' >&2
+ exit 1
+fi
+
+# go list walks the real import graph (covers aliased and blank imports, and
+# can't be tripped by a doc comment or string literal naming the module the
+# way a text grep can) instead of grepping source text. -tags integration
+# pulls itest/ into the graph too, and -test the imports that only _test.go
+# files have: a test reaching into the gateway module puts it in go.mod the
+# same as any other import, so leaving those 30-odd packages unscanned left
+# the one file type most likely to "just import it for a fixture" unchecked.
+# Two statements, not one pipeline: `|| true` is needed for grep's "no match"
+# exit 1, and on a pipeline it would swallow a go list failure too — passing
+# the check because the scan never ran, which is the hole this rewrite closes.
+deps="$(go list -tags integration -deps -test ./...)"
+offenders="$(printf '%s\n' "$deps" | grep '^github\.com/ferro-labs/ai-gateway' || true)"
+if [ -n "$offenders" ]; then
+ echo "$offenders" >&2
+ echo 'gateway-cli must not import AI Gateway packages' >&2
+ exit 1
+fi
+
+if grep -qE '(^|[[:space:]>])github\.com/ferro-labs/ai-gateway([[:space:]/]|$)' go.mod; then
+ echo 'gateway-cli must not reference the AI Gateway module in go.mod' >&2
+ exit 1
+fi
diff --git a/scripts/smoke.sh b/scripts/smoke.sh
new file mode 100755
index 0000000..89678f4
--- /dev/null
+++ b/scripts/smoke.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# Clean-machine sanity for a built ferro binary. Also the manual release gate.
+#
+# ./scripts/smoke.sh [path-to-binary]
+# FERRO_SMOKE_URL=http://localhost:8080 ./scripts/smoke.sh # adds live checks
+set -euo pipefail
+
+bin="${1:-./ferro}"
+[ -x "$bin" ] || { echo "FAIL: $bin is not executable (run: make build)" >&2; exit 1; }
+
+"$bin" version
+"$bin" --help >/dev/null
+
+# Exit codes are the API: an unknown verb must be a non-zero exit, or every
+# `ferro ... || alert` in a pipeline silently passes.
+if "$bin" definitely-not-a-verb >/dev/null 2>&1; then
+ echo "FAIL: unknown verb must exit non-zero" >&2
+ exit 1
+fi
+
+# The full-screen console must never appear on a non-TTY stdout. This runs with
+# stdout piped, so a bare invocation has to refuse rather than emit escape codes.
+command -v timeout >/dev/null || { echo "FAIL: timeout is required for the non-TTY smoke check" >&2; exit 1; }
+set +e
+bare_output="$(timeout --signal=TERM --kill-after=1s 5s "$bin" /dev/null)"
+bare_status=$?
+set -e
+if [ "$bare_status" -eq 124 ] || [ "$bare_status" -eq 137 ]; then
+ echo "FAIL: bare ferro blocked on a non-TTY invocation" >&2
+ exit 1
+fi
+if [ "$bare_status" -eq 0 ]; then
+ echo "FAIL: bare ferro must refuse a non-TTY invocation" >&2
+ exit 1
+fi
+if [[ "$bare_output" == *$'\e['* ]]; then
+ echo "FAIL: bare ferro emitted ANSI on a non-TTY stdout" >&2
+ exit 1
+fi
+
+if [ -n "${FERRO_SMOKE_URL:-}" ]; then
+ FERRO_URL="$FERRO_SMOKE_URL" "$bin" status
+ command -v python3 >/dev/null || { echo "FAIL: python3 is required for the live JSON smoke check" >&2; exit 1; }
+ # stdout must be exactly one JSON document — nothing else may leak into it.
+ FERRO_URL="$FERRO_SMOKE_URL" "$bin" status --format json | python3 -m json.tool >/dev/null
+ echo "[OK] live smoke against $FERRO_SMOKE_URL"
+fi
+
+echo "[OK] smoke passed"
diff --git a/scripts/with-gateway.sh b/scripts/with-gateway.sh
new file mode 100755
index 0000000..f040e51
--- /dev/null
+++ b/scripts/with-gateway.sh
@@ -0,0 +1,104 @@
+#!/usr/bin/env bash
+# Boot an AI Gateway checkout, run the ferro integration suite against it, and
+# always tear it down.
+#
+# FERRO_GATEWAY_SOURCE=../ai-gateway ./scripts/with-gateway.sh
+#
+# This is the one check the fixture (cmd/fakegw) cannot perform. The fixture
+# proves ferro is correct given a gateway that behaves as documented; only this
+# proves the real server agrees. Any divergence found here is a FIXTURE bug:
+# fix internal/fixture to match the gateway, then re-run.
+#
+# No provider credentials are required. The gateway starts with zero targets
+# and reports no_providers, which is enough to exercise status, keys, logs,
+# sessions, and audit — everything except live chat.
+set -euo pipefail
+
+# Checked up front: the health probe below reads curl's exit status to decide
+# the gateway is not up yet, so a missing curl looks exactly like a gateway
+# that never starts — a 15-second wait ending in the wrong diagnosis.
+command -v curl >/dev/null || { echo "curl is required to probe the gateway" >&2; exit 2; }
+
+cli="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+gateway_source="${FERRO_GATEWAY_SOURCE:-}"
+if [ -z "$gateway_source" ]; then
+ candidate="$(cd "$cli/.." && pwd)/ai-gateway"
+ if [ -f "$candidate/go.mod" ] && grep -q '^module github.com/ferro-labs/ai-gateway$' "$candidate/go.mod"; then
+ gateway_source="$candidate"
+ else
+ echo "FERRO_GATEWAY_SOURCE must point to an AI Gateway checkout" >&2
+ exit 2
+ fi
+fi
+gateway_source="$(cd "$gateway_source" && pwd)"
+work="$(mktemp -d)"
+port="${FERRO_ITEST_PORT:-18080}"
+gw_pid=""
+
+# A hex master key of the shape the gateway expects (fgw_ + 32 hex chars).
+key="fgw_$(od -An -tx1 -N16 /dev/urandom | tr -d ' \n')"
+
+cleanup() {
+ if [ -n "$gw_pid" ]; then
+ kill "$gw_pid" 2>/dev/null || true
+ wait "$gw_pid" 2>/dev/null || true
+ fi
+ rm -rf "$work"
+}
+trap cleanup EXIT
+
+echo "==> building ferrogw from $gateway_source"
+(cd "$gateway_source" && go build -o "$work/ferrogw" ./cmd/ferrogw)
+
+echo "==> scaffolding a throwaway config"
+# init writes a config and prints a master key once; we supply our own key via
+# the environment instead, so a failure here is not fatal.
+(cd "$work" && "$work/ferrogw" init -o "$work/gateway.yaml" --non-interactive >/dev/null 2>&1) \
+ || echo " (init declined; starting with an empty config)"
+
+echo "==> starting gateway on :$port"
+# A throwaway SQLite request log. Without it /admin/logs and /admin/logs/stats
+# answer 501 and the suite can only prove they are absent -- and those two carry
+# the most intricate types in internal/api (nullable duration_ms/ttft_ms/cost_usd,
+# a nullable percentile block). One file in the temp dir turns two skipped rows
+# into real decode checks.
+MASTER_KEY="$key" GATEWAY_CONFIG="$work/gateway.yaml" PORT="$port" \
+ REQUEST_LOG_STORE_BACKEND=sqlite REQUEST_LOG_STORE_DSN="$work/requestlog.db" \
+ "$work/ferrogw" serve >"$work/gateway.log" 2>&1 &
+gw_pid=$!
+
+# /health answers 503 with {"status":"no_providers"} when no provider credential
+# is present -- which is exactly how this harness runs -- so `curl -f` is the
+# wrong probe: it reports a serving gateway as dead. Ask for the status code
+# instead and accept either answer the endpoint can give.
+healthy() {
+ local code
+ code="$(curl -sS --connect-timeout 1 --max-time 1 -o /dev/null -w '%{http_code}' "http://localhost:$port/health" 2>/dev/null)" || return 1
+ [ "$code" = "200" ] || [ "$code" = "503" ]
+}
+
+startup_deadline=$((SECONDS + 15))
+while (( SECONDS < startup_deadline )); do
+ # A gateway that died is never coming up; fail fast instead of waiting out
+ # the whole loop or accepting another process already bound to the port.
+ if ! kill -0 "$gw_pid" 2>/dev/null; then
+ echo "gateway exited during startup; log follows:" >&2
+ cat "$work/gateway.log" >&2
+ exit 1
+ fi
+ if healthy; then
+ break
+ fi
+ sleep 0.25
+done
+
+if ! healthy; then
+ echo "gateway did not become healthy within 15s; log follows:" >&2
+ cat "$work/gateway.log" >&2
+ exit 1
+fi
+
+echo "==> running the integration suite"
+cd "$cli"
+FERRO_ITEST_URL="http://localhost:$port" FERRO_ITEST_KEY="$key" \
+ go test -race -tags integration ./itest/ "$@"