Архитектура
Содержание
- Структура пакетов
- Соглашения CLI
- Фасад запросов
- Дизайн повторных попыток и тайм-аутов
- Почему некоторые сервисы обращаются к другому хосту
- Соглашение о живой верификации
- Расширение таблицы структурированного поиска
- Безопасность файлов учётных данных
- TUI
Структура пакетов
main.go Пятистрочная точка входа: package main → internal/cli.Execute()
internal/cli/ Команды CLI (пакет cli), по одному файлу на группу команд:
chat.go, accounts_cli.go, coding_cli.go, ...
pkg/client/ Библиотека на Go — по одному файлу на сервис API, без зависимостей от CLI/TUI
internal/accounts/ Хранилище учётных данных нескольких аккаунтов (~/.config/go-z-ai/accounts.json)
internal/coding/ Хранилище учётных данных GLM Coding Plan + генераторы конфигураций для инструментов
internal/usageview/ Чисто презентационные помощники (временные окна, тепловые карты, форматирование) —
используются и CLI, и TUI, поэтому их вывод не может разойтись
internal/tui/ Терминальный интерфейс на Bubble Tea, по одному подпакету на вкладку
internal/fileinput/ FileOrURL: URL пропускается как есть, локальный путь кодируется в base64 —
общий для `ocr parse` и вкладки media в TUI
pkg/client не имеет никаких зависимостей от чего-либо специфичного для CLI
или TUI — он спроектирован для отдельного импорта (см. Руководство по
библиотеке) и является единственным публичным пакетом.
Всё, что находится под internal/, является реализацией, которую компилятор
запрещает импортировать внешнему коду, поэтому слои CLI/TUI можно свободно
рефакторить. И CLI, и TUI — тонкие обёртки над pkg/client.
go install github.com/SamyRai/go-z-ai@latest по-прежнему собирает корневой
main.go в бинарник go-z-ai.
Соглашения CLI
Обработчики команд, которым нужен клиент API, регистрируются как
RunE: runWithClient(runX) и принимают уже разрешённый *client.Client как
третий параметр — runWithClient (в internal/cli/common.go) разрешает его
один раз через getClient, так что обработчикам не нужно повторять
преамбулу с разрешением и проверкой в каждом из них. Сам приоритет учётных
данных живёт в resolveConfig (флаг → --account →
ZAI_API_KEY/KEY → активный аккаунт), он покрыт юнит-тестами в
credentials_test.go.
Формат вывода единообразен: каждая команда, печатающая результат,
регистрирует общий флаг --format через addFormatFlag и рендерит через
emit(cmd, v, textFn), который выдаёт красивый JSON для --format json, а
иначе запускает читаемый человеком textFn. Прогресс-логи на командах,
поддерживающих JSON, идут в stderr, поэтому stdout остаётся валидным JSON.
Фасад запросов
Каждый метод сервиса проходит через один из трёх методов Client — сервисы
никогда не создают собственный http.Client и не отправляют сырой запрос:
doRequest(ctx, method, endpoint, body, result)
→ doRequestBase(ctx, baseURL, method, endpoint, body, result) // для нестандартных базовых URL
→ doRequestBaseKey(ctx, baseURL, apiKey, method, endpoint, body, result) // для другой учётной записи
Именно здесь централизованно в одном месте сосредоточены повтор/экспоненциальная
задержка, разбор ошибок и аутентификация для каждого эндпоинта. Сервис,
которому нужен другой базовый URL (Agents) или другая учётная запись
(Embeddings/Moderations, использующие ChinaAPIKey), вызывает следующее
звено цепочки — он никогда не обходит фасад.
sendMultipart — параллельный путь для загрузок multipart/form-data (файлы,
транскрипция аудио, разбор документов) — он не выполняет повторные
попытки, поскольку повторная загрузка файла при временном сбое — это решение
вызывающей стороны, а не безопасное поведение по умолчанию.
Дизайн повторных попыток и тайм-аутов
Config.Timeoutограничивает установку соединения + TLS-рукопожатие + ожидание заголовков ответа — намеренно не весьhttp.Client.Timeout, который обрезал бы долгое чтение SSE-потокаCreateStreamпосреди генерации.- Повторные попытки применяются к ошибкам 429/5xx/сети, с экспоненциальной
задержкой, джиттером (до 25%) и поддержкой заголовка
Retry-After, доConfig.MaxRetries(по умолчанию 3). Каждая повторная попытка сначала проверяетctx.Err(), так что отменённый контекст прерывается немедленно, а не доспивает задержку. - Потоковая передача (
CreateStream) повторяет только соединение — как только SSE-поток действительно начался, сбой в середине потока доносится до вызывающей стороны, а не замалчивается повтором (нет способа узнать, какую часть ответа уже получил вызывающий).
Почему некоторые сервисы обращаются к другому хосту
| Сервис | Базовый URL | Почему |
|---|---|---|
| Embeddings, Moderations | open.bigmodel.cn |
Единственная платформа, документирующая эти эндпоинты — индекс документации api.z.ai не упоминает ни один из них. Живая верификация подтвердила, что обычный ключ z.ai аутентифицируется одинаково на обеих платформах, поэтому Config.ChinaAPIKey по умолчанию откатывается к Config.APIKey. |
| Agents | https://api.z.ai/api (голый корень, без /paas/v4) по умолчанию; https://open.bigmodel.cn/api при Config.Region = RegionChina |
Проверено на живой API — вложение /v1/agents под базовый URL chat-completions возвращает 404. |
| Всё остальное | Config.BaseURL (/api/paas/v4 либо /api/coding/paas/v4 для аккаунтов GLM Coding Plan) |
Общий случай. |
Выбор регионального шлюза (Config.Region)
Z.AI обслуживает то же семейство моделей GLM с двух региональных шлюзов:
международного хоста api.z.ai и зеркала для материкового Китая
open.bigmodel.cn. Большинство сервисов выбирают хост через Config.BaseURL
(chat, files, tools и т. д.) либо через фиксированную константу
(Embeddings/Moderations всегда используют китайский хост). Четыре сервиса —
monitor (квота/использование), biz (информация об аккаунте),
agents и определение типа аккаунта — раньше были жёстко привязаны к
api.z.ai, из-за чего ключ glm_coding_plan_china не мог достучаться до
эндпоинтов monitor/usage своего региона (и некорректно классифицировался
командами accounts add/account detect).
Config.Region (RegionGlobal по умолчанию или RegionChina) выбирает
хост только для этих четырёх регионно-зависимых сервисов. Он не
переопределяет Config.BaseURL (поверхность chat) и не меняет хост
Embeddings/Moderations. Из CLI используйте --region {global,china} или
ZAI_REGION (псевдонимы: cn, bigmodel, west). Неизвестное значение
откатывается к global, а не вызывает ошибку, поэтому опечатка никогда не
блокирует постороннюю команду.
Хосты китайского зеркала для monitor/biz/agents/определения типа
смоделированы зеркалированием структуры путей api.z.ai на
open.bigmodel.cn и помечены NOT VERIFIED LIVE — китайская платформа
прошла живую верификацию на предоставление той же OpenAPI-поверхности для
/models и /chat/completions (см. BigModelBaseURL), но хосты
monitor/biz/agents на стороне Китая пока не зафиксированы ни в одной
cassette. Зафиксируйте их через ZAI_RECORD=1, если у вас есть китайский
ключ с соответствующими правами.
Соглашение о живой верификации
Собственные SDK и документация Z.AI иногда расходятся между собой, а иногда
— с тем, что реально возвращает живой API (эндпоинт, заявленный как
необязательный, но возвращающий 400 без него; ошибка, встроенная в тело
ответа 200; поле с разным типом в двух официальных SDK). Вместо того чтобы
доверять одному источнику, новые сервисы здесь проверяются реальным вызовом
API, а взаимодействие записывается как cassette
go-vcr
(pkg/client/testdata/cassettes/) и проигрывается в ModeReplayOnly,
поэтому тестовый набор никогда не обращается к сети.
Два файла в pkg/client ведут работу по живой верификации, с разными
ролями: live_replay_test.go содержит тесты Test*Live, которые
проигрывают зафиксированные cassette как замороженные находки (проверки
прав, аномалия 200-со-встроенным-сбоем, утверждение об одном ключе для
нескольких хостов); это текущий журнал того, что подтверждено и почему это
важно. live_verify_test.go содержит инструмент записи TestVerify* —
каждый тест SKIP-ается, пока вы не захватите новую cassette успешного пути
через ZAI_RECORD=1, после чего её проигрывает; это список дел для форм,
ожидающих реального захвата.
Если вы расширяете сервис, см. Contributing § the live-verification convention прежде чем добавлять новую cassette.
Расширение таблицы структурированного поиска
Несколько типов в этой кодовой базе сопоставляют небольшое замкнутое
множество заданных API значений с человекочитаемыми метаданными через
таблицу конфигурации плюс функцию поиска, а не через цепочку
if/switch-условий — quotaWindowConfigs/findWindowConfig из
pkg/client/quota.go самый наглядный пример:
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"},
}
Когда Z.AI добавляет новый тип окна, изменение носит аддитивный характер
(добавить строку) и локализовано, с обобщённым запасным описанием для всего,
чего ещё нет в таблице, а не жёстким сбоем. Тот же паттерн встречается при
категоризации моделей (visionModelMarkers из pkg/client/models.go —
единый источник истины, так что isTextModel/isVisionModel никогда не
противоречат друг другу) и при разрешении типа аккаунта в эндпоинт
(internal/coding/plans.go). Предпочитайте эту форму ещё одной условной
ветке, когда вы добавляете новое распознаваемое значение к существующему
понятию.
Безопасность файлов учётных данных
И internal/accounts (хранилище нескольких аккаунтов), и internal/coding
(хранилище учётных данных GLM Coding Plan плюс все конфиги сторонних
инструментов, которые оно пишет — Claude Code, OpenCode, Crush, Factory
Droid, Cursor) пишут через атомарную запись во временный файл с последующим
переименованием, а не прямой записью по месту. Сбой или kill в процессе
записи оставляет исходный файл нетронутым, а не обрезанным — это важно
именно здесь, потому что некоторые из них — реальные конфиги других
программ, в которые выполняется слияние, а не файлы, которыми этот проект
владеет безраздельно. Файлы, содержащие ключ, создаются с правами 0600;
каталоги — 0700.
TUI
internal/tui — программа на Bubble Tea
с одной вкладкой на подпакет (chat, models, usage, accounts,
coding, media, tools). Каждая вкладка — независимая tea.Model со
своими Update/View; internal/tui/root.go выполняет диспетчеризацию
между ними. Длительная работа (вызов API, файловая операция) оборачивается в
замыкание tea.Cmd, которое Bubble Tea запускает в собственной goroutine —
код, вызываемый из tea.Cmd, не должен рассчитывать, что изменяемое
состояние уровня пакета свободно от конкуренции (однажды это ударило нас с
http.DefaultClient.Timeout; детали — в комментарии-документации к фиксу в
internal/coding/validator.go).