Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions internal/gateway/claude_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package gateway

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"testing"

"github.com/yetone/magpie/internal/provider"
)

// claudeRefuses has the fake Claude Code fail as Claude Code does on a
// sign-in Anthropic refused, on the accounts whose token is one of toks
// (all of them with none), and answer on the others.
func claudeRefuses(t *testing.T, calls string, toks ...string) {
t.Helper()
refused := `[ -z "` + strings.Join(toks, "") + `" ]`
for _, tok := range toks {
refused += ` || [ "$tok" = "` + tok + `" ]`
}
script := `#!/bin/sh
case "$1" in auth) exit 1;; esac
creds="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
while read -r line; do
tok=$(grep -o 'tok-[a-z]*' "$creds" | head -1)
echo "$tok" >> '` + calls + `'
if ` + refused + `; then
echo '{"type":"result","is_error":true,"result":"Failed to authenticate: OAuth session expired and could not be refreshed"}'
else
echo '{"type":"stream_event","event":{"type":"message_start","message":{"id":"m","model":"claude-sonnet-5","usage":{"input_tokens":1}}}}'
echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"healthy account"}}}'
echo '{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}}'
echo '{"type":"stream_event","event":{"type":"message_stop"}}'
echo '{"type":"result","is_error":false,"result":""}'
fi
done
`
binary := strings.Split(os.Getenv("PATH"), string(os.PathListSeparator))[0]
if err := os.WriteFile(filepath.Join(binary, "claude"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
}

func askClaudeModel(s *Server, text string) *httptest.ResponseRecorder {
body := `{"model":"claude/claude-sonnet-5","max_tokens":100,"messages":[{"role":"user","content":"` + text + `"}]}`
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/messages", strings.NewReader(body)))
return rec
}

// An account Anthropic refused goes to the next one at once, with no rest
// to wait out and then fail again: its sign-in is not run again.
func TestClaudeAuthFailureFallsBackWithoutRetryingTheLogin(t *testing.T) {
claudeMadeFirst(t, false)
s := New()
t.Cleanup(s.subscription.abortAll)
calls := filepath.Join(t.TempDir(), "calls")
claudeRefuses(t, calls, "tok-a")
for _, text := range []string{"first", "second"} {
if rec := askClaudeModel(s, text); rec.Code != 200 || !strings.Contains(rec.Body.String(), "healthy account") {
t.Fatalf("%s: %d %s", text, rec.Code, rec.Body.String())
}
}
if b, _ := os.ReadFile(calls); strings.Count(string(b), "tok-a\n") != 1 {
t.Fatalf("refused login run again: %s", b)
}
routes := s.Trace(context.Background(), 0, 0).Routes
slices.SortFunc(routes, func(a, b Route) int { return int(a.Seq - b.Seq) })
for i, route := range routes {
if len(route.Tries) != 2 || route.Tries[0].Fail != failAuth || route.Tries[0].Rest != nil {
t.Fatalf("request %d: the refusal not told as one, or rested: %+v", i, route.Tries)
}
}
if !strings.Contains(routes[0].Tries[0].Error, "OAuth session expired") || !strings.Contains(routes[1].Tries[0].Error, "sign in again") {
t.Fatalf("errors told: %q, %q", routes[0].Tries[0].Error, routes[1].Tries[0].Error)
}
}

// With every account refused, the agent is told to sign in again, not that
// nothing is ready.
func TestClaudeAllRefusedSaysSignInAgain(t *testing.T) {
claudeMadeFirst(t, false)
loginFile := filepath.Join(filepath.Dir(provider.Path()), "logins.json")
var logins []map[string]any
raw, _ := os.ReadFile(loginFile)
if err := json.Unmarshal(raw, &logins); err != nil {
t.Fatal(err)
}
for _, l := range logins {
l["on"] = false
}
if err := os.WriteFile(loginFile, mustJSON(logins), 0o600); err != nil {
t.Fatal(err)
}
claudeRefuses(t, filepath.Join(t.TempDir(), "calls"))
s := New()
t.Cleanup(s.subscription.abortAll)
askClaudeModel(s, "first")
if rec := askClaudeModel(s, "second"); rec.Code == 200 || !strings.Contains(rec.Body.String(), "sign in again") {
t.Fatalf("not told to sign in again: %d %s", rec.Code, rec.Body.String())
}
}

// Another vendor saying a Claude sign-in's words (an OpenCode plugin on
// Anthropic's OAuth: "OAuth access token has been revoked") isn't a Claude
// account Anthropic refused: it rests as any failure does, and isn't told
// as needing a sign-in that magpie would never try again (#716 review).
func TestSignInWordsFromAnotherVendorRest(t *testing.T) {
fresh(t)
revoked := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
io.WriteString(w, `{"error":{"message":"OAuth access token has been revoked"}}`)
})
serveOn(t, "a", "ka", []string{"m"}, revoked)
serveOn(t, "b", "kb", []string{"m"}, &keyed{})
if err := provider.SaveGroup(provider.Group{Name: "Two", Members: []string{"a/m", "b/m"}, Routing: provider.Ordered}); err != nil {
t.Fatal(err)
}
s := New()
if code, body := postAs(t, s, "", `{"model":"group/two","messages":[{"role":"user","content":"hi"}]}`); code != 200 || !strings.Contains(body, "from kb") {
t.Fatalf("%d %s", code, body)
}
if r := lastRoute(s); len(r.Tries) != 2 || r.Tries[0].Fail == failAuth || r.Tries[0].Rest == nil {
t.Fatalf("not rested as before: %+v", r.Tries)
}
}
30 changes: 28 additions & 2 deletions internal/gateway/claude_subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ type subscriptionRun struct {
// --effort) or was told since (setEffort); "" is Claude Code's own
effort string

// loginVersion is the credential the run's account had when it
// started (provider.ClaudeLoginVersion), which a refusal it reports is
// kept for
loginVersion string

// told is the conversation as the client had it in its last request
// here (historyKey): tool results are the run's while the client's
// conversation goes on from that one.
Expand Down Expand Up @@ -325,6 +330,9 @@ func (b *subscriptionBridge) start(ctx context.Context, req *Request, model, con
}

run := &subscriptionRun{bridge: b, token: token, model: model, cmd: cmd, tmp: tmp, schema: len(req.Schema) > 0, pending: map[string]chan mcpToolResult{}, stdin: stdin, owner: owner, effort: req.Effort}
if user, _ := ownerAccount(owner); user != "" {
run.loginVersion = provider.ClaudeLoginVersion(user)
}
// A caller may abandon a turn after receiving tool_use. Do not leave the
// parked Claude process and MCP request alive forever.
run.timer = time.AfterFunc(30*time.Minute, run.abort)
Expand Down Expand Up @@ -831,8 +839,8 @@ func (r *subscriptionRun) readOutput(rd io.Reader) {
// a run in Claude Code's own home is on whatever account
// Claude Code is signed in to by now: switched off the
// owner's, what it says is another's (nil_1024)
if f := strings.Split(r.owner, "\x00"); len(f) > 1 && !(len(f) > 2 && f[2] == ownHome && provider.ClaudeCodeMovedOff(f[1])) {
provider.NoteClaudeLimits(f[1], claudeLimits(envelope.RateLimitInfo))
if user, own := ownerAccount(r.owner); user != "" && !(own && provider.ClaudeCodeMovedOff(user)) {
provider.NoteClaudeLimits(user, claudeLimits(envelope.RateLimitInfo))
}
continue
}
Expand All @@ -851,6 +859,14 @@ func (r *subscriptionRun) readOutput(rd io.Reader) {
text += ": " + strings.Join(envelope.Errors, "; ")
}
}
// Anthropic refused the account's sign-in: kept on it, so
// it isn't run again on that one (provider/claude_auth.go) —
// not when Claude Code's own run went on as another account,
// which is read only for such an error
if user, own := ownerAccount(r.owner); user != "" && provider.ClaudeSignInRequired(text) &&
!(own && provider.ClaudeCodeMovedOff(user)) {
provider.NoteClaudeSignInFailure(user, r.loginVersion, text)
}
r.emit(Event{Kind: KError, Text: text, Status: envelope.APIErrorStatus, Code: errKind, RequestID: reqID})
r.endSegment()
case waiting && len(envelope.StructuredOutput) > 0 && string(envelope.StructuredOutput) != "null":
Expand Down Expand Up @@ -1483,6 +1499,16 @@ func (b *subscriptionBridge) removeRun(run *subscriptionRun) {
// own home.
const ownHome = "own"

// ownerAccount is the account a run's owner names, "" when it names none,
// and whether it is Claude Code's own sign-in (ownHome).
func ownerAccount(owner string) (user string, own bool) {
f := strings.Split(owner, "\x00")
if len(f) < 2 {
return "", false
}
return f[1], len(f) > 2 && f[2] == ownHome
}

func (s *Server) serveClaudeSubscription(w http.ResponseWriter, r *http.Request, from provider.Protocol, p provider.Provider, model string, body []byte, usage *Usage) (int, string) {
start := func(ctx context.Context, req *Request) (*subscriptionRun, <-chan Event, error) {
ctx = p.Via(ctx) // the account's own proxy, its CLI run's too
Expand Down
19 changes: 16 additions & 3 deletions internal/gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -1649,11 +1649,24 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
skipped = append(skipped, c.label()+": "+call.Error)
continue
}
if !last && hw.failed() && failureOf(c, hw.code(), hw.errBody()) == failAuth {
// the account's sign-in is gone, refused by Anthropic: no rest
// brings it back, so none is told; it is passed over until it
// is signed in again (provider/claude_auth.go), and the next
// one is asked
if other == nil {
other = &Try{Status: call.Status, Error: call.Error}
}
try.Fail = failAuth
s.trace.update(tr, func(t *Route) { t.Tries[len(t.Tries)-1] = try })
skipped = append(skipped, c.label()+": "+call.Error)
continue
}
if wait, ok := passing(hw.code(), hw.header, hw.errBody(), again); ok && !last && hw.failed() && spentAfter(cands[i+1:]) {
// the others left are out of their allowance (Discord, waroy: a
// Codex account run out, Grok busy a moment): this one is the
// last that may answer, and is tried again as the last is
try.Fail, try.Again = failure(hw.code(), hw.errBody()), wait.Milliseconds()
try.Fail, try.Again = failureOf(c, hw.code(), hw.errBody()), wait.Milliseconds()
s.trace.update(tr, func(t *Route) { t.Tries[len(t.Tries)-1] = try })
skipped = append(skipped, c.label()+": "+call.Error)
again++
Expand All @@ -1678,7 +1691,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
}
if wait, ok := passing(hw.code(), hw.header, hw.errBody(), again); ok && hw.failed() {
// nobody else is left: the same one again, after a moment
try.Fail, try.Again = failure(hw.code(), hw.errBody()), wait.Milliseconds()
try.Fail, try.Again = failureOf(c, hw.code(), hw.errBody()), wait.Milliseconds()
s.trace.update(tr, func(t *Route) { t.Tries[len(t.Tries)-1] = try })
skipped = append(skipped, c.label()+": "+call.Error)
again++
Expand Down Expand Up @@ -1747,7 +1760,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
} else if hw.refused {
try.Fail = failRefused
} else {
try.Fail = failure(call.Status, []byte(call.Error))
try.Fail = failureOf(c, call.Status, []byte(call.Error))
if try.Fail == failVerify && !held {
// the last one left rests too, for the app to show and the
// next requests to be held
Expand Down
14 changes: 13 additions & 1 deletion internal/gateway/routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const (
failQuota = "quota"
failRate = "rate"
failOther = "other"
failAuth = "auth"
// failCanceled: the agent went away before the answer came
failCanceled = "canceled"
// failForeign: the conversation's reasoning was sealed by another
Expand Down Expand Up @@ -191,6 +192,17 @@ func failure(status int, body []byte) string {
return failOther
}

// failureOf says why c's reply failed: a Claude account's sign-in that
// Anthropic refused is told as one (provider/claude_auth.go), passed over
// till it is signed in again; another vendor saying the same words fails
// as failure says, and rests as before.
func failureOf(c candidate, status int, body []byte) string {
if status >= 400 && c.p.Account != nil && c.p.Account.Agent == "claude" && provider.ClaudeSignInRequired(string(body)) {
return failAuth
}
return failure(status, body)
}

// openRouterSharedPool says an OpenRouter free model was refused by the
// provider's shared pool, rather than by OpenRouter's account-wide free tier.
func openRouterSharedPool(body []byte) bool {
Expand Down Expand Up @@ -307,7 +319,7 @@ func (s *Server) restAfter(c candidate, status int, header http.Header, body []b
func (s *Server) restAfterMarked(c candidate, status int, header http.Header, body []byte, sharedPool bool) Rest {
now := time.Now()
d := fallbackCooldown
why := failure(status, body)
why := failureOf(c, status, body)
r := Rest{Why: why, Status: status, By: "cooldown"}
if why == failProxy {
// the account is as good as it was; the proxy is the user's to start
Expand Down
33 changes: 26 additions & 7 deletions internal/gui/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6311,7 +6311,8 @@ function renderEndpoints(p, src) {
s.className = "res " + (x.ok ? "ok" : "bad");
s.replaceChildren();
s.append(svg(x.ok ? CHECK : "M4.5 4.5l7 7M11.5 4.5l-7 7", 10, 2));
s.append(el("span", "", x.ok ? ledTook(x.ms) : x.status ? `${x.status} · ${x.error}` : x.error));
const result = x.ok ? ledTook(x.ms) : x.status ? `${x.status} · ${x.error}` : x.error;
s.append(el("span", "", x.account ? t("Tested {user}: {result}", { user: x.account, result }) : result));
s.title = x.ok ? t("model {model}", { model: x.model }) : x.error;
}
} catch (e) { for (const s of Object.values(slots)) { s.className = "res"; s.textContent = ""; } status(e.message, "err"); }
Expand Down Expand Up @@ -7857,7 +7858,11 @@ function renderAccounts(a, p) {
const sub = subOf(a.agent);
const list = el("div", "accts");
const ls = loginsInOrder(a, p);
const several = ls.filter((l) => (l.active && !l.paused) || l.on).length > 1;
// a Claude account whose sign-in Anthropic refused, or that has none,
// can't be used till it is signed in again; another subscription's lapse
// shows on its quota line only, as before
const unusable = (l) => a.agent === "claude" && !!l.lapsed;
const several = ls.filter((l) => !unusable(l) && ((l.active && !l.paused) || l.on)).length > 1;
// kept signed in to one of the user's choosing (#524), the first is the
// first in use in the order, which Make first sets without a sign-in
const kept = keptLogin(p);
Expand All @@ -7871,18 +7876,21 @@ function renderAccounts(a, p) {
// the account Claude Code or Codex is signed in to can be paused while
// another is on: the gateway passes over it, the agent staying signed in
// to it (#263)
const pausable = (a.agent === "claude" || a.agent === "codex") && ls.some((l) => !l.active && l.on);
const pausable = (a.agent === "claude" || a.agent === "codex") && ls.some((l) => !unusable(l) && !l.active && l.on);
const quota = loginUsageOf(a.agent);
// the first, which magpie signed the agent out of while it was spent:
// it is signed back in once it has room (#408)
const back = ls.find((l) => l.returns && !l.active);
for (const l of ls) {
const on = !l.paused && (l.active || l.on);
const on = !unusable(l) && !l.paused && (l.active || l.on);
const row = el("div", "acc" + (on ? " in-use" : " off") + (l.user === justAdded ? " new" : ""));
row.dataset.accountId = l.user;
const dot = el("button", "dot tick");
if (on) dot.append(svg(CHECK, 10, 2.2));
if (l.active && (pausable || l.paused)) {
if (unusable(l)) {
dot.disabled = true;
dot.title = t("Sign in again to use this account");
} else if (l.active && (pausable || l.paused)) {
dot.title = l.paused ? t("Resume: the gateway uses this account first again") : t("Pause: the gateway uses the other accounts, {agent} stays signed in to this one", { agent: a.agentName });
dot.onclick = () => accountAction("login/" + (l.paused ? "on" : "off"), { agent: a.agent, user: l.user });
} else if (l.active) {
Expand All @@ -7897,7 +7905,18 @@ function renderAccounts(a, p) {
const [amPill, amBox] = ls.length > 1 || accountModelsOf(p, l.user).length ? accountModels(p, l.user, false, l.user) : [];
if (amPill) row.append(amPill);
row.append(el("span", "grow"));
if (l.active && kept && l.user !== firstUser) {
if (unusable(l)) {
row.append(el("span", "using", t("Sign-in required")));
const again = el("button", "text", t("Sign in again"));
again.onclick = () => startSignIn(a.agent);
row.append(again);
// the one Claude Code is signed in to can't be forgotten (ForgetLogin)
if (!l.active) {
const forget = el("button", "text quiet", t("Remove"));
forget.onclick = () => accountAction("login/forget", { agent: a.agent, user: l.user }, t("{user} removed", { user: l.user }));
row.append(forget);
}
} else if (l.active && kept && l.user !== firstUser) {
// signed in to, kept so, and tried at its place in the order
const signed = el("span", "using", l.paused ? t("Paused") : t("Signed in"));
signed.title = t("{agent} is kept signed in to this account; requests through magpie go to the accounts in their order", { agent: a.agentName });
Expand Down Expand Up @@ -7935,7 +7954,7 @@ function renderAccounts(a, p) {
row.append(forget, use);
}
}
row.append(accountQuota(l.lapsed ? { [l.user]: { error: l.lapsed } } : quota, l.user));
row.append(accountQuota(l.lapsed ? { [l.user]: { error: t(l.lapsed) } } : quota, l.user));
row.classList.add("with-aq"); // not :has(.aq), which Safari 15.0 lacks (#220)
if (amBox) row.append(amBox);
list.append(row);
Expand Down
9 changes: 9 additions & 0 deletions internal/gui/assets/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,15 @@ const I18N = {
"The agents given it use magpie's sign-in": "分配到它的 agent 都用 magpie 的登录",
"Sign-in ran out": "登录已失效",
"Sign in again": "重新登录",
"Sign-in required": "需要重新登录",
"sign-in required": "需要重新登录",
"Sign in again to use this account": "重新登录后才能使用此账号",
"Claude Code is no longer signed in; sign in again in magpie": "Claude Code 已没有有效登录,请在 magpie 中重新登录",
"its sign-in is gone; sign in again": "登录信息已不存在,请重新登录",
"Claude Code could not authenticate this account; sign in again in magpie": "Claude Code 无法认证此账号,请在 magpie 中重新登录",
"Tested {user}: {result}": "已测试 {user}:{result}",
"{who} could not authenticate. Sign in again in magpie; this login is not retried. The request went to {next}.": "{who} 认证失败。请在 magpie 中重新登录;当前登录不再重试。请求已转给 {next}。",
"{who} could not authenticate. Sign in again in magpie; this login is not retried. No other account could answer.": "{who} 认证失败。请在 magpie 中重新登录;当前登录不再重试。没有其他账号可以回答。",
"Open it to sign in again": "打开它重新登录",
"Signed in to {name} — the agents given it use magpie's sign-in": "已登录 {name}——分配到它的 agent 都用 magpie 的登录",
"Signed out of {name} — the agents are given the server's own address again": "已退出 {name}——agent 重新使用服务器自己的地址",
Expand Down
Loading
Loading