Skip to content

Commit ecd6d95

Browse files
committed
topos: multi-provider local auth — picker, loopback OAuth, API key, Ollama
When 'latere topos --local' has no model credential it now shows a provider picker (TUI): sign in with Claude (browser, loopback OAuth — no copy/paste), paste an Anthropic API key, or use Ollama (local models, no key). The choice is saved to ~/.config/latere/topos-provider.json and the agent uses it. This fixes the 429: it lets you run on a dedicated credential (your own Claude login, an API key with its own quota, or fully local Ollama) instead of the shared CLAUDE_CODE_OAUTH_TOKEN that's rate-limited alongside Claude Code. Credential order: ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN[_AUTO], saved provider config. 'latere topos login' now uses loopback (no paste). One-shot/ non-TTY fails with a clear message instead of launching the TUI. OpenAI/Gemini are omitted until their SDK adapters exist (today they're stubs). Tests cover the picker, provider resolution, config round-trip, PKCE, and token store/refresh.
1 parent a3cefaf commit ecd6d95

7 files changed

Lines changed: 475 additions & 112 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright 2026 The Latere Authors. All rights reserved.
2+
// Use of this source code is governed by an Apache-2.0
3+
// license that can be found in the LICENSE file.
4+
5+
package commands
6+
7+
import (
8+
"context"
9+
"errors"
10+
"strings"
11+
12+
"github.com/charmbracelet/bubbles/textinput"
13+
tea "github.com/charmbracelet/bubbletea"
14+
"github.com/charmbracelet/lipgloss"
15+
)
16+
17+
// The auth picker shown by `latere topos --local` when no model credential is
18+
// configured: choose a provider and sign in / enter a key, like opencode's auth
19+
// flow. Only providers whose SDK adapter actually works are offered (Anthropic,
20+
// Ollama); OpenAI/Gemini are pending their adapters.
21+
22+
type authChoice int
23+
24+
const (
25+
authCancel authChoice = iota
26+
authClaude // Claude OAuth (browser)
27+
authAPIKey // Anthropic API key
28+
authOllama // local Ollama
29+
)
30+
31+
type authResult struct {
32+
choice authChoice
33+
apiKey string
34+
}
35+
36+
type authOption struct {
37+
label string
38+
choice authChoice
39+
}
40+
41+
type authModel struct {
42+
options []authOption
43+
cursor int
44+
entering bool // true while typing an API key
45+
input textinput.Model
46+
result authResult
47+
}
48+
49+
func newAuthModel() authModel {
50+
in := textinput.New()
51+
in.Placeholder = "sk-ant-api03-..."
52+
in.Prompt = "API key: "
53+
return authModel{
54+
options: []authOption{
55+
{"Sign in with Claude (opens your browser)", authClaude},
56+
{"Use an Anthropic API key", authAPIKey},
57+
{"Use Ollama (local models, no key)", authOllama},
58+
},
59+
input: in,
60+
}
61+
}
62+
63+
func (m authModel) Init() tea.Cmd { return nil }
64+
65+
func (m authModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
66+
key, ok := msg.(tea.KeyMsg)
67+
if !ok {
68+
return m, nil
69+
}
70+
if m.entering {
71+
switch key.Type {
72+
case tea.KeyEnter:
73+
m.result = authResult{choice: authAPIKey, apiKey: strings.TrimSpace(m.input.Value())}
74+
return m, tea.Quit
75+
case tea.KeyEsc:
76+
m.entering = false
77+
return m, nil
78+
}
79+
var cmd tea.Cmd
80+
m.input, cmd = m.input.Update(msg)
81+
return m, cmd
82+
}
83+
84+
switch key.String() {
85+
case "ctrl+c", "q", "esc":
86+
m.result = authResult{choice: authCancel}
87+
return m, tea.Quit
88+
case "up", "k":
89+
if m.cursor > 0 {
90+
m.cursor--
91+
}
92+
case "down", "j":
93+
if m.cursor < len(m.options)-1 {
94+
m.cursor++
95+
}
96+
case "enter":
97+
sel := m.options[m.cursor].choice
98+
if sel == authAPIKey {
99+
m.entering = true
100+
m.input.Focus()
101+
return m, textinput.Blink
102+
}
103+
m.result = authResult{choice: sel}
104+
return m, tea.Quit
105+
}
106+
return m, nil
107+
}
108+
109+
var (
110+
authTitle = lipgloss.NewStyle().Bold(true)
111+
authSel = lipgloss.NewStyle().Bold(true)
112+
authDim = lipgloss.NewStyle().Faint(true)
113+
)
114+
115+
func (m authModel) View() string {
116+
var b strings.Builder
117+
b.WriteString(authTitle.Render("Sign in to Topos") + " " + authDim.Render("choose a model provider") + "\n\n")
118+
if m.entering {
119+
b.WriteString(m.input.View() + "\n\n")
120+
b.WriteString(authDim.Render("[enter] save [esc] back") + "\n")
121+
return b.String()
122+
}
123+
for i, o := range m.options {
124+
cursor := " "
125+
label := o.label
126+
if i == m.cursor {
127+
cursor = "▸ "
128+
label = authSel.Render(o.label)
129+
}
130+
b.WriteString(cursor + label + "\n")
131+
}
132+
b.WriteString("\n" + authDim.Render("[↑↓] move [enter] select [q] cancel") + "\n")
133+
return b.String()
134+
}
135+
136+
// runAuthPicker shows the provider picker and applies the choice: a Claude OAuth
137+
// login, a stored Anthropic API key, or Ollama. It returns an error if the user
138+
// cancels or the chosen step fails.
139+
func runAuthPicker(ctx context.Context) error {
140+
p := tea.NewProgram(newAuthModel(), tea.WithContext(ctx))
141+
fm, err := p.Run()
142+
if err != nil {
143+
return err
144+
}
145+
res := fm.(authModel).result
146+
switch res.choice {
147+
case authClaude:
148+
return loopbackClaudeLogin(ctx)
149+
case authAPIKey:
150+
if res.apiKey == "" {
151+
return errors.New("no API key entered")
152+
}
153+
return saveProviderConfig(providerConfig{Provider: "anthropic", Method: "apikey", APIKey: res.apiKey})
154+
case authOllama:
155+
return saveProviderConfig(providerConfig{Provider: "ollama"})
156+
default:
157+
return errors.New("sign-in cancelled")
158+
}
159+
}

