mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
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:
parent
689069f11a
commit
3364159201
13 changed files with 678 additions and 68 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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, "")
|
||||
|
|
|
|||
132
internal/auth/github_copilot.go
Normal file
132
internal/auth/github_copilot.go
Normal 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)))
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
33
internal/auth/store_additional_test.go
Normal file
33
internal/auth/store_additional_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
89
internal/provider/labels.go
Normal file
89
internal/provider/labels.go
Normal 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, " ")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue