Architecture
Contents
- Package layout
- CLI conventions
- The request facade
- Retry and timeout design
- Why some services hit a different host
- The live-verification convention
- Extending a structured lookup table
- Credential file safety
- The TUI
Package layout
main.go A five-line entrypoint: package main → internal/cli.Execute()
internal/cli/ CLI commands (package cli), one file per command group:
chat.go, accounts_cli.go, coding_cli.go, ...
pkg/client/ The Go library — one file per API service, no CLI/TUI dependency,
stdlib-only (no third-party imports)
pkg/observe/ OpenTelemetry Hook implementations (depends on go.opentelemetry.io/otel);
separate package so pkg/client stays stdlib-only
internal/accounts/ Multi-account credential store (~/.config/zai-client/accounts.json — dir kept as zai-client for upgrade compat)
internal/coding/ GLM Coding Plan credential store + per-tool config writers
internal/usageview/ Pure presentation helpers (time windows, heat maps, formatting) —
shared by both the CLI and the TUI so their output can never drift
internal/tui/ Bubble Tea terminal UI, one subpackage per tab
internal/fileinput/ FileOrURL: a URL passes through, a local path is base64-encoded —
shared by `ocr parse` and the TUI media tab
pkg/client is the core public package and is deliberately stdlib-only —
zero third-party imports — so anyone can depend on the client without dragging
the OTel SDK, MCP SDK, or other heavy observability/integration deps. The
observability seam is a stdlib-only Hook interface (see
Library Guide — Observability hooks);
concrete implementations live in pkg/observe (OpenTelemetry) and future
packages (pkg/mcp, etc.), each with its own dependency set so users only pay
for what they import. Everything under internal/ is implementation the
compiler forbids outside code from importing, so the CLI/TUI layers can be
refactored freely. The CLI and TUI are both thin callers of
pkg/client. go install github.com/SamyRai/go-z-ai@latest still builds the
root main.go into the go-z-ai binary.
CLI conventions
Command handlers that need an API client are registered as
RunE: runWithClient(runX) and take the resolved *client.Client as a third
parameter — runWithClient (in internal/cli/common.go) resolves it once via
getClient, so the handlers don't each repeat the resolve-and-check preamble.
Credential precedence itself lives in resolveConfig (flag → --account →
ZAI_API_KEY/KEY → active account), unit-tested in credentials_test.go.
Output format is uniform: every command that prints a result registers the
shared --format flag via addFormatFlag and renders through emit(cmd, v, textFn), which emits pretty JSON for --format json and otherwise runs the
human-readable textFn. Progress chatter on JSON-capable commands goes to
stderr so stdout stays valid JSON.
The request facade
Every service method funnels through one of three Client methods —
services never build their own http.Client or issue a raw request:
doRequest(ctx, method, endpoint, body, result)
→ doRequestBase(ctx, baseURL, method, endpoint, body, result) // for non-default base URLs
→ doRequestBaseKey(ctx, baseURL, apiKey, method, endpoint, body, result) // for a different credential
This is what centralizes retry/backoff, error parsing, and auth for every
endpoint in one place. A service that needs a different base URL (Agents) or
a different credential (Embeddings/Moderations, which use ChinaAPIKey) calls
further down the chain — it never bypasses it.
sendMultipart is the parallel path for multipart/form-data uploads (files,
audio transcription, document parsing) — it does not retry, since
re-uploading a file on a transient failure is a caller decision, not a safe
default.
Retry and timeout design
Config.Timeoutbounds dial + TLS handshake + waiting for response headers — deliberately not the wholehttp.Client.Timeout, which would truncate a long-runningCreateStreamSSE read partway through a generation.- Retries apply to 429/5xx/network errors, with exponential backoff, jitter
(up to 25%), and
Retry-Afterheader support, up toConfig.MaxRetries(default 3). Every retry checksctx.Err()first so a cancelled context aborts immediately instead of sleeping through a backoff first. - Streaming (
CreateStream) retries only the connection attempt — once the SSE stream has actually started, a mid-stream failure is surfaced to the caller, not silently retried (there's no way to know how much of the response the caller already consumed).
Why some services hit a different host
| Service | Base URL | Why |
|---|---|---|
| Embeddings, Moderations | open.bigmodel.cn |
The only platform that documents these endpoints — api.z.ai's doc index doesn't mention either. Live-verified that a regular z.ai key authenticates identically on both platforms, so Config.ChinaAPIKey falls back to Config.APIKey by default. |
| Agents | https://api.z.ai/api (bare root, no /paas/v4) by default; https://open.bigmodel.cn/api under Config.Region = RegionChina |
Verified live — nesting /v1/agents under the chat-completions base 404s. |
| Everything else | Config.BaseURL (/api/paas/v4, or /api/coding/paas/v4 for GLM Coding Plan accounts) |
The general case. |
Regional gateway selection (Config.Region)
Z.AI serves the same GLM model family from two regional gateways: the
international host api.z.ai and the China-mainland mirror
open.bigmodel.cn. Most services pick their host via Config.BaseURL (chat,
files, tools, etc.) or a fixed constant (Embeddings/Moderations always use the
China host). Four services — monitor (quota/usage), biz (account
info), agents, and account-type detection — used to be hardcoded to
api.z.ai, which left a glm_coding_plan_china key unable to reach its own
region's monitor/usage endpoints (and got mis-classified by
accounts add/account detect).
Config.Region (RegionGlobal, the default, or RegionChina) selects the
host for those four region-scoped services only. It does not override
Config.BaseURL (the chat surface) or the Embeddings/Moderations host.
From the CLI, use --region {global,china} or ZAI_REGION (aliases: cn,
bigmodel, west). An unknown value falls back to global rather than
erroring, so a typo never blocks an unrelated command.
The China mirror hosts for monitor/biz/agents/detection are modeled by
mirroring the api.z.ai path layout on open.bigmodel.cn and marked
NOT VERIFIED LIVE — the China platform is live-verified to serve the same
OpenAPI surface for /models and /chat/completions (see BigModelBaseURL),
but the monitor/biz/agents hosts on the China side have not been captured by a
cassette yet. Pin them with ZAI_RECORD=1 if you hold an entitled China key.
The live-verification convention
Z.AI's own SDKs and docs sometimes disagree with each other, and sometimes
with what the live API actually returns (an endpoint documented as optional
that 400s without it; an error embedded in a 200 response body; a field typed
differently across two official SDKs). Rather than trust a single source,
new services here are checked against a real API call and the interaction is
recorded as a go-vcr cassette
(pkg/client/testdata/cassettes/), replayed in ModeReplayOnly so the test
suite never touches the network.
Two files in pkg/client carry the live-verification work, with distinct
roles: live_replay_test.go holds the Test*Live tests that replay the
committed cassettes as frozen findings (entitlement gates, the
200-with-embedded-failure quirk, the single-key-across-hosts claim); it is the
running log of what's been confirmed and why it mattered. live_verify_test.go
holds the TestVerify* recording harness — each test SKIPS until you capture a
new success-path cassette with ZAI_RECORD=1, then replays it; it's the
to-do list of shapes still pending a real capture.
If you're extending a service, see Contributing § the live-verification convention before you add a new cassette.
Extending a structured lookup table
Several types in this codebase map a small, closed set of API-defined values
to human-readable metadata via a config table plus a lookup function, rather
than a chain of if/switch conditionals — pkg/client/quota.go's
quotaWindowConfigs/findWindowConfig is the clearest example:
var quotaWindowConfigs = []QuotaWindowConfig{
{Type: QuotaTypeTokensLimit, UnitCode: UnitCodeHourly, Number: 5, Description: "5-hour rolling token window"},
{Type: QuotaTypeTokensLimit, UnitCode: UnitCodeWeekly, Number: 1, Description: "weekly token window"},
{Type: QuotaTypeTimeLimit, UnitCode: UnitCodeMonthly, Number: 1, Description: "monthly MCP tools quota"},
}
When Z.AI adds a new window type, the change is additive (append a row) and
localized, with a generic fallback description for anything not yet in the
table rather than a hard failure. The same pattern shows up for model
categorization (pkg/client/models_catalog.go's modelsCatalog table —
each row carries a Capabilities []string that ModelDetails.HasCapability
reads, replacing the old divergent visionModelMarkers substring lists) and
for account-type-to-endpoint resolution (internal/coding/plans.go).
Prefer this shape over adding another conditional branch when you're adding a
new recognized value to an existing concept.
modelsCatalog is also the fix for a data-availability gap: Z.AI's /models
endpoint returns only the OpenAI-bare {id, object, created, owned_by}
shape, so without enrichment every Context/Pricing/Capabilities cell
renders as -/0. ModelsService.List runs each decoded model through
enrichModel, which overlays catalog fields but lets live API values
win when both are present — so the day Z.AI starts sending max_context
or pricing in /models, the real numbers take over with no code change.
Pricing/context figures are transcribed from
https://docs.z.ai/guides/overview/pricing and need periodic manual
refresh (the API gives no signal when they change); the file header carries
the "last verified" date. Uncataloged models still appear from /models
with sparse data rather than being hidden or guessed at.
Credential file safety
Both internal/accounts (the multi-account store) and internal/coding (the GLM
Coding Plan credential store, plus every third-party tool config it writes —
Claude Code, OpenCode, Crush, Factory Droid, Cursor) write via a temp-file
atomic write followed by rename, never a direct in-place write. A crash or
kill mid-write leaves the original file untouched instead of truncated —
important here specifically because several of these are other programs'
real config files being merged into, not files this project owns outright.
Files containing a key are created 0600; directories 0700.
The TUI
internal/tui is a Bubble Tea
program with one tab per subpackage (chat, models, usage, accounts,
coding, media, tools). Each tab is an independent tea.Model with its
own Update/View; internal/tui/root.go dispatches between them. Long-running
work (an API call, a filesystem operation) is wrapped in a tea.Cmd closure,
which Bubble Tea runs on its own goroutine — code called from a tea.Cmd
must not rely on package-level mutable state being uncontended (this bit us
once with http.DefaultClient.Timeout; see the fix in
internal/coding/validator.go's doc comment for the specifics).
Shared infrastructure
The chrome (header, tab bar, panel, status line, help bar) is owned by the
root model in internal/tui/root.go; each screen renders only its body. A
few cross-cutting pieces live outside the screens so they can be shared
without an import cycle:
Top header
The header is a single line: the go-z-ai app badge on the left, a
right-aligned cluster of context badges, and a spacer between them that
pushes the badges to the right edge. The badges give every tab at-a-glance
state:
- account — the active account's name (or a warn-styled
nonewhen no account is set, so the missing-credentials state is unmissable). - type —
pay-as-you-go/coding-plan, since that determines which endpoint family and which features (e.g. the Usage tab's monitor endpoints) are available. - plan —
Global/Chinafrom the coding store, surfaced because it silently changes the endpoint the Coding tab and coding-agent integrations talk to. Only shown when a plan is configured. - model — the Chat tab's currently-selected model. Useful on every tab because users often check Usage or Models while composing a chat.
On narrow terminals the header drops badges progressively (plan, then type,
keeping account + model) so the app name and the essentials stay visible.
Badges are built via uistyle.RenderBadge(label, value, valueStyle) — a
muted label paired with a role-colored value.
A spacer line sits above and below the tab strip so the chrome has breathing
room; chromeRows accounts for both spacers when sizing the inner panel.
internal/tui/uistyle— the lipgloss style vocabulary and a dual light/dark palette. The root model resolves the palette against the terminal's actual background (tea.BackgroundColorMsg) and rebuilds every shared style viauistyle.SetDark, so the whole app follows the terminal theme. Styles are packagevars reassigned on theme change; screens read them fresh at render time.internal/tui/uimsg— shared message types (Err,Status,Routed,CloseOverlay,OpenModelPicker) so screens can talk to the root without importing it.Routedwraps an async result with its originating tab so a result started in one tab still lands there after the user switches.- Overlays — a single
overlay tea.Modelslot on the root, composited above the active screen vialipgloss.Place(internal/tui/overlay.go). While open it owns keypresses; resize/background-color/routed msgs still flow to the screens beneath so they stay correctly laid out when the overlay closes. The help overlay (?), command palette (ctrl+p), and chat model picker (ctrl+m, from the chat tab) are all root-owned overlays.
Key bindings
Global (handled by the root before any screen sees the key):
| Key | Action |
|---|---|
tab / shift+tab |
next / previous tab |
? |
toggle the help overlay (all bindings) |
ctrl+p |
command palette (fuzzy-search app-wide actions) |
ctrl+c |
quit (or cancel an in-flight chat stream) |
| mouse click on tab bar | switch tab |
| mouse wheel | scroll the active viewport / list |
Per-screen bindings are surfaced in the help bar at the bottom and in the
? overlay.
Responsive layout
The app has three width tiers:
- ≥ 100 cols — two-column layouts where they exist (Models: table + live preview; Usage: quota + heatmap side by side).
- 70–99 cols — single column, full table/heatmap rows.
- < 70 cols — compact: the Models table drops the CAPS column and shortens price headers; the Usage heatmap collapses to one-line-per-section summaries; the tab bar switches to a numbered compact form.
Below 60×22 the root renders only a centered "terminal too small — resize
to at least 60×22" message instead of overlapping chrome. Screens still
receive WindowSizeMsg and floor their own dimensions, so growing back past
the threshold resumes a correctly-laid-out app with no extra work.