internal/commands/topos_claude_auth.go

Lines changed: 66 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"encoding/json"
1313
"fmt"
1414
"io"
15+
"net"
1516
"net/http"
1617
"net/url"
1718
"os"
@@ -30,7 +31,6 @@ const (
3031
// CLI integration (no secret; PKCE protects the exchange).
3132
claudeOAuthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
3233
claudeAuthorizeURL = "https://claude.ai/oauth/authorize"
33-
claudeRedirectURI = "https://console.anthropic.com/oauth/code/callback"
3434
claudeScopes = "org:create_api_key user:profile user:inference"
3535
)
3636

@@ -96,37 +96,6 @@ func newPKCE() (pkce, error) {
9696
return pkce{verifier: verifier, challenge: base64.RawURLEncoding.EncodeToString(sum[:])}, nil
9797
}
9898

99-
// claudeAuthorizeURL builds the authorize URL for the manual-paste flow
100-
// (code=true makes the callback page display the code for the user to paste).
101-
func buildClaudeAuthorizeURL(p pkce) string {
102-
q := url.Values{
103-
"code": {"true"},
104-
"client_id": {claudeOAuthClientID},
105-
"response_type": {"code"},
106-
"redirect_uri": {claudeRedirectURI},
107-
"scope": {claudeScopes},
108-
"code_challenge": {p.challenge},
109-
"code_challenge_method": {"S256"},
110-
"state": {p.verifier},
111-
}
112-
return claudeAuthorizeURL + "?" + q.Encode()
113-
}
114-
115-
// exchangeClaudeCode trades the pasted "code#state" for tokens. The callback
116-
// page returns the authorization code and state joined by '#'.
117-
func exchangeClaudeCode(ctx context.Context, httpc *http.Client, pasted string, p pkce) (claudeToken, error) {
118-
code, state, _ := strings.Cut(strings.TrimSpace(pasted), "#")
119-
body, _ := json.Marshal(map[string]string{
120-
"grant_type": "authorization_code",
121-
"code": code,
122-
"state": state,
123-
"client_id": claudeOAuthClientID,
124-
"redirect_uri": claudeRedirectURI,
125-
"code_verifier": p.verifier,
126-
})
127-
return postClaudeToken(ctx, httpc, body)
128-
}
129-
13099
// refreshClaudeToken renews an expired access token using the refresh token.
131100
func refreshClaudeToken(ctx context.Context, httpc *http.Client, refresh string) (claudeToken, error) {
132101
body, _ := json.Marshal(map[string]string{
@@ -169,32 +138,80 @@ func postClaudeToken(ctx context.Context, httpc *http.Client, body []byte) (clau
169138
return t, nil
170139
}
171140

172-
// runClaudeLogin drives the interactive login: open the browser, read the pasted
173-
// code, exchange it, and store the token.
141+
// runClaudeLogin drives the Claude OAuth login (loopback: no copy/paste).
174142
func runClaudeLogin(ctx context.Context) error {
143+
return loopbackClaudeLogin(ctx)
144+
}
145+
146+
// loopbackClaudeLogin runs the Claude OAuth flow with a localhost redirect, so
147+
// the browser hands the code back automatically — no copy/paste. It stores the
148+
// token and records the provider choice (anthropic / oauth).
149+
func loopbackClaudeLogin(ctx context.Context) error {
175150
p, err := newPKCE()
176151
if err != nil {
177152
return err
178153
}
179-
authURL := buildClaudeAuthorizeURL(p)
180-
fmt.Fprintln(os.Stderr, "Sign in to Claude to authorize the local agent:")
181-
fmt.Fprintln(os.Stderr, "\n "+authURL+"\n")
182-
_ = openBrowser(authURL)
183-
fmt.Fprint(os.Stderr, "Paste the code shown after you approve, then press Enter:\n> ")
154+
ln, err := net.Listen("tcp", "127.0.0.1:0")
155+
if err != nil {
156+
return fmt.Errorf("claude login: open loopback: %w", err)
157+
}
158+
defer func() { _ = ln.Close() }()
159+
redirectURI := fmt.Sprintf("http://localhost:%d/callback", ln.Addr().(*net.TCPAddr).Port)
184160

185-
var pasted string
186-
if _, err := fmt.Fscanln(os.Stdin, &pasted); err != nil {
187-
return fmt.Errorf("read code: %w", err)
161+
q := url.Values{
162+
"client_id": {claudeOAuthClientID},
163+
"response_type": {"code"},
164+
"redirect_uri": {redirectURI},
165+
"scope": {claudeScopes},
166+
"code_challenge": {p.challenge},
167+
"code_challenge_method": {"S256"},
168+
"state": {p.verifier},
188169
}
189-
tok, err := exchangeClaudeCode(ctx, &http.Client{Timeout: 30 * time.Second}, pasted, p)
190-
if err != nil {
191-
return err
170+
authURL := claudeAuthorizeURL + "?" + q.Encode()
171+
172+
type result struct {
173+
code, state string
192174
}
193-
if err := saveClaudeToken(tok); err != nil {
194-
return err
175+
resCh := make(chan result, 1)
176+
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
177+
qq := r.URL.Query()
178+
if qq.Get("code") == "" {
179+
http.Error(w, "missing code", http.StatusBadRequest)
180+
return
181+
}
182+
_, _ = io.WriteString(w, "Signed in. You can close this tab and return to the terminal.")
183+
resCh <- result{code: qq.Get("code"), state: qq.Get("state")}
184+
})}
185+
go func() { _ = srv.Serve(ln) }()
186+
defer func() { _ = srv.Shutdown(context.Background()) }()
187+
188+
fmt.Fprintln(os.Stderr, "Opening your browser to sign in to Claude...")
189+
fmt.Fprintln(os.Stderr, "If it doesn't open, visit:\n\n "+authURL+"\n")
190+
_ = openBrowser(authURL)
191+
192+
select {
193+
case <-ctx.Done():
194+
return ctx.Err()
195+
case res := <-resCh:
196+
body, _ := json.Marshal(map[string]string{
197+
"grant_type": "authorization_code",
198+
"code": res.code,
199+
"state": res.state,
200+
"client_id": claudeOAuthClientID,
201+
"redirect_uri": redirectURI,
202+
"code_verifier": p.verifier,
203+
})
204+
tok, err := postClaudeToken(ctx, &http.Client{Timeout: 30 * time.Second}, body)
205+
if err != nil {
206+
return err
207+
}
208+
if err := saveClaudeToken(tok); err != nil {
209+
return err
210+
}
211+
_ = saveProviderConfig(providerConfig{Provider: "anthropic", Method: "oauth"})
212+
fmt.Fprintln(os.Stderr, "Signed in to Claude.")
213+
return nil
195214
}
196-
fmt.Fprintln(os.Stderr, "Signed in to Claude. `latere topos --local` will use this token.")
197-
return nil
198215
}
199216

200217
// claudeOAuthBearer returns a usable Claude OAuth access token from the stored

internal/commands/topos_claude_auth_test.go

Lines changed: 5 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"io"
1313
"net/http"
1414
"net/http/httptest"
15-
"net/url"
1615
"path/filepath"
1716
"testing"
1817
"time"
@@ -32,33 +31,8 @@ func TestPKCEChallengeMatchesVerifier(t *testing.T) {
3231
}
3332
}
3433

35-
func TestBuildClaudeAuthorizeURL(t *testing.T) {
36-
p := pkce{verifier: "ver", challenge: "chal"}
37-
u, err := url.Parse(buildClaudeAuthorizeURL(p))
38-
if err != nil {
39-
t.Fatalf("parse: %v", err)
40-
}
41-
q := u.Query()
42-
checks := map[string]string{
43-
"client_id": claudeOAuthClientID,
44-
"response_type": "code",
45-
"code_challenge": "chal",
46-
"code_challenge_method": "S256",
47-
"state": "ver",
48-
"redirect_uri": claudeRedirectURI,
49-
}
50-
for k, want := range checks {
51-
if q.Get(k) != want {
52-
t.Errorf("authorize URL %s = %q, want %q", k, q.Get(k), want)
53-
}
54-
}
55-
}
56-
57-
func TestExchangeAndStoreClaudeToken(t *testing.T) {
58-
var gotBody map[string]string
34+
func TestPostAndStoreClaudeToken(t *testing.T) {
5935
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60-
b, _ := io.ReadAll(r.Body)
61-
_ = json.Unmarshal(b, &gotBody)
6236
_ = json.NewEncoder(w).Encode(map[string]any{
6337
"access_token": "sk-ant-oat-new", "refresh_token": "rt-1", "expires_in": 3600,
6438
})
@@ -69,23 +43,17 @@ func TestExchangeAndStoreClaudeToken(t *testing.T) {
6943
defer func() { claudeTokenURL = old }()
7044
t.Setenv("LATERE_CLAUDE_TOKEN_FILE", filepath.Join(t.TempDir(), "claude.json"))
7145

72-
tok, err := exchangeClaudeCode(context.Background(), srv.Client(), "AUTHCODE#STATEXYZ", pkce{verifier: "ver"})
46+
tok, err := postClaudeToken(context.Background(), srv.Client(), []byte(`{"grant_type":"authorization_code"}`))
7347
if err != nil {
74-
t.Fatalf("exchange: %v", err)
75-
}
76-
// The pasted "code#state" is split correctly and the verifier is sent.
77-
if gotBody["code"] != "AUTHCODE" || gotBody["state"] != "STATEXYZ" || gotBody["code_verifier"] != "ver" {
78-
t.Fatalf("token request body = %v", gotBody)
48+
t.Fatalf("postClaudeToken: %v", err)
7949
}
8050
if tok.AccessToken != "sk-ant-oat-new" || tok.RefreshToken != "rt-1" || tok.ExpiresAt.IsZero() {
8151
t.Fatalf("token = %+v", tok)
8252
}
83-
8453
if err := saveClaudeToken(tok); err != nil {
8554
t.Fatalf("save: %v", err)
8655
}
87-
got, err := loadClaudeToken()
88-
if err != nil || got.AccessToken != "sk-ant-oat-new" {
56+
if got, err := loadClaudeToken(); err != nil || got.AccessToken != "sk-ant-oat-new" {
8957
t.Fatalf("load = (%+v, %v)", got, err)
9058
}
9159
}
@@ -131,6 +99,7 @@ func TestBuildLocalModelUsesStoredClaudeLogin(t *testing.T) {
13199
t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "")
132100
t.Setenv("CLAUDE_CODE_OAUTH_TOKEN_AUTO", "")
133101
t.Setenv("LATERE_CLAUDE_TOKEN_FILE", filepath.Join(t.TempDir(), "claude.json"))
102+
t.Setenv("LATERE_TOPOS_PROVIDER_FILE", filepath.Join(t.TempDir(), "provider.json"))
134103
// A valid stored login (far-future expiry) → buildLocalModel returns a model.
135104
_ = saveClaudeToken(claudeToken{AccessToken: "sk-ant-oat-stored", ExpiresAt: time.Now().Add(time.Hour)})
136105
m, err := buildLocalModel(context.Background(), "")

0 commit comments

Comments
 (0)