diff --git a/cmd/vix/main.go b/cmd/vix/main.go index 0eb3ff9..20e026d 100644 --- a/cmd/vix/main.go +++ b/cmd/vix/main.go @@ -165,19 +165,27 @@ func main() { // for the selected model is surfaced as an error in the UI by the daemon. // Users must set their provider's env var (ANTHROPIC_API_KEY / // CLAUDE_CODE_OAUTH_TOKEN / OPENAI_API_KEY / OPENROUTER_API_KEY / - // MINIMAX_API_KEY / MIMO_API_KEY) themselves. + // MINIMAX_API_KEY / MIMO_API_KEY) themselves, or have an OAuth login + // for GitHub Copilot, OpenAI Codex, or Anthropic OAuth. var apiKey string apiKey, _ = config.ResolveProviderKey("anthropic") // includes CLAUDE_CODE_OAUTH_TOKEN fallback hasNonAnthropicKey := func() bool { + // Check static API keys for non-Anthropic providers. for _, p := range []string{"openai", "openrouter", "minimax", "mimo"} { if k, _ := config.ResolveProviderKey(p); k != "" { return true } } + // Check OAuth-stored credentials (e.g. GitHub Copilot). + for _, loginID := range []string{"github-copilot", "openai-codex", "anthropic"} { + if config.HasOAuthLogin(loginID) { + return true + } + } return false } if apiKey == "" && !hasNonAnthropicKey() && *prompt != "" { - fmt.Fprintf(os.Stderr, "Error: no API key found. Set ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, OPENAI_API_KEY, OPENROUTER_API_KEY, MINIMAX_API_KEY, or MIMO_API_KEY.\n") + fmt.Fprintf(os.Stderr, "Error: no API key found. Set ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, OPENAI_API_KEY, OPENROUTER_API_KEY, MINIMAX_API_KEY, or MIMO_API_KEY, or log in with GitHub Copilot / ChatGPT in the TUI.\n") os.Exit(1) } diff --git a/internal/auth/copilot.go b/internal/auth/copilot.go new file mode 100644 index 0000000..1413854 --- /dev/null +++ b/internal/auth/copilot.go @@ -0,0 +1,267 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" +) + +const ( + githubCopilotLoginID = "github-copilot" + + githubDeviceCodeURL = "https://github.com/login/device/code" + githubTokenURL = "https://github.com/login/oauth/access_token" + githubDeviceVerifyUI = "https://github.com/login/device" + githubClientID = "Iv1.b507a08c87ecfe98" + githubScope = "read:user" + + copilotTokenURL = "https://api.github.com/copilot_internal/v2/token" + + copilotDeviceTimeoutSeconds = 900 +) + +type githubCopilotProvider struct { + clientID string + deviceUserCodeURL string + deviceTokenURL string + deviceVerificationURI string + deviceTimeoutSeconds int + + copilotTokenURL string +} + +func newGithubCopilotProvider() *githubCopilotProvider { + p := &githubCopilotProvider{ + clientID: githubClientID, + deviceUserCodeURL: githubDeviceCodeURL, + deviceTokenURL: githubTokenURL, + deviceVerificationURI: githubDeviceVerifyUI, + deviceTimeoutSeconds: copilotDeviceTimeoutSeconds, + copilotTokenURL: copilotTokenURL, + } + if s, ok := loginSpec(githubCopilotLoginID); ok { + if s.ClientID != "" { + p.clientID = decodeClientID(s.ClientID) + } + if d := s.Device; d != nil { + if d.UserCodeURL != "" { + p.deviceUserCodeURL = d.UserCodeURL + } + if d.TokenURL != "" { + p.deviceTokenURL = d.TokenURL + } + if d.VerificationURI != "" { + p.deviceVerificationURI = d.VerificationURI + } + if d.TimeoutSeconds != 0 { + p.deviceTimeoutSeconds = d.TimeoutSeconds + } + } + } + return p +} + +func (p *githubCopilotProvider) ID() string { return githubCopilotLoginID } +func (p *githubCopilotProvider) Name() string { return "GitHub Copilot" } +func (p *githubCopilotProvider) UsesCallbackServer() bool { return false } +func (p *githubCopilotProvider) APIKey(c Credentials) string { return c.Access } + +func (p *githubCopilotProvider) Login(ctx context.Context, cb LoginCallbacks) (Credentials, error) { + device, err := p.startDeviceAuth(ctx) + if err != nil { + return Credentials{}, err + } + + if cb.OnDeviceCode != nil { + cb.OnDeviceCode(DeviceCodeInfo{ + UserCode: device.userCode, + VerificationURI: p.deviceVerificationURI, + IntervalSeconds: device.intervalSeconds, + ExpiresInSeconds: p.deviceTimeoutSeconds, + }) + } + lg().Info("github-copilot: device code issued", "user_code", device.userCode, "verification_uri", p.deviceVerificationURI, "interval_s", device.intervalSeconds) + + githubToken, err := p.pollDeviceAuth(ctx, device) + if err != nil { + return Credentials{}, err + } + + // Copilot Personal users authenticate with the raw GitHub OAuth token + // (ghu_*) directly via "Authorization: Bearer " to + // api.individual.githubcopilot.com. Only Enterprise/Business users need + // the token exchange at api.github.com/copilot_internal/v2/token. + // + // Since most personal users have no Business exchange, skip the exchange. + // The Enterprise path can be added later when vix supports org accounts. + return Credentials{ + Access: githubToken, + Refresh: githubToken, + }, nil +} + +func (p *githubCopilotProvider) RefreshToken(ctx context.Context, creds Credentials) (Credentials, error) { + githubToken := creds.Refresh + if githubToken == "" { + return Credentials{}, errors.New("github-copilot: no refresh token (GitHub OAuth token) available") + } + // Personal users don't exchange; the raw GitHub OAuth token is long-lived. + return creds, nil +} + +type githubDeviceAuth struct { + deviceCode string + userCode string + intervalSeconds int +} + +func (p *githubCopilotProvider) startDeviceAuth(ctx context.Context) (githubDeviceAuth, error) { + body := fmt.Sprintf("client_id=%s&scope=%s", p.clientID, githubScope) + status, data, err := httpRequest(ctx, http.MethodPost, p.deviceUserCodeURL, map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, strings.NewReader(body)) + if err != nil { + return githubDeviceAuth{}, err + } + if status < 200 || status >= 300 { + return githubDeviceAuth{}, fmt.Errorf("github device code request failed with status %d: %s", status, string(data)) + } + + var resp struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + Interval int `json:"interval"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return githubDeviceAuth{}, fmt.Errorf("invalid GitHub device code response: %s", string(data)) + } + if resp.DeviceCode == "" || resp.UserCode == "" { + return githubDeviceAuth{}, fmt.Errorf("invalid GitHub device code response: %s", string(data)) + } + interval := resp.Interval + if interval <= 0 { + interval = deviceDefaultPollIntervalSeconds + } + if resp.ExpiresIn > 0 && resp.ExpiresIn < p.deviceTimeoutSeconds { + p.deviceTimeoutSeconds = resp.ExpiresIn + } + return githubDeviceAuth{ + deviceCode: resp.DeviceCode, + userCode: resp.UserCode, + intervalSeconds: interval, + }, nil +} + +func (p *githubCopilotProvider) pollDeviceAuth(ctx context.Context, device githubDeviceAuth) (string, error) { + result, err := pollDeviceCode(ctx, devicePollOptions[string]{ + Label: "github-copilot", + IntervalSeconds: device.intervalSeconds, + ExpiresInSeconds: p.deviceTimeoutSeconds, + Poll: func(ctx context.Context) (pollResult[string], error) { + body := fmt.Sprintf("client_id=%s&device_code=%s&grant_type=urn:ietf:params:oauth:grant-type:device_code", + p.clientID, device.deviceCode) + status, data, err := httpRequest(ctx, http.MethodPost, p.deviceTokenURL, map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, strings.NewReader(body)) + if err != nil { + return pollResult[string]{}, err + } + + if status >= 200 && status < 300 { + // GitHub returns HTTP 200 for success AND pending/error states. + // Check for error field first. + var errResp struct { + Error string `json:"error"` + } + if json.Unmarshal(data, &errResp) == nil && errResp.Error != "" { + switch errResp.Error { + case "authorization_pending": + return pollResult[string]{Status: pollPending}, nil + case "slow_down": + return pollResult[string]{Status: pollSlowDown}, nil + case "expired_token": + return pollResult[string]{Status: pollFailed, Message: "GitHub device code expired"}, nil + case "access_denied": + return pollResult[string]{Status: pollFailed, Message: "GitHub authorization denied"}, nil + } + return pollResult[string]{Status: pollFailed, Message: fmt.Sprintf("GitHub device auth error: %s", errResp.Error)}, nil + } + + var resp struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + } + if err := json.Unmarshal(data, &resp); err != nil || resp.AccessToken == "" { + return pollResult[string]{Status: pollFailed, Message: "invalid GitHub token response: " + string(data)}, nil + } + lg().Info("github-copilot: GitHub OAuth token obtained", "token", redact(resp.AccessToken)) + return pollResult[string]{Status: pollComplete, Value: resp.AccessToken}, nil + } + return pollResult[string]{Status: pollFailed, Message: fmt.Sprintf("GitHub token request failed with status %d", status)}, nil + }, + }) + if err != nil { + return "", err + } + return result, nil +} + +func (p *githubCopilotProvider) exchangeForCopilotToken(ctx context.Context, githubToken string) (Credentials, error) { + lg().Info("github-copilot: exchanging GitHub OAuth token for Copilot session token", "github_token", redact(githubToken)) + status, data, err := httpRequest(ctx, http.MethodGet, p.copilotTokenURL, map[string]string{ + "Authorization": "token " + githubToken, + "Accept": "application/json", + "User-Agent": "GitHubCopilot/1.0", + "Editor-Version": "vscode/1.96.0", + "Copilot-Integration-Id": "vscode-chat", + }, nil) + if err != nil { + return Credentials{}, fmt.Errorf("copilot token exchange error: %w", err) + } + if status < 200 || status >= 300 { + return Credentials{}, fmt.Errorf("copilot token exchange failed (%d): %s", status, string(data)) + } + + var resp struct { + Token string `json:"token"` + ExpiresAt int64 `json:"expires_at"` // Unix seconds + Endpoints struct { + API string `json:"api"` + } `json:"endpoints"` + } + if err := json.Unmarshal(data, &resp); err != nil || resp.Token == "" { + return Credentials{}, fmt.Errorf("invalid copilot token exchange response: %s", string(data)) + } + + expiresMS := int64(0) + if resp.ExpiresAt > 0 { + expiresMS = resp.ExpiresAt * 1000 + // Refresh 5 minutes before expiry to avoid edge cases. + refreshMargin := int64(5 * 60 * 1000) + if expiresMS > refreshMargin { + expiresMS -= refreshMargin + } + } + + extra := map[string]any{} + if resp.Endpoints.API != "" { + extra["api_endpoint"] = resp.Endpoints.API + } + + lg().Info("github-copilot: token exchange succeeded", + "expires_at_unix_s", resp.ExpiresAt, + "api_endpoint", resp.Endpoints.API, + "token", redact(resp.Token)) + return Credentials{ + Access: resp.Token, + Refresh: githubToken, + Expires: expiresMS, + Extra: extra, + }, nil +} diff --git a/internal/auth/registry.go b/internal/auth/registry.go index 4ff4bc4..525e116 100644 --- a/internal/auth/registry.go +++ b/internal/auth/registry.go @@ -15,6 +15,7 @@ var ( func init() { builtins := []Provider{ newAnthropicProvider(), + newGithubCopilotProvider(), newOpenAICodexProvider(), newOpenRouterProvider(), } diff --git a/internal/config/keyring_test.go b/internal/config/keyring_test.go index 92ac3b3..4bae5fb 100644 --- a/internal/config/keyring_test.go +++ b/internal/config/keyring_test.go @@ -157,8 +157,8 @@ func TestListStoredProviderKeys(t *testing.T) { defer DeleteProviderKey("anthropic") keys := ListStoredProviderKeys() - if len(keys) != 5 { - t.Fatalf("expected 5 provider entries, got %d", len(keys)) + if len(keys) != 6 { + t.Fatalf("expected 6 provider entries, got %d", len(keys)) } anthropicFound := false diff --git a/internal/config/providers.go b/internal/config/providers.go index b2328b8..56a3181 100644 --- a/internal/config/providers.go +++ b/internal/config/providers.go @@ -115,6 +115,23 @@ func AuthMethodsFor(provider string) []AuthMethod { return out } +// ProviderHasAPIKeyAuth reports whether a provider has at least one API key +// credential method (i.e. non-OAuth). OAuth-only providers should hide the +// API Key row in the UI to avoid confusing "Create key" buttons. +func ProviderHasAPIKeyAuth(provider string) bool { + for _, m := range AuthMethodsFor(provider) { + if m.Kind == APIKeyAuth { + return true + } + } + return false +} + +// HasOAuthLogin reports whether an OAuth login is stored for the given login id. +func HasOAuthLogin(loginID string) bool { + return auth.DefaultStorage().HasLogin(loginID) +} + // OAuthLoginID returns the internal/auth login id for a provider's OAuth method, // or "" when the provider has no OAuth login. Single source of truth for the // provider→loginID mapping, shared by the UI and credential-status helpers. diff --git a/internal/config/providers_test.go b/internal/config/providers_test.go index ca1809e..70b7a9e 100644 --- a/internal/config/providers_test.go +++ b/internal/config/providers_test.go @@ -45,7 +45,7 @@ func TestPrimaryEnvVar(t *testing.T) { func TestKnownProvidersStable(t *testing.T) { got := KnownProviders() - want := []string{"anthropic", "openai", "openrouter", "minimax", "mimo"} + want := []string{"anthropic", "openai", "openrouter", "minimax", "mimo", "github-copilot"} if len(got) != len(want) { t.Fatalf("KnownProviders len = %d, want %d", len(got), len(want)) } diff --git a/internal/protocol/cost.go b/internal/protocol/cost.go index 5d6fa0c..120831c 100644 --- a/internal/protocol/cost.go +++ b/internal/protocol/cost.go @@ -52,6 +52,15 @@ var pricingByProvider = map[string][]modelPricing{ {"mimo-v2.5", 0.14, 0.28, 0, 0}, {"mimo-v2-flash", 0.07, 0.14, 0, 0}, }, + "github-copilot": { + {"claude-opus-4", 5.00, 25.00, 6.25, 0.50}, + {"claude-sonnet-4", 3.00, 15.00, 3.75, 0.30}, + {"claude-haiku-4", 1.00, 5.00, 1.25, 0.10}, + {"gpt-5.5", 5.00, 30.00, 0, 0.50}, + {"gpt-5.4", 2.50, 15.00, 0, 0.25}, + {"gpt-5", 2.50, 10.00, 0, 0.25}, + {"gemini-2.5-pro", 1.25, 10.00, 0, 0.125}, + }, // "openrouter" intentionally absent — its CostUSD is server-reported // and short-circuits in the caller (see daemon/session.go costFor). } diff --git a/internal/providers/providers.json b/internal/providers/providers.json index 669c034..5e83487 100644 --- a/internal/providers/providers.json +++ b/internal/providers/providers.json @@ -542,6 +542,37 @@ { "spec": "mimo/mimo-v2.5-tts-voiceclone", "display_name": "Mimo v2.5 Tts Voiceclone" }, { "spec": "mimo/mimo-v2.5-tts-voicedesign", "display_name": "Mimo v2.5 Tts Voicedesign" } ] + }, + { + "id": "github-copilot", + "display_name": "GitHub Copilot", + "model_prefix": "github-copilot", + "wire_format": "chat_completions", + "effort_policy": "openai_reasoning", + "inference": { + "base_url": "https://api.individual.githubcopilot.com", + "auth_scheme": "bearer", + "headers": { + "Openai-Intent": "copilot_intent", + "Editor-Version": "vscode/1.96.0", + "Editor-Plugin-Version": "copilot-chat/0.23.0", + "Copilot-Integration-Id": "vscode-chat", + "X-Initiator": "agent" + } + }, + "credential_methods": [ + { "kind": "oauth_token", "login_id": "github-copilot", "header_style": "bearer" } + ], + "models": [ + { "spec": "github-copilot/gpt-5-mini", "display_name": "GPT 5 Mini", "context_window": 131072 }, + { "spec": "github-copilot/gpt-5.4", "display_name": "GPT 5.4", "context_window": 400000 }, + { "spec": "github-copilot/gpt-5.4-mini", "display_name": "GPT 5.4 Mini", "context_window": 400000 }, + { "spec": "github-copilot/gpt-5.5", "display_name": "GPT 5.5", "context_window": 400000 }, + { "spec": "github-copilot/claude-sonnet-4.6", "display_name": "Claude Sonnet 4.6", "context_window": 1000000 }, + { "spec": "github-copilot/claude-opus-4.7", "display_name": "Claude Opus 4.7", "context_window": 1000000 }, + { "spec": "github-copilot/claude-haiku-4.5", "display_name": "Claude Haiku 4.5", "context_window": 200000 }, + { "spec": "github-copilot/gemini-2.5-pro", "display_name": "Gemini 2.5 Pro", "context_window": 1048576 } + ] } ], "auth_logins": [ @@ -581,6 +612,18 @@ "keys_url": "https://openrouter.ai/api/v1/auth/keys", "callback_port": 53781, "callback_path": "/callback" + }, + { + "id": "github-copilot", + "flow": "oauth_gh_copilot", + "client_id_b64": "SXYxLmI1MDdhMDhjODdlY2ZlOTg=", + "scope": "read:user", + "device": { + "user_code_url": "https://github.com/login/device/code", + "token_url": "https://github.com/login/oauth/access_token", + "verification_uri": "https://github.com/login/device", + "timeout_seconds": 900 + } } ] } diff --git a/internal/providers/providers_test.go b/internal/providers/providers_test.go index 147bb89..5754502 100644 --- a/internal/providers/providers_test.go +++ b/internal/providers/providers_test.go @@ -14,7 +14,7 @@ func TestEmbeddedLoadsAndValidates(t *testing.T) { if err != nil { t.Fatalf("loadEmbedded: %v", err) } - wantIDs := []string{"anthropic", "openai", "openrouter", "minimax", "mimo"} + wantIDs := []string{"anthropic", "openai", "openrouter", "minimax", "mimo", "github-copilot"} if got := reg.IDs(); len(got) != len(wantIDs) { t.Fatalf("IDs = %v, want %v", got, wantIDs) } @@ -42,6 +42,7 @@ func TestGoldenProviderData(t *testing.T) { effortStyle string }{ {"anthropic", "anthropic", WireMessages, EffortAdaptive, AuthSchemeXAPIKey, "https://api.anthropic.com/v1", EffortStyleNone}, + {"github-copilot", "github-copilot", WireChatCompletions, EffortOpenAIReasoning, AuthSchemeBearer, "https://api.individual.githubcopilot.com", EffortStyleNone}, {"openai", "openai", WireResponses, EffortOpenAIReasoning, AuthSchemeBearer, "https://api.openai.com/v1", EffortStyleNone}, {"openrouter", "openrouter", WireChatCompletions, EffortOpenAIReasoning, AuthSchemeBearer, "https://openrouter.ai/api/v1", EffortStyleReasoningEffort}, {"minimax", "minimax", WireChatCompletions, EffortAdaptive, AuthSchemeBearer, "https://api.minimax.io/v1", EffortStyleReasoningSplit}, @@ -106,6 +107,14 @@ func TestGoldenCredentialMethods(t *testing.T) { if or.Credential[1].Kind != CredOAuthMintKey || or.Credential[1].LoginID != "openrouter" { t.Errorf("openrouter mint method = %+v", or.Credential[1]) } + + copilot, _ := reg.Lookup("github-copilot") + if len(copilot.Credential) != 1 { + t.Fatalf("github-copilot credential methods = %d, want 1", len(copilot.Credential)) + } + if copilot.Credential[0].Kind != CredOAuthToken || copilot.Credential[0].LoginID != "github-copilot" { + t.Errorf("github-copilot method = %+v", copilot.Credential[0]) + } } // TestParseModel mirrors the old llm.factory_test cases. @@ -120,6 +129,8 @@ func TestParseModel(t *testing.T) { {"anthropic/claude-opus-4-8", "anthropic", "claude-opus-4-8", false}, {"openai/gpt-5.1", "openai", "gpt-5.1", false}, {"openrouter/openai/gpt-5.1", "openrouter", "openai/gpt-5.1", false}, + {"github-copilot/gpt-5.4", "github-copilot", "gpt-5.4", false}, + {"github-copilot/claude-sonnet-4.6", "github-copilot", "claude-sonnet-4.6", false}, {"minimax/MiniMax-M2.7", "minimax", "MiniMax-M2.7", false}, {"mimo/mimo-v2.5-pro", "mimo", "mimo-v2.5-pro", false}, {"", "", "", true}, @@ -175,6 +186,8 @@ func TestDefaultEffort(t *testing.T) { }{ {"anthropic/claude-opus-4-8", "adaptive"}, {"minimax/MiniMax-M2.7", "adaptive"}, + {"github-copilot/gpt-5.4", "medium"}, + {"github-copilot/claude-sonnet-4.6", ""}, {"openai/gpt-5.1", "medium"}, {"openai/gpt-4o", ""}, {"openrouter/openai/o3", "medium"}, @@ -236,6 +249,11 @@ func TestAuthLogins(t *testing.T) { if !ok || or.Flow != FlowOAuthPKCEMint || or.CallbackPort != 53781 { t.Errorf("openrouter login = %+v", or) } + + ghc, ok := reg.AuthLogin("github-copilot") + if !ok || ghc.Flow != FlowOAuthGHCopilot || ghc.Device == nil || ghc.Device.TimeoutSeconds != 900 { + t.Errorf("github-copilot login = %+v", ghc) + } } // TestInterpolation covers ${env:VAR} and ${env:VAR:-default}. diff --git a/internal/providers/spec.go b/internal/providers/spec.go index 7807a4b..22de47c 100644 --- a/internal/providers/spec.go +++ b/internal/providers/spec.go @@ -80,7 +80,8 @@ const ( // FlowOAuthCodex is the ChatGPT/Codex browser + device-code flow. FlowOAuthCodex = "oauth_codex" // FlowOAuthPKCEMint is PKCE → minted user API key (OpenRouter). - FlowOAuthPKCEMint = "oauth_pkce_mint" + FlowOAuthPKCEMint = "oauth_pkce_mint" + FlowOAuthGHCopilot = "oauth_gh_copilot" ) // ExtraHeaders producer names: compiled functions that derive extra request diff --git a/internal/providers/validate.go b/internal/providers/validate.go index edfa753..229088e 100644 --- a/internal/providers/validate.go +++ b/internal/providers/validate.go @@ -16,6 +16,8 @@ var allowedAuthHosts = map[string]bool{ "api.anthropic.com": true, "auth.openai.com": true, "openrouter.ai": true, + "github.com": true, + "api.github.com": true, } // validWireFormats / validAuthSchemes / validCredKinds / validFlows / @@ -26,7 +28,7 @@ var ( validWireFormats = map[WireFormat]bool{WireMessages: true, WireResponses: true, WireChatCompletions: true} validAuthSchemes = map[string]bool{AuthSchemeBearer: true, AuthSchemeXAPIKey: true} validCredKinds = map[string]bool{CredAPIKey: true, CredOAuthMintKey: true, CredOAuthToken: true} - validFlows = map[string]bool{FlowOAuthPKCEToken: true, FlowOAuthCodex: true, FlowOAuthPKCEMint: true} + validFlows = map[string]bool{FlowOAuthPKCEToken: true, FlowOAuthCodex: true, FlowOAuthPKCEMint: true, FlowOAuthGHCopilot: true} validEffortStyles = map[string]bool{EffortStyleNone: true, EffortStyleReasoningEffort: true, EffortStyleReasoningSplit: true} validEfforts = map[string]bool{EffortAdaptive: true, EffortOpenAIReasoning: true, "": true} validProducers = map[string]bool{"": true, ProducerAnthropicOAuth: true, ProducerCodexOAuth: true} diff --git a/internal/ui/model.go b/internal/ui/model.go index 0eb522e..6fd104c 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -1035,6 +1035,11 @@ func (m Model) updateInner(msg tea.Msg) (tea.Model, tea.Cmd) { m.modelsLoginStatus = "Login failed: " + msg.err.Error() } else { m.modelsLoginStatus = "Logged in to " + msg.provider + "." + // Apply pending model selection from selectModel. + if m.modelsModelPending != "" && ProviderOf(m.modelsModelPending) == msg.provider { + m.applyModelSelection(m.modelsModelPending) + m.modelsModelPending = "" + } } } return m, nil @@ -1213,7 +1218,7 @@ func (m *Model) switchTab(k TabKind) tea.Cmd { // model. func (m *Model) enterModelsTab() { m.modelsFocus = modelsFocusProviders - m.modelsAuthRow = authRowAPIKey + m.modelsAuthRow = m.defaultAuthRow() m.modelsAuthBtn = 0 m.modelsModelPending = "" m.modelsInKeyInput = false @@ -1228,6 +1233,16 @@ func (m *Model) enterModelsTab() { m.clampModelsScroll() } +// defaultAuthRow returns the first auth row that should be focused for the +// selected provider, skipping the API key row when unsupported. +func (m *Model) defaultAuthRow() int { + provider := m.modelsSelectedProvider() + if provider != "" && !config.ProviderHasAPIKeyAuth(provider) { + return authRowOAuth + } + return authRowAPIKey +} + // refreshModelsProviders recomputes the logged-in / available provider split and // per-provider auth status, clamping the provider cursor to the new bounds. func (m *Model) refreshModelsProviders() { @@ -1389,7 +1404,7 @@ func (m Model) handleModelsKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.modelsModelSel = 0 m.modelsModelScroll = 0 m.modelsFilter = "" - m.modelsAuthRow = authRowAPIKey + m.modelsAuthRow = m.defaultAuthRow() m.modelsAuthBtn = 0 m.modelsLoginStatus = "" } @@ -1399,13 +1414,13 @@ func (m Model) handleModelsKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.modelsModelSel = 0 m.modelsModelScroll = 0 m.modelsFilter = "" - m.modelsAuthRow = authRowAPIKey + m.modelsAuthRow = m.defaultAuthRow() m.modelsAuthBtn = 0 m.modelsLoginStatus = "" } case "right", "l", "enter", "tab": m.modelsFocus = modelsFocusAuth - m.modelsAuthRow = authRowAPIKey + m.modelsAuthRow = m.defaultAuthRow() m.modelsAuthBtn = 0 } case modelsFocusAuth: @@ -1422,7 +1437,9 @@ func (m Model) handleModelsKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.modelsAuthBtn++ } case "up", "k": - if m.modelsAuthRow == authRowOAuth { + if m.modelsAuthRow == authRowAPIKey { + m.modelsFocus = modelsFocusProviders + } else if m.modelsAuthRow == authRowOAuth && config.ProviderHasAPIKeyAuth(m.modelsSelectedProvider()) { m.modelsAuthRow = authRowAPIKey m.modelsAuthBtn = 0 } else { @@ -1432,6 +1449,9 @@ func (m Model) handleModelsKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if m.modelsAuthRow == authRowAPIKey && len(authButtonsFor(st, authRowOAuth)) > 0 { m.modelsAuthRow = authRowOAuth m.modelsAuthBtn = 0 + } else if m.modelsAuthRow == authRowOAuth { + m.modelsFocus = modelsFocusModels + m.modelsModelSel = 0 } else { m.modelsFocus = modelsFocusModels m.modelsModelSel = 0 @@ -1528,11 +1548,16 @@ func (m Model) modelsViewportHeight() int { // selectModel applies the chosen model when its provider has a resolvable // credential, otherwise opens the key popup and remembers the pending model. func (m Model) selectModel(mod ModelInfo) (tea.Model, tea.Cmd) { - if key, _ := config.ResolveProviderKey(mod.Provider); key != "" { + if cred := config.ResolveProviderCredential(mod.Provider); cred.Source != config.KeySourceNone { m.applyModelSelection(mod.Spec) return m, nil } m.modelsModelPending = mod.Spec + if ProviderSupportsLogin(mod.Provider) { + m.modelsLoginStatus = "Starting " + mod.Provider + " login…" + startProviderLogin(mod.Provider) + return m, nil + } m.openModelsKeyInput(mod.Provider) return m, nil } diff --git a/internal/ui/tabs.go b/internal/ui/tabs.go index a0a5076..3d186f9 100644 --- a/internal/ui/tabs.go +++ b/internal/ui/tabs.go @@ -442,13 +442,16 @@ func renderModelsView(width, height int, s Styles, return " " + strings.Join(cells, " ") } - // API key row. - keyVal := "(empty)" - if st.APIKeyStored { - keyVal = st.APIKeyPrefix + "..." + // API key row — hidden when the provider has no API key auth method + // (e.g. OAuth-only providers like GitHub Copilot). + if provider == "" || config.ProviderHasAPIKeyAuth(provider) { + keyVal := "(empty)" + if st.APIKeyStored { + keyVal = st.APIKeyPrefix + "..." + } + rightLines = append(rightLines, "API Key: "+keyVal+defaultTag(st.Default == config.AuthDefaultAPIKey)) + rightLines = append(rightLines, renderButtons(authRowAPIKey)) } - rightLines = append(rightLines, "API Key: "+keyVal+defaultTag(st.Default == config.AuthDefaultAPIKey)) - rightLines = append(rightLines, renderButtons(authRowAPIKey)) // OAuth row. if st.OAuthSupported {