From 4ec85762ab5229b3baff288b4f9828141cd0a566 Mon Sep 17 00:00:00 2001 From: Daniel Marta Date: Sat, 23 May 2026 13:44:57 +0200 Subject: [PATCH] feat(auth): support named model login profiles * docs(auth): document named OAuth profile logins * feat(auth): support --profile-id in models auth login * docs: note named model login profiles --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + docs/cli/models.md | 4 ++ docs/gateway/authentication.md | 17 +++++ docs/providers/openai.md | 8 +++ src/cli/models-cli.ts | 5 ++ .../models/auth.login-profiles.test.ts | 68 +++++++++++++++++++ src/commands/models/auth.ts | 38 ++++++++++- 7 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 src/commands/models/auth.login-profiles.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f04074e8c8b9..fe3ed02e04a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Docs: clarify README onboarding and Gateway startup paths, WhatsApp QR/408 recovery, cron output language prompts, skill advanced features, gateway upstream 403 troubleshooting, and plugin fallback override guidance. Thanks @deepujain, @Zacxxx, @Jah-yee, @neyric, @usimic, @Renu-Cybe, @BigUncle, and @SeashoreShi. - Docs: clarify context-pruning ratio bounds, local dashboard recovery, CLI env markers, remote onboarding token behavior, and Peekaboo Bridge permissions for subprocess agents. Thanks @ayesha-aziz123, @dishraters, @hougangdev, and @brandonlipman. - Docs: clarify browser CDP diagnostics, Plugin SDK allowlist imports, status-reaction timing defaults, queue steering behavior, limited-tool troubleshooting, cron HEARTBEAT handling, Telegram multi-agent groups, Bitwarden SecretRef setup, and EasyRunner deployments. Thanks @Quratulain-bilal, @mbelinky, @Mickey-, @vancece, @xenouzik, @posigit, @surlymochan, @janaka, and @choiking. +- CLI/models: let `openclaw models auth login` store a single returned provider auth profile under a requested `--profile-id`, and document named Codex OAuth profile setup. (#49315) Thanks @DanielLSM. - Crabbox/Testbox: run clean sparse-checkout Testbox syncs from a temporary full checkout and route remote changed gates through Corepack pnpm. - Docs: clarify IPv4-only Gateway BYOH binding, trusted-proxy scope clearing, Android pairing approval, macOS Accessibility grants, Zalo profile env vars, password-store SecretRef setup, and Chinese memory navigation. Thanks @itskai-dev, @gwh7078, @longstoryscott, @MoeJaberr, and @yuaiccc. - Docs: consolidate GLM under Z.AI, add the Upstash Box install guide and Gateway exposure runbook, clarify MEDIA directives, Copilot and Voyage setup, config path quoting, real behavior proof, and memory-file write guidance. Thanks @BobDu, @alitariksahin, @Jefsky, @musaabhasan, @OmerZeyveli, @leno23, @WuKongAI-CMU, @luoyanglang, and @majin1102. diff --git a/docs/cli/models.md b/docs/cli/models.md index c0bf7ff9f8eb..261a4c341f09 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -169,6 +169,7 @@ openclaw models fallbacks list openclaw models auth add openclaw models auth list [--provider ] [--json] openclaw models auth login --provider +openclaw models auth login --provider openai --profile-id openai:work openclaw models auth paste-api-key --provider openclaw models auth setup-token --provider openclaw models auth paste-token @@ -205,6 +206,9 @@ openclaw models auth list --provider openai Notes: +- `login` accepts `--profile-id ` for providers that support named + profiles during login. Use this to keep multiple logins for the same + provider separate. - `paste-api-key` accepts API keys generated elsewhere, prompts for the key value, and writes it to the default profile id `:manual` unless you pass `--profile-id`. In automation, pipe the key on stdin, for example diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index 6dd0e66f5b5b..b59347e293e3 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -193,6 +193,19 @@ key in the provider dashboard when you need provider-side invalidation. ## Controlling which credential is used +### During login (CLI) + +Use `openclaw models auth login --provider --profile-id ` for +providers that support named auth profiles during login. + +```bash +openclaw models auth login --provider openai-codex --profile-id openai-codex:ritsuko +openclaw models auth login --provider openai-codex --profile-id openai-codex:lain +``` + +This is the easiest way to keep multiple OAuth logins for the same provider +separate inside one agent. + ### Per-session (chat command) Use `/model @` to pin a specific provider credential for the current session (example profile ids: `anthropic:default`, `anthropic:work`). @@ -215,6 +228,10 @@ stored profiles as `excluded_by_auth_order` instead of silently skipping them. When you debug cooldown issues, remember that rate-limit cooldowns can be tied to one model id rather than the whole provider profile. +If you change auth order or profile pinning for a chat that is already running, +send `/new` or `/reset` in that chat to start a fresh session. Existing +sessions can keep their current model/profile selection until reset. + ## Troubleshooting ### "No credentials found" diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 5826f472d8bc..d9dafe0e4c9b 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -337,6 +337,14 @@ Choose your preferred auth method and follow the setup steps. openclaw models status --probe --probe-provider openai-codex ``` + Use `--profile-id` when you want multiple Codex OAuth logins in the same + agent and later want to control them via auth ordering or `/model ...@`: + + ```bash + openclaw models auth login --provider openai-codex --profile-id openai-codex:ritsuko + openclaw models auth login --provider openai-codex --profile-id openai-codex:lain + ``` + `openai/*` is the model route for OpenAI agent turns through Codex. The `openai-codex` auth/profile provider id remains accepted for existing profiles and CLI listing. diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index c0fa0014620c..80ab9fce89eb 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -333,6 +333,10 @@ export function registerModelsCli(program: Command) { .option("--provider ", "Provider id registered by a plugin") .option("--method ", "Provider auth method id") .option("--device-code", "Use the provider device-code auth method", false) + .option( + "--profile-id ", + "Auth profile id override for single-profile login methods", + ) .option("--set-default", "Apply the provider's default model recommendation", false) .action(async (opts, command) => { if (opts.deviceCode && typeof opts.method === "string" && opts.method !== "device-code") { @@ -347,6 +351,7 @@ export function registerModelsCli(program: Command) { { provider: opts.provider as string | undefined, method: opts.deviceCode ? "device-code" : (opts.method as string | undefined), + profileId: opts.profileId as string | undefined, setDefault: Boolean(opts.setDefault), agent, }, diff --git a/src/commands/models/auth.login-profiles.test.ts b/src/commands/models/auth.login-profiles.test.ts new file mode 100644 index 000000000000..cc4e434d4028 --- /dev/null +++ b/src/commands/models/auth.login-profiles.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { resolveLoginProfiles } from "./auth.js"; + +describe("resolveLoginProfiles", () => { + it("returns original profiles when --profile-id is not provided", () => { + const profiles = [ + { + profileId: "openai-codex:default", + credential: { + type: "oauth" as const, + provider: "openai-codex", + access: "a", + refresh: "r", + expires: Date.now() + 60_000, + }, + }, + ]; + + const resolved = resolveLoginProfiles({ + result: { profiles }, + }); + + expect(resolved).toEqual(profiles); + }); + + it("overrides profile id when exactly one profile is returned", () => { + const resolved = resolveLoginProfiles({ + requestedProfileId: "openai-codex:work", + result: { + profiles: [ + { + profileId: "openai-codex:default", + credential: { + type: "oauth" as const, + provider: "openai-codex", + access: "a", + refresh: "r", + expires: Date.now() + 60_000, + }, + }, + ], + }, + }); + + expect(resolved).toHaveLength(1); + expect(resolved[0]?.profileId).toBe("openai-codex:work"); + }); + + it("throws when --profile-id is used with multi-profile auth responses", () => { + expect(() => + resolveLoginProfiles({ + requestedProfileId: "provider:manual", + result: { + profiles: [ + { + profileId: "provider:one", + credential: { type: "api_key" as const, provider: "provider", key: "k1" }, + }, + { + profileId: "provider:two", + credential: { type: "api_key" as const, provider: "provider", key: "k2" }, + }, + ], + }, + }), + ).toThrow(/--profile-id requires exactly one returned auth profile/i); + }); +}); diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 8b289f3512a8..c173b7bdabff 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -379,6 +379,7 @@ async function pickProviderTokenMethod(params: { async function persistProviderAuthResult(params: { result: ProviderAuthResult; + profiles?: ProviderAuthResult["profiles"]; agentDir: string; runtime: RuntimeEnv; prompter: ReturnType; @@ -387,7 +388,9 @@ async function persistProviderAuthResult(params: { const defaultModel = params.result.defaultModel ? normalizeAgentModelRefForConfig(params.result.defaultModel) : undefined; - for (const profile of params.result.profiles) { + const profiles = params.profiles ?? params.result.profiles; + + for (const profile of profiles) { await upsertAuthProfileWithLockOrThrow({ profileId: profile.profileId, credential: profile.credential, @@ -408,7 +411,7 @@ async function persistProviderAuthResult(params: { replaceDefaultModels: params.result.replaceDefaultModels, }); } - for (const profile of params.result.profiles) { + for (const profile of profiles) { next = applyAuthProfileConfig(next, { profileId: profile.profileId, provider: profile.credential.provider, @@ -436,7 +439,7 @@ async function persistProviderAuthResult(params: { } logConfigUpdated(params.runtime); - for (const profile of params.result.profiles) { + for (const profile of profiles) { params.runtime.log( `Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`, ); @@ -461,6 +464,7 @@ async function runProviderAuthMethod(params: { method: ProviderAuthMethod; runtime: RuntimeEnv; prompter: ReturnType; + profileId?: string; setDefault?: boolean; }) { const selectedProviderId = normalizeProviderId(params.provider.id); @@ -492,8 +496,14 @@ async function runProviderAuthMethod(params: { } } + const profiles = resolveLoginProfiles({ + result, + requestedProfileId: params.profileId, + }); + await persistProviderAuthResult({ result, + profiles, agentDir: params.agentDir, runtime: params.runtime, prompter: params.prompter, @@ -795,6 +805,7 @@ export async function modelsAuthAddCommand(opts: { agent?: string }, runtime: Ru type LoginOptions = { provider?: string; method?: string; + profileId?: string; setDefault?: boolean; yes?: boolean; agent?: string; @@ -837,6 +848,25 @@ function credentialMode(credential: AuthProfileCredential): "api_key" | "oauth" return "oauth"; } +export function resolveLoginProfiles(params: { + result: ProviderAuthResult; + requestedProfileId?: string; +}): ProviderAuthResult["profiles"] { + const requestedProfileId = params.requestedProfileId?.trim(); + if (!requestedProfileId) { + return params.result.profiles; + } + + if (params.result.profiles.length !== 1) { + throw new Error( + "--profile-id requires exactly one returned auth profile from the selected auth method.", + ); + } + + const [profile] = params.result.profiles; + return [{ ...profile, profileId: requestedProfileId }]; +} + function maybeLogOpenAICodexNativeSearchTip(runtime: RuntimeEnv, providerId: string) { if (providerId !== "openai-codex") { return; @@ -845,6 +875,7 @@ function maybeLogOpenAICodexNativeSearchTip(runtime: RuntimeEnv, providerId: str "Tip: Codex-capable models can use native Codex web search. Enable it with openclaw configure --section web (recommended mode: cached). Docs: https://docs.openclaw.ai/tools/web", ); } + export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: RuntimeEnv) { if (!process.stdin.isTTY) { throw new Error( @@ -903,6 +934,7 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim method: chosenMethod, runtime, prompter, + profileId: opts.profileId, setDefault: opts.setDefault, }); maybeLogOpenAICodexNativeSearchTip(runtime, selectedProvider.id);