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.

`)) func init() { diff --git a/internal/auth/store.go b/internal/auth/store.go index f73e7ca..5cfdebc 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -16,11 +16,13 @@ import ( // Credentials is the on-disk schema. type Credentials struct { - Anthropic ProviderCreds `json:"anthropic,omitempty"` - OpenAI ProviderCreds `json:"openai,omitempty"` - Kimi ProviderCreds `json:"kimi,omitempty"` - Google ProviderCreds `json:"google,omitempty"` - DeepSeek ProviderCreds `json:"deepseek,omitempty"` + Anthropic ProviderCreds `json:"anthropic,omitempty"` + OpenAI ProviderCreds `json:"openai,omitempty"` + Kimi ProviderCreds `json:"kimi,omitempty"` + Google ProviderCreds `json:"google,omitempty"` + DeepSeek ProviderCreds `json:"deepseek,omitempty"` + GithubCopilot ProviderCreds `json:"github_copilot,omitempty"` + AdditionalAPIKeyCreds map[string]ProviderCreds `json:"additional_api_key_creds,omitempty"` } // ProviderCreds holds credentials for a single provider. Most providers @@ -90,10 +92,31 @@ func (c *Credentials) get(provider string) *ProviderCreds { return &c.Google case "deepseek": return &c.DeepSeek + case "github-copilot": + return &c.GithubCopilot + } + if c.AdditionalAPIKeyCreds != nil { + if p, ok := c.AdditionalAPIKeyCreds[provider]; ok { + return &p + } } return nil } +func (c *Credentials) setAdditional(provider string, p ProviderCreds) { + if c.AdditionalAPIKeyCreds == nil { + c.AdditionalAPIKeyCreds = map[string]ProviderCreds{} + } + if p.APIKey == "" && p.OAuth == nil { + delete(c.AdditionalAPIKeyCreds, provider) + if len(c.AdditionalAPIKeyCreds) == 0 { + c.AdditionalAPIKeyCreds = nil + } + return + } + c.AdditionalAPIKeyCreds[provider] = p +} + // Store is a mutex-guarded read/write handle to the auth file. type Store struct { path string @@ -134,9 +157,16 @@ func (s *Store) SetAPIKey(provider, key string) error { if err != nil { return err } + if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok { + cur.APIKey = key + cur.OAuth = nil + c.setAdditional(provider, cur) + return s.saveLocked(c) + } p := c.get(provider) if p == nil { - return fmt.Errorf("unknown provider %q", provider) + c.setAdditional(provider, ProviderCreds{APIKey: key}) + return s.saveLocked(c) } p.APIKey = key if provider != "openai" { @@ -153,9 +183,16 @@ func (s *Store) SetOAuth(provider string, tok OAuthToken) error { if err != nil { return err } + if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok { + cur.APIKey = "" + cur.OAuth = &tok + c.setAdditional(provider, cur) + return s.saveLocked(c) + } p := c.get(provider) if p == nil { - return fmt.Errorf("unknown provider %q", provider) + c.setAdditional(provider, ProviderCreds{OAuth: &tok}) + return s.saveLocked(c) } if provider != "openai" { p.APIKey = "" @@ -172,9 +209,14 @@ func (s *Store) Clear(provider string) error { if err != nil { return err } + if _, ok := c.AdditionalAPIKeyCreds[provider]; ok { + c.setAdditional(provider, ProviderCreds{}) + return s.saveLocked(c) + } p := c.get(provider) if p == nil { - return fmt.Errorf("unknown provider %q", provider) + c.setAdditional(provider, ProviderCreds{}) + return s.saveLocked(c) } *p = ProviderCreds{} return s.saveLocked(c) @@ -188,9 +230,14 @@ func (s *Store) ClearAPIKey(provider string) error { if err != nil { return err } + if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok { + cur.APIKey = "" + c.setAdditional(provider, cur) + return s.saveLocked(c) + } p := c.get(provider) if p == nil { - return fmt.Errorf("unknown provider %q", provider) + return nil } p.APIKey = "" return s.saveLocked(c) @@ -204,9 +251,14 @@ func (s *Store) ClearOAuth(provider string) error { if err != nil { return err } + if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok { + cur.OAuth = nil + c.setAdditional(provider, cur) + return s.saveLocked(c) + } p := c.get(provider) if p == nil { - return fmt.Errorf("unknown provider %q", provider) + return nil } p.OAuth = nil return s.saveLocked(c) diff --git a/internal/auth/store_additional_test.go b/internal/auth/store_additional_test.go new file mode 100644 index 0000000..931bc60 --- /dev/null +++ b/internal/auth/store_additional_test.go @@ -0,0 +1,33 @@ +package auth + +import ( + "path/filepath" + "testing" +) + +func TestStoreAdditionalAPIKeyClear(t *testing.T) { + store := NewStore(filepath.Join(t.TempDir(), "auth.json")) + if err := store.SetAPIKey("groq", "gsk_test"); err != nil { + t.Fatal(err) + } + creds, err := store.Load() + if err != nil { + t.Fatal(err) + } + if got := creds.Method("groq"); got != "apikey" { + t.Fatalf("method before clear=%q", got) + } + if err := store.Clear("groq"); err != nil { + t.Fatal(err) + } + creds, err = store.Load() + if err != nil { + t.Fatal(err) + } + if got := creds.Method("groq"); got != "" { + t.Fatalf("method after clear=%q", got) + } + if len(creds.AdditionalAPIKeyCreds) != 0 { + t.Fatalf("additional creds not cleared: %+v", creds.AdditionalAPIKeyCreds) + } +} diff --git a/internal/provider/labels.go b/internal/provider/labels.go new file mode 100644 index 0000000..b4ceadf --- /dev/null +++ b/internal/provider/labels.go @@ -0,0 +1,89 @@ +package provider + +import "strings" + +// 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 "openai-responses": + return "OpenAI Responses" + case "kimi": + return "Kimi Code" + case "deepseek": + return "DeepSeek" + case "google": + return "Google (Gemini API key)" + case "github-copilot": + return "GitHub Copilot" + case "moonshotai": + return "Moonshot AI" + case "moonshotai-cn": + return "Moonshot AI CN" + case "groq": + return "Groq" + case "xai": + return "xAI" + case "cerebras": + return "Cerebras" + case "together": + return "Together AI" + case "huggingface": + return "Hugging Face" + case "openrouter": + return "OpenRouter" + case "mistral": + return "Mistral" + case "zai": + return "Z.AI" + case "xiaomi": + return "Xiaomi" + case "xiaomi-token-plan-ams": + return "Xiaomi Token Plan AMS" + case "xiaomi-token-plan-cn": + return "Xiaomi Token Plan CN" + case "xiaomi-token-plan-sgp": + return "Xiaomi Token Plan SGP" + case "minimax": + return "MiniMax" + case "minimax-cn": + return "MiniMax CN" + case "fireworks": + return "Fireworks" + case "vercel-ai-gateway": + return "Vercel AI Gateway" + case "opencode": + return "OpenCode" + case "opencode-go": + return "OpenCode Go" + case "amazon-bedrock": + return "Amazon Bedrock" + case "google-vertex": + return "Google Vertex AI" + case "azure-openai-responses": + return "Azure OpenAI" + case "cloudflare-workers-ai": + return "Cloudflare Workers AI" + case "cloudflare-ai-gateway": + return "Cloudflare AI Gateway" + case "ollama": + return "Ollama" + } + return titleProviderID(id) +} + +func titleProviderID(id string) string { + parts := strings.FieldsFunc(id, func(r rune) bool { return r == '-' || r == '_' }) + for i, p := range parts { + if p == "" { + continue + } + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + return strings.Join(parts, " ") +}