From 3e2ff7599d7d11d16e1c1a2f1aa37ed0cd1516ab Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Tue, 10 Nov 2026 18:06:00 +0100 Subject: [PATCH] feat(chatgpt): add the ChatGPT subscription adapter and login Rhema had no way to spend a ChatGPT subscription: the models were reachable only through an OpenAI API key, which bills the API account instead. The new adapter signs in through OpenAI's browser OAuth flow with PKCE and a loopback callback, stores the returned token JSON as the instance credential, and sends turns to the Codex Responses backend with the subscription's live model catalog. The flow runs on the existing InteractiveLogin seam, so the wizard, the settings dialog, and the headless frontend all drive it. The TUI shows the link's host with an open button and a copy button: enter opens the default browser, and c copies the full URL, which keeps a long authorization query off the screen and out of a manual selection. The provider registry, config, ACP, claw, and the web settings page gain the new provider kind. --- crates/rhema-claw/src/backends.rs | 3 +++ deploy/docker/claw.Dockerfile | 1 + deploy/docker/sandboxd.Dockerfile | 1 + web/claw/app/components/settings/ProvidersPage.vue | 13 +++++++++---- web/claw/app/composables/useClaw.ts | 3 ++- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/rhema-claw/src/backends.rs b/crates/rhema-claw/src/backends.rs index f2125f7..2d3272e 100644 --- a/crates/rhema-claw/src/backends.rs +++ b/crates/rhema-claw/src/backends.rs @@ -320,6 +320,7 @@ fn env_vars_for(kind: &str) -> &'static [&'static str] { "zai" => &["ZAI_API_KEY", "Z_AI_API_KEY"], "opencode" => &["OPENCODE_API_KEY"], "copilot" => &["GITHUB_COPILOT_TOKEN"], + "chatgpt" => &["CHATGPT_ACCESS_TOKEN"], _ => &[], } } @@ -366,6 +367,7 @@ pub struct ProviderParams { pub fn addable_kinds() -> &'static [&'static str] { &[ "anthropic", + "chatgpt", "copilot", "deepseek", "google", @@ -542,6 +544,7 @@ pub fn discover( "zai", "opencode", "copilot", + "chatgpt", ] { let stored_of_kind = rows.iter().any(|(_, params)| params.kind == kind); let params = if kind == "llama-cpp" { diff --git a/deploy/docker/claw.Dockerfile b/deploy/docker/claw.Dockerfile index 5e8029c..1609412 100644 --- a/deploy/docker/claw.Dockerfile +++ b/deploy/docker/claw.Dockerfile @@ -26,6 +26,7 @@ COPY crates/rhema-memory/Cargo.toml crates/rhema-memory/Cargo.toml COPY crates/rhema-mcp/Cargo.toml crates/rhema-mcp/Cargo.toml COPY crates/rhema-provider/Cargo.toml crates/rhema-provider/Cargo.toml COPY crates/rhema-provider-anthropic/Cargo.toml crates/rhema-provider-anthropic/Cargo.toml +COPY crates/rhema-provider-chatgpt/Cargo.toml crates/rhema-provider-chatgpt/Cargo.toml COPY crates/rhema-provider-copilot/Cargo.toml crates/rhema-provider-copilot/Cargo.toml COPY crates/rhema-provider-google/Cargo.toml crates/rhema-provider-google/Cargo.toml COPY crates/rhema-provider-llama-cpp/Cargo.toml crates/rhema-provider-llama-cpp/Cargo.toml diff --git a/deploy/docker/sandboxd.Dockerfile b/deploy/docker/sandboxd.Dockerfile index fd5a323..afa9857 100644 --- a/deploy/docker/sandboxd.Dockerfile +++ b/deploy/docker/sandboxd.Dockerfile @@ -28,6 +28,7 @@ COPY crates/rhema-memory/Cargo.toml crates/rhema-memory/Cargo.toml COPY crates/rhema-mcp/Cargo.toml crates/rhema-mcp/Cargo.toml COPY crates/rhema-provider/Cargo.toml crates/rhema-provider/Cargo.toml COPY crates/rhema-provider-anthropic/Cargo.toml crates/rhema-provider-anthropic/Cargo.toml +COPY crates/rhema-provider-chatgpt/Cargo.toml crates/rhema-provider-chatgpt/Cargo.toml COPY crates/rhema-provider-copilot/Cargo.toml crates/rhema-provider-copilot/Cargo.toml COPY crates/rhema-provider-google/Cargo.toml crates/rhema-provider-google/Cargo.toml COPY crates/rhema-provider-llama-cpp/Cargo.toml crates/rhema-provider-llama-cpp/Cargo.toml diff --git a/web/claw/app/components/settings/ProvidersPage.vue b/web/claw/app/components/settings/ProvidersPage.vue index 63266a3..212ea1c 100644 --- a/web/claw/app/components/settings/ProvidersPage.vue +++ b/web/claw/app/components/settings/ProvidersPage.vue @@ -98,6 +98,7 @@ function save(): void { // --- the login form --------------------------------------------------- const KIND_LABELS: Record = { anthropic: 'Anthropic', + chatgpt: 'ChatGPT (subscription)', copilot: 'GitHub Copilot', deepseek: 'DeepSeek', google: 'Google Gemini', @@ -111,10 +112,14 @@ function kindLabel(kind: string): string { return KIND_LABELS[kind] ?? kind } -/** The key field's voice: copilot logs in with a GitHub token. */ -const keyLabel = computed(() => - addKind.value === 'copilot' ? 'GitHub token' : 'API key', -) +/** The key field's voice: copilot logs in with a GitHub token, and a + * ChatGPT login takes the browser flow's credential JSON (or a bare + * access token). */ +const keyLabel = computed(() => { + if (addKind.value === 'copilot') return 'GitHub token' + if (addKind.value === 'chatgpt') return 'ChatGPT credential (JSON or access token)' + return 'API key' +}) /** A field the daemon does not demand reads as optional, not hidden. */ function label(name: string, base: string): string { diff --git a/web/claw/app/composables/useClaw.ts b/web/claw/app/composables/useClaw.ts index d1ddad9..ae8ab42 100644 --- a/web/claw/app/composables/useClaw.ts +++ b/web/claw/app/composables/useClaw.ts @@ -204,7 +204,8 @@ export type AgentImageKind = 'avatar' | 'logo' /** The credentials a provider login carries: the instance name (the * kind when omitted), the kind (required on an id's first login), an - * API key (a GitHub token for copilot), and a server URL for + * API key (a GitHub token for copilot, the sign-in JSON for chatgpt), + * and a server URL for * llama.cpp. Empty optionals are omitted, never sent as "" strings. */ export interface ProviderCredentials { name?: string