detached 3364159

Add expanded login provider support

Add GitHub Copilot subscription login and broaden API-key login to all catalog providers.

Persist credentials for additional API-key providers, include them in model filtering and logout, and fix clearing those stored credentials.

Improve provider/model/slash pickers with pagination and clearer credential-state labels.
commit
33641592012bb3cfa88ccf39158ba461e8399f99
author
patriceckhart · · 2026-05-24 09:08 UTC
parent
689069f
signature
unsigned
13 files · +678 -68
M internal/agent/cli.go +7 -3
@@ -910,13 +910,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
910910 BuildAgentForRescue: buildAgentForRescue,
911911 LoggedInProviders: func() []string {
912912 var out []string
913- for _, p := range []string{"anthropic", "openai", "openai-codex", "kimi", "deepseek", "google"} {
914- if _, _, err := ResolveCredential(p, ""); err == nil {
913+ seen := map[string]bool{}
914+ for _, p := range knownProviders {
915+ if _, _, err := ResolveCredential(p, ""); err == nil && !seen[p] {
915916 out = append(out, p)
917+ seen[p] = true
916918 }
917919 }
918920 // Ollama models are always available (no auth needed).
919- out = append(out, "ollama")
921+ if !seen["ollama"] {
922+ out = append(out, "ollama")
923+ }
920924 return out
921925 },
922926 LoadSession: loadSession,
@@ -910,13 +910,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
910 BuildAgentForRescue: buildAgentForRescue,910 BuildAgentForRescue: buildAgentForRescue,
911 LoggedInProviders: func() []string {911 LoggedInProviders: func() []string {
912 var out []string912 var out []string
913- for _, p := range []string{"anthropic", "openai", "openai-codex", "kimi", "deepseek", "google"} {913+ seen := map[string]bool{}
914- if _, _, err := ResolveCredential(p, ""); err == nil {914+ for _, p := range knownProviders {
915+ if _, _, err := ResolveCredential(p, ""); err == nil && !seen[p] {
915 out = append(out, p)916 out = append(out, p)
917+ seen[p] = true
916 }918 }
917 }919 }
918 // Ollama models are always available (no auth needed).920 // Ollama models are always available (no auth needed).
919- out = append(out, "ollama")921+ if !seen["ollama"] {
922+ out = append(out, "ollama")
923+ }
920 return out924 return out
921 },925 },
922 LoadSession: loadSession,926 LoadSession: loadSession,
M internal/agent/config.go +17 -0
@@ -256,6 +256,9 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
256256 if v := os.Getenv("COPILOT_GITHUB_TOKEN"); v != "" {
257257 return v, "apikey", "", nil
258258 }
259+ if v := os.Getenv("GITHUB_COPILOT_TOKEN"); v != "" {
260+ return v, "apikey", "", nil
261+ }
259262 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
260263 if v := os.Getenv("CLOUDFLARE_API_KEY"); v != "" {
261264 return v, "apikey", "", nil
@@ -282,6 +285,9 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
282285 if err != nil {
283286 return "", "", "", err
284287 }
288+ if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok && pc.APIKey != "" {
289+ return pc.APIKey, "apikey", "", nil
290+ }
285291 switch provider {
286292 case "anthropic":
287293 if c.Anthropic.APIKey != "" {
@@ -326,6 +332,13 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
326332 if c.Google.APIKey != "" {
327333 return c.Google.APIKey, "apikey", "", nil
328334 }
335+ case "github-copilot":
336+ if c.GithubCopilot.APIKey != "" {
337+ return c.GithubCopilot.APIKey, "apikey", "", nil
338+ }
339+ if c.GithubCopilot.OAuth != nil && c.GithubCopilot.OAuth.AccessToken != "" {
340+ return c.GithubCopilot.OAuth.AccessToken, "oauth", "", nil
341+ }
329342 }
330343 return "", "", "", fmt.Errorf("no credential for %s", provider)
331344 }
@@ -405,6 +418,10 @@ func loadOAuthToken(providerName string) *auth.OAuthToken {
405418 return nil
406419 }
407420 return loadKimiCodeCLIToken()
421+ case "github-copilot":
422+ if c.GithubCopilot.OAuth != nil {
423+ return c.GithubCopilot.OAuth
424+ }
408425 }
409426 return nil
410427 }
@@ -256,6 +256,9 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
256 if v := os.Getenv("COPILOT_GITHUB_TOKEN"); v != "" {256 if v := os.Getenv("COPILOT_GITHUB_TOKEN"); v != "" {
257 return v, "apikey", "", nil257 return v, "apikey", "", nil
258 }258 }
259+ if v := os.Getenv("GITHUB_COPILOT_TOKEN"); v != "" {
260+ return v, "apikey", "", nil
261+ }
259 case "cloudflare-workers-ai", "cloudflare-ai-gateway":262 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
260 if v := os.Getenv("CLOUDFLARE_API_KEY"); v != "" {263 if v := os.Getenv("CLOUDFLARE_API_KEY"); v != "" {
261 return v, "apikey", "", nil264 return v, "apikey", "", nil
@@ -282,6 +285,9 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
282 if err != nil {285 if err != nil {
283 return "", "", "", err286 return "", "", "", err
284 }287 }
288+ if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok && pc.APIKey != "" {
289+ return pc.APIKey, "apikey", "", nil
290+ }
285 switch provider {291 switch provider {
286 case "anthropic":292 case "anthropic":
287 if c.Anthropic.APIKey != "" {293 if c.Anthropic.APIKey != "" {
@@ -326,6 +332,13 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
326 if c.Google.APIKey != "" {332 if c.Google.APIKey != "" {
327 return c.Google.APIKey, "apikey", "", nil333 return c.Google.APIKey, "apikey", "", nil
328 }334 }
335+ case "github-copilot":
336+ if c.GithubCopilot.APIKey != "" {
337+ return c.GithubCopilot.APIKey, "apikey", "", nil
338+ }
339+ if c.GithubCopilot.OAuth != nil && c.GithubCopilot.OAuth.AccessToken != "" {
340+ return c.GithubCopilot.OAuth.AccessToken, "oauth", "", nil
341+ }
329 }342 }
330 return "", "", "", fmt.Errorf("no credential for %s", provider)343 return "", "", "", fmt.Errorf("no credential for %s", provider)
331 }344 }
@@ -405,6 +418,10 @@ func loadOAuthToken(providerName string) *auth.OAuthToken {
405 return nil418 return nil
406 }419 }
407 return loadKimiCodeCLIToken()420 return loadKimiCodeCLIToken()
421+ case "github-copilot":
422+ if c.GithubCopilot.OAuth != nil {
423+ return c.GithubCopilot.OAuth
424+ }
408 }425 }
409 return nil426 return nil
410 }427 }
M internal/agent/modes/interactive.go +29 -8
@@ -1926,6 +1926,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
19261926 case tui.KeyDown:
19271927 i.suggest.Down()
19281928 return false
1929+ case tui.KeyPageUp:
1930+ i.suggest.PageUp()
1931+ return false
1932+ case tui.KeyPageDown:
1933+ i.suggest.PageDown()
1934+ return false
19291935 case tui.KeyTab:
19301936 if name := i.suggest.Selection(i.ed.Value()); name != "" {
19311937 i.ed.SetValue(name)
@@ -2963,7 +2969,7 @@ func (i *Interactive) openLogoutDialog() {
29632969 }
29642970
29652971 var items []logoutItem
2966- for _, p := range []string{"anthropic", "kimi", "google"} {
2972+ for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
29672973 if creds.Has(p) {
29682974 method := creds.Method(p)
29692975 if method == "oauth" {
@@ -2982,6 +2988,11 @@ func (i *Interactive) openLogoutDialog() {
29822988 if creds.OpenAI.OAuth != nil {
29832989 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
29842990 }
2991+ for p, c := range creds.AdditionalAPIKeyCreds {
2992+ if c.APIKey != "" {
2993+ items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
2994+ }
2995+ }
29852996 if len(items) == 0 {
29862997 i.mu.Lock()
29872998 i.statusOK = "no credentials stored; already logged out"
@@ -3003,7 +3014,7 @@ func (i *Interactive) openLogoutDialog() {
30033014 // is torn down so the user is forced through /login before their next
30043015 // prompt.
30053016 //
3006-// target: "anthropic" | "openai" | "kimi" | "all"
3017+// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
30073018 func (i *Interactive) doLogout(target string) {
30083019 if i.cfg.AuthManager == nil {
30093020 i.mu.Lock()
@@ -3022,14 +3033,24 @@ func (i *Interactive) doLogout(target string) {
30223033 var providers []string
30233034 switch target {
30243035 case "", "all":
3025- providers = []string{"anthropic", "openai", "openai-codex", "kimi", "google"}
3026- case "anthropic", "openai", "openai-codex", "kimi", "google":
3036+ providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
3037+ case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
30273038 providers = []string{target}
30283039 default:
3029- i.mu.Lock()
3030- i.statusErr = "unknown provider: " + target + " (use anthropic, openai, openai-codex, kimi, google, or all)"
3031- i.mu.Unlock()
3032- return
3040+ known := false
3041+ for _, p := range auth.APIKeyProviders() {
3042+ if target == p {
3043+ known = true
3044+ break
3045+ }
3046+ }
3047+ if !known {
3048+ i.mu.Lock()
3049+ i.statusErr = "unknown provider: " + target
3050+ i.mu.Unlock()
3051+ return
3052+ }
3053+ providers = []string{target}
30333054 }
30343055
30353056 var errs []string
@@ -1926,6 +1926,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1926 case tui.KeyDown:1926 case tui.KeyDown:
1927 i.suggest.Down()1927 i.suggest.Down()
1928 return false1928 return false
1929+ case tui.KeyPageUp:
1930+ i.suggest.PageUp()
1931+ return false
1932+ case tui.KeyPageDown:
1933+ i.suggest.PageDown()
1934+ return false
1929 case tui.KeyTab:1935 case tui.KeyTab:
1930 if name := i.suggest.Selection(i.ed.Value()); name != "" {1936 if name := i.suggest.Selection(i.ed.Value()); name != "" {
1931 i.ed.SetValue(name)1937 i.ed.SetValue(name)
@@ -2963,7 +2969,7 @@ func (i *Interactive) openLogoutDialog() {
2963 }2969 }
2964 2970
2965 var items []logoutItem2971 var items []logoutItem
2966- for _, p := range []string{"anthropic", "kimi", "google"} {2972+ for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
2967 if creds.Has(p) {2973 if creds.Has(p) {
2968 method := creds.Method(p)2974 method := creds.Method(p)
2969 if method == "oauth" {2975 if method == "oauth" {
@@ -2982,6 +2988,11 @@ func (i *Interactive) openLogoutDialog() {
2982 if creds.OpenAI.OAuth != nil {2988 if creds.OpenAI.OAuth != nil {
2983 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})2989 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
2984 }2990 }
2991+ for p, c := range creds.AdditionalAPIKeyCreds {
2992+ if c.APIKey != "" {
2993+ items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
2994+ }
2995+ }
2985 if len(items) == 0 {2996 if len(items) == 0 {
2986 i.mu.Lock()2997 i.mu.Lock()
2987 i.statusOK = "no credentials stored; already logged out"2998 i.statusOK = "no credentials stored; already logged out"
@@ -3003,7 +3014,7 @@ func (i *Interactive) openLogoutDialog() {
3003 // is torn down so the user is forced through /login before their next3014 // is torn down so the user is forced through /login before their next
3004 // prompt.3015 // prompt.
3005 //3016 //
3006-// target: "anthropic" | "openai" | "kimi" | "all"3017+// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
3007 func (i *Interactive) doLogout(target string) {3018 func (i *Interactive) doLogout(target string) {
3008 if i.cfg.AuthManager == nil {3019 if i.cfg.AuthManager == nil {
3009 i.mu.Lock()3020 i.mu.Lock()
@@ -3022,14 +3033,24 @@ func (i *Interactive) doLogout(target string) {
3022 var providers []string3033 var providers []string
3023 switch target {3034 switch target {
3024 case "", "all":3035 case "", "all":
3025- providers = []string{"anthropic", "openai", "openai-codex", "kimi", "google"}3036+ providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
3026- case "anthropic", "openai", "openai-codex", "kimi", "google":3037+ case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
3027 providers = []string{target}3038 providers = []string{target}
3028 default:3039 default:
3029- i.mu.Lock()3040+ known := false
3030- i.statusErr = "unknown provider: " + target + " (use anthropic, openai, openai-codex, kimi, google, or all)"3041+ for _, p := range auth.APIKeyProviders() {
3031- i.mu.Unlock()3042+ if target == p {
3032- return3043+ known = true
3044+ break
3045+ }
3046+ }
3047+ if !known {
3048+ i.mu.Lock()
3049+ i.statusErr = "unknown provider: " + target
3050+ i.mu.Unlock()
3051+ return
3052+ }
3053+ providers = []string{target}
3033 }3054 }
3034 3055
3035 var errs []string3056 var errs []string
M internal/agent/modes/login_dialog.go +90 -36
@@ -5,6 +5,7 @@ import (
55 "path/filepath"
66
77 "github.com/patriceckhart/zot/internal/auth"
8+ "github.com/patriceckhart/zot/internal/provider"
89 "github.com/patriceckhart/zot/internal/tui"
910 )
1011
@@ -23,6 +24,8 @@ const (
2324 loginStepDone // success or error, waiting for key to dismiss
2425 )
2526
27+const loginProviderPageSize = 8
28+
2629 // loginDialog is a tiny inline dialog rendered above the editor while
2730 // the user picks their login method and provider.
2831 type loginDialog struct {
@@ -65,7 +68,13 @@ func (d *loginDialog) Open(zotHome string) {
6568 d.success = false
6669 d.url = ""
6770 d.cursor = 0
68- d.status = map[string]string{"anthropic": "", "openai": "", "openai-codex": "", "kimi": "", "deepseek": "", "google": ""}
71+ d.status = map[string]string{}
72+ for _, p := range providersForMethod("apikey") {
73+ d.status[p] = ""
74+ }
75+ for _, p := range providersForMethod("oauth") {
76+ d.status[p] = ""
77+ }
6978 // Best-effort: if the auth file can't be read, treat every
7079 // provider as not-logged-in. The status line just won't show
7180 // anything useful in that case, which is fine — the user
@@ -84,6 +93,10 @@ func (d *loginDialog) Open(zotHome string) {
8493 d.status["kimi"] = creds.Method("kimi")
8594 d.status["deepseek"] = creds.Method("deepseek")
8695 d.status["google"] = creds.Method("google")
96+ d.status["github-copilot"] = creds.Method("github-copilot")
97+ for p := range creds.AdditionalAPIKeyCreds {
98+ d.status[p] = creds.Method(p)
99+ }
87100 }
88101 }
89102
@@ -103,7 +116,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
103116 case loginStepMethod:
104117 opts := []string{
105118 "api key",
106- "subscription (claude pro/max - chatgpt plus/pro - kimi code)",
119+ "subscription (claude pro/max - chatgpt plus/pro - chatgpt codex - kimi code - github copilot)",
107120 }
108121 lines = append(lines, frameHeader(th, "login", width))
109122 for _, l := range d.renderStatusLines(th) {
@@ -125,25 +138,22 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
125138 for _, l := range d.renderStatusLines(th) {
126139 lines = append(lines, l)
127140 }
128- lines = append(lines, th.FG256(th.Muted, "choose provider:"))
129- for i, o := range opts {
130- // Annotate each provider with its current login
131- // state so the user can see at a glance which will
132- // be replaced if they pick it.
133- tag := ""
134- switch d.status[o] {
135- case "apikey":
136- tag = " (api key)"
137- case "oauth":
138- tag = " (subscription)"
139- }
140- plain := " " + providerLabel(o) + tag
141+ lines = append(lines, th.FG256(th.Muted, "pick a provider (↑/↓, enter, esc to cancel)"))
142+ start, end := d.providerPage(len(opts))
143+ for i := start; i < end; i++ {
144+ o := opts[i]
145+ tag := providerPickerTag(d.method, d.status[o])
146+ label := " " + providerLabel(o)
147+ plain := label + tag
141148 if i == d.cursor {
142149 lines = append(lines, th.PadHighlight(plain, width))
143150 } else {
144- lines = append(lines, th.FG256(th.Muted, plain))
151+ lines = append(lines, th.FG256(th.Muted, label)+th.FG256(th.Accent, tag))
145152 }
146153 }
154+ if len(opts) > loginProviderPageSize {
155+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))
156+ }
147157 lines = append(lines, frameRule(th, width))
148158 case loginStepWaiting:
149159 lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width))
@@ -212,28 +222,49 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
212222 // subscription product at all).
213223 func providersForMethod(method string) []string {
214224 if method == "oauth" {
215- return []string{"anthropic", "openai-codex", "kimi"}
225+ return []string{"anthropic", "openai-codex", "kimi", "github-copilot"}
216226 }
217- return []string{"anthropic", "openai", "kimi", "deepseek", "google"}
227+ return auth.APIKeyProviders()
218228 }
219229
220230 // providerLabel returns the user-facing label for a provider id.
221-func providerLabel(id string) string {
222- switch id {
223- case "anthropic":
224- return "Anthropic (Claude Pro/Max)"
225- case "openai":
226- return "OpenAI"
227- case "openai-codex":
228- return "OpenAI Codex (ChatGPT Plus/Pro)"
229- case "kimi":
230- return "Kimi Code"
231- case "deepseek":
232- return "DeepSeek"
233- case "google":
234- return "Google (Gemini API key)"
231+func providerLabel(id string) string { return provider.ProviderLabel(id) }
232+
233+func providerPickerTag(method, status string) string {
234+ switch method {
235+ case "apikey":
236+ // In the API-key picker, only call out an existing subscription so
237+ // users know choosing this provider will add/replace API-key auth
238+ // while subscription auth is still configured. Unconfigured rows do
239+ // not need a redundant "api key" suffix.
240+ if status == "oauth" {
241+ return " (subscription configured)"
242+ }
243+ case "oauth":
244+ // In the subscription picker, only call out an existing API key.
245+ if status == "apikey" {
246+ return " (api key configured)"
247+ }
248+ }
249+ return ""
250+}
251+
252+func (d *loginDialog) providerPage(total int) (start, end int) {
253+ if total <= loginProviderPageSize {
254+ return 0, total
255+ }
256+ if d.cursor < 0 {
257+ d.cursor = 0
258+ }
259+ if d.cursor >= total {
260+ d.cursor = total - 1
261+ }
262+ start = (d.cursor / loginProviderPageSize) * loginProviderPageSize
263+ end = start + loginProviderPageSize
264+ if end > total {
265+ end = total
235266 }
236- return id
267+ return start, end
237268 }
238269
239270 // renderStatusLines returns an overview of the current login
@@ -253,7 +284,8 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
253284 kimi := d.status["kimi"]
254285 ds := d.status["deepseek"]
255286 goog := d.status["google"]
256- if anth == "" && op == "" && codex == "" && kimi == "" && ds == "" && goog == "" {
287+ gh := d.status["github-copilot"]
288+ if anth == "" && op == "" && codex == "" && kimi == "" && ds == "" && goog == "" && gh == "" {
257289 return nil
258290 }
259291 row := func(id, method string) string {
@@ -272,15 +304,26 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
272304 }
273305 return " " + mark + " " + body
274306 }
275- return []string{
307+ out := []string{
276308 row("anthropic", anth),
277309 row("openai", op),
278310 row("openai-codex", codex),
279311 row("kimi", kimi),
280312 row("deepseek", ds),
281313 row("google", goog),
282- "",
314+ row("github-copilot", gh),
283315 }
316+ for _, p := range providersForMethod("apikey") {
317+ switch p {
318+ case "anthropic", "openai", "openai-codex", "kimi", "deepseek", "google", "github-copilot":
319+ continue
320+ }
321+ if method := d.status[p]; method != "" {
322+ out = append(out, row(p, method))
323+ }
324+ }
325+ out = append(out, "")
326+ return out
284327 }
285328
286329 // Key is the result of handling a key press.
@@ -348,6 +391,17 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
348391 if d.cursor < len(providers)-1 {
349392 d.cursor++
350393 }
394+ case tui.KeyPageUp:
395+ d.cursor -= loginProviderPageSize
396+ if d.cursor < 0 {
397+ d.cursor = 0
398+ }
399+ case tui.KeyPageDown:
400+ providers := providersForMethod(d.method)
401+ d.cursor += loginProviderPageSize
402+ if d.cursor >= len(providers) {
403+ d.cursor = len(providers) - 1
404+ }
351405 case tui.KeyEsc:
352406 d.Close()
353407 return loginDialogAction{Close: true}
@@ -5,6 +5,7 @@ import (
5 "path/filepath"5 "path/filepath"
6 6
7 "github.com/patriceckhart/zot/internal/auth"7 "github.com/patriceckhart/zot/internal/auth"
8+ "github.com/patriceckhart/zot/internal/provider"
8 "github.com/patriceckhart/zot/internal/tui"9 "github.com/patriceckhart/zot/internal/tui"
9 )10 )
10 11
@@ -23,6 +24,8 @@ const (
23 loginStepDone // success or error, waiting for key to dismiss24 loginStepDone // success or error, waiting for key to dismiss
24 )25 )
25 26
27+const loginProviderPageSize = 8
28+
26 // loginDialog is a tiny inline dialog rendered above the editor while29 // loginDialog is a tiny inline dialog rendered above the editor while
27 // the user picks their login method and provider.30 // the user picks their login method and provider.
28 type loginDialog struct {31 type loginDialog struct {
@@ -65,7 +68,13 @@ func (d *loginDialog) Open(zotHome string) {
65 d.success = false68 d.success = false
66 d.url = ""69 d.url = ""
67 d.cursor = 070 d.cursor = 0
68- d.status = map[string]string{"anthropic": "", "openai": "", "openai-codex": "", "kimi": "", "deepseek": "", "google": ""}71+ d.status = map[string]string{}
72+ for _, p := range providersForMethod("apikey") {
73+ d.status[p] = ""
74+ }
75+ for _, p := range providersForMethod("oauth") {
76+ d.status[p] = ""
77+ }
69 // Best-effort: if the auth file can't be read, treat every78 // Best-effort: if the auth file can't be read, treat every
70 // provider as not-logged-in. The status line just won't show79 // provider as not-logged-in. The status line just won't show
71 // anything useful in that case, which is fine — the user80 // anything useful in that case, which is fine — the user
@@ -84,6 +93,10 @@ func (d *loginDialog) Open(zotHome string) {
84 d.status["kimi"] = creds.Method("kimi")93 d.status["kimi"] = creds.Method("kimi")
85 d.status["deepseek"] = creds.Method("deepseek")94 d.status["deepseek"] = creds.Method("deepseek")
86 d.status["google"] = creds.Method("google")95 d.status["google"] = creds.Method("google")
96+ d.status["github-copilot"] = creds.Method("github-copilot")
97+ for p := range creds.AdditionalAPIKeyCreds {
98+ d.status[p] = creds.Method(p)
99+ }
87 }100 }
88 }101 }
89 102
@@ -103,7 +116,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
103 case loginStepMethod:116 case loginStepMethod:
104 opts := []string{117 opts := []string{
105 "api key",118 "api key",
106- "subscription (claude pro/max - chatgpt plus/pro - kimi code)",119+ "subscription (claude pro/max - chatgpt plus/pro - chatgpt codex - kimi code - github copilot)",
107 }120 }
108 lines = append(lines, frameHeader(th, "login", width))121 lines = append(lines, frameHeader(th, "login", width))
109 for _, l := range d.renderStatusLines(th) {122 for _, l := range d.renderStatusLines(th) {
@@ -125,25 +138,22 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
125 for _, l := range d.renderStatusLines(th) {138 for _, l := range d.renderStatusLines(th) {
126 lines = append(lines, l)139 lines = append(lines, l)
127 }140 }
128- lines = append(lines, th.FG256(th.Muted, "choose provider:"))141+ lines = append(lines, th.FG256(th.Muted, "pick a provider (↑/↓, enter, esc to cancel)"))
129- for i, o := range opts {142+ start, end := d.providerPage(len(opts))
130- // Annotate each provider with its current login143+ for i := start; i < end; i++ {
131- // state so the user can see at a glance which will144+ o := opts[i]
132- // be replaced if they pick it.145+ tag := providerPickerTag(d.method, d.status[o])
133- tag := ""146+ label := " " + providerLabel(o)
134- switch d.status[o] {147+ plain := label + tag
135- case "apikey":
136- tag = " (api key)"
137- case "oauth":
138- tag = " (subscription)"
139- }
140- plain := " " + providerLabel(o) + tag
141 if i == d.cursor {148 if i == d.cursor {
142 lines = append(lines, th.PadHighlight(plain, width))149 lines = append(lines, th.PadHighlight(plain, width))
143 } else {150 } else {
144- lines = append(lines, th.FG256(th.Muted, plain))151+ lines = append(lines, th.FG256(th.Muted, label)+th.FG256(th.Accent, tag))
145 }152 }
146 }153 }
154+ if len(opts) > loginProviderPageSize {
155+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))
156+ }
147 lines = append(lines, frameRule(th, width))157 lines = append(lines, frameRule(th, width))
148 case loginStepWaiting:158 case loginStepWaiting:
149 lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width))159 lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width))
@@ -212,28 +222,49 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
212 // subscription product at all).222 // subscription product at all).
213 func providersForMethod(method string) []string {223 func providersForMethod(method string) []string {
214 if method == "oauth" {224 if method == "oauth" {
215- return []string{"anthropic", "openai-codex", "kimi"}225+ return []string{"anthropic", "openai-codex", "kimi", "github-copilot"}
216 }226 }
217- return []string{"anthropic", "openai", "kimi", "deepseek", "google"}227+ return auth.APIKeyProviders()
218 }228 }
219 229
220 // providerLabel returns the user-facing label for a provider id.230 // providerLabel returns the user-facing label for a provider id.
221-func providerLabel(id string) string {231+func providerLabel(id string) string { return provider.ProviderLabel(id) }
222- switch id {232+
223- case "anthropic":233+func providerPickerTag(method, status string) string {
224- return "Anthropic (Claude Pro/Max)"234+ switch method {
225- case "openai":235+ case "apikey":
226- return "OpenAI"236+ // In the API-key picker, only call out an existing subscription so
227- case "openai-codex":237+ // users know choosing this provider will add/replace API-key auth
228- return "OpenAI Codex (ChatGPT Plus/Pro)"238+ // while subscription auth is still configured. Unconfigured rows do
229- case "kimi":239+ // not need a redundant "api key" suffix.
230- return "Kimi Code"240+ if status == "oauth" {
231- case "deepseek":241+ return " (subscription configured)"
232- return "DeepSeek"242+ }
233- case "google":243+ case "oauth":
234- return "Google (Gemini API key)"244+ // In the subscription picker, only call out an existing API key.
245+ if status == "apikey" {
246+ return " (api key configured)"
247+ }
248+ }
249+ return ""
250+}
251+
252+func (d *loginDialog) providerPage(total int) (start, end int) {
253+ if total <= loginProviderPageSize {
254+ return 0, total
255+ }
256+ if d.cursor < 0 {
257+ d.cursor = 0
258+ }
259+ if d.cursor >= total {
260+ d.cursor = total - 1
261+ }
262+ start = (d.cursor / loginProviderPageSize) * loginProviderPageSize
263+ end = start + loginProviderPageSize
264+ if end > total {
265+ end = total
235 }266 }
236- return id267+ return start, end
237 }268 }
238 269
239 // renderStatusLines returns an overview of the current login270 // renderStatusLines returns an overview of the current login
@@ -253,7 +284,8 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
253 kimi := d.status["kimi"]284 kimi := d.status["kimi"]
254 ds := d.status["deepseek"]285 ds := d.status["deepseek"]
255 goog := d.status["google"]286 goog := d.status["google"]
256- if anth == "" && op == "" && codex == "" && kimi == "" && ds == "" && goog == "" {287+ gh := d.status["github-copilot"]
288+ if anth == "" && op == "" && codex == "" && kimi == "" && ds == "" && goog == "" && gh == "" {
257 return nil289 return nil
258 }290 }
259 row := func(id, method string) string {291 row := func(id, method string) string {
@@ -272,15 +304,26 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
272 }304 }
273 return " " + mark + " " + body305 return " " + mark + " " + body
274 }306 }
275- return []string{307+ out := []string{
276 row("anthropic", anth),308 row("anthropic", anth),
277 row("openai", op),309 row("openai", op),
278 row("openai-codex", codex),310 row("openai-codex", codex),
279 row("kimi", kimi),311 row("kimi", kimi),
280 row("deepseek", ds),312 row("deepseek", ds),
281 row("google", goog),313 row("google", goog),
282- "",314+ row("github-copilot", gh),
283 }315 }
316+ for _, p := range providersForMethod("apikey") {
317+ switch p {
318+ case "anthropic", "openai", "openai-codex", "kimi", "deepseek", "google", "github-copilot":
319+ continue
320+ }
321+ if method := d.status[p]; method != "" {
322+ out = append(out, row(p, method))
323+ }
324+ }
325+ out = append(out, "")
326+ return out
284 }327 }
285 328
286 // Key is the result of handling a key press.329 // Key is the result of handling a key press.
@@ -348,6 +391,17 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
348 if d.cursor < len(providers)-1 {391 if d.cursor < len(providers)-1 {
349 d.cursor++392 d.cursor++
350 }393 }
394+ case tui.KeyPageUp:
395+ d.cursor -= loginProviderPageSize
396+ if d.cursor < 0 {
397+ d.cursor = 0
398+ }
399+ case tui.KeyPageDown:
400+ providers := providersForMethod(d.method)
401+ d.cursor += loginProviderPageSize
402+ if d.cursor >= len(providers) {
403+ d.cursor = len(providers) - 1
404+ }
351 case tui.KeyEsc:405 case tui.KeyEsc:
352 d.Close()406 d.Close()
353 return loginDialogAction{Close: true}407 return loginDialogAction{Close: true}
M internal/agent/modes/model_dialog.go +17 -0
@@ -208,6 +208,9 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
208208 if end < len(d.view) {
209209 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
210210 }
211+ if len(d.view) > visible {
212+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(d.view))))
213+ }
211214
212215 lines = append(lines, frameRule(th, width))
213216 return lines
@@ -255,6 +258,20 @@ func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
255258 if d.cursor < len(d.view)-1 {
256259 d.cursor++
257260 }
261+ case tui.KeyPageUp:
262+ if len(d.view) > 0 {
263+ d.cursor -= 14
264+ if d.cursor < 0 {
265+ d.cursor = 0
266+ }
267+ }
268+ case tui.KeyPageDown:
269+ if len(d.view) > 0 {
270+ d.cursor += 14
271+ if d.cursor >= len(d.view) {
272+ d.cursor = len(d.view) - 1
273+ }
274+ }
258275 case tui.KeyBackspace:
259276 if len(d.query) > 0 {
260277 // Drop one rune from the query.
@@ -208,6 +208,9 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
208 if end < len(d.view) {208 if end < len(d.view) {
209 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))209 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
210 }210 }
211+ if len(d.view) > visible {
212+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(d.view))))
213+ }
211 214
212 lines = append(lines, frameRule(th, width))215 lines = append(lines, frameRule(th, width))
213 return lines216 return lines
@@ -255,6 +258,20 @@ func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
255 if d.cursor < len(d.view)-1 {258 if d.cursor < len(d.view)-1 {
256 d.cursor++259 d.cursor++
257 }260 }
261+ case tui.KeyPageUp:
262+ if len(d.view) > 0 {
263+ d.cursor -= 14
264+ if d.cursor < 0 {
265+ d.cursor = 0
266+ }
267+ }
268+ case tui.KeyPageDown:
269+ if len(d.view) > 0 {
270+ d.cursor += 14
271+ if d.cursor >= len(d.view) {
272+ d.cursor = len(d.view) - 1
273+ }
274+ }
258 case tui.KeyBackspace:275 case tui.KeyBackspace:
259 if len(d.query) > 0 {276 if len(d.query) > 0 {
260 // Drop one rune from the query.277 // Drop one rune from the query.
M internal/agent/modes/slash_suggest.go +53 -1
@@ -1,6 +1,7 @@
11 package modes
22
33 import (
4+ "fmt"
45 "sort"
56 "strings"
67
@@ -54,6 +55,8 @@ var slashCatalog = []slashCommand{
5455
5556 // slashSuggester renders the popup that appears when the editor starts
5657 // with "/". It does not own any input state — the editor drives.
58+const slashSuggestPageSize = 8
59+
5760 type slashSuggester struct {
5861 cursor int
5962
@@ -277,6 +280,32 @@ func (s *slashSuggester) Down() {
277280 s.skipHeader(+1)
278281 }
279282
283+func (s *slashSuggester) PageUp() {
284+ if len(s.lastMatches) == 0 {
285+ return
286+ }
287+ s.cursor -= slashSuggestPageSize
288+ if s.cursor < 0 {
289+ s.cursor = 0
290+ }
291+ if s.lastMatches[s.cursor].Header {
292+ s.skipHeader(+1)
293+ }
294+}
295+
296+func (s *slashSuggester) PageDown() {
297+ if len(s.lastMatches) == 0 {
298+ return
299+ }
300+ s.cursor += slashSuggestPageSize
301+ if s.cursor >= len(s.lastMatches) {
302+ s.cursor = len(s.lastMatches) - 1
303+ }
304+ if s.lastMatches[s.cursor].Header {
305+ s.skipHeader(-1)
306+ }
307+}
308+
280309 // skipHeader moves the cursor by step, then keeps moving in the same
281310 // direction across header rows until it lands on a real command (or
282311 // hits the edge, in which case it bounces back to the nearest real
@@ -341,6 +370,24 @@ func (s *slashSuggester) Selection(input string) string {
341370 return m[s.cursor].Name
342371 }
343372
373+func (s *slashSuggester) page(total int) (start, end int) {
374+ if total <= slashSuggestPageSize {
375+ return 0, total
376+ }
377+ if s.cursor < 0 {
378+ s.cursor = 0
379+ }
380+ if s.cursor >= total {
381+ s.cursor = total - 1
382+ }
383+ start = (s.cursor / slashSuggestPageSize) * slashSuggestPageSize
384+ end = start + slashSuggestPageSize
385+ if end > total {
386+ end = total
387+ }
388+ return start, end
389+}
390+
344391 // Render returns the popup lines or nil.
345392 func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string {
346393 m := s.matches(input)
@@ -371,8 +418,10 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
371418 nameWidth = n
372419 }
373420 }
421+ start, end := s.page(len(m))
374422 var lines []string
375- for i, c := range m {
423+ for i := start; i < end; i++ {
424+ c := m[i]
376425 if c.Header {
377426 // Breathing room around group dividers — a blank row
378427 // before AND after makes the boundary read at a glance.
@@ -397,6 +446,9 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
397446 lines = append(lines, th.FG256(th.Muted, plain))
398447 }
399448 }
449+ if len(m) > slashSuggestPageSize {
450+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", s.cursor+1, len(m))))
451+ }
400452 // Blank row before the hint visually detaches it from the
401453 // command list and groups it with its trailing blank.
402454 lines = append(lines, "")
@@ -1,6 +1,7 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "fmt"
4 "sort"5 "sort"
5 "strings"6 "strings"
6 7
@@ -54,6 +55,8 @@ var slashCatalog = []slashCommand{
54 55
55 // slashSuggester renders the popup that appears when the editor starts56 // slashSuggester renders the popup that appears when the editor starts
56 // with "/". It does not own any input state — the editor drives.57 // with "/". It does not own any input state — the editor drives.
58+const slashSuggestPageSize = 8
59+
57 type slashSuggester struct {60 type slashSuggester struct {
58 cursor int61 cursor int
59 62
@@ -277,6 +280,32 @@ func (s *slashSuggester) Down() {
277 s.skipHeader(+1)280 s.skipHeader(+1)
278 }281 }
279 282
283+func (s *slashSuggester) PageUp() {
284+ if len(s.lastMatches) == 0 {
285+ return
286+ }
287+ s.cursor -= slashSuggestPageSize
288+ if s.cursor < 0 {
289+ s.cursor = 0
290+ }
291+ if s.lastMatches[s.cursor].Header {
292+ s.skipHeader(+1)
293+ }
294+}
295+
296+func (s *slashSuggester) PageDown() {
297+ if len(s.lastMatches) == 0 {
298+ return
299+ }
300+ s.cursor += slashSuggestPageSize
301+ if s.cursor >= len(s.lastMatches) {
302+ s.cursor = len(s.lastMatches) - 1
303+ }
304+ if s.lastMatches[s.cursor].Header {
305+ s.skipHeader(-1)
306+ }
307+}
308+
280 // skipHeader moves the cursor by step, then keeps moving in the same309 // skipHeader moves the cursor by step, then keeps moving in the same
281 // direction across header rows until it lands on a real command (or310 // direction across header rows until it lands on a real command (or
282 // hits the edge, in which case it bounces back to the nearest real311 // hits the edge, in which case it bounces back to the nearest real
@@ -341,6 +370,24 @@ func (s *slashSuggester) Selection(input string) string {
341 return m[s.cursor].Name370 return m[s.cursor].Name
342 }371 }
343 372
373+func (s *slashSuggester) page(total int) (start, end int) {
374+ if total <= slashSuggestPageSize {
375+ return 0, total
376+ }
377+ if s.cursor < 0 {
378+ s.cursor = 0
379+ }
380+ if s.cursor >= total {
381+ s.cursor = total - 1
382+ }
383+ start = (s.cursor / slashSuggestPageSize) * slashSuggestPageSize
384+ end = start + slashSuggestPageSize
385+ if end > total {
386+ end = total
387+ }
388+ return start, end
389+}
390+
344 // Render returns the popup lines or nil.391 // Render returns the popup lines or nil.
345 func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string {392 func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string {
346 m := s.matches(input)393 m := s.matches(input)
@@ -371,8 +418,10 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
371 nameWidth = n418 nameWidth = n
372 }419 }
373 }420 }
421+ start, end := s.page(len(m))
374 var lines []string422 var lines []string
375- for i, c := range m {423+ for i := start; i < end; i++ {
424+ c := m[i]
376 if c.Header {425 if c.Header {
377 // Breathing room around group dividers — a blank row426 // Breathing room around group dividers — a blank row
378 // before AND after makes the boundary read at a glance.427 // before AND after makes the boundary read at a glance.
@@ -397,6 +446,9 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
397 lines = append(lines, th.FG256(th.Muted, plain))446 lines = append(lines, th.FG256(th.Muted, plain))
398 }447 }
399 }448 }
449+ if len(m) > slashSuggestPageSize {
450+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", s.cursor+1, len(m))))
451+ }
400 // Blank row before the hint visually detaches it from the452 // Blank row before the hint visually detaches it from the
401 // command list and groups it with its trailing blank.453 // command list and groups it with its trailing blank.
402 lines = append(lines, "")454 lines = append(lines, "")
A internal/auth/github_copilot.go +132 -0
@@ -0,0 +1,132 @@
1+package auth
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "net/url"
11+ "strings"
12+ "time"
13+)
14+
15+const githubCopilotClientID = "Iv1.b507a08c87ecfe98"
16+
17+// GitHubCopilotDeviceAuthorization is GitHub's OAuth 2 device-code
18+// response used for Copilot subscription login.
19+type GitHubCopilotDeviceAuthorization struct {
20+ DeviceCode string `json:"device_code"`
21+ UserCode string `json:"user_code"`
22+ VerificationURI string `json:"verification_uri"`
23+ ExpiresIn int `json:"expires_in"`
24+ Interval int `json:"interval"`
25+}
26+
27+// RequestGitHubCopilotDeviceAuthorization starts GitHub Copilot's
28+// device-code login. The resulting GitHub access token is later traded
29+// for short-lived Copilot inference tokens by the provider client.
30+func RequestGitHubCopilotDeviceAuthorization(ctx context.Context) (GitHubCopilotDeviceAuthorization, error) {
31+ form := url.Values{}
32+ form.Set("client_id", githubCopilotClientID)
33+ form.Set("scope", "read:user")
34+ req, err := http.NewRequestWithContext(ctx, "POST", "https://github.com/login/device/code", bytes.NewBufferString(form.Encode()))
35+ if err != nil {
36+ return GitHubCopilotDeviceAuthorization{}, err
37+ }
38+ req.Header.Set("content-type", "application/x-www-form-urlencoded")
39+ req.Header.Set("accept", "application/json")
40+ req.Header.Set("user-agent", "GitHubCopilotChat/0.35.0")
41+
42+ resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
43+ if err != nil {
44+ return GitHubCopilotDeviceAuthorization{}, fmt.Errorf("github copilot device authorization: %w", err)
45+ }
46+ defer resp.Body.Close()
47+ body, _ := io.ReadAll(resp.Body)
48+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
49+ return GitHubCopilotDeviceAuthorization{}, fmt.Errorf("github copilot device authorization http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
50+ }
51+ var out GitHubCopilotDeviceAuthorization
52+ if err := json.Unmarshal(body, &out); err != nil {
53+ return out, fmt.Errorf("parse github copilot device authorization: %w", err)
54+ }
55+ if out.Interval <= 0 {
56+ out.Interval = 5
57+ }
58+ return out, nil
59+}
60+
61+// PollGitHubCopilotDeviceToken polls until GitHub's browser/device-code
62+// login completes and returns the GitHub access token.
63+func PollGitHubCopilotDeviceToken(ctx context.Context, auth GitHubCopilotDeviceAuthorization) (*OAuthToken, error) {
64+ interval := time.Duration(auth.Interval) * time.Second
65+ if interval <= 0 {
66+ interval = 5 * time.Second
67+ }
68+ deadline := time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second)
69+ for {
70+ if auth.ExpiresIn > 0 && time.Now().After(deadline) {
71+ return nil, fmt.Errorf("github copilot device login expired")
72+ }
73+ tok, retry, err := pollGitHubCopilotDeviceTokenOnce(ctx, auth.DeviceCode, interval)
74+ if err != nil {
75+ return nil, err
76+ }
77+ if tok != nil {
78+ return tok, nil
79+ }
80+ select {
81+ case <-ctx.Done():
82+ return nil, ctx.Err()
83+ case <-time.After(retry):
84+ }
85+ }
86+}
87+
88+func pollGitHubCopilotDeviceTokenOnce(ctx context.Context, deviceCode string, interval time.Duration) (*OAuthToken, time.Duration, error) {
89+ form := url.Values{}
90+ form.Set("client_id", githubCopilotClientID)
91+ form.Set("device_code", deviceCode)
92+ form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
93+ req, err := http.NewRequestWithContext(ctx, "POST", "https://github.com/login/oauth/access_token", bytes.NewBufferString(form.Encode()))
94+ if err != nil {
95+ return nil, 0, err
96+ }
97+ req.Header.Set("content-type", "application/x-www-form-urlencoded")
98+ req.Header.Set("accept", "application/json")
99+ req.Header.Set("user-agent", "GitHubCopilotChat/0.35.0")
100+ resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
101+ if err != nil {
102+ return nil, 0, fmt.Errorf("github copilot token poll: %w", err)
103+ }
104+ defer resp.Body.Close()
105+ body, _ := io.ReadAll(resp.Body)
106+ var raw struct {
107+ AccessToken string `json:"access_token"`
108+ TokenType string `json:"token_type"`
109+ Scope string `json:"scope"`
110+ Error string `json:"error"`
111+ ErrorDescription string `json:"error_description"`
112+ }
113+ _ = json.Unmarshal(body, &raw)
114+ if resp.StatusCode == http.StatusOK && raw.AccessToken != "" {
115+ return &OAuthToken{
116+ AccessToken: raw.AccessToken,
117+ TokenType: raw.TokenType,
118+ Scope: raw.Scope,
119+ ClientID: githubCopilotClientID,
120+ }, 0, nil
121+ }
122+ if raw.Error == "authorization_pending" || resp.StatusCode == http.StatusBadRequest {
123+ return nil, interval, nil
124+ }
125+ if raw.Error == "slow_down" {
126+ return nil, interval + 5*time.Second, nil
127+ }
128+ if raw.Error != "" {
129+ return nil, 0, fmt.Errorf("github copilot token poll: %s: %s", raw.Error, raw.ErrorDescription)
130+ }
131+ return nil, 0, fmt.Errorf("github copilot token poll http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
132+}
@@ -0,0 +1,132 @@
1+package auth
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "net/url"
11+ "strings"
12+ "time"
13+)
14+
15+const githubCopilotClientID = "Iv1.b507a08c87ecfe98"
16+
17+// GitHubCopilotDeviceAuthorization is GitHub's OAuth 2 device-code
18+// response used for Copilot subscription login.
19+type GitHubCopilotDeviceAuthorization struct {
20+ DeviceCode string `json:"device_code"`
21+ UserCode string `json:"user_code"`
22+ VerificationURI string `json:"verification_uri"`
23+ ExpiresIn int `json:"expires_in"`
24+ Interval int `json:"interval"`
25+}
26+
27+// RequestGitHubCopilotDeviceAuthorization starts GitHub Copilot's
28+// device-code login. The resulting GitHub access token is later traded
29+// for short-lived Copilot inference tokens by the provider client.
30+func RequestGitHubCopilotDeviceAuthorization(ctx context.Context) (GitHubCopilotDeviceAuthorization, error) {
31+ form := url.Values{}
32+ form.Set("client_id", githubCopilotClientID)
33+ form.Set("scope", "read:user")
34+ req, err := http.NewRequestWithContext(ctx, "POST", "https://github.com/login/device/code", bytes.NewBufferString(form.Encode()))
35+ if err != nil {
36+ return GitHubCopilotDeviceAuthorization{}, err
37+ }
38+ req.Header.Set("content-type", "application/x-www-form-urlencoded")
39+ req.Header.Set("accept", "application/json")
40+ req.Header.Set("user-agent", "GitHubCopilotChat/0.35.0")
41+
42+ resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
43+ if err != nil {
44+ return GitHubCopilotDeviceAuthorization{}, fmt.Errorf("github copilot device authorization: %w", err)
45+ }
46+ defer resp.Body.Close()
47+ body, _ := io.ReadAll(resp.Body)
48+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
49+ return GitHubCopilotDeviceAuthorization{}, fmt.Errorf("github copilot device authorization http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
50+ }
51+ var out GitHubCopilotDeviceAuthorization
52+ if err := json.Unmarshal(body, &out); err != nil {
53+ return out, fmt.Errorf("parse github copilot device authorization: %w", err)
54+ }
55+ if out.Interval <= 0 {
56+ out.Interval = 5
57+ }
58+ return out, nil
59+}
60+
61+// PollGitHubCopilotDeviceToken polls until GitHub's browser/device-code
62+// login completes and returns the GitHub access token.
63+func PollGitHubCopilotDeviceToken(ctx context.Context, auth GitHubCopilotDeviceAuthorization) (*OAuthToken, error) {
64+ interval := time.Duration(auth.Interval) * time.Second
65+ if interval <= 0 {
66+ interval = 5 * time.Second
67+ }
68+ deadline := time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second)
69+ for {
70+ if auth.ExpiresIn > 0 && time.Now().After(deadline) {
71+ return nil, fmt.Errorf("github copilot device login expired")
72+ }
73+ tok, retry, err := pollGitHubCopilotDeviceTokenOnce(ctx, auth.DeviceCode, interval)
74+ if err != nil {
75+ return nil, err
76+ }
77+ if tok != nil {
78+ return tok, nil
79+ }
80+ select {
81+ case <-ctx.Done():
82+ return nil, ctx.Err()
83+ case <-time.After(retry):
84+ }
85+ }
86+}
87+
88+func pollGitHubCopilotDeviceTokenOnce(ctx context.Context, deviceCode string, interval time.Duration) (*OAuthToken, time.Duration, error) {
89+ form := url.Values{}
90+ form.Set("client_id", githubCopilotClientID)
91+ form.Set("device_code", deviceCode)
92+ form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
93+ req, err := http.NewRequestWithContext(ctx, "POST", "https://github.com/login/oauth/access_token", bytes.NewBufferString(form.Encode()))
94+ if err != nil {
95+ return nil, 0, err
96+ }
97+ req.Header.Set("content-type", "application/x-www-form-urlencoded")
98+ req.Header.Set("accept", "application/json")
99+ req.Header.Set("user-agent", "GitHubCopilotChat/0.35.0")
100+ resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
101+ if err != nil {
102+ return nil, 0, fmt.Errorf("github copilot token poll: %w", err)
103+ }
104+ defer resp.Body.Close()
105+ body, _ := io.ReadAll(resp.Body)
106+ var raw struct {
107+ AccessToken string `json:"access_token"`
108+ TokenType string `json:"token_type"`
109+ Scope string `json:"scope"`
110+ Error string `json:"error"`
111+ ErrorDescription string `json:"error_description"`
112+ }
113+ _ = json.Unmarshal(body, &raw)
114+ if resp.StatusCode == http.StatusOK && raw.AccessToken != "" {
115+ return &OAuthToken{
116+ AccessToken: raw.AccessToken,
117+ TokenType: raw.TokenType,
118+ Scope: raw.Scope,
119+ ClientID: githubCopilotClientID,
120+ }, 0, nil
121+ }
122+ if raw.Error == "authorization_pending" || resp.StatusCode == http.StatusBadRequest {
123+ return nil, interval, nil
124+ }
125+ if raw.Error == "slow_down" {
126+ return nil, interval + 5*time.Second, nil
127+ }
128+ if raw.Error != "" {
129+ return nil, 0, fmt.Errorf("github copilot token poll: %s: %s", raw.Error, raw.ErrorDescription)
130+ }
131+ return nil, 0, fmt.Errorf("github copilot token poll http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
132+}
M internal/auth/manager.go +47 -3
@@ -81,7 +81,7 @@ func (m *Manager) Close() {
8181 // StartAPIKey launches the API-key login flow.
8282 func (m *Manager) StartAPIKey(provider string) (string, error) {
8383 if !isKnownAPIKeyProvider(provider) {
84- return "", fmt.Errorf("provider must be anthropic, openai, kimi, deepseek, or google")
84+ return "", fmt.Errorf(apiKeyProviderMessage())
8585 }
8686 if err := m.ensureKeyServer(); err != nil {
8787 return "", err
@@ -135,6 +135,9 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
135135 if provider == "kimi" {
136136 return m.StartKimiDeviceOAuth()
137137 }
138+ if provider == "github-copilot" {
139+ return m.StartGitHubCopilotDeviceOAuth()
140+ }
138141 storeProvider := provider
139142 var op OAuthProvider
140143 switch provider {
@@ -148,7 +151,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
148151 case "deepseek":
149152 return "", fmt.Errorf("deepseek login is api-key only; use api key login")
150153 default:
151- return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, deepseek, or google")
154+ return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, github-copilot, deepseek, or google")
152155 }
153156
154157 m.mu.Lock()
@@ -254,6 +257,44 @@ func (m *Manager) StartKimiDeviceOAuth() (string, error) {
254257 return url, nil
255258 }
256259
260+// StartGitHubCopilotDeviceOAuth starts GitHub Copilot's device-code subscription login.
261+func (m *Manager) StartGitHubCopilotDeviceOAuth() (string, error) {
262+ ctx, cancel := context.WithCancel(context.Background())
263+ m.mu.Lock()
264+ if m.oauthCancel != nil {
265+ m.oauthCancel()
266+ }
267+ m.oauthCtx = ctx
268+ m.oauthCancel = cancel
269+ m.mu.Unlock()
270+
271+ dev, err := RequestGitHubCopilotDeviceAuthorization(ctx)
272+ if err != nil {
273+ return "", err
274+ }
275+ loginURL := dev.VerificationURI
276+ if dev.UserCode != "" {
277+ loginURL += "?user_code=" + url.QueryEscape(dev.UserCode)
278+ }
279+ go m.maybeOpen(loginURL)
280+ m.emit(Event{Kind: "started", Provider: "github-copilot", Method: "oauth", URL: loginURL})
281+ go func() {
282+ tok, err := PollGitHubCopilotDeviceToken(ctx, dev)
283+ if err != nil {
284+ if ctx.Err() == nil {
285+ m.emit(Event{Kind: "error", Provider: "github-copilot", Method: "oauth", Message: err.Error()})
286+ }
287+ return
288+ }
289+ if err := m.store.SetOAuth("github-copilot", *tok); err != nil {
290+ m.emit(Event{Kind: "error", Provider: "github-copilot", Method: "oauth", Message: err.Error()})
291+ return
292+ }
293+ m.emit(Event{Kind: "success", Provider: "github-copilot", Method: "oauth"})
294+ }()
295+ return loginURL, nil
296+}
297+
257298 // StartManualOAuth begins an OAuth flow but does NOT start a local
258299 // callback server or open a browser. The returned URL is shown to the
259300 // user so they can complete the authorization on another device; the
@@ -262,6 +303,9 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
262303 if provider == "kimi" {
263304 return m.StartKimiDeviceOAuth()
264305 }
306+ if provider == "github-copilot" {
307+ return m.StartGitHubCopilotDeviceOAuth()
308+ }
265309 storeProvider := provider
266310 var op OAuthProvider
267311 switch provider {
@@ -275,7 +319,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
275319 case "deepseek":
276320 return "", fmt.Errorf("deepseek login is api-key only; use api key login")
277321 default:
278- return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, deepseek, or google")
322+ return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, github-copilot, deepseek, or google")
279323 }
280324
281325 pkce, err := NewPKCE()
@@ -81,7 +81,7 @@ func (m *Manager) Close() {
81 // StartAPIKey launches the API-key login flow.81 // StartAPIKey launches the API-key login flow.
82 func (m *Manager) StartAPIKey(provider string) (string, error) {82 func (m *Manager) StartAPIKey(provider string) (string, error) {
83 if !isKnownAPIKeyProvider(provider) {83 if !isKnownAPIKeyProvider(provider) {
84- return "", fmt.Errorf("provider must be anthropic, openai, kimi, deepseek, or google")84+ return "", fmt.Errorf(apiKeyProviderMessage())
85 }85 }
86 if err := m.ensureKeyServer(); err != nil {86 if err := m.ensureKeyServer(); err != nil {
87 return "", err87 return "", err
@@ -135,6 +135,9 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
135 if provider == "kimi" {135 if provider == "kimi" {
136 return m.StartKimiDeviceOAuth()136 return m.StartKimiDeviceOAuth()
137 }137 }
138+ if provider == "github-copilot" {
139+ return m.StartGitHubCopilotDeviceOAuth()
140+ }
138 storeProvider := provider141 storeProvider := provider
139 var op OAuthProvider142 var op OAuthProvider
140 switch provider {143 switch provider {
@@ -148,7 +151,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
148 case "deepseek":151 case "deepseek":
149 return "", fmt.Errorf("deepseek login is api-key only; use api key login")152 return "", fmt.Errorf("deepseek login is api-key only; use api key login")
150 default:153 default:
151- return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, deepseek, or google")154+ return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, github-copilot, deepseek, or google")
152 }155 }
153 156
154 m.mu.Lock()157 m.mu.Lock()
@@ -254,6 +257,44 @@ func (m *Manager) StartKimiDeviceOAuth() (string, error) {
254 return url, nil257 return url, nil
255 }258 }
256 259
260+// StartGitHubCopilotDeviceOAuth starts GitHub Copilot's device-code subscription login.
261+func (m *Manager) StartGitHubCopilotDeviceOAuth() (string, error) {
262+ ctx, cancel := context.WithCancel(context.Background())
263+ m.mu.Lock()
264+ if m.oauthCancel != nil {
265+ m.oauthCancel()
266+ }
267+ m.oauthCtx = ctx
268+ m.oauthCancel = cancel
269+ m.mu.Unlock()
270+
271+ dev, err := RequestGitHubCopilotDeviceAuthorization(ctx)
272+ if err != nil {
273+ return "", err
274+ }
275+ loginURL := dev.VerificationURI
276+ if dev.UserCode != "" {
277+ loginURL += "?user_code=" + url.QueryEscape(dev.UserCode)
278+ }
279+ go m.maybeOpen(loginURL)
280+ m.emit(Event{Kind: "started", Provider: "github-copilot", Method: "oauth", URL: loginURL})
281+ go func() {
282+ tok, err := PollGitHubCopilotDeviceToken(ctx, dev)
283+ if err != nil {
284+ if ctx.Err() == nil {
285+ m.emit(Event{Kind: "error", Provider: "github-copilot", Method: "oauth", Message: err.Error()})
286+ }
287+ return
288+ }
289+ if err := m.store.SetOAuth("github-copilot", *tok); err != nil {
290+ m.emit(Event{Kind: "error", Provider: "github-copilot", Method: "oauth", Message: err.Error()})
291+ return
292+ }
293+ m.emit(Event{Kind: "success", Provider: "github-copilot", Method: "oauth"})
294+ }()
295+ return loginURL, nil
296+}
297+
257 // StartManualOAuth begins an OAuth flow but does NOT start a local298 // StartManualOAuth begins an OAuth flow but does NOT start a local
258 // callback server or open a browser. The returned URL is shown to the299 // callback server or open a browser. The returned URL is shown to the
259 // user so they can complete the authorization on another device; the300 // user so they can complete the authorization on another device; the
@@ -262,6 +303,9 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
262 if provider == "kimi" {303 if provider == "kimi" {
263 return m.StartKimiDeviceOAuth()304 return m.StartKimiDeviceOAuth()
264 }305 }
306+ if provider == "github-copilot" {
307+ return m.StartGitHubCopilotDeviceOAuth()
308+ }
265 storeProvider := provider309 storeProvider := provider
266 var op OAuthProvider310 var op OAuthProvider
267 switch provider {311 switch provider {
@@ -275,7 +319,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
275 case "deepseek":319 case "deepseek":
276 return "", fmt.Errorf("deepseek login is api-key only; use api key login")320 return "", fmt.Errorf("deepseek login is api-key only; use api key login")
277 default:321 default:
278- return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, deepseek, or google")322+ return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, github-copilot, deepseek, or google")
279 }323 }
280 324
281 pkce, err := NewPKCE()325 pkce, err := NewPKCE()
M internal/auth/probe.go +78 -0
@@ -4,6 +4,8 @@ import (
44 "context"
55 "fmt"
66 "net/http"
7+ "os"
8+ "strings"
79 "time"
810 )
911
@@ -115,10 +117,86 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
115117 return err
116118 }
117119 req.Header.Set("authorization", "Bearer "+key)
120+ case "xiaomi":
121+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.xiaomimimo.com/v1/models", nil)
122+ if err != nil {
123+ return err
124+ }
125+ req.Header.Set("authorization", "Bearer "+key)
126+ case "xiaomi-token-plan-ams":
127+ req, err = http.NewRequestWithContext(ctx, "GET", "https://token-plan-ams.xiaomimimo.com/v1/models", nil)
128+ if err != nil {
129+ return err
130+ }
131+ req.Header.Set("authorization", "Bearer "+key)
132+ case "xiaomi-token-plan-cn":
133+ req, err = http.NewRequestWithContext(ctx, "GET", "https://token-plan-cn.xiaomimimo.com/v1/models", nil)
134+ if err != nil {
135+ return err
136+ }
137+ req.Header.Set("authorization", "Bearer "+key)
138+ case "xiaomi-token-plan-sgp":
139+ req, err = http.NewRequestWithContext(ctx, "GET", "https://token-plan-sgp.xiaomimimo.com/v1/models", nil)
140+ if err != nil {
141+ return err
142+ }
143+ req.Header.Set("authorization", "Bearer "+key)
144+ case "minimax":
145+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.minimax.io/v1/models", nil)
146+ if err != nil {
147+ return err
148+ }
149+ req.Header.Set("authorization", "Bearer "+key)
150+ case "minimax-cn":
151+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.minimaxi.com/v1/models", nil)
152+ if err != nil {
153+ return err
154+ }
155+ req.Header.Set("authorization", "Bearer "+key)
156+ case "fireworks":
157+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.fireworks.ai/inference/v1/models", nil)
158+ if err != nil {
159+ return err
160+ }
161+ req.Header.Set("authorization", "Bearer "+key)
162+ case "vercel-ai-gateway":
163+ req, err = http.NewRequestWithContext(ctx, "GET", "https://ai-gateway.vercel.sh/v1/models", nil)
164+ if err != nil {
165+ return err
166+ }
167+ req.Header.Set("authorization", "Bearer "+key)
168+ case "opencode":
169+ req, err = http.NewRequestWithContext(ctx, "GET", "https://opencode.ai/zen/v1/models", nil)
170+ if err != nil {
171+ return err
172+ }
173+ req.Header.Set("authorization", "Bearer "+key)
174+ case "opencode-go":
175+ req, err = http.NewRequestWithContext(ctx, "GET", "https://opencode.ai/zen/go/v1/models", nil)
176+ if err != nil {
177+ return err
178+ }
179+ req.Header.Set("authorization", "Bearer "+key)
180+ case "azure-openai-responses":
181+ return nil
182+ case "amazon-bedrock":
183+ return nil
184+ case "google-vertex":
185+ return nil
186+ case "cloudflare-workers-ai", "cloudflare-ai-gateway":
187+ return nil
188+ case "github-copilot":
189+ return nil
118190 default:
119191 return fmt.Errorf("unknown provider %q", provider)
120192 }
121193
194+ if strings.Contains(req.URL.String(), "{CLOUDFLARE_ACCOUNT_ID}") {
195+ if acct := os.Getenv("CLOUDFLARE_ACCOUNT_ID"); acct != "" {
196+ u := strings.ReplaceAll(req.URL.String(), "{CLOUDFLARE_ACCOUNT_ID}", acct)
197+ req.URL, _ = req.URL.Parse(u)
198+ }
199+ }
122200 resp, err := c.Do(req)
123201 if err != nil {
124202 return fmt.Errorf("probe %s: %w", provider, err)
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "net/http"6 "net/http"
7+ "os"
8+ "strings"
7 "time"9 "time"
8 )10 )
9 11
@@ -115,10 +117,86 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
115 return err117 return err
116 }118 }
117 req.Header.Set("authorization", "Bearer "+key)119 req.Header.Set("authorization", "Bearer "+key)
120+ case "xiaomi":
121+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.xiaomimimo.com/v1/models", nil)
122+ if err != nil {
123+ return err
124+ }
125+ req.Header.Set("authorization", "Bearer "+key)
126+ case "xiaomi-token-plan-ams":
127+ req, err = http.NewRequestWithContext(ctx, "GET", "https://token-plan-ams.xiaomimimo.com/v1/models", nil)
128+ if err != nil {
129+ return err
130+ }
131+ req.Header.Set("authorization", "Bearer "+key)
132+ case "xiaomi-token-plan-cn":
133+ req, err = http.NewRequestWithContext(ctx, "GET", "https://token-plan-cn.xiaomimimo.com/v1/models", nil)
134+ if err != nil {
135+ return err
136+ }
137+ req.Header.Set("authorization", "Bearer "+key)
138+ case "xiaomi-token-plan-sgp":
139+ req, err = http.NewRequestWithContext(ctx, "GET", "https://token-plan-sgp.xiaomimimo.com/v1/models", nil)
140+ if err != nil {
141+ return err
142+ }
143+ req.Header.Set("authorization", "Bearer "+key)
144+ case "minimax":
145+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.minimax.io/v1/models", nil)
146+ if err != nil {
147+ return err
148+ }
149+ req.Header.Set("authorization", "Bearer "+key)
150+ case "minimax-cn":
151+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.minimaxi.com/v1/models", nil)
152+ if err != nil {
153+ return err
154+ }
155+ req.Header.Set("authorization", "Bearer "+key)
156+ case "fireworks":
157+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.fireworks.ai/inference/v1/models", nil)
158+ if err != nil {
159+ return err
160+ }
161+ req.Header.Set("authorization", "Bearer "+key)
162+ case "vercel-ai-gateway":
163+ req, err = http.NewRequestWithContext(ctx, "GET", "https://ai-gateway.vercel.sh/v1/models", nil)
164+ if err != nil {
165+ return err
166+ }
167+ req.Header.Set("authorization", "Bearer "+key)
168+ case "opencode":
169+ req, err = http.NewRequestWithContext(ctx, "GET", "https://opencode.ai/zen/v1/models", nil)
170+ if err != nil {
171+ return err
172+ }
173+ req.Header.Set("authorization", "Bearer "+key)
174+ case "opencode-go":
175+ req, err = http.NewRequestWithContext(ctx, "GET", "https://opencode.ai/zen/go/v1/models", nil)
176+ if err != nil {
177+ return err
178+ }
179+ req.Header.Set("authorization", "Bearer "+key)
180+ case "azure-openai-responses":
181+ return nil
182+ case "amazon-bedrock":
183+ return nil
184+ case "google-vertex":
185+ return nil
186+ case "cloudflare-workers-ai", "cloudflare-ai-gateway":
187+ return nil
188+ case "github-copilot":
189+ return nil
118 default:190 default:
119 return fmt.Errorf("unknown provider %q", provider)191 return fmt.Errorf("unknown provider %q", provider)
120 }192 }
121 193
194+ if strings.Contains(req.URL.String(), "{CLOUDFLARE_ACCOUNT_ID}") {
195+ if acct := os.Getenv("CLOUDFLARE_ACCOUNT_ID"); acct != "" {
196+ u := strings.ReplaceAll(req.URL.String(), "{CLOUDFLARE_ACCOUNT_ID}", acct)
197+ req.URL, _ = req.URL.Parse(u)
198+ }
199+ }
122 resp, err := c.Do(req)200 resp, err := c.Do(req)
123 if err != nil {201 if err != nil {
124 return fmt.Errorf("probe %s: %w", provider, err)202 return fmt.Errorf("probe %s: %w", provider, err)
M internal/auth/server.go +24 -7
@@ -96,17 +96,34 @@ func (s *Server) Shutdown(ctx context.Context) error {
9696 }
9797
9898 // isKnownAPIKeyProvider reports whether the given provider supports
99-// API-key login through the loopback flow. Kept centralized so adding a
100-// provider only touches one place. OAuth-only paths are handled
99+// API-key login through the loopback flow. OAuth-only paths are handled
101100 // elsewhere (manager.StartOAuth).
102101 func isKnownAPIKeyProvider(p string) bool {
103- switch p {
104- case "anthropic", "openai", "kimi", "google", "deepseek":
105- return true
102+ for _, provider := range APIKeyProviders() {
103+ if p == provider {
104+ return true
105+ }
106106 }
107107 return false
108108 }
109109
110+func apiKeyProviderMessage() string {
111+ return "provider must be one of: " + strings.Join(APIKeyProviders(), ", ")
112+}
113+
114+// APIKeyProviders is the ordered list shown by /login -> api key.
115+func APIKeyProviders() []string {
116+ return []string{
117+ "anthropic", "openai", "kimi", "deepseek", "google",
118+ "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together",
119+ "huggingface", "openrouter", "mistral", "zai",
120+ "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
121+ "minimax", "minimax-cn", "fireworks", "vercel-ai-gateway",
122+ "opencode", "opencode-go", "amazon-bedrock", "google-vertex", "azure-openai-responses",
123+ "github-copilot", "cloudflare-workers-ai", "cloudflare-ai-gateway",
124+ }
125+}
126+
110127 // ---- handlers ----
111128
112129 func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -124,7 +141,7 @@ func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) {
124141 case http.MethodGet:
125142 provider := r.URL.Query().Get("provider")
126143 if !isKnownAPIKeyProvider(provider) {
127- http.Error(w, "provider must be anthropic, openai, kimi, deepseek, or google", http.StatusBadRequest)
144+ http.Error(w, apiKeyProviderMessage(), http.StatusBadRequest)
128145 return
129146 }
130147 tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider})
@@ -244,7 +261,7 @@ var tpl = template.Must(template.New("index").Parse(`<!doctype html><html lang="
244261 <a href="/apikey?provider=google">google gemini api key →</a>
245262 </p>
246263 <hr class="rule">
247-<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro - kimi code), close this tab and run /login inside <span class="zot">zot</span>.</p>
264+<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro - kimi code - github copilot), close this tab and run /login inside <span class="zot">zot</span>.</p>
248265 </body></html>`))
249266
250267 func init() {
@@ -96,17 +96,34 @@ func (s *Server) Shutdown(ctx context.Context) error {
96 }96 }
97 97
98 // isKnownAPIKeyProvider reports whether the given provider supports98 // isKnownAPIKeyProvider reports whether the given provider supports
99-// API-key login through the loopback flow. Kept centralized so adding a99+// API-key login through the loopback flow. OAuth-only paths are handled
100-// provider only touches one place. OAuth-only paths are handled
101 // elsewhere (manager.StartOAuth).100 // elsewhere (manager.StartOAuth).
102 func isKnownAPIKeyProvider(p string) bool {101 func isKnownAPIKeyProvider(p string) bool {
103- switch p {102+ for _, provider := range APIKeyProviders() {
104- case "anthropic", "openai", "kimi", "google", "deepseek":103+ if p == provider {
105- return true104+ return true
105+ }
106 }106 }
107 return false107 return false
108 }108 }
109 109
110+func apiKeyProviderMessage() string {
111+ return "provider must be one of: " + strings.Join(APIKeyProviders(), ", ")
112+}
113+
114+// APIKeyProviders is the ordered list shown by /login -> api key.
115+func APIKeyProviders() []string {
116+ return []string{
117+ "anthropic", "openai", "kimi", "deepseek", "google",
118+ "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together",
119+ "huggingface", "openrouter", "mistral", "zai",
120+ "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
121+ "minimax", "minimax-cn", "fireworks", "vercel-ai-gateway",
122+ "opencode", "opencode-go", "amazon-bedrock", "google-vertex", "azure-openai-responses",
123+ "github-copilot", "cloudflare-workers-ai", "cloudflare-ai-gateway",
124+ }
125+}
126+
110 // ---- handlers ----127 // ---- handlers ----
111 128
112 func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {129 func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -124,7 +141,7 @@ func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) {
124 case http.MethodGet:141 case http.MethodGet:
125 provider := r.URL.Query().Get("provider")142 provider := r.URL.Query().Get("provider")
126 if !isKnownAPIKeyProvider(provider) {143 if !isKnownAPIKeyProvider(provider) {
127- http.Error(w, "provider must be anthropic, openai, kimi, deepseek, or google", http.StatusBadRequest)144+ http.Error(w, apiKeyProviderMessage(), http.StatusBadRequest)
128 return145 return
129 }146 }
130 tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider})147 tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider})
@@ -244,7 +261,7 @@ var tpl = template.Must(template.New("index").Parse(`<!doctype html><html lang="
244 <a href="/apikey?provider=google">google gemini api key →</a>261 <a href="/apikey?provider=google">google gemini api key →</a>
245 </p>262 </p>
246 <hr class="rule">263 <hr class="rule">
247-<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro - kimi code), close this tab and run /login inside <span class="zot">zot</span>.</p>264+<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro - kimi code - github copilot), close this tab and run /login inside <span class="zot">zot</span>.</p>
248 </body></html>`))265 </body></html>`))
249 266
250 func init() {267 func init() {
M internal/auth/store.go +62 -10
@@ -16,11 +16,13 @@ import (
1616
1717 // Credentials is the on-disk schema.
1818 type Credentials struct {
19- Anthropic ProviderCreds `json:"anthropic,omitempty"`
20- OpenAI ProviderCreds `json:"openai,omitempty"`
21- Kimi ProviderCreds `json:"kimi,omitempty"`
22- Google ProviderCreds `json:"google,omitempty"`
23- DeepSeek ProviderCreds `json:"deepseek,omitempty"`
19+ Anthropic ProviderCreds `json:"anthropic,omitempty"`
20+ OpenAI ProviderCreds `json:"openai,omitempty"`
21+ Kimi ProviderCreds `json:"kimi,omitempty"`
22+ Google ProviderCreds `json:"google,omitempty"`
23+ DeepSeek ProviderCreds `json:"deepseek,omitempty"`
24+ GithubCopilot ProviderCreds `json:"github_copilot,omitempty"`
25+ AdditionalAPIKeyCreds map[string]ProviderCreds `json:"additional_api_key_creds,omitempty"`
2426 }
2527
2628 // ProviderCreds holds credentials for a single provider. Most providers
@@ -90,10 +92,31 @@ func (c *Credentials) get(provider string) *ProviderCreds {
9092 return &c.Google
9193 case "deepseek":
9294 return &c.DeepSeek
95+ case "github-copilot":
96+ return &c.GithubCopilot
97+ }
98+ if c.AdditionalAPIKeyCreds != nil {
99+ if p, ok := c.AdditionalAPIKeyCreds[provider]; ok {
100+ return &p
101+ }
93102 }
94103 return nil
95104 }
96105
106+func (c *Credentials) setAdditional(provider string, p ProviderCreds) {
107+ if c.AdditionalAPIKeyCreds == nil {
108+ c.AdditionalAPIKeyCreds = map[string]ProviderCreds{}
109+ }
110+ if p.APIKey == "" && p.OAuth == nil {
111+ delete(c.AdditionalAPIKeyCreds, provider)
112+ if len(c.AdditionalAPIKeyCreds) == 0 {
113+ c.AdditionalAPIKeyCreds = nil
114+ }
115+ return
116+ }
117+ c.AdditionalAPIKeyCreds[provider] = p
118+}
119+
97120 // Store is a mutex-guarded read/write handle to the auth file.
98121 type Store struct {
99122 path string
@@ -134,9 +157,16 @@ func (s *Store) SetAPIKey(provider, key string) error {
134157 if err != nil {
135158 return err
136159 }
160+ if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
161+ cur.APIKey = key
162+ cur.OAuth = nil
163+ c.setAdditional(provider, cur)
164+ return s.saveLocked(c)
165+ }
137166 p := c.get(provider)
138167 if p == nil {
139- return fmt.Errorf("unknown provider %q", provider)
168+ c.setAdditional(provider, ProviderCreds{APIKey: key})
169+ return s.saveLocked(c)
140170 }
141171 p.APIKey = key
142172 if provider != "openai" {
@@ -153,9 +183,16 @@ func (s *Store) SetOAuth(provider string, tok OAuthToken) error {
153183 if err != nil {
154184 return err
155185 }
186+ if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
187+ cur.APIKey = ""
188+ cur.OAuth = &tok
189+ c.setAdditional(provider, cur)
190+ return s.saveLocked(c)
191+ }
156192 p := c.get(provider)
157193 if p == nil {
158- return fmt.Errorf("unknown provider %q", provider)
194+ c.setAdditional(provider, ProviderCreds{OAuth: &tok})
195+ return s.saveLocked(c)
159196 }
160197 if provider != "openai" {
161198 p.APIKey = ""
@@ -172,9 +209,14 @@ func (s *Store) Clear(provider string) error {
172209 if err != nil {
173210 return err
174211 }
212+ if _, ok := c.AdditionalAPIKeyCreds[provider]; ok {
213+ c.setAdditional(provider, ProviderCreds{})
214+ return s.saveLocked(c)
215+ }
175216 p := c.get(provider)
176217 if p == nil {
177- return fmt.Errorf("unknown provider %q", provider)
218+ c.setAdditional(provider, ProviderCreds{})
219+ return s.saveLocked(c)
178220 }
179221 *p = ProviderCreds{}
180222 return s.saveLocked(c)
@@ -188,9 +230,14 @@ func (s *Store) ClearAPIKey(provider string) error {
188230 if err != nil {
189231 return err
190232 }
233+ if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
234+ cur.APIKey = ""
235+ c.setAdditional(provider, cur)
236+ return s.saveLocked(c)
237+ }
191238 p := c.get(provider)
192239 if p == nil {
193- return fmt.Errorf("unknown provider %q", provider)
240+ return nil
194241 }
195242 p.APIKey = ""
196243 return s.saveLocked(c)
@@ -204,9 +251,14 @@ func (s *Store) ClearOAuth(provider string) error {
204251 if err != nil {
205252 return err
206253 }
254+ if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
255+ cur.OAuth = nil
256+ c.setAdditional(provider, cur)
257+ return s.saveLocked(c)
258+ }
207259 p := c.get(provider)
208260 if p == nil {
209- return fmt.Errorf("unknown provider %q", provider)
261+ return nil
210262 }
211263 p.OAuth = nil
212264 return s.saveLocked(c)
@@ -16,11 +16,13 @@ import (
16 16
17 // Credentials is the on-disk schema.17 // Credentials is the on-disk schema.
18 type Credentials struct {18 type Credentials struct {
19- Anthropic ProviderCreds `json:"anthropic,omitempty"`19+ Anthropic ProviderCreds `json:"anthropic,omitempty"`
20- OpenAI ProviderCreds `json:"openai,omitempty"`20+ OpenAI ProviderCreds `json:"openai,omitempty"`
21- Kimi ProviderCreds `json:"kimi,omitempty"`21+ Kimi ProviderCreds `json:"kimi,omitempty"`
22- Google ProviderCreds `json:"google,omitempty"`22+ Google ProviderCreds `json:"google,omitempty"`
23- DeepSeek ProviderCreds `json:"deepseek,omitempty"`23+ DeepSeek ProviderCreds `json:"deepseek,omitempty"`
24+ GithubCopilot ProviderCreds `json:"github_copilot,omitempty"`
25+ AdditionalAPIKeyCreds map[string]ProviderCreds `json:"additional_api_key_creds,omitempty"`
24 }26 }
25 27
26 // ProviderCreds holds credentials for a single provider. Most providers28 // ProviderCreds holds credentials for a single provider. Most providers
@@ -90,10 +92,31 @@ func (c *Credentials) get(provider string) *ProviderCreds {
90 return &c.Google92 return &c.Google
91 case "deepseek":93 case "deepseek":
92 return &c.DeepSeek94 return &c.DeepSeek
95+ case "github-copilot":
96+ return &c.GithubCopilot
97+ }
98+ if c.AdditionalAPIKeyCreds != nil {
99+ if p, ok := c.AdditionalAPIKeyCreds[provider]; ok {
100+ return &p
101+ }
93 }102 }
94 return nil103 return nil
95 }104 }
96 105
106+func (c *Credentials) setAdditional(provider string, p ProviderCreds) {
107+ if c.AdditionalAPIKeyCreds == nil {
108+ c.AdditionalAPIKeyCreds = map[string]ProviderCreds{}
109+ }
110+ if p.APIKey == "" && p.OAuth == nil {
111+ delete(c.AdditionalAPIKeyCreds, provider)
112+ if len(c.AdditionalAPIKeyCreds) == 0 {
113+ c.AdditionalAPIKeyCreds = nil
114+ }
115+ return
116+ }
117+ c.AdditionalAPIKeyCreds[provider] = p
118+}
119+
97 // Store is a mutex-guarded read/write handle to the auth file.120 // Store is a mutex-guarded read/write handle to the auth file.
98 type Store struct {121 type Store struct {
99 path string122 path string
@@ -134,9 +157,16 @@ func (s *Store) SetAPIKey(provider, key string) error {
134 if err != nil {157 if err != nil {
135 return err158 return err
136 }159 }
160+ if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
161+ cur.APIKey = key
162+ cur.OAuth = nil
163+ c.setAdditional(provider, cur)
164+ return s.saveLocked(c)
165+ }
137 p := c.get(provider)166 p := c.get(provider)
138 if p == nil {167 if p == nil {
139- return fmt.Errorf("unknown provider %q", provider)168+ c.setAdditional(provider, ProviderCreds{APIKey: key})
169+ return s.saveLocked(c)
140 }170 }
141 p.APIKey = key171 p.APIKey = key
142 if provider != "openai" {172 if provider != "openai" {
@@ -153,9 +183,16 @@ func (s *Store) SetOAuth(provider string, tok OAuthToken) error {
153 if err != nil {183 if err != nil {
154 return err184 return err
155 }185 }
186+ if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
187+ cur.APIKey = ""
188+ cur.OAuth = &tok
189+ c.setAdditional(provider, cur)
190+ return s.saveLocked(c)
191+ }
156 p := c.get(provider)192 p := c.get(provider)
157 if p == nil {193 if p == nil {
158- return fmt.Errorf("unknown provider %q", provider)194+ c.setAdditional(provider, ProviderCreds{OAuth: &tok})
195+ return s.saveLocked(c)
159 }196 }
160 if provider != "openai" {197 if provider != "openai" {
161 p.APIKey = ""198 p.APIKey = ""
@@ -172,9 +209,14 @@ func (s *Store) Clear(provider string) error {
172 if err != nil {209 if err != nil {
173 return err210 return err
174 }211 }
212+ if _, ok := c.AdditionalAPIKeyCreds[provider]; ok {
213+ c.setAdditional(provider, ProviderCreds{})
214+ return s.saveLocked(c)
215+ }
175 p := c.get(provider)216 p := c.get(provider)
176 if p == nil {217 if p == nil {
177- return fmt.Errorf("unknown provider %q", provider)218+ c.setAdditional(provider, ProviderCreds{})
219+ return s.saveLocked(c)
178 }220 }
179 *p = ProviderCreds{}221 *p = ProviderCreds{}
180 return s.saveLocked(c)222 return s.saveLocked(c)
@@ -188,9 +230,14 @@ func (s *Store) ClearAPIKey(provider string) error {
188 if err != nil {230 if err != nil {
189 return err231 return err
190 }232 }
233+ if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
234+ cur.APIKey = ""
235+ c.setAdditional(provider, cur)
236+ return s.saveLocked(c)
237+ }
191 p := c.get(provider)238 p := c.get(provider)
192 if p == nil {239 if p == nil {
193- return fmt.Errorf("unknown provider %q", provider)240+ return nil
194 }241 }
195 p.APIKey = ""242 p.APIKey = ""
196 return s.saveLocked(c)243 return s.saveLocked(c)
@@ -204,9 +251,14 @@ func (s *Store) ClearOAuth(provider string) error {
204 if err != nil {251 if err != nil {
205 return err252 return err
206 }253 }
254+ if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
255+ cur.OAuth = nil
256+ c.setAdditional(provider, cur)
257+ return s.saveLocked(c)
258+ }
207 p := c.get(provider)259 p := c.get(provider)
208 if p == nil {260 if p == nil {
209- return fmt.Errorf("unknown provider %q", provider)261+ return nil
210 }262 }
211 p.OAuth = nil263 p.OAuth = nil
212 return s.saveLocked(c)264 return s.saveLocked(c)
A internal/auth/store_additional_test.go +33 -0
@@ -0,0 +1,33 @@
1+package auth
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+)
7+
8+func TestStoreAdditionalAPIKeyClear(t *testing.T) {
9+ store := NewStore(filepath.Join(t.TempDir(), "auth.json"))
10+ if err := store.SetAPIKey("groq", "gsk_test"); err != nil {
11+ t.Fatal(err)
12+ }
13+ creds, err := store.Load()
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ if got := creds.Method("groq"); got != "apikey" {
18+ t.Fatalf("method before clear=%q", got)
19+ }
20+ if err := store.Clear("groq"); err != nil {
21+ t.Fatal(err)
22+ }
23+ creds, err = store.Load()
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ if got := creds.Method("groq"); got != "" {
28+ t.Fatalf("method after clear=%q", got)
29+ }
30+ if len(creds.AdditionalAPIKeyCreds) != 0 {
31+ t.Fatalf("additional creds not cleared: %+v", creds.AdditionalAPIKeyCreds)
32+ }
33+}
@@ -0,0 +1,33 @@
1+package auth
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+)
7+
8+func TestStoreAdditionalAPIKeyClear(t *testing.T) {
9+ store := NewStore(filepath.Join(t.TempDir(), "auth.json"))
10+ if err := store.SetAPIKey("groq", "gsk_test"); err != nil {
11+ t.Fatal(err)
12+ }
13+ creds, err := store.Load()
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ if got := creds.Method("groq"); got != "apikey" {
18+ t.Fatalf("method before clear=%q", got)
19+ }
20+ if err := store.Clear("groq"); err != nil {
21+ t.Fatal(err)
22+ }
23+ creds, err = store.Load()
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ if got := creds.Method("groq"); got != "" {
28+ t.Fatalf("method after clear=%q", got)
29+ }
30+ if len(creds.AdditionalAPIKeyCreds) != 0 {
31+ t.Fatalf("additional creds not cleared: %+v", creds.AdditionalAPIKeyCreds)
32+ }
33+}
A internal/provider/labels.go +89 -0
@@ -0,0 +1,89 @@
1+package provider
2+
3+import "strings"
4+
5+// ProviderLabel returns the user-facing label for a provider id.
6+func ProviderLabel(id string) string {
7+ switch id {
8+ case "anthropic":
9+ return "Anthropic (Claude Pro/Max)"
10+ case "openai":
11+ return "OpenAI"
12+ case "openai-codex":
13+ return "OpenAI Codex (ChatGPT Plus/Pro)"
14+ case "openai-responses":
15+ return "OpenAI Responses"
16+ case "kimi":
17+ return "Kimi Code"
18+ case "deepseek":
19+ return "DeepSeek"
20+ case "google":
21+ return "Google (Gemini API key)"
22+ case "github-copilot":
23+ return "GitHub Copilot"
24+ case "moonshotai":
25+ return "Moonshot AI"
26+ case "moonshotai-cn":
27+ return "Moonshot AI CN"
28+ case "groq":
29+ return "Groq"
30+ case "xai":
31+ return "xAI"
32+ case "cerebras":
33+ return "Cerebras"
34+ case "together":
35+ return "Together AI"
36+ case "huggingface":
37+ return "Hugging Face"
38+ case "openrouter":
39+ return "OpenRouter"
40+ case "mistral":
41+ return "Mistral"
42+ case "zai":
43+ return "Z.AI"
44+ case "xiaomi":
45+ return "Xiaomi"
46+ case "xiaomi-token-plan-ams":
47+ return "Xiaomi Token Plan AMS"
48+ case "xiaomi-token-plan-cn":
49+ return "Xiaomi Token Plan CN"
50+ case "xiaomi-token-plan-sgp":
51+ return "Xiaomi Token Plan SGP"
52+ case "minimax":
53+ return "MiniMax"
54+ case "minimax-cn":
55+ return "MiniMax CN"
56+ case "fireworks":
57+ return "Fireworks"
58+ case "vercel-ai-gateway":
59+ return "Vercel AI Gateway"
60+ case "opencode":
61+ return "OpenCode"
62+ case "opencode-go":
63+ return "OpenCode Go"
64+ case "amazon-bedrock":
65+ return "Amazon Bedrock"
66+ case "google-vertex":
67+ return "Google Vertex AI"
68+ case "azure-openai-responses":
69+ return "Azure OpenAI"
70+ case "cloudflare-workers-ai":
71+ return "Cloudflare Workers AI"
72+ case "cloudflare-ai-gateway":
73+ return "Cloudflare AI Gateway"
74+ case "ollama":
75+ return "Ollama"
76+ }
77+ return titleProviderID(id)
78+}
79+
80+func titleProviderID(id string) string {
81+ parts := strings.FieldsFunc(id, func(r rune) bool { return r == '-' || r == '_' })
82+ for i, p := range parts {
83+ if p == "" {
84+ continue
85+ }
86+ parts[i] = strings.ToUpper(p[:1]) + p[1:]
87+ }
88+ return strings.Join(parts, " ")
89+}
@@ -0,0 +1,89 @@
1+package provider
2+
3+import "strings"
4+
5+// ProviderLabel returns the user-facing label for a provider id.
6+func ProviderLabel(id string) string {
7+ switch id {
8+ case "anthropic":
9+ return "Anthropic (Claude Pro/Max)"
10+ case "openai":
11+ return "OpenAI"
12+ case "openai-codex":
13+ return "OpenAI Codex (ChatGPT Plus/Pro)"
14+ case "openai-responses":
15+ return "OpenAI Responses"
16+ case "kimi":
17+ return "Kimi Code"
18+ case "deepseek":
19+ return "DeepSeek"
20+ case "google":
21+ return "Google (Gemini API key)"
22+ case "github-copilot":
23+ return "GitHub Copilot"
24+ case "moonshotai":
25+ return "Moonshot AI"
26+ case "moonshotai-cn":
27+ return "Moonshot AI CN"
28+ case "groq":
29+ return "Groq"
30+ case "xai":
31+ return "xAI"
32+ case "cerebras":
33+ return "Cerebras"
34+ case "together":
35+ return "Together AI"
36+ case "huggingface":
37+ return "Hugging Face"
38+ case "openrouter":
39+ return "OpenRouter"
40+ case "mistral":
41+ return "Mistral"
42+ case "zai":
43+ return "Z.AI"
44+ case "xiaomi":
45+ return "Xiaomi"
46+ case "xiaomi-token-plan-ams":
47+ return "Xiaomi Token Plan AMS"
48+ case "xiaomi-token-plan-cn":
49+ return "Xiaomi Token Plan CN"
50+ case "xiaomi-token-plan-sgp":
51+ return "Xiaomi Token Plan SGP"
52+ case "minimax":
53+ return "MiniMax"
54+ case "minimax-cn":
55+ return "MiniMax CN"
56+ case "fireworks":
57+ return "Fireworks"
58+ case "vercel-ai-gateway":
59+ return "Vercel AI Gateway"
60+ case "opencode":
61+ return "OpenCode"
62+ case "opencode-go":
63+ return "OpenCode Go"
64+ case "amazon-bedrock":
65+ return "Amazon Bedrock"
66+ case "google-vertex":
67+ return "Google Vertex AI"
68+ case "azure-openai-responses":
69+ return "Azure OpenAI"
70+ case "cloudflare-workers-ai":
71+ return "Cloudflare Workers AI"
72+ case "cloudflare-ai-gateway":
73+ return "Cloudflare AI Gateway"
74+ case "ollama":
75+ return "Ollama"
76+ }
77+ return titleProviderID(id)
78+}
79+
80+func titleProviderID(id string) string {
81+ parts := strings.FieldsFunc(id, func(r rune) bool { return r == '-' || r == '_' })
82+ for i, p := range parts {
83+ if p == "" {
84+ continue
85+ }
86+ parts[i] = strings.ToUpper(p[:1]) + p[1:]
87+ }
88+ return strings.Join(parts, " ")
89+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close