diff --git a/internal/gateway/chatgpt_api_test.go b/internal/gateway/chatgpt_api_test.go new file mode 100644 index 00000000..5f306c69 --- /dev/null +++ b/internal/gateway/chatgpt_api_test.go @@ -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) + } + }) + } +} diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 5a4f94a7..2d0e7080 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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 diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 2e371175..1b6d39e2 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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", diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 1fccaeda..dc5f240d 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -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" { diff --git a/internal/gui/tests/chatgpt-api-sub.test.cjs b/internal/gui/tests/chatgpt-api-sub.test.cjs new file mode 100644 index 00000000..a0347568 --- /dev/null +++ b/internal/gui/tests/chatgpt-api-sub.test.cjs @@ -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(); + }); + }); +} diff --git a/internal/provider/account.go b/internal/provider/account.go index 1220f0ed..7db21449 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -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. diff --git a/internal/provider/chatgpt_api.go b/internal/provider/chatgpt_api.go new file mode 100644 index 00000000..b36f5811 --- /dev/null +++ b/internal/provider/chatgpt_api.go @@ -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 +} diff --git a/internal/provider/chatgpt_api_test.go b/internal/provider/chatgpt_api_test.go new file mode 100644 index 00000000..3eb92a7d --- /dev/null +++ b/internal/provider/chatgpt_api_test.go @@ -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) + } + } +} diff --git a/internal/provider/login_usage.go b/internal/provider/login_usage.go index d0357c4b..f09ed4f5 100644 --- a/internal/provider/login_usage.go +++ b/internal/provider/login_usage.go @@ -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 diff --git a/internal/provider/logins.go b/internal/provider/logins.go index 639ebe12..4457eb52 100644 --- a/internal/provider/logins.go +++ b/internal/provider/logins.go @@ -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) } diff --git a/internal/provider/logins_on.go b/internal/provider/logins_on.go index efe86356..17201ac8 100644 --- a/internal/provider/logins_on.go +++ b/internal/provider/logins_on.go @@ -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) } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 92fb948a..2d12b00b 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -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 { diff --git a/internal/provider/side_logins.go b/internal/provider/side_logins.go index 7ad68a61..cfd03179 100644 --- a/internal/provider/side_logins.go +++ b/internal/provider/side_logins.go @@ -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 diff --git a/internal/provider/signin.go b/internal/provider/signin.go index a33b3aee..75f71fc3 100644 --- a/internal/provider/signin.go +++ b/internal/provider/signin.go @@ -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 diff --git a/internal/provider/subscription_usage.go b/internal/provider/subscription_usage.go index 8d2662af..b0755997 100644 --- a/internal/provider/subscription_usage.go +++ b/internal/provider/subscription_usage.go @@ -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() {