-
Notifications
You must be signed in to change notification settings - Fork 134
fix: accept a provider id in auth login, and diagnose plan/account on a Codex 400 #1181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| /** | ||
| * Non-secret identity claims carried by OAuth access tokens, plus the | ||
| * diagnostics built on top of them. | ||
| * | ||
| * Why this exists: a ChatGPT OAuth credential that belongs to a free-plan | ||
| * account fails every Codex model request with a 400 whose body only says the | ||
| * *model* is unsupported — it never says the real problem is WHICH account you | ||
| * signed in with. These helpers pull the two non-secret claims that answer | ||
| * that question (plan type + account id) so the CLI can say it out loud. | ||
| * | ||
| * SECURITY: nothing here may return, log, or embed the token, the refresh | ||
| * token, or any other credential material. Only `plan` and a TRUNCATED account | ||
| * id ever leave this module. Callers render the returned strings directly to | ||
| * the terminal, so treat every return value as user-visible output. | ||
| */ | ||
|
|
||
| /** Account ids are opaque identifiers, not secrets, but there is no reason to | ||
| * print one in full — the first segment is enough to tell two accounts apart. */ | ||
| const ACCOUNT_ID_VISIBLE_CHARS = 8 | ||
|
|
||
| /** The OpenAI-namespaced claim bag inside ChatGPT id/access tokens. */ | ||
| const OPENAI_AUTH_CLAIM = "https://api.openai.com/auth" | ||
|
|
||
| export interface OAuthIdentity { | ||
| /** ChatGPT plan type claim, e.g. "free", "plus", "pro". */ | ||
| plan?: string | ||
| /** Raw account id — mask with `maskAccountId` before displaying. */ | ||
| accountId?: string | ||
| } | ||
|
|
||
| /** | ||
| * Decode the payload of a JWT-shaped token WITHOUT verifying its signature. | ||
| * | ||
| * Verification is deliberately skipped: these claims are used for human-facing | ||
| * diagnostics only, never for an authorization decision. Returns undefined for | ||
| * anything that is not a three-segment token with a JSON object payload. | ||
| */ | ||
| export function decodeJwtClaims(token: string): Record<string, unknown> | undefined { | ||
| const parts = token.split(".") | ||
| if (parts.length !== 3) return undefined | ||
| try { | ||
| const decoded = JSON.parse(Buffer.from(parts[1], "base64url").toString()) | ||
| if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) return undefined | ||
| return decoded as Record<string, unknown> | ||
| } catch { | ||
| return undefined | ||
| } | ||
| } | ||
|
|
||
| /** Claim values are rendered straight into a terminal and into error text, and | ||
| * the token is never signature-verified, so treat every claim as untrusted | ||
| * input: keep printable ASCII only (no control or escape sequences) and cap the | ||
| * length so a hostile claim cannot wallpaper the output. */ | ||
| const MAX_CLAIM_LENGTH = 64 | ||
|
|
||
| function sanitizeClaim(value: string): string | undefined { | ||
| const cleaned = value.replace(/[^\x20-\x7e]/g, "").slice(0, MAX_CLAIM_LENGTH) | ||
| return cleaned.length > 0 ? cleaned : undefined | ||
| } | ||
|
|
||
| function stringClaim(source: Record<string, unknown> | undefined, key: string): string | undefined { | ||
| const value = source?.[key] | ||
| return typeof value === "string" && value.length > 0 ? sanitizeClaim(value) : undefined | ||
| } | ||
|
|
||
| /** | ||
| * Pull the plan type and account id out of an OAuth access token. Both claims | ||
| * appear either at the top level or inside the OpenAI-namespaced claim bag, | ||
| * depending on which flow minted the token, so check both. | ||
| */ | ||
| export function extractOAuthIdentity(accessToken: string | undefined): OAuthIdentity { | ||
| if (!accessToken) return {} | ||
| const claims = decodeJwtClaims(accessToken) | ||
| if (!claims) return {} | ||
| const namespaced = claims[OPENAI_AUTH_CLAIM] | ||
| const nested = | ||
| namespaced && typeof namespaced === "object" && !Array.isArray(namespaced) | ||
| ? (namespaced as Record<string, unknown>) | ||
| : undefined | ||
| return { | ||
| plan: stringClaim(claims, "chatgpt_plan_type") ?? stringClaim(nested, "chatgpt_plan_type"), | ||
| accountId: stringClaim(claims, "chatgpt_account_id") ?? stringClaim(nested, "chatgpt_account_id"), | ||
| } | ||
| } | ||
|
|
||
| /** Truncate an account id for display. */ | ||
| export function maskAccountId(accountId: string | undefined): string | undefined { | ||
| if (!accountId) return undefined | ||
| if (accountId.length <= ACCOUNT_ID_VISIBLE_CHARS) return accountId | ||
| return accountId.slice(0, ACCOUNT_ID_VISIBLE_CHARS) + "…" | ||
| } | ||
|
|
||
| /** | ||
| * One-line summary of an OAuth credential for `auth list`, e.g. | ||
| * `free plan, account 4f3a1b2c…`. Returns undefined when the token carries | ||
| * neither claim (nothing useful to show, so show nothing). | ||
| */ | ||
| export function describeOAuthIdentity(accessToken: string | undefined): string | undefined { | ||
| const { plan, accountId } = extractOAuthIdentity(accessToken) | ||
| const masked = maskAccountId(accountId) | ||
| const parts = [plan ? `${plan} plan` : undefined, masked ? `account ${masked}` : undefined].filter((x): x is string => | ||
| Boolean(x), | ||
| ) | ||
| return parts.length > 0 ? parts.join(", ") : undefined | ||
| } | ||
|
|
||
| /** | ||
| * The 400 body OpenAI returns when a Codex request is made with a ChatGPT | ||
| * credential whose plan has no Codex entitlement: | ||
| * | ||
| * {"detail":"The 'gpt-5.6' model is not supported when using Codex with a | ||
| * ChatGPT account."} | ||
| * | ||
| * Matching on the stable phrase rather than the whole sentence keeps this | ||
| * working as the quoted model id changes. | ||
| */ | ||
| const CODEX_PLAN_MISMATCH_PATTERN = /not supported when using Codex with a ChatGPT account/i | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This phrase-only matcher rewrites model-specific 400s for Plus/Pro or unknown-plan credentials as entitlement failures. Gate the rewrite on a known non-entitled plan, and preserve the original model error for entitled or unknown plans. Prompt for AI agents |
||
|
|
||
| export function isCodexPlanMismatchBody(body: string | undefined): boolean { | ||
| return Boolean(body) && CODEX_PLAN_MISMATCH_PATTERN.test(body!) | ||
|
Comment on lines
+117
to
+120
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a valid Plus/Pro credential receives this wording because the requested model itself is unavailable or retired, this phrase-only matcher still rewrites the response as an account-plan failure. The resulting message can literally say the user is on the Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| /** Plans that do carry Codex access. On one of these the 400 is NOT an | ||
| * entitlement problem, so the message must not claim the plan is too low — | ||
| * it would contradict what the user is paying for. */ | ||
| const CODEX_ENTITLED_PLANS = new Set(["plus", "pro", "team", "business", "enterprise"]) | ||
|
|
||
| /** | ||
| * Build the actionable replacement for that 400. Names the account and plan the | ||
| * request actually used; on an unentitled plan it says to switch accounts, and | ||
| * on an entitled one it says the model, not the plan, is the problem. | ||
| */ | ||
| export function codexPlanMismatchMessage(identity: OAuthIdentity, originalDetail?: string): string { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a claim or matched provider detail contains control characters, this helper emits them unchanged into terminal-facing output. Sanitize or allowlist plan/account values and strip and bound (Based on your team's feedback about preventing response-body leaks in errors.) Prompt for AI agents |
||
| const masked = maskAccountId(identity.accountId) | ||
| const who = masked ? `Signed in as ChatGPT account ${masked}` : "Signed in with a ChatGPT account" | ||
| const provider = originalDetail ? `\nProvider said: ${originalDetail}` : "" | ||
|
|
||
| if (identity.plan && CODEX_ENTITLED_PLANS.has(identity.plan.toLowerCase())) { | ||
| return ( | ||
| `${who} on the \`${identity.plan}\` plan, which does include Codex — so this is the model, ` + | ||
| `not the plan. This model is not offered to ChatGPT-subscription accounts; pick a different ` + | ||
| `model, or use an OpenAI API key.\n` + | ||
| `If that account is not the one you meant to use, \`altimate-code auth list\` shows which is stored.` + | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The new entitled-plan branch of Prompt for AI agents |
||
| provider | ||
| ) | ||
| } | ||
|
|
||
| const plan = identity.plan | ||
| ? ` on the \`${identity.plan}\` plan` | ||
| : " whose plan could not be read from the stored credential" | ||
| return ( | ||
| `${who}${plan}. Codex models require a ChatGPT Plus or Pro plan, ` + | ||
| `so this account cannot run them.\n` + | ||
| `If you have a Plus/Pro account, switch to it:\n` + | ||
| ` altimate-code auth logout openai\n` + | ||
| ` altimate-code auth login openai\n` + | ||
| `Otherwise use an OpenAI API key instead of the ChatGPT subscription, ` + | ||
| `or pick a model from another provider.` + | ||
| provider | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Given a raw 400 response body and the access token the request used, return | ||
| * an enriched `detail` string — or undefined when the body is not this failure | ||
| * (leave every other error untouched). | ||
| */ | ||
| export function enrichCodexPlanMismatchBody(body: string | undefined, accessToken: string | undefined) { | ||
| if (!isCodexPlanMismatchBody(body)) return undefined | ||
| let detail: string | undefined | ||
| try { | ||
| const parsed = JSON.parse(body!) | ||
| if (parsed && typeof parsed.detail === "string") detail = parsed.detail | ||
| } catch { | ||
| // Body was not JSON — the phrase still matched, so still enrich, just | ||
| // without quoting a `detail` field we could not parse out. | ||
| } | ||
| return codexPlanMismatchMessage(extractOAuthIdentity(accessToken), detail) | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,5 +1,8 @@ | ||||||
| import type { Argv } from "yargs" | ||||||
| import { Auth } from "../../auth" | ||||||
| // altimate_change start — surface OAuth plan/account in `auth list` | ||||||
| import { describeOAuthIdentity } from "../../auth/oauth-claims" | ||||||
| // altimate_change end | ||||||
| import { cmd } from "./cmd" | ||||||
| import { CliError, effectCmd, fail } from "../effect-cmd" | ||||||
| import { UI } from "../ui" | ||||||
|
|
@@ -209,6 +212,45 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( | |||||
| return false | ||||||
| }) | ||||||
|
|
||||||
| // altimate_change start — `auth login <provider>` used to be fed straight into a | ||||||
| // fetch() for well-known auth metadata, so the natural `auth login openai` died on | ||||||
| // "fetch() URL is invalid". Only an actual http(s) URL takes the well-known path; | ||||||
| // anything else is treated as a provider id, exactly like `--provider`. | ||||||
| export function isAuthProviderUrl(value: string | undefined): value is string { | ||||||
| if (!value) return false | ||||||
| try { | ||||||
| const { protocol } = new URL(value) | ||||||
| return protocol === "http:" || protocol === "https:" | ||||||
| } catch { | ||||||
| return false | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| export type LoginTarget = | ||||||
| /** Self-hosted auth provider: fetch its `.well-known/opencode` metadata. */ | ||||||
| | { kind: "url"; url: string } | ||||||
| /** A provider id/name — same path the interactive picker resolves to. */ | ||||||
| | { kind: "provider"; provider: string } | ||||||
| /** Nothing specified: prompt with the provider list. */ | ||||||
| | { kind: "picker" } | ||||||
|
|
||||||
| /** | ||||||
| * Decide which login path an invocation takes. A URL positional keeps the | ||||||
| * well-known behavior it always had; anything else is a provider id, so | ||||||
| * `auth login openai` works like `auth login --provider openai` rather than | ||||||
| * being handed to fetch(). A URL positional always wins — it is the only way to | ||||||
| * reach the well-known path, and it is what pre-existing invocations pass. When | ||||||
| * the positional is not a URL, an explicit `--provider` flag takes precedence | ||||||
| * over it, following the usual flag-beats-positional convention. | ||||||
| */ | ||||||
| export function resolveLoginTarget(args: { target?: string; provider?: string }): LoginTarget { | ||||||
| if (isAuthProviderUrl(args.target)) return { kind: "url", url: args.target.replace(/\/+$/, "") } | ||||||
| const provider = args.provider ?? args.target | ||||||
| if (provider) return { kind: "provider", provider } | ||||||
| return { kind: "picker" } | ||||||
| } | ||||||
| // altimate_change end | ||||||
|
|
||||||
| export function resolvePluginProviders(input: { | ||||||
| hooks: Hooks[] | ||||||
| existingProviders: Record<string, unknown> | ||||||
|
|
@@ -265,7 +307,13 @@ export const ProvidersListCommand = effectCmd({ | |||||
|
|
||||||
| for (const [providerID, result] of results) { | ||||||
| const name = database[providerID]?.name || providerID | ||||||
| yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) | ||||||
| // altimate_change start — show which account/plan an OAuth credential belongs to, | ||||||
| // so a wrong-account login is visible here instead of surfacing later as an | ||||||
| // unexplained model error. Only non-secret claims are decoded, and the account id | ||||||
| // is truncated; the token itself is never rendered. | ||||||
| const identity = result.type === "oauth" ? describeOAuthIdentity(result.access) : undefined | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Fall back to the stored OAuth account ID when the access token has no account claim. Otherwise Prompt for AI agents |
||||||
| yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}${identity ? ` (${identity})` : ""}`) | ||||||
|
Comment on lines
+314
to
+315
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an OpenAI credential gets its account ID from the ID token or the supported Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When an OAuth token contains control characters in Prompt for AI agents
Suggested change
|
||||||
| // altimate_change end | ||||||
| } | ||||||
|
|
||||||
| yield* Prompt.outro(`${results.length} credentials`) | ||||||
|
|
@@ -297,18 +345,19 @@ export const ProvidersListCommand = effectCmd({ | |||||
| }) | ||||||
|
|
||||||
| export const ProvidersLoginCommand = effectCmd({ | ||||||
| command: "login [url]", | ||||||
| // altimate_change start — the positional accepts a provider id as well as a URL | ||||||
| command: "login [target]", | ||||||
| describe: "log in to a provider", | ||||||
| // URL login skips instance bootstrap, which would load remote config with the stale token and crash before re-auth. | ||||||
| instance: (args) => !args.url, | ||||||
| // A provider id goes down the picker path instead, which needs the instance. | ||||||
| instance: (args) => resolveLoginTarget(args).kind !== "url", | ||||||
| builder: (yargs: Argv) => | ||||||
| yargs | ||||||
| .positional("url", { | ||||||
| // altimate_change start — branding | ||||||
| describe: "altimate auth provider", | ||||||
| // altimate_change end | ||||||
| .positional("target", { | ||||||
| describe: "provider id (e.g. openai) or altimate auth provider URL", | ||||||
| type: "string", | ||||||
| }) | ||||||
| // altimate_change end | ||||||
| .option("provider", { | ||||||
| alias: ["p"], | ||||||
| describe: "provider id or name to log in to (skips provider selection)", | ||||||
|
|
@@ -324,8 +373,11 @@ export const ProvidersLoginCommand = effectCmd({ | |||||
|
|
||||||
| UI.empty() | ||||||
| yield* Prompt.intro("Add credential") | ||||||
| if (args.url) { | ||||||
| const url = args.url.replace(/\/+$/, "") | ||||||
| // altimate_change start — only real http(s) URLs take the well-known path | ||||||
| const target = resolveLoginTarget(args) | ||||||
| if (target.kind === "url") { | ||||||
| const url = target.url | ||||||
| // altimate_change end | ||||||
| const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () => | ||||||
| fetch(`${url}/.well-known/opencode`).then((x) => x.json()), | ||||||
| )) as { | ||||||
|
|
@@ -417,13 +469,20 @@ export const ProvidersLoginCommand = effectCmd({ | |||||
| ] | ||||||
|
|
||||||
| let provider: string | ||||||
| if (args.provider) { | ||||||
| const input = args.provider | ||||||
| // altimate_change start — a non-URL positional is a provider id, same as --provider | ||||||
| if (target.kind === "provider") { | ||||||
| const input = target.provider | ||||||
| // altimate_change end | ||||||
| const byID = options.find((x) => x.value === input) | ||||||
| const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) | ||||||
| const match = byID ?? byName | ||||||
| if (!match) { | ||||||
| return yield* fail(`Unknown provider "${input}"`) | ||||||
| // altimate_change start — say what to do next instead of just rejecting | ||||||
| return yield* fail( | ||||||
| `Unknown provider "${input}". Run \`altimate-code auth login\` with no arguments to pick from the list, ` + | ||||||
| `or pass a full auth provider URL including https://.`, | ||||||
| ) | ||||||
| // altimate_change end | ||||||
| } | ||||||
| provider = match.value | ||||||
| } else { | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,9 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" | |
| import { Log } from "../util/log" | ||
| import { Installation } from "../installation" | ||
| import { Auth, OAUTH_DUMMY_KEY } from "../auth" | ||
| // altimate_change start — plan/account diagnostics for wrong-account logins | ||
| import { decodeJwtClaims, enrichCodexPlanMismatchBody, extractOAuthIdentity, maskAccountId } from "../auth/oauth-claims" | ||
| // altimate_change end | ||
| import os from "os" | ||
| import { ProviderTransform } from "@/provider/transform" | ||
| import { ModelID, ProviderID } from "@/provider/schema" | ||
|
|
@@ -88,13 +91,10 @@ export interface IdTokenClaims { | |
| } | ||
|
|
||
| export function parseJwtClaims(token: string): IdTokenClaims | undefined { | ||
| const parts = token.split(".") | ||
| if (parts.length !== 3) return undefined | ||
| try { | ||
| return JSON.parse(Buffer.from(parts[1], "base64url").toString()) | ||
| } catch { | ||
| return undefined | ||
| } | ||
| // altimate_change start — one JWT decoder, shared with auth/oauth-claims which | ||
| // reads the same tokens for the plan/account diagnostics | ||
| return decodeJwtClaims(token) as IdTokenClaims | undefined | ||
| // altimate_change end | ||
| } | ||
|
|
||
| export function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined { | ||
|
|
@@ -532,10 +532,39 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> { | |
| ? new URL(CODEX_API_ENDPOINT) | ||
| : parsed | ||
|
|
||
| return fetch(url, { | ||
| const response = await fetch(url, { | ||
| ...init, | ||
| headers, | ||
| }) | ||
|
|
||
| // altimate_change start — a wrong-account login is otherwise undiagnosable. | ||
| // A free-plan ChatGPT credential fails every Codex request with a 400 that | ||
| // blames the MODEL ("...is not supported when using Codex with a ChatGPT | ||
| // account"), never the account. Replace that body with one naming the plan | ||
| // and account the request actually used, plus the commands to switch. | ||
| // Only this one 400 shape is touched; every other response passes through. | ||
| if (response.status === 400) { | ||
| const body = await response | ||
| .clone() | ||
| .text() | ||
| .catch(() => "") | ||
| const enriched = enrichCodexPlanMismatchBody(body, currentAuth.access) | ||
| if (enriched) { | ||
| const identity = extractOAuthIdentity(currentAuth.access) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: Redundant
Reply with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: On every matching Codex 400, this decodes and parses Prompt for AI agents |
||
| log.warn("codex request rejected for this account's plan", { | ||
| plan: identity.plan, | ||
| account: maskAccountId(identity.accountId), | ||
| }) | ||
| return new Response(JSON.stringify({ detail: enriched }), { | ||
| status: response.status, | ||
| statusText: response.statusText, | ||
| headers: { "content-type": "application/json" }, | ||
| }) | ||
|
Comment on lines
+558
to
+562
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the matched 400 includes diagnostic headers such as a request identifier, constructing the replacement with only Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
| // altimate_change end | ||
|
|
||
| return response | ||
| }, | ||
| } | ||
| }, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This new module exposes only direct named exports and omits the package-required self-reexport, so its new consumers import individual helpers instead of the namespace projection mandated for modules in this package. Add the
OAuthClaimsself-reexport and consume the helpers through it.AGENTS.md reference: packages/opencode/AGENTS.md:L17-L20
Useful? React with 👍 / 👎.