diff --git a/cmd/chat_config_constants.go b/cmd/chat_config_constants.go index bf1dd5eb..71552964 100644 --- a/cmd/chat_config_constants.go +++ b/cmd/chat_config_constants.go @@ -22,6 +22,7 @@ const ( configEntryOllamaURL = "ollama-url" configEntryKeyView = "key-view" configEntryXiaomiRegion = "xiaomi-region" + configEntryZAIRegion = "zai-region" ) // Providers referenced by config UI flows. diff --git a/cmd/chat_config_deployment.go b/cmd/chat_config_deployment.go index 072f9686..4648a6fd 100644 --- a/cmd/chat_config_deployment.go +++ b/cmd/chat_config_deployment.go @@ -66,6 +66,9 @@ func saveCredentialAsync(inference hawkconfig.CredentialInference, secret string if inference.ProviderID == hawkconfig.ProviderXiaomiTokenPlan { hawkconfig.ApplyXiaomiTokenPlanRegionEnv(ctx) } + if inference.ProviderID == hawkconfig.ProviderZAICoding { + hawkconfig.ApplyZAIRegionEnv(ctx) + } rtInf := config.InferenceFromOption(credentialOptionFromHawk(inference)) if err := runtime.SaveCredential(ctx, rtInf, secret); err != nil { return configApplyCredentialsMsg{ diff --git a/cmd/chat_config_gateways.go b/cmd/chat_config_gateways.go index 4d5e4f5c..a5fc2c87 100644 --- a/cmd/chat_config_gateways.go +++ b/cmd/chat_config_gateways.go @@ -54,6 +54,13 @@ func (m chatModel) configGatewayRows() []configGatewayRow { display += " · region required" } } + if id == hawkconfig.ProviderZAICoding { + if reg := hawkconfig.ZAIRegionLabel(id); reg != "" { + display += " · " + reg + } else { + display += " · region" + } + } rows = append(rows, configGatewayRow{ ID: id, DisplayName: display, @@ -113,6 +120,11 @@ func (m chatModel) refreshConfigGateway() (chatModel, tea.Cmd) { m.configNotice = "Pick Token Plan region (cn / sgp / ams) before refresh" return m.startConfigXiaomiTokenPlanRegion(), nil } + if row.ID == hawkconfig.ProviderZAICoding && hawkconfig.NeedsZAIRegion(row.ID) { + m.configNotice = "Pick Coding Plan region (international / cn) before refresh" + return m.startConfigZAIRegion(row.ID), nil + } + if !row.HasKey { m.configNotice = fmt.Sprintf("Select %s and press enter to paste an API key", row.DisplayName) return m, nil @@ -206,12 +218,20 @@ func (m chatModel) configGatewaysView() string { if targetIdx >= 0 && targetIdx < len(rows) && rows[targetIdx].ID == hawkconfig.ProviderXiaomiTokenPlan { hint = "Token Plan: enter pick region (cn/sgp/ams) then key · g change region" } + if targetIdx >= 0 && targetIdx < len(rows) && rows[targetIdx].ID == hawkconfig.ProviderZAICoding { + hint = "Coding Plan: enter pick region (international/cn) then key · g change region" + } + b.WriteString("\n" + mutedStyle.Render(indent+hint)) } else { hints := "enter use gateway · k view key · delete remove · r refresh" if targetIdx >= 0 && targetIdx < len(rows) && rows[targetIdx].ID == hawkconfig.ProviderXiaomiTokenPlan { hints = "enter · g region · k key · delete · r refresh" } + if targetIdx >= 0 && targetIdx < len(rows) && rows[targetIdx].ID == hawkconfig.ProviderZAICoding { + hints = "enter · g region · k key · delete · r refresh" + } + b.WriteString("\n" + configTableSelectionFooter(len(rows), m.configScroll, end, mutedStyle, hints)) } return m.configTabShellView(b.String()) @@ -258,6 +278,11 @@ func (m chatModel) handleConfigGatewaysSelect() (chatModel, tea.Cmd) { return m.startConfigXiaomiTokenPlanRegion(), nil } } + if row.ID == hawkconfig.ProviderZAICoding && (!row.HasKey || hawkconfig.NeedsZAIRegion(row.ID)) { + m.configGatewayFocus = m.configSel + return m.startConfigZAIRegion(row.ID), nil + } + if !row.HasKey { if row.ID == configProviderOllama { return m.startConfigOllamaURL() diff --git a/cmd/chat_config_keys.go b/cmd/chat_config_keys.go index a02d3250..f781a577 100644 --- a/cmd/chat_config_keys.go +++ b/cmd/chat_config_keys.go @@ -75,6 +75,11 @@ func (m chatModel) startConfigKeyForProvider(provider string) (chatModel, tea.Cm return m.startConfigXiaomiTokenPlanRegion(), nil } } + if provider == hawkconfig.ProviderZAICoding && hawkconfig.NeedsZAIRegion(provider) { + m.configPostSaveKeysProvider = provider + return m.startConfigZAIRegion(provider), nil + } + name := hawkconfig.GatewayDisplayName(provider) m.configNotice = "Paste API key for " + name return m.startConfigEntry(configEntryAPIKeyPaste, provider) @@ -85,6 +90,11 @@ func (m chatModel) startConfigKeyReplace(provider string) (chatModel, tea.Cmd) { m.configPostSaveKeysProvider = provider return m.startConfigXiaomiTokenPlanRegion(), nil } + if provider == hawkconfig.ProviderZAICoding && hawkconfig.NeedsZAIRegion(provider) { + m.configPostSaveKeysProvider = provider + return m.startConfigZAIRegion(provider), nil + } + m.configReplaceProvider = provider m.configEntry = configEntryNone m.configNotice = "Paste replacement API key for " + hawkconfig.GatewayDisplayName(provider) diff --git a/cmd/chat_config_panel.go b/cmd/chat_config_panel.go index 718e7bae..dd4b88d5 100644 --- a/cmd/chat_config_panel.go +++ b/cmd/chat_config_panel.go @@ -64,6 +64,9 @@ func (m chatModel) configPanelView() string { if m.configEntry == configEntryXiaomiRegion { return m.configXiaomiRegionView() } + if m.configEntry == configEntryZAIRegion { + return m.configZAIRegionView() + } switch m.configTab { case configTabGateways: return m.configGatewaysView() @@ -583,6 +586,12 @@ func (m chatModel) handleConfigKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { } return m.handleConfigXiaomiRegionKey(msg) } + if m.configEntry == configEntryZAIRegion { + if m.configSaving { + return m, nil + } + return m.handleConfigZAIRegionKey(msg) + } if m.configEntry != configEntryNone { if m.configSaving { return m, nil diff --git a/cmd/chat_config_zai.go b/cmd/chat_config_zai.go new file mode 100644 index 00000000..9a1160d0 --- /dev/null +++ b/cmd/chat_config_zai.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "context" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" +) + +var zaiRegions = []struct { + id string + label string +}{ + {id: "international", label: "International (api.z.ai)"}, + {id: "cn", label: "China (open.bigmodel.cn)"}, +} + +func zaiRegionIndex(region string) int { + region = strings.ToLower(strings.TrimSpace(region)) + if region == "" { + return 0 + } + for i, r := range zaiRegions { + if r.id == region { + return i + } + } + return 0 +} + +func (m chatModel) startConfigZAIRegion(providerID string) chatModel { + m.configEntry = configEntryZAIRegion + m.configProvider = providerID + if hawkconfig.NeedsZAIRegion(providerID) { + m.configZAIRegionSel = 0 + } else { + m.configZAIRegionSel = zaiRegionIndex(hawkconfig.ZAIRegionLabel(providerID)) + } + name := hawkconfig.GatewayDisplayName(providerID) + notice := "Select " + name + " region (↑↓ · enter · esc cancel)" + if saved := hawkconfig.ZAIRegionLabel(providerID); saved != "" { + notice = name + " region · current " + saved + " (↑↓ · enter · esc cancel)" + } + m.configNotice = notice + return m +} + +func (m chatModel) configZAIRegionView() string { + mutedStyle := configMutedStyle() + accentStyle := configAccentStyle() + rowStyle := configRowStyle() + var b strings.Builder + prov := m.configProvider + name := hawkconfig.GatewayDisplayName(prov) + b.WriteString(renderConfigBreadcrumb(name+" region") + "\n\n") + for i, r := range zaiRegions { + prefix := " " + if i == m.configZAIRegionSel { + prefix = "> " + } + line := prefix + r.label + if i == m.configZAIRegionSel { + b.WriteString(accentStyle.Render(line) + "\n") + } else { + b.WriteString(rowStyle.Render(line) + "\n") + } + } + b.WriteString("\n" + mutedStyle.Render(" Coding Plan uses dedicated /coding/paas/v4 on the chosen region")) + return m.configTabShellView(b.String()) +} + +func (m chatModel) handleConfigZAIRegionKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + prov := m.configProvider + m.configEntry = configEntryNone + m.configProvider = "" + if idx := m.configGatewayRowIndex(prov); idx >= 0 { + m.configSel = idx + } + m.configNotice = "" + return m, nil + case tea.KeyUp: + if m.configZAIRegionSel > 0 { + m.configZAIRegionSel-- + } + return m, nil + case tea.KeyDown: + if m.configZAIRegionSel < len(zaiRegions)-1 { + m.configZAIRegionSel++ + } + return m, nil + case tea.KeyEnter: + if m.configZAIRegionSel < 0 || m.configZAIRegionSel >= len(zaiRegions) { + return m, nil + } + region := zaiRegions[m.configZAIRegionSel].id + prov := m.configProvider + if err := hawkconfig.SetZAIRegion(prov, region); err != nil { + m.configNotice = "Region: " + err.Error() + return m, nil + } + InvalidateModelCacheProvider(prov) + m.configEntry = configEntryNone + ctx := context.Background() + if post := strings.TrimSpace(m.configPostSaveKeysProvider); post == prov { + m.configPostSaveKeysProvider = "" + return m.startConfigKeyReplace(post) + } + if hawkconfig.HasStoredCredentialForProvider(ctx, prov) { + m.configNotice = "Region saved (" + region + ") — press r to refresh models" + if idx := m.configGatewayRowIndex(prov); idx >= 0 { + m.configSel = idx + } + return m, nil + } + m.configNotice = "Region saved (" + region + ") — paste Z.AI API key" + return m.startConfigKeyForProvider(prov) + default: + return m, nil + } +} diff --git a/cmd/chat_model.go b/cmd/chat_model.go index 34aa9117..829545ec 100644 --- a/cmd/chat_model.go +++ b/cmd/chat_model.go @@ -183,6 +183,7 @@ type chatModel struct { configSaving bool // blocks hub/list input while async credential work runs configPendingOllamaURL string configXiaomiRegionSel int // Token Plan region picker index + configZAIRegionSel int // Z.AI (general or coding) region picker index pluginRuntime *plugin.Runtime spinnerVerb string // Per-turn token counters shown next to the spinner (↑ input, ↓ output). diff --git a/cmd/errors.go b/cmd/errors.go index 83a5c42e..ceddc740 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -43,7 +43,8 @@ func friendlyError(err error) string { {[]string{"gemini_api_key", "google_api_key", "gemini api key"}, "GEMINI_API_KEY", "Gemini"}, {[]string{"openrouter_api_key", "openrouter api key"}, "OPENROUTER_API_KEY", "OpenRouter"}, {[]string{"canopywave_api_key", "canopywave api key"}, "CANOPYWAVE_API_KEY", "CanopyWave"}, - {[]string{"zai_api_key", "z.ai api key", "z-ai api key"}, "ZAI_API_KEY", "Z.AI"}, + {[]string{"zai_payg_api_key", "zai_api_key"}, "ZAI_API_KEY", "Z.AI"}, + {[]string{"zai_coding_api_key", "zai_coding_api_key"}, "ZAI_CODING_API_KEY", "Z.AI Coding Plan"}, {[]string{"xai_api_key", "xai api key"}, "XAI_API_KEY", "xAI (Grok)"}, {[]string{"opencodego_api_key", "opencodego api key"}, "OPENCODEGO_API_KEY", "OpenCodeGo"}, {[]string{"moonshot_api_key", "moonshot api key"}, "MOONSHOT_API_KEY", "Kimi (Moonshot)"}, @@ -447,7 +448,7 @@ func providerDNSHost(provider string) string { return "api.x.ai" case "canopywave": return "inference.canopywave.io" - case "z-ai", "zai": + case "zai_payg", "zai_coding": return "api.z.ai" case "kimi", "moonshotai": return "api.moonshot.ai" diff --git a/cmd/options.go b/cmd/options.go index df203dca..811f8c20 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -311,7 +311,7 @@ func configureSession(sess *engine.Session, settings hawkconfig.Settings, maxTur sess.Autonomy = lvl } - // GLM/Z.ai extended reasoning toggle (applied in the stream loop for z-ai). + // GLM/Z.AI extended reasoning toggle (applied in the stream loop for zai_coding/zai_payg). sess.GLMThinkingEnabled = settings.GLMThinkingEnabled return nil diff --git a/docs/architecture.md b/docs/architecture.md index 6091e7d3..017dfc88 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@
-# 🦅 hawk Architecture +# bird hawk Architecture **AI Coding Agent for Your Terminal** @@ -12,51 +12,51 @@ --- -## 🎯 Overview +## target Overview hawk is an AI-powered coding agent for the terminal. It reads codebases, writes and edits files, runs tests, and manages git — all through natural language. Zero CGO, single static binary for linux/darwin/windows on amd64/arm64. --- -## 🧱 Layered Architecture +## blocks Layered Architecture ``` hawk/ -├── api/openapi.yaml 📜 Daemon REST API contract (OpenAPI 3.1) -├── cmd/ 🖥️ Cobra CLI commands (200+ files) -│ ├── hawk/main.go ⚡ Entry point — calls cmd.Execute() -│ ├── root.go ⚙️ Root command, flag definitions -│ ├── daemon.go 🔮 Daemon start/stop/status -│ ├── chat.go 💬 Interactive TUI chat +├── api/openapi.yaml file-text Daemon REST API contract (OpenAPI 3.1) +├── cmd/ terminal Cobra CLI commands (200+ files) +│ ├── hawk/main.go zap Entry point — calls cmd.Execute() +│ ├── root.go settings Root command, flag definitions +│ ├── daemon.go server Daemon start/stop/status +│ ├── chat.go message-square Interactive TUI chat │ └── ... ├── internal/ -│ ├── api/ 🌐 HTTP server (:4590) — 8 REST endpoints -│ ├── daemon/ 🔮 Daemon lifecycle (PID file, socket) -│ ├── engine/ 🧠 Agent execution loop -│ │ ├── session.go 🔄 Core agent loop (Stream, agentLoop) -│ │ ├── ctxmgr/ 📦 Context packing and visualization -│ │ ├── token/ 💰 Budget allocation and prediction -│ │ ├── streaming/ 📡 Response cache and stream optimizer -│ │ ├── planning/ 🎯 Goals and task decomposition -│ │ └── workflow/ 🔧 JSON-defined automation pipelines -│ ├── tool/ 🛠️ 40+ built-in tools -│ ├── config/ ⚙️ Settings, env manager, migration -│ ├── session/ 💾 SQLite persistence, search, export -│ ├── permissions/ 🛡️ Guardian, rules DSL, boundary checker -│ ├── sandbox/ 🏖️ Landlock + seccomp isolation -│ ├── intelligence/ 🧬 Repo map, AST analysis, deps -│ ├── multiagent/ 👥 Personas, inter-agent messaging -│ ├── mcp/ 🔌 MCP client and server -│ ├── bridge/ 🌉 Bridges to ecosystem services -│ └── resilience/ 🔄 Circuit breaker, retry, rate limit -├── shared/types/ 📤 Cross-repo exported types -├── docs/ 📖 Architecture docs -└── external/ 🔗 Local go.work checkouts +│ ├── api/ globe HTTP server (:4590) — 8 REST endpoints +│ ├── daemon/ server Daemon lifecycle (PID file, socket) +│ ├── engine/ brain Agent execution loop +│ │ ├── session.go refresh-cw Core agent loop (Stream, agentLoop) +│ │ ├── ctxmgr/ package Context packing and visualization +│ │ ├── token/ coins Budget allocation and prediction +│ │ ├── streaming/ radio Response cache and stream optimizer +│ │ ├── planning/ target Goals and task decomposition +│ │ └── workflow/ wrench JSON-defined automation pipelines +│ ├── tool/ hammer 40+ built-in tools +│ ├── config/ settings Settings, env manager, migration +│ ├── session/ database SQLite persistence, search, export +│ ├── permissions/ shield Guardian, rules DSL, boundary checker +│ ├── sandbox/ box Landlock + seccomp isolation +│ ├── intelligence/ git-branch Repo map, AST analysis, deps +│ ├── multiagent/ users Personas, inter-agent messaging +│ ├── mcp/ plug MCP client and server +│ ├── bridge/ link Bridges to ecosystem services +│ └── resilience/ refresh-cw Circuit breaker, retry, rate limit +├── shared/types/ share-2 Cross-repo exported types +├── docs/ book-open Architecture docs +└── external/ link Local go.work checkouts ``` --- -## 🌐 Daemon HTTP API (:4590) +## globe Daemon HTTP API (:4590) | | | |---|---| @@ -65,49 +65,49 @@ hawk/ | **Auth** | Bearer token or `X-API-Key`. Set via `HAWK_DAEMON_API_KEY` |
-📡 Endpoint Summary +radio Endpoint Summary | Method | Path | Description | |--------|------|-------------| -| `GET` | `/v1/health` | 🩺 Health check | -| `GET` | `/v1/version` | 🏷️ Version info | -| `POST` | `/v1/chat` | 💬 Send message (JSON or SSE) | -| `GET` | `/v1/sessions` | 📋 List sessions | -| `GET` | `/v1/sessions/{id}` | 🔍 Get session | -| `GET` | `/v1/sessions/{id}/messages` | 💬 Get messages | -| `DELETE` | `/v1/sessions/{id}` | 🗑️ Delete session | -| `GET` | `/v1/stats` | 📊 Usage statistics | +| `GET` | `/v1/health` | heart Health check | +| `GET` | `/v1/version` | tag Version info | +| `POST` | `/v1/chat` | message-square Send message (JSON or SSE) | +| `GET` | `/v1/sessions` | list List sessions | +| `GET` | `/v1/sessions/{id}` | search Get session | +| `GET` | `/v1/sessions/{id}/messages` | message-square Get messages | +| `DELETE` | `/v1/sessions/{id}` | trash-2 Delete session | +| `GET` | `/v1/stats` | bar-chart Usage statistics |
--- -## 🔗 Ecosystem Integration +## link Ecosystem Integration | Service | Role | Connection | |---------|------|------------| -| 🦅 **eyrie** | LLM provider runtime | `:8080` — all LLM calls routed here | -| 🧠 **yaad** | Persistent memory | `:3456` — session context, recall | -| 👁️ **sight** | Code review | Library — diff-based review | -| 🔍 **inspect** | Security audit | Library — website scanning | -| ✂️ **tok** | Token optimization | Library — compression, secrets | -| 📸 **trace** | Session capture | CLI hook — git-native capture | +| bird **eyrie** | LLM provider runtime | `:8080` — all LLM calls routed here | +| brain **yaad** | Persistent memory | `:3456` — session context, recall | +| eye **sight** | Code review | Library — diff-based review | +| search **inspect** | Security audit | Library — website scanning | +| scissors **tok** | Token optimization | Library — compression, secrets | +| camera **trace** | Session capture | CLI hook — git-native capture | -> 💡 **hawk never talks to LLM APIs directly** — all calls go through eyrie. +> lightbulb **hawk never talks to LLM APIs directly** — all calls go through eyrie. --- -## 🛡️ Tool Safety Layer +## shield Tool Safety Layer Every tool call passes through the permission system before execution: ``` -Tool Call → 🛡️ Guardian (rules DSL) → 🧱 Boundary Checker → 👤 User Approval → 🏖️ Sandbox (landlock/seccomp) → ✅ Execute +Tool Call → shield Guardian (rules DSL) → blocks Boundary Checker → user User Approval → box Sandbox (landlock/seccomp) → check-circle Execute ``` --- -## 📐 Key Design Decisions +## ruler Key Design Decisions | Decision | Rationale | |----------|-----------| diff --git a/docs/plans/z-ai-proper-implementation.md b/docs/plans/z-ai-proper-implementation.md new file mode 100644 index 00000000..8ba7d1ba --- /dev/null +++ b/docs/plans/z-ai-proper-implementation.md @@ -0,0 +1,337 @@ +# Z.AI Proper Gateway Implementation Plan + +**Status:** Plan (ready for implementation) +**Date:** 2026 (current) +**Owners:** Hawk + Eyrie teams (cross-repo via go.work) +**Related:** Xiaomi MiMo per-plan/region split (the direct precedent) +**Goal:** First-class support for Z.AI (Zhipu/GLM) **Coding Plan** (subscription/quota, dedicated endpoint) alongside general **pay-as-you-go** API, with **region awareness** (global vs CN), matching the maturity, dynamism, reuse, reliability, and UX of the Xiaomi implementation while preserving the "live when configured + registry-driven" architecture. + +--- + +## 1. Executive Summary + +Current Z.AI support is a single generic live-only OpenAI-compat gateway (`z-ai` / `z-ai-direct`, `ZAI_API_KEY`, default `https://api.z.ai/api/paas/v4`). This is insufficient. + +Z.AI reality (confirmed against official quick-start and tooling usage): +- **GLM Coding Plan**: Subscription-based (Lite/Pro/Max tiers, prompt/quota model with 5-hour rolling windows + MCP quotas). Marketed for Cursor, Claude Code, Cline, etc. **Must** use the dedicated coding endpoint for correct billing/quota consumption and plan-eligible models. +- **General API (pay-as-you-go)**: Standard token billing on the general endpoint. +- **Endpoints** (from Z.AI developer docs): + - General: `https://api.z.ai/api/paas/v4` (or CN equivalent) + - Coding Plan: `https://api.z.ai/api/coding/paas/v4` +- **Regions**: `api.z.ai` (global/international branding, primary for Coding Plan docs) vs China platform (`open.bigmodel.cn` / bigmodel.cn family). Affects billing, quotas, latency, and model availability. CN equivalents of the coding path exist or are expected. +- Reference catalog currently lists only minimal `z-ai/glm-4.5-air:free`; real breadth (GLM-4.5/4.7/5/Flash/V variants, vision/tooling) comes via live `/models` on the correct base. + +The architecture (ProviderSpec + live fetchers + decorator clients + Hawk gateway surface) is already excellent: dynamic (new gateway = spec + fetcher + data), heavily reused (OpenAI client + compat flags + ProtocolRouter patterns), reliable (retriable-only failover, negative caching, probes), secure (centralized CredentialEnv, no secrets in JSON), and fast (compiled catalog, on-demand counts). + +**The gap is only specialization surface for Z.AI**, exactly analogous to the pre-split state of Xiaomi MiMo (which received dedicated `xiaomi_mimo_token_plan` + `payg`, region picker, `catalog/xiaomi/`, dual-protocol client, Hawk UI, config resolution, and detailed docs). + +This plan adds **one new setup gateway** (`z_ai_coding`) while keeping the existing `z-ai` (general) fully backward-compatible. Total setup gateways become 19. No breaking changes for existing users. + +--- + +## 2. Current State (Precise Inventory) + +### Eyrie (external/eyrie) +- `catalog/registry/providers.go:68` (single entry): + ```go + { + ProviderID: "z-ai", DisplayName: "Z.AI", DeploymentID: "z-ai-direct", SortOrder: 7, + RequiresKey: true, CredentialEnv: "ZAI_API_KEY", + BaseURLEnv: []string{"ZAI_BASE_URL", "ZAI_API_BASE", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, + ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/paas/v4", + LiveFetcherKey: "z-ai", LiveCatalogKey: "z-ai", + APIProtocolID: "openai-chat-completions", AdapterID: "z-ai", + }, + ``` +- `catalog/live/fetchers.go:26,50,729`: + - `DefaultZAIBaseURL = "https://api.z.ai/api/paas/v4"` + - Registry: `"z-ai": FetchZAI` + - `FetchZAI`: `fetchOpenAICompatModels(..., envOr(..., "ZAI_BASE_URL", DefaultZAIBaseURL), "ZAI_API_KEY", "Bearer")` + `enrichFromOpenRouter(entries, "z-ai/")` +- `setup/deployment.go:223`: + - `"z-ai-direct"` → `client.NewOpenAIClient(..., &client.ZAICompat)` +- `client/compat.go:43`: + - `ZAICompat = OpenAICompatConfig{ ThinkingFormat: "zai", MaxTokensField: "max_tokens", SupportsUsageInStreaming: true }` +- `config/providers.go:27`, `config/profiles.go`, `config/provider_env.go`, `config/runtime.go`, etc.: `ProviderZAI`, `ZAIRuntimeProfile`, `DefaultZAIOpenAIBaseURL`, env collection for `ZAI_API_KEY` / `ZAI_BASE_URL*`. +- `catalog/live/zai_test.go` exists (thin coverage noted in docs). +- No `catalog/zai/` subpackage (unlike `catalog/xiaomi/`). + +### Hawk +- `internal/config/catalog_api.go`: + - `AllSetupGateways()` pulls from `registry.CredentialRegistry()` (dynamic). + - `setupGatewayRegistryID` switch already has `case "zai": return "z-ai"` (plus xiaomi special cases, google→gemini, xai→grok). + - `GatewayDisplayName`, `IsSetupGateway`, `GatewayForModel`, `ActiveGateway` all go through the normalizer. +- `cmd/chat_config_gateways.go`: Special-case only for `ProviderXiaomiTokenPlan` (region flow before key paste, hints in footer). +- No `chat_config_zai.go` or `internal/config/zai_setup.go`. +- `internal/config/catalog_gateways_test.go:14`: Hard `len(gws) != 18` + explicit want list (includes the two xiaomi + two minimax). +- `internal/config/xiaomi_setup.go` + `cmd/chat_config_xiaomi.go` + `xiaomi_setup_test.go`: The full Hawk-side pattern for region-aware plan gateways. +- `internal/config/eyrie_apply.go` and `credentials_store.go`: Xiaomi-specific Apply/region env injection. + +### Docs (stale in places) +- `external/eyrie/docs/guides/CREDENTIAL-SETUP-FLOW.md`: Lists 12 gateways (stale), has a full "Xiaomi MiMo (two gateways...)" subsection with tables for keys/bases/paths. Z.AI is one line: "live /models only". +- `external/eyrie/docs/guides/DYNAMIC-MODEL-DISCOVERY.md`: Notes "thin test coverage (z-ai...)", "All 12 setup gateways", Z.AI row describes only generic OpenAI-compat. +- Hawk `docs/DYNAMIC-MODELS.md` and others reference the gateway surface generically. +- Reference catalog (langdag) has minimal data for z-ai. + +### Architecture Strengths (no changes needed) +- Everything funnels through ProviderSpec + live fetch + `runtime.ListModels(Source: auto)`. +- Decorators (Weighted/Fallback/RateLimit/Tracing/ProtocolRouter) are provider-agnostic. +- Credential centralization + guardian in Hawk front everything. +- `go work sync` + submodule hygiene enforced in CI. + +--- + +## 3. Xiaomi Precedent (Copy This Pattern) + +Xiaomi split was the first "billing plan + region + special hosts" case. + +**Eyrie additions:** +- Two `ProviderSpec` rows with distinct `ProviderID`, `DisplayName`, `CredentialEnv`, `BaseURLEnv`, `LiveFetcherKey`, `LiveCatalogKey`, `DeploymentID`, `ProbeBaseURL` (empty for token plan because resolved). +- New package `catalog/xiaomi/`: + - `endpoints.go`: `Billing`/`Region` types + constants for every host (payg + 3 token-plan regions × OpenAI + Anthropic), `NormalizeRegion`, `BillingForProvider`, `ResolveOpenAIBase`/`ResolveAnthropicBase` (override wins, region required for token plan), key-shape mismatch hints (`tp-` vs `sk-`). + - `platform.go` + `http.go`: Separate platform catalog fetch for rich metadata (context/pricing/names) because inference `/v1/models` is sparse. `ApplyPlatformMetadata`. +- `client/mimo.go`: `NewMiMoClient` (dual OpenAI + Anthropic bases, compat, retriable failover via existing machinery). +- `config/xiaomi_profile.go`: Env consts (`EnvXiaomi*`), `ResolveXiaomiOpenAIBase`/`ResolveXiaomiAnthropicBase` (load provider.json + delegate to catalog/xiaomi), `IsXiaomiMimoProvider`, legacy migration. +- `setup/deployment.go`: `newMiMoDeploymentClient` that resolves bases via config + xiaomi package before `NewMiMoClient`. +- Registry live fetchers: `FetchXiaomiPayg` + `FetchXiaomiTokenPlan` (registered under the two keys). + +**Hawk additions (thin UI + bridge only):** +- `internal/config/xiaomi_setup.go`: `ProviderXiaomiTokenPlan` const, `NeedsXiaomiTokenPlanRegion`, `SetXiaomiTokenPlanRegion` (persist + set envs for probe + derive base), `XiaomiTokenPlanRegionLabel`, `ApplyXiaomiTokenPlanRegionEnv`. +- `cmd/chat_config_xiaomi.go`: Region list (cn/sgp/ams), picker view, key handler that calls Set + invalidates cache + routes to key paste or post-save flow. Special hints. +- `cmd/chat_config_gateways.go`: In `handleConfigGatewaysSelect` and hint rendering: if the row is the token-plan gateway and needs region (or no key), launch region flow first. +- Tests + `catalog_gateways_test.go` updates. +- `eyrie_apply.go` etc. call the Apply*Env hook. + +**Result:** Users see two distinct rows in /config, get region prompt only for token plan, correct hosts are used for probe/fetch/chat, key mismatch hints, rich models, full docs. + +Z.AI needs the same treatment (plan split + region), but likely simpler client side (no Anthropic dual path documented yet; both paths are OpenAI-compat with the existing "zai" thinking format). + +--- + +## 4. Proposed Design + +### 4.1 Registry Entries (external/eyrie/catalog/registry/providers.go) +Add after the existing z-ai (keep the original as general payg for backward compat + users who intentionally use general API): + +```go +{ + ProviderID: "z-ai", DisplayName: "Z.AI", DeploymentID: "z-ai-direct", SortOrder: 7, + // ... (unchanged, general /paas/v4) +}, +{ + ProviderID: "z_ai_coding", DisplayName: "Z.AI — Coding Plan", DeploymentID: "z_ai_coding-direct", SortOrder: 7, // or 19 after re-sort + RequiresKey: true, CredentialEnv: "ZAI_CODING_API_KEY", + BaseURLEnv: []string{"ZAI_CODING_BASE_URL", "ZAI_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, + ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/coding/paas/v4", + LiveFetcherKey: "z_ai_coding", LiveCatalogKey: "z_ai_coding", + APIProtocolID: "openai-chat-completions", AdapterID: "z-ai", +}, +``` + +(Alternative naming: `z_ai_coding_plan` to match `xiaomi_mimo_token_plan` verbosity. `z_ai_coding` is shorter and clear in TUI. Choose one; document alias handling.) + +Add `case "z_ai_coding", "zai-coding", "z-ai_coding": return "z_ai_coding"` in Hawk's `setupGatewayRegistryID`. + +### 4.2 New Eyrie Package: catalog/zai/ (modeled exactly on catalog/xiaomi/) +- `endpoints.go`: + - Types: `Plan` ("general" | "coding"), `Region` ("global" | "cn" or more specific if needed). + - Constants for bases: + - General global: `https://api.z.ai/api/paas/v4` + - Coding global: `https://api.z.ai/api/coding/paas/v4` + - CN variants (research + docs): `https://open.bigmodel.cn/api/paas/v4`, `https://open.bigmodel.cn/api/coding/paas/v4` (or the actual CN coding host; confirm at implementation time). + - `NormalizeRegion`, `PlanForProvider`, `ResolveOpenAIBase(plan, region, override string)`. + - Optional: key hinting if dashboard produces distinguishable prefixes for coding keys. +- `platform.go` or enrichment (optional; start with OpenRouter "z-ai/" enrichment which FetchZAI already does; add dedicated if Z.AI coding catalog differs significantly). +- Tests: `endpoints_test.go` (table-driven, like xiaomi). + +### 4.3 Live Fetchers (catalog/live/fetchers.go) +- Keep `FetchZAI` for the `z-ai` key (general). +- Add: + ```go + "z_ai_coding": FetchZAICoding, + ``` +- Implement `FetchZAICoding` (or a single `FetchZAIWithPlan`): + - Resolve base via new `config.ResolveZAIOpenAIBase("z_ai_coding", cfg)` (or env first). + - Call `fetchOpenAICompatModels(..., resolvedBase, key, "Bearer")`. + - Same OpenRouter enrichment (or "z_ai_coding/" if they publish distinct). +- Export `DefaultZAICodingBaseURL` etc. in `config/providers.go`. + +Update Registry map and any `fetchers_test` / `live_test`. + +### 4.4 Client + Deployment + Config (minimal) +- `setup/deployment.go`: Add case `"z_ai_coding-direct":` → resolve base (via new config helper + LoadProviderConfig) then `NewOpenAIClient(apiKey, resolved, &client.ZAICompat)`. Reuse the same compat (thinking "zai" format applies). +- `config/providers.go`: Add `DefaultZAICodingOpenAIBaseURL`. +- `config/xai_profile.go` or new `config/zai_profile.go` (or extend existing ZAI bits): + - Env consts: `EnvZAICodingAPIKey`, `EnvZAICodingBaseURL`, `EnvZAICodingRegion` (or plan-specific). + - `ResolveZAIOpenAIBase(providerID string, cfg *ProviderConfig)`. + - Migration for any legacy. +- `profiles.go` / `provider_env.go` / `runtime.go`: Wire the new provider ID into profiles, env collection, and `ZAICodingRuntimeProfile` if distinct mode needed (likely same "openai" mode). +- No new client file needed initially (reuse OpenAI path + ZAICompat). If future dual-protocol or coding-specific headers appear, add `NewZAIClient` parallel to MiMo. + +### 4.5 Hawk Surface (UI + Bridge) +- `internal/config/zai_setup.go` (new, modeled 1:1 on `xiaomi_setup.go`): + ```go + const ProviderZAICoding = "z_ai_coding" + + func NeedsZAIRegionOrPlan(providerID string) bool { ... } + func SetZAIRegion(...) error { ... } + func ZAIRegionLabel() string { ... } + func ApplyZAIRegionEnv(ctx context.Context) { ... } // sets process envs before probe + ``` + Delegate to `eyriecfg` (new Resolve helpers) + `catalog/zai`. +- `cmd/chat_config_zai.go` (new): + - Region/plan options (e.g. "Global (Coding)", "China (Coding)", "Global (General)" — or separate flows). + - View + key handler. Special footer hints: "Coding Plan keys from z.ai dashboard · uses /coding/paas/v4". +- `cmd/chat_config_gateways.go`: + - In select + hints: if row.ID == hawkconfig.ProviderZAICoding && needs region/plan → launch zai flow (like Xiaomi). + - Update any hardcoded Xiaomi-only hints to a helper or switch. +- Update `catalog_gateways_test.go`: change `18` → `19`, add "z_ai_coding" to want list or remove brittle explicit map. +- `eyrie_apply.go`, startup, cache invalidation: call the new Apply hook for the coding provider. +- `catalog_api.go`: add alias cases in `setupGatewayRegistryID` (keep the switch small; long-term consider adding `Aliases []string` to ProviderSpec + derive logic in eyrie registry to kill the switch). + +### 4.6 Other Surfaces +- Credentials migrate/alias: `credentials/store.go` etc. for `zai_coding_api_key` → `ZAI_CODING_API_KEY`. +- Runtime profiles and deployment env sync. +- Any conformance or verify tests that enumerate providers. + +--- + +## 5. Implementation Phases (Actionable, File-by-File) + +### Phase 0 — Foundations (Eyrie, no UX yet) +1. Add the second `ProviderSpec` row in `external/eyrie/catalog/registry/providers.go`. +2. Add consts + `ResolveZAIOpenAIBase` (and region/plan types) in a new `external/eyrie/catalog/zai/endpoints.go` (copy structure from xiaomi/endpoints.go; include CN bases once confirmed). +3. Update `external/eyrie/catalog/live/fetchers.go`: + - New default const. + - New fetcher func + registration `"z_ai_coding": FetchZAICoding`. + - (FetchZAI stays for the general key.) +4. `external/eyrie/config/providers.go`: new `DefaultZAICodingOpenAIBaseURL`. +5. `external/eyrie/setup/deployment.go`: add case for `z_ai_coding-direct` (resolve base first). +6. Wire minimal profile/env bits (can live in existing ZAI sections or small new `zai_profile.go` modeled on `xiaomi_profile.go`). +7. Update `external/eyrie/catalog/live/zai_test.go` (or add `zai_coding_test.go`) + any live parity tests. +8. `go test -race ./external/eyrie/catalog/...` (and full package). + +**Deliverable:** `z_ai_coding` appears in `registry.All()` and can be resolved; live fetch works when `ZAI_CODING_API_KEY` + correct base is set. + +### Phase 1 — Eyrie Config + Runtime Polish +- Full resolution + provider.json storage for region/plan (parallel to `XiaomiMimo*` fields). +- Legacy migration if anyone had custom ZAI_BASE_URL pointing at coding before. +- Ensure `runtime.ListModels` + discover use the right fetcher key per deployment. +- Update any default model / catalog bootstrap for the new provider ID. + +### Phase 2 — Hawk UI + Config Bridge +1. Create `internal/config/zai_setup.go` + `_test.go` (table-driven; use `credentials.MapStore`). +2. Create `cmd/chat_config_zai.go` + `_test.go` (region/plan picker modeled exactly on xiaomi; include "g" hotkey support for "change region/plan"). +3. Edit `cmd/chat_config_gateways.go`: + - Import and use the new const. + - Add conditionals for the coding provider ID in select/hints (extract a small helper if the if-chain grows). +4. Edit `internal/config/catalog_api.go` (add cases to the switch for aliases). +5. Edit `internal/config/eyrie_apply.go`, `catalog_startup.go`, ui caches etc. to call Apply hook for coding provider. +6. Update `internal/config/catalog_gateways_test.go` (19 gateways, "z_ai_coding" present). +7. `go test -race ./internal/config/... ./cmd/... -run 'Gateway|ZAI|Config'`. + +### Phase 3 — Tests & Hardening +- Table-driven tests for resolution, fetch (with env overrides), region normalize. +- Integration-style via `scripts/test-config-flow.sh` or new zai flow test. +- Update hawk `catalog_startup_test.go`, `ui_cache_test` etc. that range over `AllSetupGateways()`. +- Run full `go test -race -count=1 ./...`. +- `make smoke`, `make ci` (local). + +### Phase 4 — Documentation (required for "proper") +- `external/eyrie/docs/guides/CREDENTIAL-SETUP-FLOW.md`: + - Fix header count. + - Add full subsection for Z.AI parallel to Xiaomi (tables for general vs coding, global vs CN bases, key source, "Coding Plan keys from z.ai dashboard after subscribe", note that Coding Plan is intended for supported coding tools). + - Official links (from research): Z.AI quick-start, devpack, platform dashboard. +- `external/eyrie/docs/guides/DYNAMIC-MODEL-DISCOVERY.md`: update "12" → "19", remove "thin coverage (z-ai)" note, add Z.AI row with plan/region details. +- Hawk `docs/DYNAMIC-MODELS.md` and `docs/ECOSYSTEM-CONFIG.md` if they enumerate. +- `external/eyrie/CHANGELOG.md` + Hawk `CHANGELOG.md` entries (conventional). +- Optional: contribute richer z-ai entries (including coding variants) to the reference catalog JSON. + +### Phase 5 — Git / PR Hygiene (AGENTS.md) +- Work on feature branch only: `git checkout -b feat/z_ai_coding-plan-support`. +- Conventional commits (no co-author trailers — lefthook + history rules). +- `go fmt` / `go vet` / `golangci-lint` clean locally. +- Full `-race` + `make smoke` + `make ci` (or background) must be green before PR. +- `gh pr create --fill` (or with description referencing this plan). +- Address any required 8 status checks. +- After approval/CI: `gh pr merge --squash --delete-branch` (or admin if needed). +- Post-merge: verify `origin/main` clean, no lingering feature branches, `go work sync` clean, submodules updated, only main remote. +- (If history issues ever arise again: follow prior filter-branch + gh api protected-branch relax pattern, but avoid.) + +--- + +## 6. Backward Compatibility & Migration +- Existing `z-ai` + `ZAI_API_KEY` + `ZAI_BASE_URL` (or env fallbacks) continue to target the general endpoint exactly as today. No change in behavior. +- Users with Coding Plan subscriptions will see a new row "Z.AI — Coding Plan" in the Gateways tab. They paste the plan key (separate env `ZAI_CODING_API_KEY` recommended so both can coexist). +- Old custom `ZAI_BASE_URL` pointing at coding path will still work for the general row (override wins); the new coding row will prefer its own env + resolved value. +- Provider.json fields for region/plan are additive. +- Live discovery for the new gateway ID works immediately after key save (same as Xiaomi). +- No impact on non-setup providers or aggregators. + +--- + +## 7. Open Questions / Risks (Resolve During Implementation) +- Exact CN coding base URL? (Confirm on official CN docs / dashboard at implementation time; default to documented patterns.) +- Do Coding Plan keys have a distinguishable prefix (like Xiaomi `tp-`)? If yes, add `KeyMismatchHint` + append on probe errors. +- Does the coding endpoint return meaningfully different model metadata (pricing is quota-based, not token)? Fetcher may need light post-processing or skip certain enrichment. +- Is an Anthropic-compat path published for the coding plan (unlikely per current docs; if added later, extend like MiMo). +- Should we allow the same key env for both rows (with warning) or enforce distinct like Xiaomi? Distinct is cleaner for quota tracking. +- Reference catalog updates (optional follow-up). +- SortOrder: keep z-ai at 7; place coding immediately after or give it its own logical order. + +--- + +## 8. Verification Checklist (Before PR + On Main) +- [ ] `AllSetupGateways()` returns 19 items including both z-ai variants; test passes. +- [ ] `/config` shows two distinct Z.AI rows with correct display names. +- [ ] Selecting Coding Plan (no region/plan set) triggers picker → persist → key paste flow. +- [ ] Probe + live list + chat all use `/coding/paas/v4` (or CN) when the coding gateway + region chosen. +- [ ] General `z-ai` row unaffected. +- [ ] `ZAI_CODING_API_KEY` and `ZAI_API_KEY` can both be stored. +- [ ] Region change ("g" or re-select) updates provider.json + derives correct base for probe/fetch. +- [ ] Full `go test -race -count=1 ./...` green. +- [ ] `make smoke` and local `make ci` (lint/vet/module hygiene) clean. +- [ ] Docs updated + table counts match reality. +- [ ] gh PR flow followed; 8 checks green on the PR; merged to main via gh; branches cleaned; main + origin in sync; no co-authors in new commits. + +--- + +## 9. Appendix — Copy-Paste Starting Points + +**Hawk bridge (internal/config/zai_setup.go skeleton):** +```go +package config + +import ( + "context" + "os" + "strings" + + eyriecfg "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/catalog/zai" +) + +const ProviderZAICoding = "z_ai_coding" + +func NeedsZAIRegionOrPlan(providerID string) bool { /* similar to Xiaomi */ } +func SetZAIRegionOrPlan(...) error { /* persist to provider.json via eyriecfg, set envs, derive base */ } +func ApplyZAIRegionEnv(ctx context.Context) { /* ... */ } +``` + +**Eyrie endpoints (external/eyrie/catalog/zai/endpoints.go):** +Copy the structure of `xiaomi/endpoints.go` (Billing/Region → Plan/Region, all the Resolve* funcs, const bases for coding/general × global/cn). + +**Gateway select special case (cmd/chat_config_gateways.go):** +Add parallel to the existing XiaomiTokenPlan block (search for `ProviderXiaomiTokenPlan`). + +**Test count bump:** +Only the one `len(gws) != 18` assertion + the want map in `internal/config/catalog_gateways_test.go`. + +--- + +**End of Plan** + +This document is the single source for the implementation. After writing code, update this file with "Implemented" status + links to the merged PR(s). + +Follow AGENTS.md at every step: tests beside source, table-driven where multi-case, conventional signed commits, feature branch + gh PR only, full `-race` + make ci green, no direct main, ecosystem (go.work + external/eyrie) hygiene. + +When ready to execute: create the feature branch and begin Phase 0 in eyrie (the registry + fetcher + catalog/zai package changes are the highest-leverage first commits). diff --git a/external/eyrie b/external/eyrie index c0e27790..f3abc3b6 160000 --- a/external/eyrie +++ b/external/eyrie @@ -1 +1 @@ -Subproject commit c0e2779031a9b6d17cd2897399c29236835934b1 +Subproject commit f3abc3b6cb393c24ae78bdb42c91431428b199ea diff --git a/internal/config/catalog_api.go b/internal/config/catalog_api.go index 0a0a2928..489689d5 100644 --- a/internal/config/catalog_api.go +++ b/internal/config/catalog_api.go @@ -65,7 +65,8 @@ func AllSetupGateways() []string { } // setupGatewayRegistryID maps catalog/engine aliases to credential registry gateway ids. -// Registry ids keep underscores (e.g. xiaomi_mimo); do not hyphenate via normalizeProviderName. +// Most registry IDs use underscores for multi-word plans (e.g. xiaomi_mimo_token_plan). +// Z.AI uses underscore naming for uniformity with Xiaomi/MiniMax plan splits: zai_payg and zai_coding (no legacy aliases). func setupGatewayRegistryID(provider string) string { p := strings.ToLower(strings.TrimSpace(provider)) switch p { @@ -73,8 +74,10 @@ func setupGatewayRegistryID(provider string) string { return "gemini" case "xai": return "grok" - case "zai": - return "z-ai" + case "zai_payg": + return "zai_payg" + case "zai_coding": + return "zai_coding" case "xiaomi_mimo", "xiaomi-mimo": return "xiaomi_mimo_payg" case "xiaomi_mimo_token_plan", "xiaomi-mimo-token-plan": diff --git a/internal/config/catalog_gateways_test.go b/internal/config/catalog_gateways_test.go index e025cff5..51fc00b7 100644 --- a/internal/config/catalog_gateways_test.go +++ b/internal/config/catalog_gateways_test.go @@ -11,15 +11,15 @@ import ( func TestAllSetupGateways_RegistryOnly(t *testing.T) { gws := AllSetupGateways() - if len(gws) != 18 { - t.Fatalf("expected 18 setup gateways, got %d: %v", len(gws), gws) + if len(gws) != 19 { + t.Fatalf("expected 19 setup gateways, got %d: %v", len(gws), gws) } for _, id := range gws { if id == "ai21" || id == "alibaba" { t.Fatalf("owner slug %q should not be a gateway", id) } } - want := map[string]bool{"azure": true, "bedrock": true, "gemini": true, "grok": true, "openrouter": true, "kimi": true, "vertex": true, "xiaomi_mimo_payg": true, "xiaomi_mimo_token_plan": true, "deepseek": true, "minimax_token_plan": true, "minimax_payg": true} + want := map[string]bool{"azure": true, "bedrock": true, "gemini": true, "grok": true, "openrouter": true, "kimi": true, "vertex": true, "xiaomi_mimo_payg": true, "xiaomi_mimo_token_plan": true, "deepseek": true, "minimax_token_plan": true, "minimax_payg": true, "zai_payg": true, "zai_coding": true} for id := range want { found := false for _, gw := range gws { @@ -57,6 +57,12 @@ func TestSetupGatewayRegistryID_PreservesUnderscores(t *testing.T) { if got := setupGatewayRegistryID("xiaomi_mimo"); got != "xiaomi_mimo_payg" { t.Fatalf("legacy xiaomi_mimo = %q", got) } + if got := setupGatewayRegistryID("zai_payg"); got != "zai_payg" { + t.Fatalf("zai_payg = %q", got) + } + if got := setupGatewayRegistryID("zai_coding"); got != "zai_coding" { + t.Fatalf("zai_coding = %q", got) + } } func TestCredentialInferenceForProvider_XiaomiPayg(t *testing.T) { diff --git a/internal/config/eyrie_apply.go b/internal/config/eyrie_apply.go index bed78c05..f52981ae 100644 --- a/internal/config/eyrie_apply.go +++ b/internal/config/eyrie_apply.go @@ -18,6 +18,9 @@ func ApplyEyrieCredentialsForProvider(ctx context.Context, providerID string) (* if providerID == ProviderXiaomiTokenPlan { ApplyXiaomiTokenPlanRegionEnv(ctx) } + if providerID == ProviderZAICoding { + ApplyZAIRegionEnv(ctx) + } result, err := setup.ApplyCredentialsForProvider(ctx, providerID, eyriecfg.DiscoveryCredentials(ctx)) if err != nil { return nil, err @@ -47,6 +50,9 @@ func RefreshGatewayCatalog(ctx context.Context, providerID string) (string, erro if providerID == ProviderXiaomiTokenPlan { ApplyXiaomiTokenPlanRegionEnv(ctx) } + if providerID == ProviderZAICoding { + ApplyZAIRegionEnv(ctx) + } result, err := setup.DiscoverProviderCatalog(ctx, providerID, eyriecfg.DiscoveryCredentials(ctx)) if err != nil { return "", err diff --git a/internal/config/zai_setup.go b/internal/config/zai_setup.go new file mode 100644 index 00000000..ae8a4b23 --- /dev/null +++ b/internal/config/zai_setup.go @@ -0,0 +1,122 @@ +package config + +import ( + "context" + "os" + "strings" + + "github.com/GrayCodeAI/eyrie/catalog/zai" + eyriecfg "github.com/GrayCodeAI/eyrie/config" +) + +const ( + ProviderZAIPayg = "zai_payg" + ProviderZAICoding = "zai_coding" +) + +// NeedsZAIRegion reports whether the Z.AI gateway still needs a region pick for the chosen plan. +func NeedsZAIRegion(providerID string) bool { + p := strings.TrimSpace(providerID) + if p != ProviderZAICoding { + return false + } + cfg := eyriecfg.LoadProviderConfig("") + if cfg == nil { + return true + } + region := zaiRegionFromConfig(cfg, p) + _, err := zai.NormalizeRegion(region) + return err != nil +} + +func zaiRegionFromConfig(cfg *eyriecfg.ProviderConfig, providerID string) string { + if cfg == nil { + return "" + } + if providerID == ProviderZAICoding { + return cfg.ZAICodingRegion + } + return cfg.ZAIRegion +} + +// SetZAIRegion persists the region (international or cn) for the given Z.AI gateway and syncs env + derived base. +func SetZAIRegion(providerID, region string) error { + normalized, err := zai.NormalizeRegion(region) + if err != nil { + return err + } + + cfg := eyriecfg.LoadProviderConfig("") + if cfg == nil { + cfg = &eyriecfg.ProviderConfig{} + } + + if providerID == ProviderZAICoding { + cfg.ZAICodingRegion = string(normalized) + } else { + cfg.ZAIRegion = string(normalized) + } + + if saveErr := eyriecfg.SaveProviderConfig(cfg, ""); saveErr != nil { + return saveErr + } + + _ = os.Setenv("ZAI_REGION", string(normalized)) + + plan, _ := zai.PlanForProvider(providerID) + base, err := zai.ResolveOpenAIBase(plan, normalized, "") + if err == nil && base != "" { + if providerID == ProviderZAICoding { + _ = os.Setenv("ZAI_CODING_BASE_URL", base) + cfg.ZAICodingBaseURL = base + } else { + _ = os.Setenv("ZAI_BASE_URL", base) + cfg.ZAIBaseURL = base + } + _ = eyriecfg.SaveProviderConfig(cfg, "") + } + return nil +} + +// ZAIRegionLabel returns the saved region label or "". +func ZAIRegionLabel(providerID string) string { + cfg := eyriecfg.LoadProviderConfig("") + if cfg == nil { + return "" + } + r := zaiRegionFromConfig(cfg, providerID) + norm, err := zai.NormalizeRegion(r) + if err != nil { + return "" + } + return string(norm) +} + +// ApplyZAIRegionEnv sets process envs from provider.json before probe/fetch/chat. +func ApplyZAIRegionEnv(ctx context.Context) { + _ = ctx + cfg := eyriecfg.LoadProviderConfig("") + if cfg == nil { + return + } + + // General + if r := strings.TrimSpace(cfg.ZAIRegion); r != "" { + _ = os.Setenv("ZAI_REGION", r) + plan := zai.PlanGeneral + norm, _ := zai.NormalizeRegion(r) + if base, err := zai.ResolveOpenAIBase(plan, norm, cfg.ZAIBaseURL); err == nil && base != "" { + _ = os.Setenv("ZAI_BASE_URL", base) + } + } + + // Coding Plan + if r := strings.TrimSpace(cfg.ZAICodingRegion); r != "" { + _ = os.Setenv("ZAI_CODING_REGION", r) + plan := zai.PlanCoding + norm, _ := zai.NormalizeRegion(r) + if base, err := zai.ResolveOpenAIBase(plan, norm, cfg.ZAICodingBaseURL); err == nil && base != "" { + _ = os.Setenv("ZAI_CODING_BASE_URL", base) + } + } +} diff --git a/internal/engine/chat_service.go b/internal/engine/chat_service.go index a3a106b1..13911db7 100644 --- a/internal/engine/chat_service.go +++ b/internal/engine/chat_service.go @@ -162,9 +162,9 @@ func (c *ChatService) BuildOptions(systemPrompt, activeModel string, maxTokens i EnableCaching: c.provider == "anthropic", Tools: tools, } - // GLM/Z.ai extended reasoning toggle: only meaningful for the z-ai - // provider, where eyrie emits thinking={type:enabled|disabled}. - if c.provider == "z-ai" && c.glmThinkingEnabled != nil { + // GLM/Z.ai extended reasoning toggle: only meaningful for Z.AI + // providers, where eyrie emits thinking={type:enabled|disabled}. + if isZAIProvider(c.provider) && c.glmThinkingEnabled != nil { opts.GLMThinkingEnabled = c.glmThinkingEnabled } // Structured output: request a JSON-schema-constrained response when set. @@ -238,6 +238,16 @@ func contains(s, sub string) bool { return len(sub) > 0 && len(s) >= len(sub) && (s == sub || (len(s) > 0 && indexOf(s, sub) >= 0)) } +// isZAIProvider reports whether the provider is a Z.AI gateway (payg or coding). +func isZAIProvider(provider string) bool { + switch provider { + case "zai_payg", "zai_coding": + return true + default: + return false + } +} + func indexOf(s, sub string) int { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { diff --git a/internal/engine/chat_service_test.go b/internal/engine/chat_service_test.go index 4ca0501a..d3b47be8 100644 --- a/internal/engine/chat_service_test.go +++ b/internal/engine/chat_service_test.go @@ -44,19 +44,19 @@ func TestChatService_BuildOptions_NonAnthropicCaching(t *testing.T) { func TestChatService_BuildOptions_GLMThinking(t *testing.T) { enabled := true svc := NewChatService(NewMockClientForTest(), ChatServiceConfig{ - Provider: "z-ai", + Provider: "zai_payg", Model: "glm-4", GLMThinkingEnabled: &enabled, }) opts := svc.BuildOptions("sys", "glm-4", 1024, nil) if opts.GLMThinkingEnabled == nil || !*opts.GLMThinkingEnabled { - t.Error("expected GLMThinkingEnabled=true for z-ai") + t.Error("expected GLMThinkingEnabled=true for zai_payg") } - // Sanity: setting GLMThinkingEnabled on a non-z-ai provider is ignored. + // Sanity: setting GLMThinkingEnabled on a non-zai provider is ignored. svc2 := NewChatService(NewMockClientForTest(), ChatServiceConfig{Provider: "openai", GLMThinkingEnabled: &enabled}) opts2 := svc2.BuildOptions("sys", "gpt-4o", 1024, nil) if opts2.GLMThinkingEnabled != nil { - t.Error("GLMThinkingEnabled should be nil for non-z-ai provider") + t.Error("GLMThinkingEnabled should be nil for non-zai provider") } } diff --git a/internal/engine/session.go b/internal/engine/session.go index e8183754..18dc7d60 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -130,7 +130,7 @@ type Session struct { OnCompaction OnCompaction Verbose bool // show tool calls, timing, token counts in output // GLMThinkingEnabled toggles GLM/Z.ai extended reasoning on outgoing requests - // (applied only when provider is "z-ai"). nil leaves the model default. + // (applied only when provider is zai_payg or zai_coding). nil leaves the model default. GLMThinkingEnabled *bool // Cost optimization diff --git a/internal/provider/routing/catalog.go b/internal/provider/routing/catalog.go index db929812..428e129e 100644 --- a/internal/provider/routing/catalog.go +++ b/internal/provider/routing/catalog.go @@ -152,8 +152,7 @@ func canonicalProvider(provider string) string { return "google" case "grok": return "xai" - case "zai": - return "z-ai" + // Z.AI uses zai_payg and zai_coding directly — no aliases. default: return provider }