From 33641592012bb3cfa88ccf39158ba461e8399f99 Mon Sep 17 00:00:00 2001
From: patriceckhart
Date: Sun, 24 May 2026 11:08:09 +0200
Subject: [PATCH] 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.
---
internal/agent/cli.go | 10 +-
internal/agent/config.go | 17 ++++
internal/agent/modes/interactive.go | 37 +++++--
internal/agent/modes/login_dialog.go | 126 ++++++++++++++++-------
internal/agent/modes/model_dialog.go | 17 ++++
internal/agent/modes/slash_suggest.go | 54 +++++++++-
internal/auth/github_copilot.go | 132 +++++++++++++++++++++++++
internal/auth/manager.go | 50 +++++++++-
internal/auth/probe.go | 78 +++++++++++++++
internal/auth/server.go | 31 ++++--
internal/auth/store.go | 72 ++++++++++++--
internal/auth/store_additional_test.go | 33 +++++++
internal/provider/labels.go | 89 +++++++++++++++++
13 files changed, 678 insertions(+), 68 deletions(-)
create mode 100644 internal/auth/github_copilot.go
create mode 100644 internal/auth/store_additional_test.go
create mode 100644 internal/provider/labels.go
diff --git a/internal/agent/cli.go b/internal/agent/cli.go
index a2b7368..e891d33 100644
--- a/internal/agent/cli.go
+++ b/internal/agent/cli.go
@@ -910,13 +910,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
BuildAgentForRescue: buildAgentForRescue,
LoggedInProviders: func() []string {
var out []string
- for _, p := range []string{"anthropic", "openai", "openai-codex", "kimi", "deepseek", "google"} {
- if _, _, err := ResolveCredential(p, ""); err == nil {
+ seen := map[string]bool{}
+ for _, p := range knownProviders {
+ if _, _, err := ResolveCredential(p, ""); err == nil && !seen[p] {
out = append(out, p)
+ seen[p] = true
}
}
// Ollama models are always available (no auth needed).
- out = append(out, "ollama")
+ if !seen["ollama"] {
+ out = append(out, "ollama")
+ }
return out
},
LoadSession: loadSession,
diff --git a/internal/agent/config.go b/internal/agent/config.go
index ee0c207..1fc0f66 100644
--- a/internal/agent/config.go
+++ b/internal/agent/config.go
@@ -256,6 +256,9 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
if v := os.Getenv("COPILOT_GITHUB_TOKEN"); v != "" {
return v, "apikey", "", nil
}
+ if v := os.Getenv("GITHUB_COPILOT_TOKEN"); v != "" {
+ return v, "apikey", "", nil
+ }
case "cloudflare-workers-ai", "cloudflare-ai-gateway":
if v := os.Getenv("CLOUDFLARE_API_KEY"); v != "" {
return v, "apikey", "", nil
@@ -282,6 +285,9 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
if err != nil {
return "", "", "", err
}
+ if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok && pc.APIKey != "" {
+ return pc.APIKey, "apikey", "", nil
+ }
switch provider {
case "anthropic":
if c.Anthropic.APIKey != "" {
@@ -326,6 +332,13 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
if c.Google.APIKey != "" {
return c.Google.APIKey, "apikey", "", nil
}
+ case "github-copilot":
+ if c.GithubCopilot.APIKey != "" {
+ return c.GithubCopilot.APIKey, "apikey", "", nil
+ }
+ if c.GithubCopilot.OAuth != nil && c.GithubCopilot.OAuth.AccessToken != "" {
+ return c.GithubCopilot.OAuth.AccessToken, "oauth", "", nil
+ }
}
return "", "", "", fmt.Errorf("no credential for %s", provider)
}
@@ -405,6 +418,10 @@ func loadOAuthToken(providerName string) *auth.OAuthToken {
return nil
}
return loadKimiCodeCLIToken()
+ case "github-copilot":
+ if c.GithubCopilot.OAuth != nil {
+ return c.GithubCopilot.OAuth
+ }
}
return nil
}
diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go
index b7bbb5e..19081e5 100644
--- a/internal/agent/modes/interactive.go
+++ b/internal/agent/modes/interactive.go
@@ -1926,6 +1926,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
case tui.KeyDown:
i.suggest.Down()
return false
+ case tui.KeyPageUp:
+ i.suggest.PageUp()
+ return false
+ case tui.KeyPageDown:
+ i.suggest.PageDown()
+ return false
case tui.KeyTab:
if name := i.suggest.Selection(i.ed.Value()); name != "" {
i.ed.SetValue(name)
@@ -2963,7 +2969,7 @@ func (i *Interactive) openLogoutDialog() {
}
var items []logoutItem
- for _, p := range []string{"anthropic", "kimi", "google"} {
+ for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
if creds.Has(p) {
method := creds.Method(p)
if method == "oauth" {
@@ -2982,6 +2988,11 @@ func (i *Interactive) openLogoutDialog() {
if creds.OpenAI.OAuth != nil {
items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
}
+ for p, c := range creds.AdditionalAPIKeyCreds {
+ if c.APIKey != "" {
+ items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
+ }
+ }
if len(items) == 0 {
i.mu.Lock()
i.statusOK = "no credentials stored; already logged out"
@@ -3003,7 +3014,7 @@ func (i *Interactive) openLogoutDialog() {
// is torn down so the user is forced through /login before their next
// prompt.
//
-// target: "anthropic" | "openai" | "kimi" | "all"
+// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
func (i *Interactive) doLogout(target string) {
if i.cfg.AuthManager == nil {
i.mu.Lock()
@@ -3022,14 +3033,24 @@ func (i *Interactive) doLogout(target string) {
var providers []string
switch target {
case "", "all":
- providers = []string{"anthropic", "openai", "openai-codex", "kimi", "google"}
- case "anthropic", "openai", "openai-codex", "kimi", "google":
+ providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
+ case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
providers = []string{target}
default:
- i.mu.Lock()
- i.statusErr = "unknown provider: " + target + " (use anthropic, openai, openai-codex, kimi, google, or all)"
- i.mu.Unlock()
- return
+ known := false
+ for _, p := range auth.APIKeyProviders() {
+ if target == p {
+ known = true
+ break
+ }
+ }
+ if !known {
+ i.mu.Lock()
+ i.statusErr = "unknown provider: " + target
+ i.mu.Unlock()
+ return
+ }
+ providers = []string{target}
}
var errs []string
diff --git a/internal/agent/modes/login_dialog.go b/internal/agent/modes/login_dialog.go
index 4e2137c..cc7c792 100644
--- a/internal/agent/modes/login_dialog.go
+++ b/internal/agent/modes/login_dialog.go
@@ -5,6 +5,7 @@ import (
"path/filepath"
"github.com/patriceckhart/zot/internal/auth"
+ "github.com/patriceckhart/zot/internal/provider"
"github.com/patriceckhart/zot/internal/tui"
)
@@ -23,6 +24,8 @@ const (
loginStepDone // success or error, waiting for key to dismiss
)
+const loginProviderPageSize = 8
+
// loginDialog is a tiny inline dialog rendered above the editor while
// the user picks their login method and provider.
type loginDialog struct {
@@ -65,7 +68,13 @@ func (d *loginDialog) Open(zotHome string) {
d.success = false
d.url = ""
d.cursor = 0
- d.status = map[string]string{"anthropic": "", "openai": "", "openai-codex": "", "kimi": "", "deepseek": "", "google": ""}
+ d.status = map[string]string{}
+ for _, p := range providersForMethod("apikey") {
+ d.status[p] = ""
+ }
+ for _, p := range providersForMethod("oauth") {
+ d.status[p] = ""
+ }
// Best-effort: if the auth file can't be read, treat every
// provider as not-logged-in. The status line just won't show
// anything useful in that case, which is fine — the user
@@ -84,6 +93,10 @@ func (d *loginDialog) Open(zotHome string) {
d.status["kimi"] = creds.Method("kimi")
d.status["deepseek"] = creds.Method("deepseek")
d.status["google"] = creds.Method("google")
+ d.status["github-copilot"] = creds.Method("github-copilot")
+ for p := range creds.AdditionalAPIKeyCreds {
+ d.status[p] = creds.Method(p)
+ }
}
}
@@ -103,7 +116,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
case loginStepMethod:
opts := []string{
"api key",
- "subscription (claude pro/max - chatgpt plus/pro - kimi code)",
+ "subscription (claude pro/max - chatgpt plus/pro - chatgpt codex - kimi code - github copilot)",
}
lines = append(lines, frameHeader(th, "login", width))
for _, l := range d.renderStatusLines(th) {
@@ -125,25 +138,22 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
for _, l := range d.renderStatusLines(th) {
lines = append(lines, l)
}
- lines = append(lines, th.FG256(th.Muted, "choose provider:"))
- for i, o := range opts {
- // Annotate each provider with its current login
- // state so the user can see at a glance which will
- // be replaced if they pick it.
- tag := ""
- switch d.status[o] {
- case "apikey":
- tag = " (api key)"
- case "oauth":
- tag = " (subscription)"
- }
- plain := " " + providerLabel(o) + tag
+ lines = append(lines, th.FG256(th.Muted, "pick a provider (↑/↓, enter, esc to cancel)"))
+ start, end := d.providerPage(len(opts))
+ for i := start; i < end; i++ {
+ o := opts[i]
+ tag := providerPickerTag(d.method, d.status[o])
+ label := " " + providerLabel(o)
+ plain := label + tag
if i == d.cursor {
lines = append(lines, th.PadHighlight(plain, width))
} else {
- lines = append(lines, th.FG256(th.Muted, plain))
+ lines = append(lines, th.FG256(th.Muted, label)+th.FG256(th.Accent, tag))
}
}
+ if len(opts) > loginProviderPageSize {
+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))
+ }
lines = append(lines, frameRule(th, width))
case loginStepWaiting:
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 {
// subscription product at all).
func providersForMethod(method string) []string {
if method == "oauth" {
- return []string{"anthropic", "openai-codex", "kimi"}
+ return []string{"anthropic", "openai-codex", "kimi", "github-copilot"}
}
- return []string{"anthropic", "openai", "kimi", "deepseek", "google"}
+ return auth.APIKeyProviders()
}
// providerLabel returns the user-facing label for a provider id.
-func providerLabel(id string) string {
- switch id {
- case "anthropic":
- return "Anthropic (Claude Pro/Max)"
- case "openai":
- return "OpenAI"
- case "openai-codex":
- return "OpenAI Codex (ChatGPT Plus/Pro)"
- case "kimi":
- return "Kimi Code"
- case "deepseek":
- return "DeepSeek"
- case "google":
- return "Google (Gemini API key)"
+func providerLabel(id string) string { return provider.ProviderLabel(id) }
+
+func providerPickerTag(method, status string) string {
+ switch method {
+ case "apikey":
+ // In the API-key picker, only call out an existing subscription so
+ // users know choosing this provider will add/replace API-key auth
+ // while subscription auth is still configured. Unconfigured rows do
+ // not need a redundant "api key" suffix.
+ if status == "oauth" {
+ return " (subscription configured)"
+ }
+ case "oauth":
+ // In the subscription picker, only call out an existing API key.
+ if status == "apikey" {
+ return " (api key configured)"
+ }
}
- return id
+ return ""
+}
+
+func (d *loginDialog) providerPage(total int) (start, end int) {
+ if total <= loginProviderPageSize {
+ return 0, total
+ }
+ if d.cursor < 0 {
+ d.cursor = 0
+ }
+ if d.cursor >= total {
+ d.cursor = total - 1
+ }
+ start = (d.cursor / loginProviderPageSize) * loginProviderPageSize
+ end = start + loginProviderPageSize
+ if end > total {
+ end = total
+ }
+ return start, end
}
// renderStatusLines returns an overview of the current login
@@ -253,7 +284,8 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
kimi := d.status["kimi"]
ds := d.status["deepseek"]
goog := d.status["google"]
- if anth == "" && op == "" && codex == "" && kimi == "" && ds == "" && goog == "" {
+ gh := d.status["github-copilot"]
+ if anth == "" && op == "" && codex == "" && kimi == "" && ds == "" && goog == "" && gh == "" {
return nil
}
row := func(id, method string) string {
@@ -272,15 +304,26 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
}
return " " + mark + " " + body
}
- return []string{
+ out := []string{
row("anthropic", anth),
row("openai", op),
row("openai-codex", codex),
row("kimi", kimi),
row("deepseek", ds),
row("google", goog),
- "",
+ row("github-copilot", gh),
}
+ for _, p := range providersForMethod("apikey") {
+ switch p {
+ case "anthropic", "openai", "openai-codex", "kimi", "deepseek", "google", "github-copilot":
+ continue
+ }
+ if method := d.status[p]; method != "" {
+ out = append(out, row(p, method))
+ }
+ }
+ out = append(out, "")
+ return out
}
// Key is the result of handling a key press.
@@ -348,6 +391,17 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
if d.cursor < len(providers)-1 {
d.cursor++
}
+ case tui.KeyPageUp:
+ d.cursor -= loginProviderPageSize
+ if d.cursor < 0 {
+ d.cursor = 0
+ }
+ case tui.KeyPageDown:
+ providers := providersForMethod(d.method)
+ d.cursor += loginProviderPageSize
+ if d.cursor >= len(providers) {
+ d.cursor = len(providers) - 1
+ }
case tui.KeyEsc:
d.Close()
return loginDialogAction{Close: true}
diff --git a/internal/agent/modes/model_dialog.go b/internal/agent/modes/model_dialog.go
index 866aff9..6dea456 100644
--- a/internal/agent/modes/model_dialog.go
+++ b/internal/agent/modes/model_dialog.go
@@ -208,6 +208,9 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
if end < len(d.view) {
lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
}
+ if len(d.view) > visible {
+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(d.view))))
+ }
lines = append(lines, frameRule(th, width))
return lines
@@ -255,6 +258,20 @@ func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
if d.cursor < len(d.view)-1 {
d.cursor++
}
+ case tui.KeyPageUp:
+ if len(d.view) > 0 {
+ d.cursor -= 14
+ if d.cursor < 0 {
+ d.cursor = 0
+ }
+ }
+ case tui.KeyPageDown:
+ if len(d.view) > 0 {
+ d.cursor += 14
+ if d.cursor >= len(d.view) {
+ d.cursor = len(d.view) - 1
+ }
+ }
case tui.KeyBackspace:
if len(d.query) > 0 {
// Drop one rune from the query.
diff --git a/internal/agent/modes/slash_suggest.go b/internal/agent/modes/slash_suggest.go
index 16b10e6..3cd4770 100644
--- a/internal/agent/modes/slash_suggest.go
+++ b/internal/agent/modes/slash_suggest.go
@@ -1,6 +1,7 @@
package modes
import (
+ "fmt"
"sort"
"strings"
@@ -54,6 +55,8 @@ var slashCatalog = []slashCommand{
// slashSuggester renders the popup that appears when the editor starts
// with "/". It does not own any input state — the editor drives.
+const slashSuggestPageSize = 8
+
type slashSuggester struct {
cursor int
@@ -277,6 +280,32 @@ func (s *slashSuggester) Down() {
s.skipHeader(+1)
}
+func (s *slashSuggester) PageUp() {
+ if len(s.lastMatches) == 0 {
+ return
+ }
+ s.cursor -= slashSuggestPageSize
+ if s.cursor < 0 {
+ s.cursor = 0
+ }
+ if s.lastMatches[s.cursor].Header {
+ s.skipHeader(+1)
+ }
+}
+
+func (s *slashSuggester) PageDown() {
+ if len(s.lastMatches) == 0 {
+ return
+ }
+ s.cursor += slashSuggestPageSize
+ if s.cursor >= len(s.lastMatches) {
+ s.cursor = len(s.lastMatches) - 1
+ }
+ if s.lastMatches[s.cursor].Header {
+ s.skipHeader(-1)
+ }
+}
+
// skipHeader moves the cursor by step, then keeps moving in the same
// direction across header rows until it lands on a real command (or
// hits the edge, in which case it bounces back to the nearest real
@@ -341,6 +370,24 @@ func (s *slashSuggester) Selection(input string) string {
return m[s.cursor].Name
}
+func (s *slashSuggester) page(total int) (start, end int) {
+ if total <= slashSuggestPageSize {
+ return 0, total
+ }
+ if s.cursor < 0 {
+ s.cursor = 0
+ }
+ if s.cursor >= total {
+ s.cursor = total - 1
+ }
+ start = (s.cursor / slashSuggestPageSize) * slashSuggestPageSize
+ end = start + slashSuggestPageSize
+ if end > total {
+ end = total
+ }
+ return start, end
+}
+
// Render returns the popup lines or nil.
func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string {
m := s.matches(input)
@@ -371,8 +418,10 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
nameWidth = n
}
}
+ start, end := s.page(len(m))
var lines []string
- for i, c := range m {
+ for i := start; i < end; i++ {
+ c := m[i]
if c.Header {
// Breathing room around group dividers — a blank row
// 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
lines = append(lines, th.FG256(th.Muted, plain))
}
}
+ if len(m) > slashSuggestPageSize {
+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", s.cursor+1, len(m))))
+ }
// Blank row before the hint visually detaches it from the
// command list and groups it with its trailing blank.
lines = append(lines, "")
diff --git a/internal/auth/github_copilot.go b/internal/auth/github_copilot.go
new file mode 100644
index 0000000..7902a87
--- /dev/null
+++ b/internal/auth/github_copilot.go
@@ -0,0 +1,132 @@
+package auth
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const githubCopilotClientID = "Iv1.b507a08c87ecfe98"
+
+// GitHubCopilotDeviceAuthorization is GitHub's OAuth 2 device-code
+// response used for Copilot subscription login.
+type GitHubCopilotDeviceAuthorization struct {
+ DeviceCode string `json:"device_code"`
+ UserCode string `json:"user_code"`
+ VerificationURI string `json:"verification_uri"`
+ ExpiresIn int `json:"expires_in"`
+ Interval int `json:"interval"`
+}
+
+// RequestGitHubCopilotDeviceAuthorization starts GitHub Copilot's
+// device-code login. The resulting GitHub access token is later traded
+// for short-lived Copilot inference tokens by the provider client.
+func RequestGitHubCopilotDeviceAuthorization(ctx context.Context) (GitHubCopilotDeviceAuthorization, error) {
+ form := url.Values{}
+ form.Set("client_id", githubCopilotClientID)
+ form.Set("scope", "read:user")
+ req, err := http.NewRequestWithContext(ctx, "POST", "https://github.com/login/device/code", bytes.NewBufferString(form.Encode()))
+ if err != nil {
+ return GitHubCopilotDeviceAuthorization{}, err
+ }
+ req.Header.Set("content-type", "application/x-www-form-urlencoded")
+ req.Header.Set("accept", "application/json")
+ req.Header.Set("user-agent", "GitHubCopilotChat/0.35.0")
+
+ resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
+ if err != nil {
+ return GitHubCopilotDeviceAuthorization{}, fmt.Errorf("github copilot device authorization: %w", err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return GitHubCopilotDeviceAuthorization{}, fmt.Errorf("github copilot device authorization http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+ var out GitHubCopilotDeviceAuthorization
+ if err := json.Unmarshal(body, &out); err != nil {
+ return out, fmt.Errorf("parse github copilot device authorization: %w", err)
+ }
+ if out.Interval <= 0 {
+ out.Interval = 5
+ }
+ return out, nil
+}
+
+// PollGitHubCopilotDeviceToken polls until GitHub's browser/device-code
+// login completes and returns the GitHub access token.
+func PollGitHubCopilotDeviceToken(ctx context.Context, auth GitHubCopilotDeviceAuthorization) (*OAuthToken, error) {
+ interval := time.Duration(auth.Interval) * time.Second
+ if interval <= 0 {
+ interval = 5 * time.Second
+ }
+ deadline := time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second)
+ for {
+ if auth.ExpiresIn > 0 && time.Now().After(deadline) {
+ return nil, fmt.Errorf("github copilot device login expired")
+ }
+ tok, retry, err := pollGitHubCopilotDeviceTokenOnce(ctx, auth.DeviceCode, interval)
+ if err != nil {
+ return nil, err
+ }
+ if tok != nil {
+ return tok, nil
+ }
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(retry):
+ }
+ }
+}
+
+func pollGitHubCopilotDeviceTokenOnce(ctx context.Context, deviceCode string, interval time.Duration) (*OAuthToken, time.Duration, error) {
+ form := url.Values{}
+ form.Set("client_id", githubCopilotClientID)
+ form.Set("device_code", deviceCode)
+ form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
+ req, err := http.NewRequestWithContext(ctx, "POST", "https://github.com/login/oauth/access_token", bytes.NewBufferString(form.Encode()))
+ if err != nil {
+ return nil, 0, err
+ }
+ req.Header.Set("content-type", "application/x-www-form-urlencoded")
+ req.Header.Set("accept", "application/json")
+ req.Header.Set("user-agent", "GitHubCopilotChat/0.35.0")
+ resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
+ if err != nil {
+ return nil, 0, fmt.Errorf("github copilot token poll: %w", err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ var raw struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ Scope string `json:"scope"`
+ Error string `json:"error"`
+ ErrorDescription string `json:"error_description"`
+ }
+ _ = json.Unmarshal(body, &raw)
+ if resp.StatusCode == http.StatusOK && raw.AccessToken != "" {
+ return &OAuthToken{
+ AccessToken: raw.AccessToken,
+ TokenType: raw.TokenType,
+ Scope: raw.Scope,
+ ClientID: githubCopilotClientID,
+ }, 0, nil
+ }
+ if raw.Error == "authorization_pending" || resp.StatusCode == http.StatusBadRequest {
+ return nil, interval, nil
+ }
+ if raw.Error == "slow_down" {
+ return nil, interval + 5*time.Second, nil
+ }
+ if raw.Error != "" {
+ return nil, 0, fmt.Errorf("github copilot token poll: %s: %s", raw.Error, raw.ErrorDescription)
+ }
+ return nil, 0, fmt.Errorf("github copilot token poll http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+}
diff --git a/internal/auth/manager.go b/internal/auth/manager.go
index 4b61302..e592fe2 100644
--- a/internal/auth/manager.go
+++ b/internal/auth/manager.go
@@ -81,7 +81,7 @@ func (m *Manager) Close() {
// StartAPIKey launches the API-key login flow.
func (m *Manager) StartAPIKey(provider string) (string, error) {
if !isKnownAPIKeyProvider(provider) {
- return "", fmt.Errorf("provider must be anthropic, openai, kimi, deepseek, or google")
+ return "", fmt.Errorf(apiKeyProviderMessage())
}
if err := m.ensureKeyServer(); err != nil {
return "", err
@@ -135,6 +135,9 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
if provider == "kimi" {
return m.StartKimiDeviceOAuth()
}
+ if provider == "github-copilot" {
+ return m.StartGitHubCopilotDeviceOAuth()
+ }
storeProvider := provider
var op OAuthProvider
switch provider {
@@ -148,7 +151,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
case "deepseek":
return "", fmt.Errorf("deepseek login is api-key only; use api key login")
default:
- return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, deepseek, or google")
+ return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, github-copilot, deepseek, or google")
}
m.mu.Lock()
@@ -254,6 +257,44 @@ func (m *Manager) StartKimiDeviceOAuth() (string, error) {
return url, nil
}
+// StartGitHubCopilotDeviceOAuth starts GitHub Copilot's device-code subscription login.
+func (m *Manager) StartGitHubCopilotDeviceOAuth() (string, error) {
+ ctx, cancel := context.WithCancel(context.Background())
+ m.mu.Lock()
+ if m.oauthCancel != nil {
+ m.oauthCancel()
+ }
+ m.oauthCtx = ctx
+ m.oauthCancel = cancel
+ m.mu.Unlock()
+
+ dev, err := RequestGitHubCopilotDeviceAuthorization(ctx)
+ if err != nil {
+ return "", err
+ }
+ loginURL := dev.VerificationURI
+ if dev.UserCode != "" {
+ loginURL += "?user_code=" + url.QueryEscape(dev.UserCode)
+ }
+ go m.maybeOpen(loginURL)
+ m.emit(Event{Kind: "started", Provider: "github-copilot", Method: "oauth", URL: loginURL})
+ go func() {
+ tok, err := PollGitHubCopilotDeviceToken(ctx, dev)
+ if err != nil {
+ if ctx.Err() == nil {
+ m.emit(Event{Kind: "error", Provider: "github-copilot", Method: "oauth", Message: err.Error()})
+ }
+ return
+ }
+ if err := m.store.SetOAuth("github-copilot", *tok); err != nil {
+ m.emit(Event{Kind: "error", Provider: "github-copilot", Method: "oauth", Message: err.Error()})
+ return
+ }
+ m.emit(Event{Kind: "success", Provider: "github-copilot", Method: "oauth"})
+ }()
+ return loginURL, nil
+}
+
// StartManualOAuth begins an OAuth flow but does NOT start a local
// callback server or open a browser. The returned URL is shown to the
// user so they can complete the authorization on another device; the
@@ -262,6 +303,9 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
if provider == "kimi" {
return m.StartKimiDeviceOAuth()
}
+ if provider == "github-copilot" {
+ return m.StartGitHubCopilotDeviceOAuth()
+ }
storeProvider := provider
var op OAuthProvider
switch provider {
@@ -275,7 +319,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
case "deepseek":
return "", fmt.Errorf("deepseek login is api-key only; use api key login")
default:
- return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, deepseek, or google")
+ return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, github-copilot, deepseek, or google")
}
pkce, err := NewPKCE()
diff --git a/internal/auth/probe.go b/internal/auth/probe.go
index 86a4446..53c2b1c 100644
--- a/internal/auth/probe.go
+++ b/internal/auth/probe.go
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"net/http"
+ "os"
+ "strings"
"time"
)
@@ -115,10 +117,86 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
return err
}
req.Header.Set("authorization", "Bearer "+key)
+ case "xiaomi":
+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.xiaomimimo.com/v1/models", nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("authorization", "Bearer "+key)
+ case "xiaomi-token-plan-ams":
+ req, err = http.NewRequestWithContext(ctx, "GET", "https://token-plan-ams.xiaomimimo.com/v1/models", nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("authorization", "Bearer "+key)
+ case "xiaomi-token-plan-cn":
+ req, err = http.NewRequestWithContext(ctx, "GET", "https://token-plan-cn.xiaomimimo.com/v1/models", nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("authorization", "Bearer "+key)
+ case "xiaomi-token-plan-sgp":
+ req, err = http.NewRequestWithContext(ctx, "GET", "https://token-plan-sgp.xiaomimimo.com/v1/models", nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("authorization", "Bearer "+key)
+ case "minimax":
+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.minimax.io/v1/models", nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("authorization", "Bearer "+key)
+ case "minimax-cn":
+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.minimaxi.com/v1/models", nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("authorization", "Bearer "+key)
+ case "fireworks":
+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.fireworks.ai/inference/v1/models", nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("authorization", "Bearer "+key)
+ case "vercel-ai-gateway":
+ req, err = http.NewRequestWithContext(ctx, "GET", "https://ai-gateway.vercel.sh/v1/models", nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("authorization", "Bearer "+key)
+ case "opencode":
+ req, err = http.NewRequestWithContext(ctx, "GET", "https://opencode.ai/zen/v1/models", nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("authorization", "Bearer "+key)
+ case "opencode-go":
+ req, err = http.NewRequestWithContext(ctx, "GET", "https://opencode.ai/zen/go/v1/models", nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("authorization", "Bearer "+key)
+ case "azure-openai-responses":
+ return nil
+ case "amazon-bedrock":
+ return nil
+ case "google-vertex":
+ return nil
+ case "cloudflare-workers-ai", "cloudflare-ai-gateway":
+ return nil
+ case "github-copilot":
+ return nil
default:
return fmt.Errorf("unknown provider %q", provider)
}
+ if strings.Contains(req.URL.String(), "{CLOUDFLARE_ACCOUNT_ID}") {
+ if acct := os.Getenv("CLOUDFLARE_ACCOUNT_ID"); acct != "" {
+ u := strings.ReplaceAll(req.URL.String(), "{CLOUDFLARE_ACCOUNT_ID}", acct)
+ req.URL, _ = req.URL.Parse(u)
+ }
+ }
resp, err := c.Do(req)
if err != nil {
return fmt.Errorf("probe %s: %w", provider, err)
diff --git a/internal/auth/server.go b/internal/auth/server.go
index feb481d..e3aff81 100644
--- a/internal/auth/server.go
+++ b/internal/auth/server.go
@@ -96,17 +96,34 @@ func (s *Server) Shutdown(ctx context.Context) error {
}
// isKnownAPIKeyProvider reports whether the given provider supports
-// API-key login through the loopback flow. Kept centralized so adding a
-// provider only touches one place. OAuth-only paths are handled
+// API-key login through the loopback flow. OAuth-only paths are handled
// elsewhere (manager.StartOAuth).
func isKnownAPIKeyProvider(p string) bool {
- switch p {
- case "anthropic", "openai", "kimi", "google", "deepseek":
- return true
+ for _, provider := range APIKeyProviders() {
+ if p == provider {
+ return true
+ }
}
return false
}
+func apiKeyProviderMessage() string {
+ return "provider must be one of: " + strings.Join(APIKeyProviders(), ", ")
+}
+
+// APIKeyProviders is the ordered list shown by /login -> api key.
+func APIKeyProviders() []string {
+ return []string{
+ "anthropic", "openai", "kimi", "deepseek", "google",
+ "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together",
+ "huggingface", "openrouter", "mistral", "zai",
+ "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
+ "minimax", "minimax-cn", "fireworks", "vercel-ai-gateway",
+ "opencode", "opencode-go", "amazon-bedrock", "google-vertex", "azure-openai-responses",
+ "github-copilot", "cloudflare-workers-ai", "cloudflare-ai-gateway",
+ }
+}
+
// ---- handlers ----
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -124,7 +141,7 @@ func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
provider := r.URL.Query().Get("provider")
if !isKnownAPIKeyProvider(provider) {
- http.Error(w, "provider must be anthropic, openai, kimi, deepseek, or google", http.StatusBadRequest)
+ http.Error(w, apiKeyProviderMessage(), http.StatusBadRequest)
return
}
tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider})
@@ -244,7 +261,7 @@ var tpl = template.Must(template.New("index").Parse(`google gemini api key →
-for a subscription login (claude pro/max - chatgpt plus/pro - kimi code), close this tab and run /login inside zot.
+for a subscription login (claude pro/max - chatgpt plus/pro - kimi code - github copilot), close this tab and run /login inside zot.