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.
This commit is contained in:
patriceckhart 2026-05-24 11:08:09 +02:00
parent 689069f11a
commit 3364159201
13 changed files with 678 additions and 68 deletions

View file

@ -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,

View file

@ -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
}

View file

@ -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

View file

@ -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}

View file

@ -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.

View file

@ -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, "")

View file

@ -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)))
}

View file

@ -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()

View file

@ -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)

View file

@ -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(`<!doctype html><html lang="
<a href="/apikey?provider=google">google gemini api key </a>
</p>
<hr class="rule">
<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>
<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>
</body></html>`))
func init() {

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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, " ")
}