provider, gateway, gui: a ChatGPT plan can be signed in with OpenAI's Sign in with ChatGPT, as the ChatGPT API subscription, and asked on api.openai.com's Responses with the agent's own instructions (#933, knothhe)

OpenAI's Sign in with ChatGPT lets an open-source app on the user's machine use their ChatGPT plan through the public API: it registers itself on the way (client_id dynamic_agent_client, an ext_agent_host_id kept for this machine, agent_name_hint magpie), the callback brings the client id OpenAI issued, and the token (scope chatgpt.tokens.use.direct, resource https://api.openai.com/v1) is sent as a Bearer to api.openai.com/v1/responses. The new subscription, chatgpt-api ("ChatGPT API" in the add sheet, beside Codex's ChatGPT), signs in so in magpie (PKCE, a loopback callback on 1455 or any free port, the ID token's issuer, audience, nonce and expiry checked), keeps its accounts as the other in-magpie sign-ins do, refreshes under the issued client id (rotated refresh token, serialised; a refresh token OpenAI won't take again marks the account to sign in again), revokes it on sign-out, and lists the models /v1/models gives the token. Requests go with the agent's own instructions — no Codex prompt, no Codex headers; the Codex path is unchanged — stored nowhere and streamed, without the fields the API refuses for a ChatGPT token (max_output_tokens, temperature, prompt_cache_retention, …) and without hosted tools; a system message is a developer one. OpenAI's subscription_sharing_* refusals are said with what to do (the plan's usage at chatgpt.com/settings/usage, an ineligible account, a capability the token can't use). There is no usage meter: the token has no usage API.

Tested against fake OpenAI auth and API servers (TestChatGPTAPISignIn, …Refused, …Refresh, …Body, …Explain; gateway TestChatGPTAPIRequests over chat completions, messages and responses) and the add sheet in Playwright on Chromium and WebKit (chatgpt-api-sub.test.cjs); the gateway and GUI tests fail without the change. Not yet signed in with a real ChatGPT account: the consent page needs the account's owner. go vet, go build (GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
This commit is contained in:
yetone committed 2026-10-06 01:22:44 +08:00
1 parent 8d3cf5f86e
commit 7a926dd3ce
15 files changed
+1369 -6

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
package gateway
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
// #933: a ChatGPT plan signed in with OpenAI's Sign in with ChatGPT is
// asked on api.openai.com's Responses with its token, whichever API the
// agent speaks, with the agent's own instructions — none of Codex's — and
// without the fields that API refuses for a ChatGPT token.
func TestChatGPTAPIRequests(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
f := &fake{t: t, reply: sse(
`data: {"type":"response.created","response":{"id":"r1","model":"gpt-5.5"}}`,
`data: {"type":"response.output_text.delta","delta":"pong"}`,
`data: {"type":"response.completed","response":{"id":"r1","status":"completed","usage":{"input_tokens":7,"output_tokens":1}}}`)}
up := httptest.NewServer(f)
t.Cleanup(up.Close)
t.Cleanup(provider.SIWCForTest(up.URL, up.URL+"/v1"))
auth, _ := json.Marshal(map[string]any{
"clientId": "oaiapp_1", "hostId": "urn:uuid:00000000-0000-4000-8000-000000000000", "sub": "user-1",
"email": "me@example.com", "access": "at-live", "refresh": "rt-live",
"expires": time.Now().Add(time.Hour), "scopes": []string{"chatgpt.tokens.use.direct"},
})
dir := filepath.Dir(provider.Path())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal([]map[string]any{{
"agent": provider.ChatGPTAPIID, "user": "me@example.com", "plan": "plus", "on": true, "auth": json.RawMessage(auth),
}})
if err := os.WriteFile(filepath.Join(dir, "logins.json"), raw, 0o600); err != nil {
t.Fatal(err)
}
for _, tt := range []struct{ name, path, body string }{
{"chat", "/v1/chat/completions", `{"model":"chatgpt-api/gpt-5.5","max_tokens":64,"temperature":0.3,"messages":[{"role":"system","content":"You are my agent."},{"role":"user","content":"ping"}]}`},
{"messages", "/v1/messages", `{"model":"chatgpt-api/gpt-5.5","max_tokens":64,"temperature":0.3,"system":"You are my agent.","messages":[{"role":"user","content":"ping"}]}`},
{"responses", "/v1/responses", `{"model":"chatgpt-api/gpt-5.5","max_output_tokens":64,"temperature":0.3,"store":true,"instructions":"You are my agent.","input":"ping"}`},
} {
t.Run(tt.name, func(t *testing.T) {
f.got, f.path, f.head = nil, "", nil
code, out := post(t, tt.path, tt.body)
if code != 200 || !strings.Contains(out, "pong") {
t.Fatalf("%d %s", code, out)
}
if f.path != "/v1/responses" {
t.Fatalf("went to %s", f.path)
}
if got := f.head.Get("Authorization"); got != "Bearer at-live" {
t.Errorf("Authorization %q", got)
}
if f.head.Get("chatgpt-account-id") != "" || f.head.Get("originator") != "" {
t.Errorf("Codex's headers sent: %v", f.head)
}
var m map[string]any
if err := json.Unmarshal(f.got, &m); err != nil {
t.Fatalf("upstream %s", f.got)
}
if m["model"] != "gpt-5.5" || m["store"] != false || m["stream"] != true {
t.Errorf("body %s", f.got)
}
for _, k := range []string{"max_output_tokens", "temperature", "prompt_cache_retention"} {
if _, ok := m[k]; ok {
t.Errorf("%s sent: %s", k, f.got)
}
}
body := string(f.got)
if !strings.Contains(body, "You are my agent.") || strings.Contains(body, "Codex") {
t.Errorf("instructions not the agent's own: %s", body)
}
})
}
}
+4 -1
View File
@@ -4813,7 +4813,7 @@ function accountPlan(a) {
}
return name + " " + plan;
};
if (agent === "codex") return named("ChatGPT", a.plan && cap(a.plan));
if (agent === "codex" || agent === "chatgpt-api") return named("ChatGPT", a.plan && cap(a.plan));
if (agent === "copilot") return named("GitHub", a.plan && cap(a.plan));
if (agent === "claude") return named("Claude", a.plan && cap(a.plan));
if (agent === "cursor") return named("Cursor", a.plan && cap(a.plan));
@@ -9202,6 +9202,9 @@ const SUBS = [
{ agent: "claude", name: "Claude", icon: "claude-color", plans: "Pro · Max · Team", importable: true, risk: true,
riskNote: "Anthropic may suspend or ban a Claude account it sees used outside its own apps. magpie sends requests through Claude Code, but Anthropic may still act on them; you use it at your own risk. Use an account you can afford to lose." },
{ agent: "codex", name: "ChatGPT", icon: "openai", plans: "Plus · Pro · Business", importable: true },
// OpenAI's Sign in with ChatGPT: the plan asked on api.openai.com, with the agent's own instructions (#933)
{ agent: "chatgpt-api", name: "ChatGPT API", icon: "openai", plans: "Plus · Pro · Business", own: true,
hint: "Sign in with ChatGPT: your plan on OpenAI's own API, with your agent's own instructions rather than Codex's." },
// cursor-agent keeps one account; signing in again replaces it
{ agent: "cursor", name: "Cursor", icon: "cursor", plans: "Pro · Ultra · Teams", single: true },
// so does Grok Build
+3
View File
@@ -992,6 +992,7 @@ const I18N = {
"Add another {name} account": "添加另一个 {name} 账号",
"WorkBuddy AI (international)": "WorkBuddy 国际版 (WorkBuddy AI)",
"Qoder (international)": "Qoder 国际版 (qoder.com)",
"Sign in with ChatGPT: your plan on OpenAI's own API, with your agent's own instructions rather than Codex's.": "用 ChatGPT 登录(Sign in with ChatGPT):通过 OpenAI 官方 API 使用你的套餐,用的是你 agent 自己的指令,而不是 Codex 的。",
"For accounts on qoder.com, the international site.": "适用于 qoder.com(国际站)的账号。",
"For accounts on qoder.cn: signed in with an Alibaba Cloud account or a phone number.": "适用于 qoder.cn 的账号:阿里云账号 / 手机号登录。",
"Sign in to another {name} account": "换一个 {name} 账号登录",
@@ -4378,6 +4379,7 @@ const I18N = {
"Add another {name} account": "別の {name} アカウントを追加",
"WorkBuddy AI (international)": "WorkBuddy AI(海外版)",
"Qoder (international)": "Qoder(海外版)",
"Sign in with ChatGPT: your plan on OpenAI's own API, with your agent's own instructions rather than Codex's.": "Sign in with ChatGPT:OpenAI 公式の API でプランを使います。指示は Codex のものではなく、エージェント自身のものです。",
"For accounts on qoder.com, the international site.": "海外サイト qoder.com のアカウント向けです。",
"For accounts on qoder.cn: signed in with an Alibaba Cloud account or a phone number.": "qoder.cn のアカウント向けです:Alibaba Cloud アカウントまたは電話番号でサインインします。",
"Sign in to another {name} account": "別の {name} アカウントでサインイン",
@@ -7756,6 +7758,7 @@ const I18N = {
"Add another {name} account": "Weiteres {name}-Konto hinzufügen",
"WorkBuddy AI (international)": "WorkBuddy AI (international)",
"Qoder (international)": "Qoder (international)",
"Sign in with ChatGPT: your plan on OpenAI's own API, with your agent's own instructions rather than Codex's.": "Sign in with ChatGPT: dein Abo über OpenAIs eigene API, mit den eigenen Anweisungen deines Agenten statt denen von Codex.",
"For accounts on qoder.com, the international site.": "Für Konten auf qoder.com, der internationalen Website.",
"For accounts on qoder.cn: signed in with an Alibaba Cloud account or a phone number.": "Für Konten auf qoder.cn: angemeldet mit einem Alibaba-Cloud-Konto oder einer Telefonnummer.",
"Sign in to another {name} account": "Bei einem anderen {name}-Konto anmelden",
+3
View File
@@ -219,6 +219,9 @@ func accountLabel(p provider.Provider) (name, icon string) {
} else if a.Agent == provider.MiMoID {
// a Xiaomi MiMo account, not MiMo Code (the agent "mimo" also names)
name, icon = "Xiaomi MiMo", "mimocode"
} else if a.Agent == provider.ChatGPTAPIID {
// a ChatGPT plan through OpenAI's API, not Codex's backend
name, icon = "ChatGPT API", "openai"
} else if ag, err := agent.Find(a.Agent); err == nil {
name, icon = ag.Name, ag.Icon
} else if a.Agent == "cursor" {
@@ -0,0 +1,89 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// #933: OpenAI's Sign in with ChatGPT is a subscription of its own in the
// add sheet, "ChatGPT API" beside Codex's "ChatGPT" — two rows that read
// apart — its title saying what it is, and its row starts that sign-in
// (agent chatgpt-api) and none of Codex's. Its account is named by its plan
// as a ChatGPT one is. English and Chinese; no backend, the API is faked.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const anthropic = { id: "anthropic", name: "Anthropic", icon: "claude", preset: "anthropic", models: [], agents: [], key: { set: true, masked: "sk-…ab12" } };
function server(lang, list, posted) {
return async (route) => {
const req = route.request();
const url = new URL(req.url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } });
if (url.pathname === "/api/providers") return json({ providers: list, presets: [], excluded: [], gateway: { running: true, window: true } });
if (url.pathname === "/api/groups") return json({ groups: [], pools: [] });
if (url.pathname === "/api/gateway/trace") return json({ routes: [] });
if (url.pathname === "/api/signin") {
posted.push(JSON.parse(req.postData() || "{}"));
return json({ id: "s1", agent: "chatgpt-api", state: "waiting", url: "https://auth.openai.com/api/accounts/authorize?client_id=dynamic_agent_client" });
}
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
};
}
const L = {
en: { hint: /your plan on OpenAI's own API, with your agent's own instructions rather than Codex's/ },
zh: { hint: /通过 OpenAI 官方 API 使用你的套餐/ },
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
test(engine + ": Sign in with ChatGPT is its own subscription", async (t) => {
assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
for (const lang of ["en", "zh"]) {
await t.test(lang, async () => {
const posted = [];
const page = await (await browser.newContext({ viewport: { width: 900, height: 800 } })).newPage();
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server(lang, [anthropic], posted));
await page.goto("http://magpie.test/?view=providers");
await page.locator("#addProvider").click();
const sheet = page.locator("#addSheet");
await sheet.locator(".tile").first().waitFor();
const row = (name) => sheet.locator(".tile").filter({ has: page.locator(".n", { hasText: new RegExp("^" + name + "$") }) });
assert.equal(await row("ChatGPT").count(), 1, "Codex's row");
assert.equal(await row("ChatGPT API").count(), 1, "Sign in with ChatGPT's row");
assert.match(await row("ChatGPT API").getAttribute("title"), L[lang].hint);
assert.doesNotMatch(await row("ChatGPT").getAttribute("title"), L[lang].hint);
const y = await page.evaluate(() => scrollY);
await row("ChatGPT API").click();
for (let i = 0; i < 50 && !posted.length; i++) await page.waitForTimeout(20);
assert.deepEqual(posted, [{ agent: "chatgpt-api" }]);
assert.equal(await page.evaluate(() => scrollY), y, "the click doesn't scroll");
assert.deepEqual(errors, []);
await page.context().close();
});
}
// its account named by its plan, as a ChatGPT one is
await t.test("account name", async () => {
const list = [{ id: "chatgpt-api", name: "ChatGPT API", icon: "openai", models: [], agents: [],
account: { agent: "chatgpt-api", user: "me@example.com", plan: "plus", logins: [{ user: "me@example.com", plan: "plus", active: true, on: true }] } }];
const page = await (await browser.newContext({ viewport: { width: 900, height: 800 } })).newPage();
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server("en", list, []));
await page.goto("http://magpie.test/?view=providers");
const r = page.locator('#providers .row[data-id="chatgpt-api"]');
await r.waitFor();
assert.match(await r.textContent(), /ChatGPT Plus/);
assert.deepEqual(errors, []);
await page.context().close();
});
});
}
+4 -1
View File
@@ -737,6 +737,9 @@ func Accounts() []Provider {
if p, ok := mimoAccount(); ok {
out = append(out, p)
}
if p, ok := siwcAccount(); ok {
out = append(out, p)
}
for _, agent := range []string{"gemini", "antigravity"} {
if p, ok := googleAccountOf(agent); ok {
out = append(out, p)
@@ -749,7 +752,7 @@ func Accounts() []Provider {
// builtinOrder is the built-ins' ids in the order Accounts lists them.
var builtinOrder = slices.Concat([]string{"claude", "codex", "copilot", "cursor", "grok", "devin", "kiro", "zcode",
"workbuddy", WorkBuddyAIID, CommandCodePlanID}, qoderAgents, []string{"zed", "factory", MiMoID, "gemini", "antigravity"})
"workbuddy", WorkBuddyAIID, CommandCodePlanID}, qoderAgents, []string{"zed", "factory", MiMoID, ChatGPTAPIID, "gemini", "antigravity"})
// placeMoved adds the plugins' accounts to the built-ins': one a built-in
// was moved onto stands where the built-in stood, the others go last.
+723
View File
@@ -0,0 +1,723 @@
package provider
// A ChatGPT API subscription is a ChatGPT plan used through OpenAI's own
// API, as OpenAI's Sign in with ChatGPT lets an open-source app that runs
// on the user's machine (#933): the account's access token is sent to
// api.openai.com/v1/responses with the agent's own instructions, nothing of
// Codex's in it. It sits beside the Codex subscription, which signs in as
// Codex CLI and asks ChatGPT's Codex backend, and changes nothing of that.
//
// The sign-in registers magpie with OpenAI on the way (a public client,
// no secret): the browser is sent to OpenAI's authorize page as
// dynamic_agent_client, and comes back with the client id OpenAI issued
// beside the code, which the code, every refresh and the sign-out go with.
// ext_agent_host_id names this machine, the same every time. Each account
// keeps its own client id. Plus shares one usage limit (five hours)
// between ChatGPT and every app it is used in; OpenAI says how much is left
// only at chatgpt.com/settings/usage.
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/appdir"
"github.com/yetone/magpie/internal/catalog"
)
// ChatGPTAPIID is the subscription's id, as a provider and in logins.json.
const ChatGPTAPIID = "chatgpt-api"
// Where OpenAI's sign-in and API are; vars so tests can point them
// elsewhere. siwcResource is what the token is for, whichever API answers.
var (
siwcIssuer = "https://auth.openai.com"
siwcAuthorizeURL = "https://auth.openai.com/api/accounts/authorize"
siwcTokenURL = "https://auth.openai.com/api/accounts/oauth/token"
siwcAPI = "https://api.openai.com/v1"
// siwcCallbackAddr is where the browser is sent back to first, as
// OpenAI's own apps are; another port when that one is busy (a Codex
// sign-in waiting there), only the port of a loopback address may vary
siwcCallbackAddr = "127.0.0.1:1455"
)
const (
siwcResource = "https://api.openai.com/v1"
// siwcDynamicClient registers the app on its first sign-in
siwcDynamicClient = "dynamic_agent_client"
siwcAgentName = "magpie"
// siwcDirectScope lets the token be sent to api.openai.com itself
siwcDirectScope = "chatgpt.tokens.use.direct"
siwcScopes = "openid profile email offline_access resource.invoke " + siwcDirectScope
// siwcMargin is how long before it expires a token is refreshed, so no
// request starts with one about to
siwcMargin = 3 * time.Minute
// SIWCUsageURL is where a ChatGPT account's usage is shown
SIWCUsageURL = "https://chatgpt.com/settings/usage"
)
var siwcClient = &http.Client{Timeout: 30 * time.Second}
// ErrSIWCSignIn is an account OpenAI no longer refreshes: it must be
// signed in again.
var ErrSIWCSignIn = errors.New("the ChatGPT sign-in has expired — sign in again")
// siwcCreds is what a sign-in leaves: the client id OpenAI issued, this
// machine's id it was issued for, who signed in and the tokens.
type siwcCreds struct {
ClientID string `json:"clientId"`
HostID string `json:"hostId"`
Subject string `json:"sub,omitempty"`
Email string `json:"email,omitempty"`
IDToken string `json:"idToken,omitempty"`
Access string `json:"access"`
Refresh string `json:"refresh"`
Expires time.Time `json:"expires"`
Scopes []string `json:"scopes,omitempty"`
}
func siwcSaved(l savedLogin) (siwcCreds, bool) {
var c siwcCreds
if json.Unmarshal(l.Auth, &c) != nil || c.ClientID == "" || c.Refresh == "" {
return siwcCreds{}, false
}
return c, true
}
func siwcSide() []sideLogin {
return sideLogins(ChatGPTAPIID, "", func(l savedLogin) bool {
_, ok := siwcSaved(l)
return ok
})
}
func siwcLoginList() []Login { return loginsOf(siwcSide()) }
func switchSIWCLogin(user string) error { return switchSideLogin(ChatGPTAPIID, user, siwcSide()) }
func setSIWCLoginOn(user string, on bool) error {
return setSideLoginOn(ChatGPTAPIID, user, on, siwcSide())
}
// forgetSIWCLogin removes an account, and has OpenAI revoke its refresh
// token: signed out, it is gone there too.
func forgetSIWCLogin(user string) error {
return forgetSideLogin(ChatGPTAPIID, user, siwcSide(), func(l savedLogin) {
if c, ok := siwcSaved(l); ok {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = siwcRevoke(ctx, c)
}()
}
})
}
func siwcLookup(user string) (siwcCreds, bool) {
loginsMu.Lock()
defer loginsMu.Unlock()
for _, l := range readLogins() {
if l.Agent == ChatGPTAPIID && strings.EqualFold(l.User, user) {
return siwcSaved(l)
}
}
return siwcCreds{}, false
}
// siwcHostID is this machine's id for OpenAI, made once and kept.
func siwcHostID() (string, error) {
path := filepath.Join(appdir.Config(), "chatgpt-api-host")
if b, err := os.ReadFile(path); err == nil {
if id := strings.TrimSpace(string(b)); strings.HasPrefix(id, "urn:uuid:") {
return id, nil
}
}
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
b[6], b[8] = b[6]&0x0f|0x40, b[8]&0x3f|0x80 // a version 4 UUID
h := hex.EncodeToString(b)
id := "urn:uuid:" + h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", err
}
return id, os.WriteFile(path, []byte(id+"\n"), 0o600)
}
// ---- the sign-in ------------------------------------------------------------
// listenSIWCCallback takes the port the browser is sent back to.
func listenSIWCCallback() (net.Listener, error) {
if ln, err := net.Listen("tcp", siwcCallbackAddr); err == nil {
return ln, nil
}
return net.Listen("tcp", "127.0.0.1:0")
}
// siwcAuthorize is OpenAI's page the sign-in opens.
func siwcAuthorize(redirect, state, nonce, challenge, hostID string) string {
q := url.Values{}
q.Set("client_id", siwcDynamicClient)
q.Set("agent_name_hint", siwcAgentName)
q.Set("ext_agent_host_id", hostID)
q.Set("response_type", "code")
q.Set("redirect_uri", redirect)
q.Set("resource", siwcResource)
q.Set("scope", siwcScopes)
q.Set("state", state)
q.Set("nonce", nonce)
q.Set("code_challenge", challenge)
q.Set("code_challenge_method", "S256")
return siwcAuthorizeURL + "?" + q.Encode()
}
// siwcDone finishes a sign-in whose browser came back with q.
func (s *signInFlow) siwcDone(ctx context.Context, w http.ResponseWriter, q url.Values) {
user, plan, err := siwcSignedIn(ctx, q.Get("code"), strings.TrimSpace(q.Get("client_id")), s.verifier, s.redirect, s.nonce, s.hostID)
if err != nil {
s.finish(SignInState{State: "failed", Error: err.Error()})
signInPage(w, false, "Sign-in didn't finish", err.Error())
return
}
s.finish(SignInState{State: "done", User: user, Plan: plan, Using: strings.EqualFold(activeOf(siwcSide()), user)})
signInPage(w, true, "You're signed in", fmt.Sprintf("%s is added to magpie. You can close this tab.", user))
}
// siwcSignedIn trades the code for the account's tokens and keeps it.
func siwcSignedIn(ctx context.Context, code, clientID, verifier, redirect, nonce, hostID string) (user, plan string, err error) {
if clientID == "" {
return "", "", errors.New("ChatGPT sign-in: OpenAI didn't finish registering magpie (no client id came back); start it again")
}
tok, err := siwcPostToken(ctx, url.Values{"grant_type": {"authorization_code"}, "client_id": {clientID},
"code": {code}, "code_verifier": {verifier}, "redirect_uri": {redirect}, "resource": {siwcResource}})
if err != nil {
return "", "", fmt.Errorf("ChatGPT sign-in: %w", err)
}
if tok.IDToken == "" {
return "", "", errors.New("ChatGPT sign-in: OpenAI sent no ID token")
}
c, err := siwcFromToken(tok, siwcCreds{ClientID: clientID, HostID: hostID})
if err != nil {
return "", "", fmt.Errorf("ChatGPT sign-in: %w", err)
}
id := jwtClaims(tok.IDToken)
if err := siwcCheckID(id, clientID, nonce); err != nil {
return "", "", fmt.Errorf("ChatGPT sign-in: %w", err)
}
c.Subject, c.Email = claimString(id, "sub"), claimString(id, "email")
user = firstNonEmpty(c.Email, c.Subject)
if user == "" {
return "", "", errors.New("ChatGPT sign-in: OpenAI didn't say which account signed in")
}
plan = siwcPlan(c)
auth, err := json.Marshal(c)
if err != nil {
return "", "", err
}
if err := addSideLogin(savedLogin{Agent: ChatGPTAPIID, User: user, Plan: plan, Auth: auth}, "", func(savedLogin) {}); err != nil {
return "", "", err
}
// signed in again: whatever OpenAI refused before is over
_ = editSideLogin(ChatGPTAPIID, user, func(ls []savedLogin, i int) ([]savedLogin, error) {
ls[i].Lapsed = ""
return ls, nil
})
forgetAccountCaches()
// the account's own list, so the picker has it before its first request
if ms, err := siwcFetchModels(ctx, user); err == nil {
_ = catalog.SaveLive(ChatGPTAPIID, siwcAPI, ms)
}
return user, plan, nil
}
// siwcCheckID checks the ID token is this sign-in's: OpenAI's, for the
// client it issued, with the nonce the sign-in sent, not expired.
func siwcCheckID(id map[string]any, clientID, nonce string) error {
if id == nil {
return errors.New("OpenAI sent an unreadable ID token")
}
if iss := claimString(id, "iss"); strings.TrimRight(iss, "/") != strings.TrimRight(siwcIssuer, "/") {
return fmt.Errorf("the ID token is from %q, not OpenAI", iss)
}
aud := false
switch v := id["aud"].(type) {
case string:
aud = v == clientID
case []any:
aud = slices.Contains(v, any(clientID))
}
if !aud {
return errors.New("the ID token is for another app")
}
if claimString(id, "nonce") != nonce {
return errors.New("the ID token isn't this sign-in's")
}
if exp, ok := id["exp"].(float64); ok && time.Unix(int64(exp), 0).Before(time.Now().Add(-time.Minute)) {
return errors.New("the ID token has expired")
}
return nil
}
// siwcPlan is the ChatGPT plan a token names, "" when none does.
func siwcPlan(c siwcCreds) string {
for _, t := range []string{c.IDToken, c.Access} {
if p := claimString(jwtClaims(t), "https://api.openai.com/auth", "chatgpt_plan_type"); p != "" {
return p
}
}
return ""
}
// siwcTokenReply is the token endpoint's answer.
type siwcTokenReply struct {
Access string `json:"access_token"`
Refresh string `json:"refresh_token"`
IDToken string `json:"id_token"`
ExpiresIn float64 `json:"expires_in"`
Scope string `json:"scope"`
}
// siwcTokenError is the token endpoint's refusal, with its OAuth code.
type siwcTokenError struct {
Status int
Code string
Msg string
}
func (e *siwcTokenError) Error() string {
return strings.TrimSpace(fmt.Sprintf("%s %s", firstNonEmpty(e.Code, http.StatusText(e.Status)), e.Msg))
}
func siwcPostToken(ctx context.Context, form url.Values) (siwcTokenReply, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, siwcTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return siwcTokenReply{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
res, err := siwcClient.Do(req)
if err != nil {
return siwcTokenReply{}, err
}
defer res.Body.Close()
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode != http.StatusOK {
var e struct {
Error any `json:"error"`
Desc string `json:"error_description"`
Code string `json:"code"`
}
_ = json.Unmarshal(b, &e)
out := &siwcTokenError{Status: res.StatusCode, Code: e.Code, Msg: e.Desc}
switch v := e.Error.(type) {
case string:
out.Code = firstNonEmpty(out.Code, v)
case map[string]any:
code, _ := v["code"].(string)
msg, _ := v["message"].(string)
out.Code, out.Msg = firstNonEmpty(out.Code, code), firstNonEmpty(out.Msg, msg)
}
if out.Code == "" && out.Msg == "" {
out.Msg = APIError(b, res.Status)
}
return siwcTokenReply{}, out
}
var tok siwcTokenReply
if err := json.Unmarshal(b, &tok); err != nil {
return siwcTokenReply{}, fmt.Errorf("unreadable token answer: %w", err)
}
return tok, nil
}
// siwcFromToken is c with the tokens of a token answer in it.
func siwcFromToken(tok siwcTokenReply, c siwcCreds) (siwcCreds, error) {
if tok.Access == "" || tok.Refresh == "" {
return c, errors.New("OpenAI sent no token")
}
if tok.ExpiresIn <= 0 {
return c, errors.New("OpenAI's token has no lifetime")
}
scopes := strings.Fields(tok.Scope)
if !slices.Contains(scopes, siwcDirectScope) {
return c, errors.New("OpenAI didn't grant magpie the use of the subscription through its API (" + siwcDirectScope + "): this ChatGPT account may not be eligible")
}
c.Access, c.Refresh, c.Scopes = tok.Access, tok.Refresh, scopes
c.Expires = time.Now().Add(time.Duration(tok.ExpiresIn * float64(time.Second))).UTC()
if tok.IDToken != "" {
c.IDToken = tok.IDToken
}
return c, nil
}
// siwcDead are the refusals of a refresh token that will never work again.
var siwcDead = []string{"invalid_grant", "invalid_refresh_token", "token_expired", "refresh_token_expired",
"refresh_token_invalidated", "refresh_token_reused"}
// siwcRefreshing keeps two requests from refreshing at once: the refresh
// token rotates, and the second would send the one the first used up.
var siwcRefreshing sync.Mutex
// siwcFresh is an account's credentials with an access token that isn't
// about to expire, refreshed when it is, or when force is set (OpenAI
// turned the one in hand away).
func siwcFresh(ctx context.Context, user string, force bool) (siwcCreds, error) {
c, ok := siwcLookup(user)
if !ok {
return siwcCreds{}, fmt.Errorf("no ChatGPT account %q", user)
}
if !force && time.Until(c.Expires) > siwcMargin {
return c, nil
}
siwcRefreshing.Lock()
defer siwcRefreshing.Unlock()
// refreshed by another request while this one waited
if n, ok := siwcLookup(user); ok && n.Refresh != c.Refresh && time.Until(n.Expires) > siwcMargin {
return n, nil
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
tok, err := siwcPostToken(ctx, url.Values{"grant_type": {"refresh_token"}, "client_id": {c.ClientID},
"refresh_token": {c.Refresh}, "resource": {siwcResource}})
var te *siwcTokenError
if errors.As(err, &te) && slices.Contains(siwcDead, te.Code) {
msg := user + "'s ChatGPT sign-in has expired — sign in again"
_ = editSideLogin(ChatGPTAPIID, user, func(ls []savedLogin, i int) ([]savedLogin, error) {
ls[i].Lapsed = msg
return ls, nil
})
return siwcCreds{}, fmt.Errorf("%s: %w", user, ErrSIWCSignIn)
}
if errors.As(err, &te) && te.Code == "invalid_client" {
return siwcCreds{}, fmt.Errorf("ChatGPT: OpenAI no longer knows the client magpie registered for %s (%s) — sign in again", user, te.Error())
}
if err != nil {
if !force && time.Until(c.Expires) > 0 {
return c, nil // a hiccup: the token in hand still does
}
return siwcCreds{}, fmt.Errorf("ChatGPT token refresh: %w", err)
}
if c, err = siwcFromToken(tok, c); err != nil {
return siwcCreds{}, fmt.Errorf("ChatGPT token refresh: %w", err)
}
auth, err := json.Marshal(c)
if err != nil {
return siwcCreds{}, err
}
if err := editSideLogin(ChatGPTAPIID, user, func(ls []savedLogin, i int) ([]savedLogin, error) {
ls[i].Auth, ls[i].Lapsed = auth, ""
if p := siwcPlan(c); p != "" {
ls[i].Plan = p
}
return ls, nil
}); err != nil {
return siwcCreds{}, err
}
return c, nil
}
// siwcRevoke has OpenAI revoke an account's refresh token, at the
// revocation endpoint its discovery document names.
func siwcRevoke(ctx context.Context, c siwcCreds) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(siwcIssuer, "/")+"/.well-known/openid-configuration", nil)
if err != nil {
return err
}
res, err := siwcClient.Do(req)
if err != nil {
return err
}
var d struct {
Revoke string `json:"revocation_endpoint"`
}
err = json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&d)
res.Body.Close()
if err != nil || d.Revoke == "" {
return errors.New("ChatGPT: OpenAI names no revocation endpoint")
}
form := url.Values{"token": {c.Refresh}, "token_type_hint": {"refresh_token"}, "client_id": {c.ClientID}}
req, err = http.NewRequestWithContext(ctx, http.MethodPost, d.Revoke, strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if res, err = siwcClient.Do(req); err != nil {
return err
}
res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("ChatGPT sign-out: %s", res.Status)
}
return nil
}
// ---- models and requests ----------------------------------------------------
// siwcFetchModels is the account's model list: OpenAI's /v1/models for
// the token, the ones it lists for a picker, in its order.
func siwcFetchModels(ctx context.Context, user string) ([]catalog.Model, error) {
c, err := siwcFresh(ctx, user, false)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(siwcAPI, "/")+"/models", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Access)
res, err := siwcClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
b, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20))
if res.StatusCode != http.StatusOK {
msg := "ChatGPT models: " + APIError(b, res.Status)
if more := siwcExplain(res.StatusCode, b); more != "" {
msg += " — " + more
}
return nil, errors.New(msg)
}
ms := parseSIWCModels(b)
if len(ms) == 0 {
return nil, errors.New("ChatGPT lists no models for this account")
}
return ms, nil
}
func parseSIWCModels(b []byte) []catalog.Model {
var list struct {
Models []struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Visibility string `json:"visibility"`
Input []string `json:"input_modalities"`
Levels []struct {
Effort string `json:"effort"`
} `json:"supported_reasoning_levels"`
Context int `json:"context_window"`
Max int `json:"max_context_window"`
} `json:"models"`
}
if json.Unmarshal(b, &list) != nil {
return nil
}
var out []catalog.Model
for _, m := range list.Models {
if m.Slug == "" || m.Visibility != "" && m.Visibility != "list" {
continue
}
mm := catalog.Model{ID: m.Slug, Name: m.DisplayName, Provider: "openai", Context: m.Context, APIs: []string{string(Responses)}}
if m.Max > m.Context {
mm.MaxContext = m.Max
}
if m.Input != nil {
yes := slices.Contains(m.Input, "image")
mm.ImageInput, mm.Images = &yes, yes
}
for _, l := range m.Levels {
mm.Efforts = append(mm.Efforts, l.Effort)
}
out = append(out, mm)
}
return out
}
// siwcDropped are the Responses fields Sign in with ChatGPT refuses: the
// request goes without them. previous_response_id too: nothing is stored,
// so the whole conversation is sent each time.
var siwcDropped = []string{"background", "conversation", "max_output_tokens", "max_tool_calls", "metadata",
"moderation", "multi_agent", "prompt", "prompt_cache_retention", "safety_identifier", "temperature",
"top_logprobs", "top_p", "truncation", "user", "previous_response_id"}
// siwcHostedTools are the tools OpenAI runs itself that it won't run for
// a ChatGPT token: a request offering one is refused whole.
var siwcHostedTools = []string{"image_generation", "file_search", "code_interpreter", "computer_use",
"computer_use_preview", "computer", "mcp", "tool_search"}
// siwcBody makes a Responses request one Sign in with ChatGPT takes:
// stored nowhere and streamed, without the fields and tools it refuses,
// and a system message as the developer one it takes in its place. The
// instructions stay the agent's own.
func siwcBody(body []byte) []byte {
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
var m map[string]any
if dec.Decode(&m) != nil {
return body
}
for _, k := range siwcDropped {
delete(m, k)
}
m["store"], m["stream"] = false, true
switch in := m["input"].(type) {
case string:
m["input"] = []any{map[string]any{"type": "message", "role": "user",
"content": []any{map[string]any{"type": "input_text", "text": in}}}}
case []any:
for _, it := range in {
item, ok := it.(map[string]any)
if !ok {
continue
}
if item["role"] == "system" {
item["role"] = "developer"
}
// Codex's Lite hands its tools over as an input item
if tools, ok := item["tools"].([]any); ok && item["type"] == "additional_tools" {
item["tools"] = siwcOwnTools(tools)
}
}
}
if tools, ok := m["tools"].([]any); ok {
if tools = siwcOwnTools(tools); len(tools) == 0 {
delete(m, "tools")
delete(m, "tool_choice")
} else {
m["tools"] = tools
}
}
out, err := json.Marshal(m)
if err != nil {
return body
}
return out
}
// siwcOwnTools is tools without those OpenAI would run itself.
func siwcOwnTools(tools []any) []any {
return slices.DeleteFunc(tools, func(t any) bool {
tm, _ := t.(map[string]any)
typ, _ := tm["type"].(string)
return slices.Contains(siwcHostedTools, typ)
})
}
// siwcErrorCode is the code an API refusal names: error.code, or a
// detail's.
func siwcErrorCode(body []byte) (code, param string) {
var e struct {
Error struct {
Code string `json:"code"`
Type string `json:"type"`
Param string `json:"param"`
} `json:"error"`
Detail any `json:"detail"`
}
if json.Unmarshal(body, &e) != nil {
return "", ""
}
if e.Error.Code != "" {
return e.Error.Code, e.Error.Param
}
switch d := e.Detail.(type) {
case map[string]any:
code, _ = d["code"].(string)
param, _ = d["param"].(string)
return code, param
case string:
for _, c := range []string{"subscription_sharing_", "chatpass_v2_"} {
if i := strings.Index(d, c); i >= 0 {
end := strings.IndexFunc(d[i:], func(r rune) bool { return !(r == '_' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9') })
if end < 0 {
end = len(d) - i
}
return d[i : i+end], ""
}
}
}
return e.Error.Type, ""
}
// siwcExplain says what the user can do about a refusal of OpenAI's.
func siwcExplain(status int, body []byte) string {
code, param := siwcErrorCode(body)
switch {
case code == "subscription_sharing_usage_limit_exceeded":
return "the ChatGPT plan's usage limit, shared with ChatGPT and the other apps it is used in, is reached; see " + SIWCUsageURL
case code == "subscription_sharing_usage_unavailable", code == "subscription_sharing_user_unavailable":
return "OpenAI can't tell the account's usage right now; try again in a moment"
case code == "subscription_sharing_user_not_eligible":
return "this ChatGPT account can't be used through OpenAI's API (its plan isn't eligible)"
case code == "subscription_sharing_unsupported_capability":
if param != "" {
return "a ChatGPT sign-in can't use " + param + " through OpenAI's API"
}
return "a ChatGPT sign-in can't use part of this request through OpenAI's API"
case code == "subscription_sharing_route_not_supported":
return "a ChatGPT sign-in can't use this endpoint of OpenAI's API"
case code == "subscription_sharing_invalid_user":
return "OpenAI no longer takes this ChatGPT sign-in; sign in again"
case strings.HasPrefix(code, "chatpass_v2_"):
return "ChatGPT refused the account (" + code + ")"
case status == http.StatusUnauthorized:
return "OpenAI turned the ChatGPT sign-in away; sign in again"
}
return ""
}
func siwcProvider(l Login) Provider {
user := l.User
a := &Account{Agent: ChatGPTAPIID, User: user, Plan: l.Plan, Stream: true}
a.sign = func(ctx context.Context, req *http.Request, _ []byte) error {
c, err := siwcFresh(ctx, user, false)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Access)
return nil
}
a.body = siwcBody
// the GPT models' efforts and windows, for those its list leaves out
a.models = catalog.Codex
a.fetch = func(ctx context.Context) ([]catalog.Model, error) {
ms, err := siwcFetchModels(ctx, user)
if err != nil {
return nil, err
}
return ms, catalog.SaveLive(ChatGPTAPIID, siwcAPI, ms)
}
a.explain = siwcExplain
return Provider{ID: ChatGPTAPIID, Name: "ChatGPT API", Icon: "openai", Responses: siwcAPI,
Website: "https://chatgpt.com", Account: a}
}
func siwcAccount() (Provider, bool) {
ls := siwcLoginList()
if len(ls) == 0 {
return Provider{}, false
}
return siwcProvider(ls[0]), true
}
// SIWCForTest points the sign-in and the API at test servers.
func SIWCForTest(issuer, api string) func() {
oldI, oldA, oldT, oldS := siwcIssuer, siwcAuthorizeURL, siwcTokenURL, siwcAPI
siwcIssuer, siwcAuthorizeURL, siwcTokenURL, siwcAPI = issuer, issuer+"/api/accounts/authorize", issuer+"/api/accounts/oauth/token", api
return func() { siwcIssuer, siwcAuthorizeURL, siwcTokenURL, siwcAPI = oldI, oldA, oldT, oldS }
}
// siwcAlsoOn is the ChatGPT API accounts in use behind the first.
func siwcAlsoOn() []Provider {
var out []Provider
for _, l := range siwcLoginList() {
if !l.Active && l.On {
out = append(out, siwcProvider(l))
}
}
return out
}
+416
View File
@@ -0,0 +1,416 @@
package provider
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
)
// fakeOpenAI stands in for auth.openai.com and api.openai.com, as Sign in
// with ChatGPT uses them.
type fakeOpenAI struct {
t *testing.T
mu sync.Mutex
auth *httptest.Server
api *httptest.Server
forms []url.Values // what the token endpoint was sent
revoked []url.Values
nonce string // the ID token's
scope string
noID bool
refuse string // a refresh refused with this OAuth code
modelsOf string // the token /models was asked with
n int
}
func newFakeOpenAI(t *testing.T) *fakeOpenAI {
f := &fakeOpenAI{t: t, scope: "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"}
f.auth = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/.well-known/openid-configuration":
json.NewEncoder(w).Encode(map[string]any{"issuer": f.auth.URL, "revocation_endpoint": f.auth.URL + "/oauth/revoke"})
case "/oauth/revoke":
r.ParseForm()
f.mu.Lock()
f.revoked = append(f.revoked, r.PostForm)
f.mu.Unlock()
case "/api/accounts/oauth/token":
r.ParseForm()
f.mu.Lock()
defer f.mu.Unlock()
f.forms = append(f.forms, r.PostForm)
f.n++
if r.PostForm.Get("grant_type") == "refresh_token" && f.refuse != "" {
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]any{"error": f.refuse, "error_description": "no"})
return
}
out := map[string]any{
"access_token": "at-" + string(rune('0'+f.n)),
"refresh_token": "rt-" + string(rune('0'+f.n)),
"token_type": "Bearer", "expires_in": 3600, "scope": f.scope,
}
if !f.noID {
out["id_token"] = fakeJWT(map[string]any{"iss": f.auth.URL, "aud": r.PostForm.Get("client_id"),
"sub": "user-1", "email": "me@example.com", "nonce": f.nonce, "exp": float64(time.Now().Add(time.Hour).Unix()),
"https://api.openai.com/auth": map[string]any{"chatgpt_plan_type": "plus"}})
}
json.NewEncoder(w).Encode(out)
default:
http.NotFound(w, r)
}
}))
f.api = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/models" {
f.mu.Lock()
f.modelsOf = r.Header.Get("Authorization")
f.mu.Unlock()
io.WriteString(w, `{"models":[
{"slug":"gpt-5.5","display_name":"GPT-5.5","visibility":"list","context_window":272000,"input_modalities":["text","image"],"supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}]},
{"slug":"gpt-hidden","display_name":"Hidden","visibility":"hide"},
{"slug":"gpt-5.4-mini","display_name":"GPT-5.4 mini","visibility":"list"}]}`)
return
}
http.NotFound(w, r)
}))
t.Cleanup(f.auth.Close)
t.Cleanup(f.api.Close)
t.Cleanup(SIWCForTest(f.auth.URL, f.api.URL+"/v1"))
old := siwcCallbackAddr
ln, _ := net.Listen("tcp", "127.0.0.1:0")
siwcCallbackAddr = ln.Addr().String()
ln.Close()
t.Cleanup(func() { siwcCallbackAddr = old })
return f
}
func (f *fakeOpenAI) lastForm() url.Values {
f.mu.Lock()
defer f.mu.Unlock()
return f.forms[len(f.forms)-1]
}
// signInVia runs a sign-in to its callback, which OpenAI sends with the
// client id it issued (clientID, "" for none), and waits for its end.
func signInVia(t *testing.T, f *fakeOpenAI, clientID string, nonce func(string) string) SignInState {
t.Helper()
st, err := StartSignIn(ChatGPTAPIID)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { CancelSignIn(st.ID) })
u, err := url.Parse(st.URL)
if err != nil {
t.Fatal(err)
}
q := u.Query()
f.mu.Lock()
f.nonce = nonce(q.Get("nonce"))
f.mu.Unlock()
back := url.Values{"code": {"the-code"}, "state": {q.Get("state")}, "scope": {q.Get("scope")}}
if clientID != "" {
back.Set("client_id", clientID)
}
res, err := http.Get(q.Get("redirect_uri") + "?" + back.Encode())
if err != nil {
t.Fatal(err)
}
res.Body.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := WaitSignIn(ctx, st.ID)
if err != nil {
t.Fatal(err)
}
return out
}
func same(s string) string { return s }
// #933: OpenAI's Sign in with ChatGPT, as an open-source app on the user's
// machine uses it: registered on the way as dynamic_agent_client, the
// code traded under the client id OpenAI issued, the account kept with it,
// its models OpenAI's /v1/models for the token, and requests sent to
// api.openai.com's Responses.
func TestChatGPTAPISignIn(t *testing.T) {
claudeHome(t)
f := newFakeOpenAI(t)
st, err := StartSignIn(ChatGPTAPIID)
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse(st.URL)
q := u.Query()
CancelSignIn(st.ID)
if !strings.HasPrefix(st.URL, f.auth.URL+"/api/accounts/authorize?") {
t.Fatalf("opens %s", st.URL)
}
for k, want := range map[string]string{"client_id": "dynamic_agent_client", "agent_name_hint": "magpie",
"response_type": "code", "resource": "https://api.openai.com/v1", "code_challenge_method": "S256",
"scope": "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"} {
if q.Get(k) != want {
t.Errorf("%s = %q, want %q", k, q.Get(k), want)
}
}
for _, k := range []string{"state", "nonce", "code_challenge"} {
if q.Get(k) == "" {
t.Errorf("no %s", k)
}
}
host := q.Get("ext_agent_host_id")
if !strings.HasPrefix(host, "urn:uuid:") || len(host) != len("urn:uuid:")+36 {
t.Errorf("ext_agent_host_id %q", host)
}
if r, _ := url.Parse(q.Get("redirect_uri")); r == nil || r.Hostname() != "127.0.0.1" || r.Path != "/auth/callback" {
t.Errorf("redirect_uri %q", q.Get("redirect_uri"))
}
done := signInVia(t, f, "oaiapp_123", same)
if done.State != "done" || done.User != "me@example.com" || done.Plan != "plus" {
t.Fatalf("sign-in ended %+v", done)
}
form := f.lastForm()
for k, want := range map[string]string{"grant_type": "authorization_code", "client_id": "oaiapp_123",
"code": "the-code", "resource": "https://api.openai.com/v1"} {
if form.Get(k) != want {
t.Errorf("code trade %s = %q, want %q", k, form.Get(k), want)
}
}
if form.Get("code_verifier") == "" || form.Get("client_secret") != "" {
t.Errorf("code trade %v", form)
}
// the same machine id next time
st2, _ := StartSignIn(ChatGPTAPIID)
u2, _ := url.Parse(st2.URL)
CancelSignIn(st2.ID)
if got := u2.Query().Get("ext_agent_host_id"); got != host {
t.Errorf("host id %q, then %q", host, got)
}
c, ok := siwcLookup("me@example.com")
if !ok || c.ClientID != "oaiapp_123" || c.HostID != host || c.Access != "at-1" || c.Refresh != "rt-1" || c.Subject != "user-1" {
t.Fatalf("kept %+v", c)
}
var p Provider
for _, a := range Accounts() {
if a.ID == ChatGPTAPIID {
p = a
}
}
if p.Account == nil || p.Responses != f.api.URL+"/v1" || p.Account.User != "me@example.com" {
t.Fatalf("provider %+v", p)
}
if got := Logins(ChatGPTAPIID); len(got) != 1 || !got[0].Active {
t.Fatalf("logins %+v", got)
}
ms, err := p.Fetch(context.Background())
if err != nil {
t.Fatal(err)
}
if len(ms) != 2 || ms[0].ID != "gpt-5.5" || ms[1].ID != "gpt-5.4-mini" || !ms[0].Images || len(ms[0].Efforts) != 2 {
t.Errorf("models %+v", ms)
}
if f.modelsOf != "Bearer at-1" {
t.Errorf("models asked with %q", f.modelsOf)
}
req, _ := http.NewRequest("POST", p.Responses+"/responses", nil)
if err := p.Account.sign(context.Background(), req, nil); err != nil || req.Header.Get("Authorization") != "Bearer at-1" {
t.Errorf("signed %q, %v", req.Header.Get("Authorization"), err)
}
// signed out: OpenAI revokes the refresh token
if err := ForgetLogin(ChatGPTAPIID, "me@example.com"); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(5 * time.Second)
for {
f.mu.Lock()
n := len(f.revoked)
f.mu.Unlock()
if n > 0 || time.Now().After(deadline) {
break
}
time.Sleep(20 * time.Millisecond)
}
f.mu.Lock()
defer f.mu.Unlock()
if len(f.revoked) != 1 || f.revoked[0].Get("token") != "rt-1" || f.revoked[0].Get("client_id") != "oaiapp_123" ||
f.revoked[0].Get("token_type_hint") != "refresh_token" {
t.Errorf("revoked %v", f.revoked)
}
if _, ok := siwcLookup("me@example.com"); ok {
t.Error("still kept")
}
}
// A sign-in OpenAI didn't finish registering, one whose grant leaves out
// the direct use of the token, or whose ID token isn't this sign-in's,
// keeps nothing.
func TestChatGPTAPISignInRefused(t *testing.T) {
for _, tt := range []struct {
name, client, want string
nonce func(string) string
scope string
}{
{"no client id", "", "no client id", same, ""},
{"no direct scope", "oaiapp_1", "chatgpt.tokens.use.direct", same, "openid profile email offline_access"},
{"other nonce", "oaiapp_1", "isn't this sign-in's", func(string) string { return "someone-else" }, ""},
} {
t.Run(tt.name, func(t *testing.T) {
claudeHome(t)
f := newFakeOpenAI(t)
if tt.scope != "" {
f.scope = tt.scope
}
st := signInVia(t, f, tt.client, tt.nonce)
if st.State != "failed" || !strings.Contains(st.Error, tt.want) {
t.Fatalf("ended %+v", st)
}
if ls := Logins(ChatGPTAPIID); len(ls) != 0 {
t.Errorf("kept %+v", ls)
}
})
}
}
// A token about to expire is refreshed under the client id OpenAI issued,
// for the API's resource, and the rotated refresh token kept; a refresh
// token OpenAI won't take again has the account signed in again.
func TestChatGPTAPIRefresh(t *testing.T) {
claudeHome(t)
f := newFakeOpenAI(t)
if st := signInVia(t, f, "oaiapp_9", same); st.State != "done" {
t.Fatalf("sign-in %+v", st)
}
user := "me@example.com"
expire := func() {
if err := editSideLogin(ChatGPTAPIID, user, func(ls []savedLogin, i int) ([]savedLogin, error) {
c, _ := siwcSaved(ls[i])
c.Expires = time.Now().Add(time.Minute)
ls[i].Auth, _ = json.Marshal(c)
return ls, nil
}); err != nil {
t.Fatal(err)
}
}
expire()
c, err := siwcFresh(context.Background(), user, false)
if err != nil {
t.Fatal(err)
}
form := f.lastForm()
if form.Get("grant_type") != "refresh_token" || form.Get("client_id") != "oaiapp_9" || form.Get("refresh_token") != "rt-1" ||
form.Get("resource") != "https://api.openai.com/v1" || form.Has("scope") {
t.Errorf("refresh sent %v", form)
}
if c.Access != "at-2" || c.Refresh != "rt-2" || time.Until(c.Expires) < 50*time.Minute {
t.Errorf("refreshed %+v", c)
}
if kept, _ := siwcLookup(user); kept.Refresh != "rt-2" || kept.IDToken == "" {
t.Errorf("kept %+v", kept)
}
// fresh: not refreshed again
n := len(f.forms)
if _, err := siwcFresh(context.Background(), user, false); err != nil || len(f.forms) != n {
t.Errorf("refreshed a fresh token (%v)", err)
}
expire()
f.mu.Lock()
f.refuse = "refresh_token_reused"
f.mu.Unlock()
if _, err := siwcFresh(context.Background(), user, false); !errors.Is(err, ErrSIWCSignIn) {
t.Fatalf("a dead refresh token: %v", err)
}
loginsMu.Lock()
ls := readLogins()
loginsMu.Unlock()
if len(ls) != 1 || !strings.Contains(ls[0].Lapsed, "sign in again") {
t.Errorf("not marked lapsed: %+v", ls)
}
}
// The request goes as Sign in with ChatGPT takes it: stored nowhere and
// streamed, without the fields it refuses or the tools OpenAI would run
// itself, a system message as a developer one; the agent's instructions
// and its own tools are left as they are.
func TestChatGPTAPIBody(t *testing.T) {
in := `{"model":"gpt-5.5","instructions":"You are my agent.","store":true,"stream":false,"max_output_tokens":100,
"temperature":0.2,"top_p":1,"metadata":{"a":"b"},"user":"u","truncation":"auto","previous_response_id":"resp_1",
"prompt_cache_retention":"24h","safety_identifier":"s","prompt_cache_key":"k","reasoning":{"effort":"high"},
"input":[{"role":"system","content":"be brief"},{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},
{"type":"additional_tools","role":"developer","tools":[{"type":"tool_search","execution":"client"},{"type":"namespace","name":"functions","tools":[]}]}],
"tools":[{"type":"function","name":"shell","parameters":{"type":"object"}},{"type":"image_generation"},{"type":"web_search"}],
"tool_choice":"auto"}`
var m map[string]any
if err := json.Unmarshal(siwcBody([]byte(in)), &m); err != nil {
t.Fatal(err)
}
for _, k := range []string{"max_output_tokens", "temperature", "top_p", "metadata", "user", "truncation",
"previous_response_id", "prompt_cache_retention", "safety_identifier"} {
if _, ok := m[k]; ok {
t.Errorf("%s left in", k)
}
}
if m["store"] != false || m["stream"] != true || m["instructions"] != "You are my agent." || m["prompt_cache_key"] != "k" || m["reasoning"] == nil {
t.Errorf("body %v", m)
}
input := m["input"].([]any)
if input[0].(map[string]any)["role"] != "developer" {
t.Errorf("system message %v", input[0])
}
if at := input[2].(map[string]any)["tools"].([]any); len(at) != 1 || at[0].(map[string]any)["type"] != "namespace" {
t.Errorf("additional tools %v", at)
}
tools := m["tools"].([]any)
if len(tools) != 2 || tools[0].(map[string]any)["name"] != "shell" || tools[1].(map[string]any)["type"] != "web_search" {
t.Errorf("tools %v", tools)
}
if m["tool_choice"] != "auto" {
t.Errorf("tool_choice %v", m["tool_choice"])
}
// a string input is a user message
m = nil
_ = json.Unmarshal(siwcBody([]byte(`{"model":"gpt-5.5","input":"hello","tools":[{"type":"file_search"}],"tool_choice":"auto"}`)), &m)
if in, ok := m["input"].([]any); !ok || len(in) != 1 {
t.Errorf("input %v", m["input"])
}
if _, ok := m["tools"]; ok {
t.Errorf("tools %v", m["tools"])
}
}
// OpenAI's refusals of a ChatGPT token say what to do: the plan's usage
// shared with ChatGPT, an account that isn't eligible, a capability the
// token can't use.
func TestChatGPTAPIExplain(t *testing.T) {
for _, tt := range []struct {
status int
body string
want string
}{
{429, `{"error":{"code":"subscription_sharing_usage_limit_exceeded","message":"limit"}}`, "chatgpt.com/settings/usage"},
{403, `{"detail":{"code":"subscription_sharing_user_not_eligible"}}`, "isn't eligible"},
{503, `{"detail":"subscription_sharing_usage_unavailable: try later"}`, "try again"},
{400, `{"error":{"code":"subscription_sharing_unsupported_capability","param":"tools[0].type"}}`, "tools[0].type"},
{401, `{"detail":"Unauthorized"}`, "sign in again"},
{500, `{"error":{"message":"boom"}}`, ""},
} {
got := siwcExplain(tt.status, []byte(tt.body))
if tt.want == "" && got != "" || !strings.Contains(got, tt.want) {
t.Errorf("%d %s: %q", tt.status, tt.body, got)
}
}
}
+2
View File
@@ -181,6 +181,8 @@ func builtinLogins(agent string) (logins []Login, ok bool) {
logins = factoryLoginList()
case MiMoID:
logins = mimoLoginList()
case ChatGPTAPIID:
logins = siwcLoginList()
case "gemini", "antigravity":
logins = googleLoginList(agent)
case "cursor": // one account, the one cursor-agent is signed in to
+7 -1
View File
@@ -593,6 +593,8 @@ func Logins(agent string) []Login {
return factoryLoginList()
case MiMoID:
return mimoLoginList()
case ChatGPTAPIID:
return siwcLoginList()
case "gemini", "antigravity":
return googleLoginList(agent)
case "":
@@ -617,7 +619,7 @@ func Logins(agent string) []Login {
{WorkBuddyAIID, func() []Login { return wbLoginList(wbAI) }}, {CommandCodePlanID, cmdLoginList},
{"qoder", func() []Login { return loginsOf(qoderLogins()) }},
{QoderCNID, func() []Login { return loginsOf(qoderLoginsOf(QoderCNID)) }}, {"zed", zedLoginList}, {"factory", factoryLoginList},
{MiMoID, mimoLoginList}, {"gemini", func() []Login { return googleLoginList("gemini") }},
{MiMoID, mimoLoginList}, {ChatGPTAPIID, siwcLoginList}, {"gemini", func() []Login { return googleLoginList("gemini") }},
{"antigravity", func() []Login { return googleLoginList("antigravity") }},
} {
if !Moved(b.id) {
@@ -805,6 +807,8 @@ func switchLogin(agent, user string) error {
return switchFactoryLogin(user)
case MiMoID:
return switchMiMoLogin(user)
case ChatGPTAPIID:
return switchSIWCLogin(user)
case "gemini", "antigravity":
return switchGoogleLogin(agent, user)
}
@@ -977,6 +981,8 @@ func ForgetLogin(agent, user string) error {
return forgetFactoryLogin(user)
case MiMoID:
return forgetMiMoLogin(user)
case ChatGPTAPIID:
return forgetSIWCLogin(user)
case "gemini", "antigravity":
return forgetGoogleLogin(agent, user)
}
+5
View File
@@ -51,6 +51,8 @@ func SetLoginOn(agent, user string, on bool) error {
return setFactoryLoginOn(user, on)
case MiMoID:
return setMiMoLoginOn(user, on)
case ChatGPTAPIID:
return setSIWCLoginOn(user, on)
case "gemini", "antigravity":
return setGoogleLoginOn(agent, user, on)
}
@@ -162,6 +164,9 @@ func (p Provider) AlsoOn() []Provider {
if p.Account != nil && p.Account.Agent == MiMoID {
return mimoAlsoOn()
}
if p.Account != nil && p.Account.Agent == ChatGPTAPIID {
return siwcAlsoOn()
}
if p.Account != nil && (p.Account.Agent == "gemini" || p.Account.Agent == "antigravity") {
return googleAlsoOn(p.Account.Agent)
}
+1 -1
View File
@@ -688,7 +688,7 @@ func freeName(name string) string {
}
// accountIDs are the ids of the subscriptions magpie can list (account.go).
var accountIDs = []string{"antigravity", "claude", "codex", CommandCodePlanID, "copilot", "cursor", "devin", "factory", "gemini", "grok", "kiro", MiMoID, "qoder", QoderCNID, "workbuddy", WorkBuddyAIID, "zcode", "zed"}
var accountIDs = []string{"antigravity", "claude", "codex", CommandCodePlanID, "copilot", "cursor", "devin", "factory", "gemini", "grok", "kiro", MiMoID, ChatGPTAPIID, "qoder", QoderCNID, "workbuddy", WorkBuddyAIID, "zcode", "zed"}
func stored(id string) bool {
for _, p := range load().Providers {
+1 -1
View File
@@ -282,7 +282,7 @@ func addSideLogin(l savedLogin, ownUser string, dup func(savedLogin)) error {
// agent's own store as Claude Code's and Codex's are.
func sideAgent(agent string) bool {
switch agent {
case "grok", "copilot", "zcode", "kiro", "devin", "workbuddy", WorkBuddyAIID, CommandCodePlanID, "gemini", "antigravity", "qoder", QoderCNID, "zed", "factory", MiMoID:
case "grok", "copilot", "zcode", "kiro", "devin", "workbuddy", WorkBuddyAIID, CommandCodePlanID, "gemini", "antigravity", "qoder", QoderCNID, "zed", "factory", MiMoID, ChatGPTAPIID:
return true
}
return false
+21
View File
@@ -81,6 +81,10 @@ type signInFlow struct {
site string // where to sign in, for an agent with more than one (ZCode: "zai" or "bigmodel")
plugin string // a plugin's sign-in session, finished with the code pasted back
claude *claudeCLISignIn // Claude Code's own sign-in, run by magpie
// nonce and hostID are a ChatGPT API sign-in's: what its ID token must
// carry, and this machine's id it registers magpie for (chatgpt_api.go)
nonce, hostID string
// claimed is a callback being traded for the account: the browser's own
// or a pasted address, whichever came first
claimed bool
@@ -220,6 +224,19 @@ func (s *signInFlow) begin() error {
q.Set("state", s.state)
q.Set("originator", "codex_cli_rs")
s.st.URL = codexAuthorizeURL + "?" + q.Encode()
case ChatGPTAPIID:
// OpenAI's Sign in with ChatGPT, which registers magpie on the way
if s.hostID, err = siwcHostID(); err != nil {
return err
}
if ln, err = listenSIWCCallback(); err != nil {
return err
}
s.redirect = fmt.Sprintf("http://127.0.0.1:%d/auth/callback", ln.Addr().(*net.TCPAddr).Port)
s.nonce = randomToken(24)
s.mu.Lock()
s.st.URL = siwcAuthorize(s.redirect, s.state, s.nonce, challenge, s.hostID)
s.mu.Unlock()
case "cursor":
// Cursor has no sign-in of its own to borrow: its CLI signs in
if err := startCursorSignIn(s); err != nil {
@@ -604,6 +621,10 @@ func (s *signInFlow) callback(w http.ResponseWriter, r *http.Request) {
s.googleDone(ctx, w, app, q.Get("code"))
return
}
if s.st.Agent == ChatGPTAPIID {
s.siwcDone(ctx, w, q)
return
}
l, err := s.exchange(ctx, q.Get("code"))
if err == nil && s.st.Agent == "devin" {
// the exchange kept it already: the CLI's own, or one beside it
+1 -1
View File
@@ -284,7 +284,7 @@ func fetchSubscriptionUsage(ctx context.Context) []SubscriptionQuota {
for _, p := range load().Providers {
hidden[p.ID] = hidden[p.ID] || p.ID != r.provider
}
for _, id := range []string{"claude", "cursor", "grok", "codex", "copilot", "kiro", "zcode", wbCN.id, wbAI.id, CommandCodePlanID, "qoder", QoderCNID, "zed", "devin", "factory", MiMoID, "gemini", "antigravity"} {
for _, id := range []string{"claude", "cursor", "grok", "codex", "copilot", "kiro", "zcode", wbCN.id, wbAI.id, CommandCodePlanID, "qoder", QoderCNID, "zed", "devin", "factory", MiMoID, ChatGPTAPIID, "gemini", "antigravity"} {
hidden[id] = hidden[id] || id != r.provider
}
for _, pp := range plugin.Cached() {