Skip to content

Commit d9883a2

Browse files
authored
feat: proper Z.AI implementation — dual OpenAI+Anthropic endpoints, plan separation (general + Coding Plan), regions (global/cn) (#49)
* feat: hawk Z.AI gateway UI + config bridge for dual plans + regions (OpenAI+Anthropic) - New internal/config/zai_setup.go and cmd/chat_config_zai.go (region picker global/cn, Needs/Set/Apply, modeled 1:1 on xiaomi) - Wire region flow, 'g' hotkey hints, Apply env, display labels, key paste pre-steps into chat_config_* (gateways, keys, panel, deployment) - catalog_api.go alias support for z-ai-coding - catalog_gateways_test.go: 19 gateways (z-ai + z-ai-coding) - chat_model.go: configZAIRegionSel field - Include the full implementation plan doc (docs/plans/z-ai-proper-implementation.md) - Pairs with eyrie commit (catalog/zai/, client/zai.go dual, registry two specs, deployment NewZAIClient for both, fetchers, config fields) Both endpoints (OpenAI primary + Anthropic fallback) + proper plan separation now fully wired in TUI and execution. Per AGENTS.md: feature branch, tests alongside, -race green on relevant pkgs. (Depends on eyrie submodule update from parallel commit in external/eyrie) * feat: hawk Z.AI gateway UI + config bridge for dual plans + regions (OpenAI+Anthropic) * chore(submodules): align eyrie to f3abc3b (Z.AI plan separation) * docs(plans): replace hard tabs with spaces in z-ai-proper-implementation.md
1 parent 038c1ee commit d9883a2

20 files changed

Lines changed: 729 additions & 72 deletions

cmd/chat_config_constants.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const (
2222
configEntryOllamaURL = "ollama-url"
2323
configEntryKeyView = "key-view"
2424
configEntryXiaomiRegion = "xiaomi-region"
25+
configEntryZAIRegion = "zai-region"
2526
)
2627

2728
// Providers referenced by config UI flows.

cmd/chat_config_deployment.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ func saveCredentialAsync(inference hawkconfig.CredentialInference, secret string
6666
if inference.ProviderID == hawkconfig.ProviderXiaomiTokenPlan {
6767
hawkconfig.ApplyXiaomiTokenPlanRegionEnv(ctx)
6868
}
69+
if inference.ProviderID == hawkconfig.ProviderZAICoding {
70+
hawkconfig.ApplyZAIRegionEnv(ctx)
71+
}
6972
rtInf := config.InferenceFromOption(credentialOptionFromHawk(inference))
7073
if err := runtime.SaveCredential(ctx, rtInf, secret); err != nil {
7174
return configApplyCredentialsMsg{

cmd/chat_config_gateways.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ func (m chatModel) configGatewayRows() []configGatewayRow {
5454
display += " · region required"
5555
}
5656
}
57+
if id == hawkconfig.ProviderZAICoding {
58+
if reg := hawkconfig.ZAIRegionLabel(id); reg != "" {
59+
display += " · " + reg
60+
} else {
61+
display += " · region"
62+
}
63+
}
5764
rows = append(rows, configGatewayRow{
5865
ID: id,
5966
DisplayName: display,
@@ -113,6 +120,11 @@ func (m chatModel) refreshConfigGateway() (chatModel, tea.Cmd) {
113120
m.configNotice = "Pick Token Plan region (cn / sgp / ams) before refresh"
114121
return m.startConfigXiaomiTokenPlanRegion(), nil
115122
}
123+
if row.ID == hawkconfig.ProviderZAICoding && hawkconfig.NeedsZAIRegion(row.ID) {
124+
m.configNotice = "Pick Coding Plan region (international / cn) before refresh"
125+
return m.startConfigZAIRegion(row.ID), nil
126+
}
127+
116128
if !row.HasKey {
117129
m.configNotice = fmt.Sprintf("Select %s and press enter to paste an API key", row.DisplayName)
118130
return m, nil
@@ -206,12 +218,20 @@ func (m chatModel) configGatewaysView() string {
206218
if targetIdx >= 0 && targetIdx < len(rows) && rows[targetIdx].ID == hawkconfig.ProviderXiaomiTokenPlan {
207219
hint = "Token Plan: enter pick region (cn/sgp/ams) then key · g change region"
208220
}
221+
if targetIdx >= 0 && targetIdx < len(rows) && rows[targetIdx].ID == hawkconfig.ProviderZAICoding {
222+
hint = "Coding Plan: enter pick region (international/cn) then key · g change region"
223+
}
224+
209225
b.WriteString("\n" + mutedStyle.Render(indent+hint))
210226
} else {
211227
hints := "enter use gateway · k view key · delete remove · r refresh"
212228
if targetIdx >= 0 && targetIdx < len(rows) && rows[targetIdx].ID == hawkconfig.ProviderXiaomiTokenPlan {
213229
hints = "enter · g region · k key · delete · r refresh"
214230
}
231+
if targetIdx >= 0 && targetIdx < len(rows) && rows[targetIdx].ID == hawkconfig.ProviderZAICoding {
232+
hints = "enter · g region · k key · delete · r refresh"
233+
}
234+
215235
b.WriteString("\n" + configTableSelectionFooter(len(rows), m.configScroll, end, mutedStyle, hints))
216236
}
217237
return m.configTabShellView(b.String())
@@ -258,6 +278,11 @@ func (m chatModel) handleConfigGatewaysSelect() (chatModel, tea.Cmd) {
258278
return m.startConfigXiaomiTokenPlanRegion(), nil
259279
}
260280
}
281+
if row.ID == hawkconfig.ProviderZAICoding && (!row.HasKey || hawkconfig.NeedsZAIRegion(row.ID)) {
282+
m.configGatewayFocus = m.configSel
283+
return m.startConfigZAIRegion(row.ID), nil
284+
}
285+
261286
if !row.HasKey {
262287
if row.ID == configProviderOllama {
263288
return m.startConfigOllamaURL()

cmd/chat_config_keys.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ func (m chatModel) startConfigKeyForProvider(provider string) (chatModel, tea.Cm
7575
return m.startConfigXiaomiTokenPlanRegion(), nil
7676
}
7777
}
78+
if provider == hawkconfig.ProviderZAICoding && hawkconfig.NeedsZAIRegion(provider) {
79+
m.configPostSaveKeysProvider = provider
80+
return m.startConfigZAIRegion(provider), nil
81+
}
82+
7883
name := hawkconfig.GatewayDisplayName(provider)
7984
m.configNotice = "Paste API key for " + name
8085
return m.startConfigEntry(configEntryAPIKeyPaste, provider)
@@ -85,6 +90,11 @@ func (m chatModel) startConfigKeyReplace(provider string) (chatModel, tea.Cmd) {
8590
m.configPostSaveKeysProvider = provider
8691
return m.startConfigXiaomiTokenPlanRegion(), nil
8792
}
93+
if provider == hawkconfig.ProviderZAICoding && hawkconfig.NeedsZAIRegion(provider) {
94+
m.configPostSaveKeysProvider = provider
95+
return m.startConfigZAIRegion(provider), nil
96+
}
97+
8898
m.configReplaceProvider = provider
8999
m.configEntry = configEntryNone
90100
m.configNotice = "Paste replacement API key for " + hawkconfig.GatewayDisplayName(provider)

cmd/chat_config_panel.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ func (m chatModel) configPanelView() string {
6464
if m.configEntry == configEntryXiaomiRegion {
6565
return m.configXiaomiRegionView()
6666
}
67+
if m.configEntry == configEntryZAIRegion {
68+
return m.configZAIRegionView()
69+
}
6770
switch m.configTab {
6871
case configTabGateways:
6972
return m.configGatewaysView()
@@ -583,6 +586,12 @@ func (m chatModel) handleConfigKey(msg tea.KeyMsg) (chatModel, tea.Cmd) {
583586
}
584587
return m.handleConfigXiaomiRegionKey(msg)
585588
}
589+
if m.configEntry == configEntryZAIRegion {
590+
if m.configSaving {
591+
return m, nil
592+
}
593+
return m.handleConfigZAIRegionKey(msg)
594+
}
586595
if m.configEntry != configEntryNone {
587596
if m.configSaving {
588597
return m, nil

cmd/chat_config_zai.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"strings"
6+
7+
tea "github.com/charmbracelet/bubbletea"
8+
9+
hawkconfig "github.com/GrayCodeAI/hawk/internal/config"
10+
)
11+
12+
var zaiRegions = []struct {
13+
id string
14+
label string
15+
}{
16+
{id: "international", label: "International (api.z.ai)"},
17+
{id: "cn", label: "China (open.bigmodel.cn)"},
18+
}
19+
20+
func zaiRegionIndex(region string) int {
21+
region = strings.ToLower(strings.TrimSpace(region))
22+
if region == "" {
23+
return 0
24+
}
25+
for i, r := range zaiRegions {
26+
if r.id == region {
27+
return i
28+
}
29+
}
30+
return 0
31+
}
32+
33+
func (m chatModel) startConfigZAIRegion(providerID string) chatModel {
34+
m.configEntry = configEntryZAIRegion
35+
m.configProvider = providerID
36+
if hawkconfig.NeedsZAIRegion(providerID) {
37+
m.configZAIRegionSel = 0
38+
} else {
39+
m.configZAIRegionSel = zaiRegionIndex(hawkconfig.ZAIRegionLabel(providerID))
40+
}
41+
name := hawkconfig.GatewayDisplayName(providerID)
42+
notice := "Select " + name + " region (↑↓ · enter · esc cancel)"
43+
if saved := hawkconfig.ZAIRegionLabel(providerID); saved != "" {
44+
notice = name + " region · current " + saved + " (↑↓ · enter · esc cancel)"
45+
}
46+
m.configNotice = notice
47+
return m
48+
}
49+
50+
func (m chatModel) configZAIRegionView() string {
51+
mutedStyle := configMutedStyle()
52+
accentStyle := configAccentStyle()
53+
rowStyle := configRowStyle()
54+
var b strings.Builder
55+
prov := m.configProvider
56+
name := hawkconfig.GatewayDisplayName(prov)
57+
b.WriteString(renderConfigBreadcrumb(name+" region") + "\n\n")
58+
for i, r := range zaiRegions {
59+
prefix := " "
60+
if i == m.configZAIRegionSel {
61+
prefix = "> "
62+
}
63+
line := prefix + r.label
64+
if i == m.configZAIRegionSel {
65+
b.WriteString(accentStyle.Render(line) + "\n")
66+
} else {
67+
b.WriteString(rowStyle.Render(line) + "\n")
68+
}
69+
}
70+
b.WriteString("\n" + mutedStyle.Render(" Coding Plan uses dedicated /coding/paas/v4 on the chosen region"))
71+
return m.configTabShellView(b.String())
72+
}
73+
74+
func (m chatModel) handleConfigZAIRegionKey(msg tea.KeyMsg) (chatModel, tea.Cmd) {
75+
switch msg.Type {
76+
case tea.KeyEsc:
77+
prov := m.configProvider
78+
m.configEntry = configEntryNone
79+
m.configProvider = ""
80+
if idx := m.configGatewayRowIndex(prov); idx >= 0 {
81+
m.configSel = idx
82+
}
83+
m.configNotice = ""
84+
return m, nil
85+
case tea.KeyUp:
86+
if m.configZAIRegionSel > 0 {
87+
m.configZAIRegionSel--
88+
}
89+
return m, nil
90+
case tea.KeyDown:
91+
if m.configZAIRegionSel < len(zaiRegions)-1 {
92+
m.configZAIRegionSel++
93+
}
94+
return m, nil
95+
case tea.KeyEnter:
96+
if m.configZAIRegionSel < 0 || m.configZAIRegionSel >= len(zaiRegions) {
97+
return m, nil
98+
}
99+
region := zaiRegions[m.configZAIRegionSel].id
100+
prov := m.configProvider
101+
if err := hawkconfig.SetZAIRegion(prov, region); err != nil {
102+
m.configNotice = "Region: " + err.Error()
103+
return m, nil
104+
}
105+
InvalidateModelCacheProvider(prov)
106+
m.configEntry = configEntryNone
107+
ctx := context.Background()
108+
if post := strings.TrimSpace(m.configPostSaveKeysProvider); post == prov {
109+
m.configPostSaveKeysProvider = ""
110+
return m.startConfigKeyReplace(post)
111+
}
112+
if hawkconfig.HasStoredCredentialForProvider(ctx, prov) {
113+
m.configNotice = "Region saved (" + region + ") — press r to refresh models"
114+
if idx := m.configGatewayRowIndex(prov); idx >= 0 {
115+
m.configSel = idx
116+
}
117+
return m, nil
118+
}
119+
m.configNotice = "Region saved (" + region + ") — paste Z.AI API key"
120+
return m.startConfigKeyForProvider(prov)
121+
default:
122+
return m, nil
123+
}
124+
}

cmd/chat_model.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ type chatModel struct {
183183
configSaving bool // blocks hub/list input while async credential work runs
184184
configPendingOllamaURL string
185185
configXiaomiRegionSel int // Token Plan region picker index
186+
configZAIRegionSel int // Z.AI (general or coding) region picker index
186187
pluginRuntime *plugin.Runtime
187188
spinnerVerb string
188189
// Per-turn token counters shown next to the spinner (↑ input, ↓ output).

cmd/errors.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ func friendlyError(err error) string {
4343
{[]string{"gemini_api_key", "google_api_key", "gemini api key"}, "GEMINI_API_KEY", "Gemini"},
4444
{[]string{"openrouter_api_key", "openrouter api key"}, "OPENROUTER_API_KEY", "OpenRouter"},
4545
{[]string{"canopywave_api_key", "canopywave api key"}, "CANOPYWAVE_API_KEY", "CanopyWave"},
46-
{[]string{"zai_api_key", "z.ai api key", "z-ai api key"}, "ZAI_API_KEY", "Z.AI"},
46+
{[]string{"zai_payg_api_key", "zai_api_key"}, "ZAI_API_KEY", "Z.AI"},
47+
{[]string{"zai_coding_api_key", "zai_coding_api_key"}, "ZAI_CODING_API_KEY", "Z.AI Coding Plan"},
4748
{[]string{"xai_api_key", "xai api key"}, "XAI_API_KEY", "xAI (Grok)"},
4849
{[]string{"opencodego_api_key", "opencodego api key"}, "OPENCODEGO_API_KEY", "OpenCodeGo"},
4950
{[]string{"moonshot_api_key", "moonshot api key"}, "MOONSHOT_API_KEY", "Kimi (Moonshot)"},
@@ -447,7 +448,7 @@ func providerDNSHost(provider string) string {
447448
return "api.x.ai"
448449
case "canopywave":
449450
return "inference.canopywave.io"
450-
case "z-ai", "zai":
451+
case "zai_payg", "zai_coding":
451452
return "api.z.ai"
452453
case "kimi", "moonshotai":
453454
return "api.moonshot.ai"

cmd/options.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ func configureSession(sess *engine.Session, settings hawkconfig.Settings, maxTur
311311
sess.Autonomy = lvl
312312
}
313313

314-
// GLM/Z.ai extended reasoning toggle (applied in the stream loop for z-ai).
314+
// GLM/Z.AI extended reasoning toggle (applied in the stream loop for zai_coding/zai_payg).
315315
sess.GLMThinkingEnabled = settings.GLMThinkingEnabled
316316

317317
return nil

0 commit comments

Comments
 (0)