diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c985316..8c96746b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +- Renamed the "Subscription" connection type to "Claude Subscription" in Profile settings, to distinguish it from the new "ChatGPT Subscription" connection type +- Added support for signing in with a ChatGPT Plus/Pro/Team subscription as a new profile connection type, routing sessions through OpenAI's Codex backend instead of requiring an OpenAI API key; click "Copy sign in link" to copy the sign-in URL, open it in your own browser, then paste the resulting code back to complete sign-in — the plugin never opens a browser or waits on it automatically. Once signed in, "Model Aliases…" is available for this profile and fetches the live list of models available to your account instead of a fixed list; if the fetch fails, the upstream error is shown in the dialog's status line + # 1.2.28 (2026-07-15) - Fixed a "Warning" dialog showing raw XML content sometimes appearing at IDE startup after using a diff view diff --git a/README.md b/README.md index 72061277..35a3dc8d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ NetBeans Claude Code GUI is a NetBeans IDE plugin that embeds the Claude Code CL - **File attachments** — attach files via `@path` tokens with auto-completion popup, drag-and-drop, and clipboard paste support - **Multiple profiles** — run Claude Code under separate accounts or API keys for different projects, each with an isolated config directory, authentication, proxy, and model settings - **OpenAI-compatible proxy** — route Claude Code through any OpenAI-compatible provider (OpenAI, Azure OpenAI, Groq, DeepSeek, Ollama, LM Studio, and others) via a built-in translation proxy; no changes to Claude Code CLI required +- **ChatGPT subscription sign-in** — route Claude Code through OpenAI's Codex backend using your ChatGPT Plus/Pro/Team subscription instead of an API key; sign in via a copy-link/paste-code flow, then pick from your account's live model list - **Session management** — start new sessions, continue the last session, or resume a specific past session; sessions persist across IDE restarts - **Markdown Preview** — live-rendered markdown tab for plan files and MCP-initiated previews; includes a find bar (Ctrl+F) and font zoom (Alt+Scroll) - **Auto Plan Preview** — when Claude writes a plan file, a live preview tab opens automatically as soon as you accept the diff diff --git a/docs/screenshots/profiles-tab.png b/docs/screenshots/profiles-tab.png index 9bdabdf1..d676bc26 100644 Binary files a/docs/screenshots/profiles-tab.png and b/docs/screenshots/profiles-tab.png differ diff --git a/docs/user-manual.md b/docs/user-manual.md index 8c6f9af4..b01f9b64 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -485,16 +485,17 @@ Select the authentication type using the radio buttons: | Type | Fields to fill in | How to authenticate | |------|------------------|-------------------| | **Claude Managed** | None | Claude manages authentication itself. Run `claude` in a terminal once to set it up, or Claude will prompt you on first launch. Use `/login` in the session terminal to re-authenticate. | -| **Subscription** | OAuth token | Obtain the token at [claude.ai](https://claude.ai) (requires Pro, Max, Team, or Enterprise subscription). **Note:** OAuth tokens expire periodically and must be regenerated and re-entered manually. For a smoother experience, prefer **Claude Managed** instead. | +| **Claude Subscription** | OAuth token | Obtain the token at [claude.ai](https://claude.ai) (requires Pro, Max, Team, or Enterprise subscription). **Note:** OAuth tokens expire periodically and must be regenerated and re-entered manually. For a smoother experience, prefer **Claude Managed** instead. | | **Claude API** | API key | Obtain the key at [console.anthropic.com](https://console.anthropic.com) (requires an Anthropic Console account with API access). | | **Claude-compatible API** | API key + Base URL | Use the API key and base URL provided by your third-party provider (Anthropic-compatible endpoint). | | **OpenAI-compatible API** | API key + Base URL | Route Claude Code through any OpenAI-compatible provider. The plugin starts an internal proxy that translates Anthropic API calls to OpenAI Chat Completions format. See [OpenAI-compatible API](#openai-compatible-api) below. | +| **ChatGPT Subscription** | Sign in via browser | Route Claude Code through OpenAI's Codex backend using a ChatGPT Plus/Pro/Team subscription instead of an API key. See [ChatGPT Subscription](#chatgpt-subscription) below. | > **Note:** Changes to connection parameters (API key, Base URL) take effect only after restarting the session. -### Model aliases (Claude-compatible API only) +### Model aliases (Claude-compatible API, OpenAI-compatible API, and ChatGPT Subscription) -If your provider names models differently from Anthropic's standard names, the plugin cannot match them to the `sonnet`, `opus`, and `haiku` aliases used by Claude Code. In that case, set the alias to the actual model ID used by your provider (for example, with an `anthropic/` prefix). +If your provider names models differently from Anthropic's standard names, the plugin cannot match them to the `sonnet`, `opus`, and `haiku` aliases used by Claude Code. In that case, set the alias to the actual model ID used by your provider (for example, with an `anthropic/` prefix). For **ChatGPT Subscription**, **Model Aliases…** becomes available once you're signed in and fetches the live list of models available to your account instead of a fixed list. Click **Model Aliases…** to open the Model Aliases dialog. @@ -512,7 +513,7 @@ The dialog shows a table with three columns: | Button | Effect | |--------|--------| -| **Fetch** | Queries the provider's models endpoint using the configured API key, fills the table with discovered model IDs, and marks each as available (✓) or unavailable (✗) | +| **Fetch** | Queries the provider's models endpoint (using the configured API key, or your signed-in ChatGPT session for **ChatGPT Subscription**), fills the table with discovered model IDs, and marks each as available (✓) or unavailable (✗) | | **Add** | Opens an input dialog; enter a model ID to append a new row to the table (with blank alias and no availability mark) | | **Rename** | Opens an input dialog pre-filled with the selected row's ID; confirm to update the model ID in place | | **↑ / ↓** | Reorder rows — the order determines how models appear in the status bar model selector | @@ -592,6 +593,33 @@ Without model aliases, Claude Code will send Anthropic model names (e.g. `claude - Images in conversations (base64 content) are forwarded in OpenAI vision format; provider must support it. - Changes to the profile's Base URL or API Key take effect only after restarting the session. +### ChatGPT Subscription + +Routes Claude Code through OpenAI's own Codex backend using your ChatGPT Plus/Pro/Team subscription, instead of an OpenAI API key. Like **OpenAI-compatible API**, this works via the plugin's internal proxy — no changes to Claude Code CLI are required. + +**How it works:** + +The plugin never opens or waits on a browser itself, since the browser you sign in with may be on a different network path (proxy, VPN, remote machine) than the plugin. Instead: + +1. Click **Copy sign in link** — the plugin generates a one-time sign-in link and copies it to the clipboard. +2. Paste the link into your own browser and sign in to your ChatGPT account there. +3. After signing in, copy the code shown on the confirmation page and paste it back into the field in the plugin. +4. Click **Complete Sign-in**. The profile now shows **Signed in as ``**. + +You can click **Copy sign in link** again at any time to cancel a stuck attempt and generate a fresh link — there is no timeout to wait out. + +**Setup:** + +1. Create a new profile and select **ChatGPT Subscription** as the connection type. +2. Sign in as described above. +3. Open **Model Aliases…** and click **Fetch** to pull the current list of models available to your account, then assign aliases the same way as for [OpenAI-compatible API](#model-aliases-claude-compatible-api-openai-compatible-api-and-chatgpt-subscription) above. + +**Limitations:** + +- Only models Codex actually supports for a ChatGPT-subscription login work — arbitrary model IDs will be rejected by OpenAI's backend. +- Usage is subject to your ChatGPT subscription's own rate limits, shared across all Codex/ChatGPT clients on that account. +- Sign-in tokens are refreshed automatically while a session is running; if a refresh fails (e.g. the session was revoked from your OpenAI account), sign in again from the Profiles tab. + ### Extra environment variables Arbitrary `KEY=VALUE` pairs injected into the Claude process environment. Applied last — they override all other profile variables. Use this for provider-specific configuration. diff --git a/pom.xml b/pom.xml index d7baeb89..71b5ba21 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.0-SNAPSHOT + 1.3.18-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ClaudeCodeInstaller.java b/src/main/java/io/github/nbplugins/claudecodegui/ClaudeCodeInstaller.java index 12048b70..5071fe29 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ClaudeCodeInstaller.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ClaudeCodeInstaller.java @@ -263,6 +263,14 @@ public void registerOpenAIProxy(String uuid, String baseUrl, String apiKey, } } + @Override + public void registerChatgptSubscriptionProxy(String uuid, String profileId, String accessToken, + String accountId, io.github.nbplugins.claudecodegui.settings.ProxyConfiguration proxy) { + if (mcpServer != null) { + mcpServer.registerChatgptSubscriptionProxy(uuid, profileId, accessToken, accountId, proxy); + } + } + @Override public void deregisterOpenAIProxy(String uuid) { if (mcpServer != null) { diff --git a/src/main/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptOAuthClient.java b/src/main/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptOAuthClient.java new file mode 100644 index 00000000..f1176b9e --- /dev/null +++ b/src/main/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptOAuthClient.java @@ -0,0 +1,529 @@ +package io.github.nbplugins.claudecodegui.chatgptauth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.github.nbplugins.claudecodegui.settings.ProxyConfiguration; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Runs the PKCE OAuth flow used to authenticate a ChatGPT Plus/Pro/Team + * subscription, and exchanges an authorization code or refresh token for an + * access token. + * + *

Reuses OpenAI's own Codex CLI public client id and endpoints, since + * this plugin has no registered OAuth application of its own — the same + * approach taken by third-party tools such as {@code raine/claude-code-proxy}. + * No client secret is required (public PKCE client). + * + *

This client never opens a browser and never waits for the OAuth + * redirect over the network: {@link #beginSignIn()} builds the authorize URL + * and binds a local listener purely as a convenience (it renders the + * authorization code on a friendly page when the browser can reach it — see + * {@link #handleCallback}), but completion is always driven by the caller + * manually calling {@link PendingSignIn#completeWithCode}. This is + * deliberate: the browser used to complete sign-in may run under different + * network conditions than the plugin (a different proxy, or on a machine + * that can't reach this one at all), so relying on the redirect actually + * reaching the local listener would be fragile. + * + *

The resulting {@link TokenSet} is used both to call OpenAI's Codex + * Responses API ({@code https://chatgpt.com/backend-api/codex/responses}) + * and to refresh the access token via {@link #refresh}, wrapped + * by {@link ChatGptTokenManager} for expiry tracking and single-flight refresh. + */ +public final class ChatGptOAuthClient { + + private static final Logger LOG = Logger.getLogger(ChatGptOAuthClient.class.getName()); + + static final String CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; + static final String ISSUER = "https://auth.openai.com"; + static final String AUTHORIZE_ENDPOINT = ISSUER + "/oauth/authorize"; + static final String TOKEN_ENDPOINT = ISSUER + "/oauth/token"; + static final int PRIMARY_CALLBACK_PORT = 1455; + static final int FALLBACK_CALLBACK_PORT = 1457; + static final String SCOPES = "openid profile email offline_access api.connectors.read api.connectors.invoke"; + private static final String CALLBACK_PATH = "/auth/callback"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * Result of a successful login or token refresh: the OAuth token set plus + * account identity claims extracted from the {@code id_token}. + * + * @param accessToken bearer token sent to the Codex Responses API + * @param refreshToken used to obtain a new access token when it expires + * @param accountId {@code chatgpt_account_id} claim, sent as the + * {@code ChatGPT-Account-Id} header + * @param email {@code email} claim, if present; used only for display + * @param expiresAt access-token expiry instant + */ + public record TokenSet(String accessToken, String refreshToken, String accountId, + String email, Instant expiresAt) {} + + /** + * Starts a new sign-in attempt: generates fresh PKCE parameters, binds a + * local one-shot HTTP listener (primary port {@value #PRIMARY_CALLBACK_PORT}, + * falling back to {@value #FALLBACK_CALLBACK_PORT}), and builds the + * authorize URL. + * + *

Purely local work — no network call — so this is safe to call + * directly on the EDT. The returned {@link PendingSignIn} must eventually + * be completed via {@link PendingSignIn#completeWithCode} or discarded + * via {@link PendingSignIn#close()} to release the bound port. + * + * @return a new pending sign-in attempt + * @throws OAuthException if neither the primary nor fallback port can be bound + */ + public PendingSignIn beginSignIn() throws OAuthException { + String codeVerifier = generateCodeVerifier(); + String codeChallenge = deriveCodeChallenge(codeVerifier); + String state = generateState(); + + HttpServer server; + int port; + try { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", PRIMARY_CALLBACK_PORT), 0); + port = PRIMARY_CALLBACK_PORT; + } catch (IOException primaryFailed) { + try { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", FALLBACK_CALLBACK_PORT), 0); + port = FALLBACK_CALLBACK_PORT; + } catch (IOException fallbackFailed) { + throw new OAuthException("Ports " + PRIMARY_CALLBACK_PORT + "/" + FALLBACK_CALLBACK_PORT + + " are in use — close other Codex/Claude Code sign-in flows and retry.", + fallbackFailed); + } + } + + server.createContext(CALLBACK_PATH, this::handleCallback); + server.start(); + + String authorizeUrl = buildAuthorizeUrl(codeVerifier, codeChallenge, state, port); + return new PendingSignIn(authorizeUrl, codeVerifier, port, server); + } + + /** + * A single in-progress sign-in attempt: an authorize URL the user opens + * themselves, plus the PKCE state needed to redeem the code they paste + * back once they've completed sign-in in their browser. + */ + public final class PendingSignIn implements AutoCloseable { + private final String authorizeUrl; + private final String codeVerifier; + private final int port; + private final HttpServer server; + private volatile boolean closed; + + private PendingSignIn(String authorizeUrl, String codeVerifier, int port, HttpServer server) { + this.authorizeUrl = authorizeUrl; + this.codeVerifier = codeVerifier; + this.port = port; + this.server = server; + } + + /** The authorize URL to present to the user (e.g. copy to clipboard). */ + public String authorizeUrl() { return authorizeUrl; } + + /** + * Redeems the authorization code the user pasted back after + * completing sign-in in their browser. + * + *

Accepts either the bare code or the full redirect URL the + * browser landed on (e.g. copied from the address bar when the + * browser couldn't reach this machine's local listener) — the code + * is extracted from either form. Performs a real network call to + * the token endpoint; callers must invoke this off the EDT. + * + * @param pastedCodeOrUrl the code, or a URL containing a {@code code} query parameter + * @param proxyConfig proxy settings to use for the token-exchange HTTP call, + * read fresh at call time (not captured at {@link #beginSignIn()}) + * @return the obtained token set + * @throws OAuthException if no code could be extracted from the input, + * or the token exchange fails + */ + public TokenSet completeWithCode(String pastedCodeOrUrl, ProxyConfiguration proxyConfig) + throws OAuthException { + String code = extractCode(pastedCodeOrUrl); + if (code.isBlank()) { + throw new OAuthException("Could not find a sign-in code in the pasted text."); + } + HttpClient httpClient = proxyConfig.applyTo( + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30))).build(); + return exchangeCodeForTokens(httpClient, code, codeVerifier, port); + } + + /** Stops the local listener, releasing its port. Safe to call more than once. */ + @Override + public void close() { + if (closed) return; + closed = true; + server.stop(0); + } + } + + /** + * Handles a hit on the local listener, when the user's browser is able to + * reach this machine: renders the authorization code (or an error) on a + * simple page for the user to copy back into NetBeans. Purely a + * convenience — does not drive completion of any pending sign-in. + */ + private void handleCallback(HttpExchange exchange) throws IOException { + try { + Map params = parseQuery(exchange.getRequestURI().getRawQuery()); + String error = params.get("error"); + String code = params.get("code"); + + String html = (error == null && code != null) + ? "" + + "

Sign-in code:

" + + "

" + escapeHtml(code) + "

" + + "

Copy this code and paste it back into NetBeans, then click \"Complete Sign-in\".

" + + "" + : "

Sign-in failed" + + (error != null ? ": " + escapeHtml(error) : "") + + ". You can close this tab and try again in NetBeans.

"; + byte[] bytes = html.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/html; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } catch (Exception e) { + LOG.log(Level.WARNING, "Error rendering ChatGPT sign-in callback page", e); + } + } + + private static String escapeHtml(String s) { + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + // ------------------------------------------------------------------------- + // PKCE / URL building — package-private for unit testing + // ------------------------------------------------------------------------- + + static String generateCodeVerifier() { + byte[] bytes = new byte[32]; + new SecureRandom().nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + static String deriveCodeChallenge(String verifier) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(verifier.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } catch (Exception e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + private static String generateState() { + byte[] bytes = new byte[16]; + new SecureRandom().nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + static String buildAuthorizeUrl(String codeVerifier, String codeChallenge, String state, int callbackPort) { + String redirectUri = "http://localhost:" + callbackPort + CALLBACK_PATH; + return AUTHORIZE_ENDPOINT + + "?response_type=code" + + "&client_id=" + urlEncode(CLIENT_ID) + + "&redirect_uri=" + urlEncode(redirectUri) + + "&scope=" + urlEncode(SCOPES) + + "&code_challenge=" + urlEncode(codeChallenge) + + "&code_challenge_method=S256" + + "&state=" + urlEncode(state); + } + + private static String urlEncode(String s) { + return URLEncoder.encode(s, StandardCharsets.UTF_8); + } + + private static Map parseQuery(String rawQuery) { + Map out = new java.util.LinkedHashMap<>(); + if (rawQuery == null || rawQuery.isBlank()) return out; + for (String pair : rawQuery.split("&")) { + int eq = pair.indexOf('='); + if (eq < 0) continue; + String k = java.net.URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8); + String v = java.net.URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8); + out.put(k, v); + } + return out; + } + + /** + * Extracts an authorization code from either a bare code string or a full + * redirect URL containing a {@code code} query parameter. + * + * @param pastedCodeOrUrl user-pasted text — a code or a URL + * @return the extracted code, or the trimmed input as-is if it doesn't + * look like a URL (treated as a bare code); never {@code null} + */ + static String extractCode(String pastedCodeOrUrl) { + if (pastedCodeOrUrl == null) return ""; + String trimmed = pastedCodeOrUrl.trim(); + int queryStart = trimmed.indexOf('?'); + boolean looksLikeUrl = trimmed.startsWith("http://") || trimmed.startsWith("https://") || queryStart >= 0; + if (looksLikeUrl) { + String query = queryStart >= 0 ? trimmed.substring(queryStart + 1) : ""; + // A URL/query string with no "code" param has no code to extract — don't fall back + // to treating the whole URL as a bare code, that would just fail confusingly later. + return parseQuery(query).getOrDefault("code", ""); + } + return trimmed; + } + + // ------------------------------------------------------------------------- + // Token exchange + // ------------------------------------------------------------------------- + + static TokenSet exchangeCodeForTokens(HttpClient httpClient, String code, String codeVerifier, int callbackPort) + throws OAuthException { + String redirectUri = "http://localhost:" + callbackPort + CALLBACK_PATH; + String form = "grant_type=authorization_code" + + "&code=" + urlEncode(code) + + "&redirect_uri=" + urlEncode(redirectUri) + + "&client_id=" + urlEncode(CLIENT_ID) + + "&code_verifier=" + urlEncode(codeVerifier); + return requestTokens(httpClient, form); + } + + /** + * Exchanges a refresh token for a new access token. Called by + * {@link ChatGptTokenManager} when the current access token is near expiry. + * + * @param httpClient client configured with the profile's proxy settings + * @param refreshToken the current refresh token + * @return the refreshed token set (OpenAI may rotate the refresh token) + * @throws OAuthException if the refresh request fails (e.g. revoked token) + */ + public TokenSet refresh(HttpClient httpClient, String refreshToken) throws OAuthException { + String form = "grant_type=refresh_token" + + "&refresh_token=" + urlEncode(refreshToken) + + "&client_id=" + urlEncode(CLIENT_ID); + return requestTokens(httpClient, form); + } + + // ------------------------------------------------------------------------- + // Model discovery + // ------------------------------------------------------------------------- + + static final String MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models"; + + /** + * Value sent as the required {@code client_version} query parameter on + * {@link #MODELS_ENDPOINT} — the backend rejects requests missing it with + * {@code HTTP 400 "Field required"}, and (confirmed against + * {@code openai/codex}'s {@code codex-rs/codex-api/src/endpoint/models.rs} + * and {@code ModelInfo.minimal_client_version}) also uses it to filter out + * any model whose {@code minimal_client_version} exceeds this value — so + * an old/low value here silently returns an empty {@code models} array + * rather than an error. Update this to (approximately) the current Codex + * CLI release — see {@code gh release list --repo openai/codex} — if + * fetches start returning nothing again. + */ + static final String CLIENT_VERSION = "0.144.6"; + + /** + * Fetches the list of model IDs available to this ChatGPT account on the + * Codex backend, for the "Model Aliases…" dialog's "Fetch" button — an + * alternative to the hardcoded model list this connection type used to have. + * + *

This endpoint is not officially documented (reverse-engineered the + * same way as the rest of this Codex integration — see class Javadoc), so + * {@link #parseModelIds} is deliberately tolerant of a few plausible + * response shapes rather than assuming one exact schema. + * + * @param httpClient client configured with the profile's proxy settings + * @param accessToken current (already-refreshed) ChatGPT OAuth access token + * @param accountId {@code chatgpt_account_id} claim, sent as {@code ChatGPT-Account-Id} + * @return the available model IDs + * @throws OAuthException if the request fails, the response can't be parsed, + * or no models could be recognized in the response + * (the exception message then includes the raw body, + * since this endpoint's exact shape isn't documented) + */ + public java.util.List fetchModels(HttpClient httpClient, String accessToken, String accountId) + throws OAuthException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(MODELS_ENDPOINT + "?client_version=" + urlEncode(CLIENT_VERSION))) + .header("Authorization", "Bearer " + accessToken) + .header("ChatGPT-Account-Id", accountId) + .header("Accept", "application/json") + .header("originator", "codex_cli_rs") + .header("User-Agent", "netbeans-plugin-claude-code-gui") + .timeout(Duration.ofSeconds(30)) + .GET() + .build(); + + HttpResponse response; + try { + response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new OAuthException("Could not reach the ChatGPT models endpoint: " + e.getMessage(), e); + } + + if (response.statusCode() >= 400) { + // Include the upstream body — it usually states the actual reason + // (wrong endpoint/headers/auth), so the user sees a real cause in + // the dialog's status line instead of an opaque bare status code. + String detail = response.body() == null ? "" : response.body().trim(); + if (detail.length() > 300) detail = detail.substring(0, 300) + "…"; + throw new OAuthException("ChatGPT models endpoint returned HTTP " + response.statusCode() + + (detail.isBlank() ? "" : ": " + detail)); + } + + LOG.fine("ChatGPT models endpoint response: " + response.body()); + + java.util.List ids; + try { + ids = parseModelIds(MAPPER.readTree(response.body())); + } catch (Exception e) { + throw new OAuthException("Invalid response from the ChatGPT models endpoint", e); + } + + if (ids.isEmpty()) { + // parseModelIds tolerates a few plausible shapes, but this endpoint is + // undocumented — surface the raw body so the actual shape can be + // diagnosed from the dialog's status line instead of digging in logs. + String body = response.body() == null ? "" : response.body().trim(); + if (body.length() > 500) body = body.substring(0, 500) + "…"; + throw new OAuthException("ChatGPT models endpoint returned no recognizable models. Raw response: " + + (body.isBlank() ? "(empty)" : body)); + } + + return ids; + } + + /** + * Extracts model IDs from a Codex {@code /models} response. Confirmed + * against {@code openai/codex}'s own source (both response envelope and + * per-model field name): {@code {"models":[{"slug":...}]}}. Also tolerates + * a {@code {"data":[{"id":...}]}} envelope (matching the OpenAI-compatible + * {@code /v1/models} convention), a bare top-level array, and — within + * each array entry — an {@code id}, {@code model}, or {@code name} field + * as a fallback if {@code slug} is absent, or a bare ID string. + * + * @param json the parsed response body + * @return extracted model IDs, in response order; never {@code null} + */ + static java.util.List parseModelIds(JsonNode json) { + java.util.List ids = new java.util.ArrayList<>(); + // Confirmed live shape: {"models":[...]}. Also tolerate the OpenAI-compatible + // {"data":[...]} envelope and a bare top-level array, in case the shape + // varies by account/rollout — this endpoint is undocumented. + JsonNode array = null; + for (String key : new String[]{"models", "data"}) { + JsonNode candidate = json.path(key); + if (candidate.isArray()) { + array = candidate; + break; + } + } + if (array == null && json.isArray()) { + array = json; + } + if (array != null) { + for (JsonNode item : array) { + // "slug" is the confirmed field name in openai/codex's ModelInfo + // (codex-rs/codex-protocol/src/openai_models.rs); id/model/name are + // kept as tolerant fallbacks in case the shape varies. + String id = item.isTextual() ? item.asText() + : item.path("slug").asText(item.path("id").asText( + item.path("model").asText(item.path("name").asText("")))); + if (!id.isBlank()) ids.add(id); + } + } + return ids; + } + + private static TokenSet requestTokens(HttpClient httpClient, String form) throws OAuthException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(TOKEN_ENDPOINT)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)) + .timeout(Duration.ofSeconds(30)) + .build(); + + HttpResponse response; + try { + response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new OAuthException("Could not reach ChatGPT sign-in server: " + e.getMessage(), e); + } + + if (response.statusCode() >= 400) { + throw new OAuthException("ChatGPT session expired or was revoked — please sign in again " + + "in Profile settings. (HTTP " + response.statusCode() + ")"); + } + + JsonNode json; + try { + json = MAPPER.readTree(response.body()); + } catch (Exception e) { + throw new OAuthException("Invalid response from ChatGPT sign-in server", e); + } + + String accessToken = json.path("access_token").asText(""); + String refreshTokenOut = json.path("refresh_token").asText(""); + String idToken = json.path("id_token").asText(""); + long expiresIn = json.path("expires_in").asLong(3600); + + if (accessToken.isBlank()) { + throw new OAuthException("ChatGPT sign-in server did not return an access token"); + } + + String accountId = ""; + String email = ""; + if (!idToken.isBlank()) { + JsonNode claims = parseIdTokenClaims(idToken); + if (claims != null) { + accountId = claims.path("https://api.openai.com/auth").path("chatgpt_account_id").asText(""); + email = claims.path("email").asText(""); + } + } + + return new TokenSet(accessToken, refreshTokenOut, accountId, email, + Instant.now().plusSeconds(expiresIn)); + } + + /** + * Extracts the claims payload from a JWT without verifying its signature. + * Safe here because the token is obtained directly from {@code auth.openai.com} + * over TLS, not accepted from an untrusted third party. + */ + private static JsonNode parseIdTokenClaims(String idToken) { + String[] parts = idToken.split("\\."); + if (parts.length < 2) return null; + try { + String payload = parts[1]; + // JWT base64url omits padding; Base64.getUrlDecoder() requires it be added back. + int rem = payload.length() % 4; + if (rem != 0) payload += "=".repeat(4 - rem); + byte[] decoded = Base64.getUrlDecoder().decode(payload); + return MAPPER.readTree(decoded); + } catch (Exception e) { + LOG.log(Level.WARNING, "Could not parse id_token claims", e); + return null; + } + } +} diff --git a/src/main/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptTokenManager.java b/src/main/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptTokenManager.java new file mode 100644 index 00000000..2b952805 --- /dev/null +++ b/src/main/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptTokenManager.java @@ -0,0 +1,165 @@ +package io.github.nbplugins.claudecodegui.chatgptauth; + +import io.github.nbplugins.claudecodegui.chatgptauth.ChatGptOAuthClient.TokenSet; +import io.github.nbplugins.claudecodegui.settings.ClaudeProfile; +import io.github.nbplugins.claudecodegui.settings.ClaudeProfileStore; +import io.github.nbplugins.claudecodegui.settings.ProxyConfiguration; +import java.net.http.HttpClient; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +/** + * Keeps a profile's ChatGPT access token fresh. + * + *

Refreshes the access token shortly before it expires, using the + * profile's own {@link ProxyConfiguration} for the refresh HTTP call (the + * same network conditions the rest of that profile's traffic uses). + * Concurrent callers for the same profile share a single in-flight refresh + * (single-flight via {@link ConcurrentHashMap}), so a burst of proxy + * requests near expiry never triggers a refresh stampede. + * + *

On successful refresh the new token set is persisted back onto the + * profile via {@link ClaudeProfileStore} (OpenAI may rotate the refresh + * token, so both access and refresh tokens are overwritten). + */ +public final class ChatGptTokenManager { + + private static final Logger LOG = Logger.getLogger(ChatGptTokenManager.class.getName()); + + /** Refresh when the access token is within this long of expiring. */ + static final Duration REFRESH_SKEW = Duration.ofMinutes(5); + + /** + * Performs the actual refresh-token HTTP exchange. Implemented by + * {@link ChatGptOAuthClient#refresh} in production; replaced by a fake in + * tests so no real network call is made. + */ + @FunctionalInterface + interface TokenRefresher { + TokenSet refresh(HttpClient client, String refreshToken) throws OAuthException; + } + + private final Clock clock; + private final TokenRefresher refresher; + private final ConcurrentHashMap> inFlight = new ConcurrentHashMap<>(); + + /** Creates a manager using the system clock and a new {@link ChatGptOAuthClient}. */ + public ChatGptTokenManager() { + this(Clock.systemUTC(), new ChatGptOAuthClient()::refresh); + } + + /** + * Creates a manager with injectable dependencies, for testing. + * + * @param clock clock used to evaluate token expiry + * @param refresher performs the refresh HTTP exchange + */ + ChatGptTokenManager(Clock clock, TokenRefresher refresher) { + this.clock = clock; + this.refresher = refresher; + } + + /** + * Returns a valid access token for {@code profile}, refreshing first if + * the current token is within {@link #REFRESH_SKEW} of expiry (or has no + * recorded expiry). + * + * @param profile the profile to read/refresh tokens for + * @return a valid access token + * @throws OAuthException if the profile is not signed in, or refresh fails + */ + public String getValidAccessToken(ClaudeProfile profile) throws OAuthException { + if (!profile.isSignedIntoChatgpt()) { + throw new OAuthException("Not signed in to ChatGPT — sign in in Profile settings."); + } + if (!needsRefresh(profile)) { + return profile.getChatgptAccessToken(); + } + return refresh(profile).accessToken(); + } + + /** + * Forces a refresh regardless of the current token's expiry. + * + * @param profile the profile to refresh + * @return the refreshed token set + * @throws OAuthException if the refresh HTTP call fails + */ + public TokenSet refresh(ClaudeProfile profile) throws OAuthException { + String profileId = profile.getId(); + CompletableFuture future = inFlight.computeIfAbsent(profileId, + id -> CompletableFuture.supplyAsync(() -> doRefresh(profile))); + try { + return future.join(); + } catch (java.util.concurrent.CompletionException e) { + Throwable cause = e.getCause(); + if (cause instanceof OAuthUncheckedException oue) { + throw oue.checked(); + } + throw new OAuthException("Refresh failed: " + e.getMessage(), e); + } finally { + inFlight.remove(profileId, future); + } + } + + private TokenSet doRefresh(ClaudeProfile profile) { + try { + HttpClient httpClient = ProxyConfiguration.from(profile).applyTo( + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30))).build(); + TokenSet refreshed = refresher.refresh(httpClient, profile.getChatgptRefreshToken()); + persist(profile, refreshed); + LOG.fine(() -> "ChatGPT token refreshed for profile " + profile.getName()); + return refreshed; + } catch (OAuthException e) { + throw new OAuthUncheckedException(e); + } + } + + private void persist(ClaudeProfile profile, TokenSet tokenSet) { + profile.setChatgptAccessToken(tokenSet.accessToken()); + if (!tokenSet.refreshToken().isBlank()) { + profile.setChatgptRefreshToken(tokenSet.refreshToken()); + } + if (!tokenSet.accountId().isBlank()) { + profile.setChatgptAccountId(tokenSet.accountId()); + } + if (!tokenSet.email().isBlank()) { + profile.setChatgptEmail(tokenSet.email()); + } + profile.setChatgptTokenExpiresAt(tokenSet.expiresAt().toString()); + + if (profile.isDefault()) { + java.util.List all = ClaudeProfileStore.getProfiles(); + ClaudeProfileStore.saveProfiles(all); + } else { + ClaudeProfileStore.saveProfile(profile); + } + } + + private boolean needsRefresh(ClaudeProfile profile) { + String expiresAt = profile.getChatgptTokenExpiresAt(); + if (expiresAt.isBlank()) return true; + try { + Instant expiry = Instant.parse(expiresAt); + return Instant.now(clock).plus(REFRESH_SKEW).isAfter(expiry); + } catch (Exception e) { + return true; + } + } + + /** Unchecked wrapper so {@link OAuthException} can cross a {@link CompletableFuture} boundary. */ + private static final class OAuthUncheckedException extends RuntimeException { + private final OAuthException checked; + + OAuthUncheckedException(OAuthException checked) { + super(checked.getMessage(), checked); + this.checked = checked; + } + + OAuthException checked() { return checked; } + } +} diff --git a/src/main/java/io/github/nbplugins/claudecodegui/chatgptauth/OAuthException.java b/src/main/java/io/github/nbplugins/claudecodegui/chatgptauth/OAuthException.java new file mode 100644 index 00000000..843a93c1 --- /dev/null +++ b/src/main/java/io/github/nbplugins/claudecodegui/chatgptauth/OAuthException.java @@ -0,0 +1,30 @@ +package io.github.nbplugins.claudecodegui.chatgptauth; + +/** + * Thrown when the ChatGPT OAuth login or token-refresh flow fails. + * + *

{@link #getMessage()} is written to be shown directly to the user (e.g. + * in an Anthropic-format proxy error or a dialog), so callers should not + * wrap it further before display. + */ +public final class OAuthException extends Exception { + + /** + * Creates a new exception with a user-facing message. + * + * @param message human-readable description of the failure + */ + public OAuthException(String message) { + super(message); + } + + /** + * Creates a new exception with a user-facing message and cause. + * + * @param message human-readable description of the failure + * @param cause underlying cause + */ + public OAuthException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/io/github/nbplugins/claudecodegui/mcp/MCPSseServer.java b/src/main/java/io/github/nbplugins/claudecodegui/mcp/MCPSseServer.java index 8dc675f0..fb451171 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/mcp/MCPSseServer.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/mcp/MCPSseServer.java @@ -169,6 +169,23 @@ public void registerOpenAIProxy(String uuid, String baseUrl, String apiKey, Prox LOGGER.log(Level.FINE, "OpenAI proxy registered: uuid={0}", uuid); } + /** + * Registers a ChatGPT-subscription proxy session, routed to OpenAI's + * Codex Responses API instead of the generic Chat Completions API. + * + * @param uuid unique session identifier + * @param profileId owning profile id, used to re-fetch the live profile for token refresh + * @param accessToken ChatGPT OAuth access token + * @param accountId {@code chatgpt_account_id} claim + * @param proxy proxy settings from the profile (used by the servlet's HttpClient) + */ + public void registerChatgptSubscriptionProxy(String uuid, String profileId, String accessToken, + String accountId, ProxyConfiguration proxy) { + openAIProxies.put(uuid, new OpenAIProxyConfig(OpenAIProxyConfig.Mode.CHATGPT_CODEX, + "https://chatgpt.com/backend-api/codex", accessToken, proxy, profileId, accessToken, accountId)); + LOGGER.log(Level.FINE, "ChatGPT subscription proxy registered: uuid={0}", uuid); + } + /** * Deregisters an OpenAI-compatible proxy session. * diff --git a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslator.java b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslator.java new file mode 100644 index 00000000..266d7c7f --- /dev/null +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslator.java @@ -0,0 +1,734 @@ +package io.github.nbplugins.claudecodegui.openaiproxy; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Stateless translator between Anthropic Messages API format and OpenAI's + * Responses API format, as served by the ChatGPT-subscription Codex backend + * ({@code https://chatgpt.com/backend-api/codex/responses}). + * + *

This is a different upstream shape from {@link AnthropicToOpenAITranslator} + * (which targets the Chat Completions API): the Responses API represents a + * conversation as a flat {@code input[]} array of typed items ({@code message}, + * {@code function_call}, {@code function_call_output}) rather than a + * {@code messages[]} array of role/content pairs, and streams named SSE + * events ({@code response.output_text.delta}, {@code response.completed}, ...) + * rather than a single repeated chunk shape. + * + *

Request (Anthropic → Responses)

+ *
    + *
  • {@code system} → {@code instructions}
  • + *
  • {@code messages[]} → flat {@code input[]} items ({@code message}, + * {@code function_call}, {@code function_call_output})
  • + *
  • Images inside {@code tool_result} content → {@code "[image omitted: ]"} + * placeholder text (the Codex backend does not accept images there)
  • + *
  • Claude Code's {@code output_config.effort} → Codex {@code reasoning.effort}
  • + *
  • Claude Code's hosted {@code web_search_20250305} tool → Codex's native + * {@code web_search} tool type
  • + *
  • {@code prompt_cache_key} ← the session id used to route this request + * through {@code /openai-proxy/{uuid}/...}
  • + *
+ * + *

Response (Responses → Anthropic)

+ *
    + *
  • {@code output[]} message/function_call items → Anthropic {@code content} blocks
  • + *
  • {@code reasoning} output items are dropped, not forwarded (Claude Code's + * thinking-block UI expects Anthropic-native signatures Codex traces don't provide)
  • + *
+ */ +public final class AnthropicToCodexTranslator { + + static final com.fasterxml.jackson.databind.ObjectMapper MAPPER = AnthropicToOpenAITranslator.MAPPER; + + private AnthropicToCodexTranslator() {} + + // ------------------------------------------------------------------------- + // Request translation + // ------------------------------------------------------------------------- + + /** + * Translates an Anthropic {@code POST /v1/messages} request body to an + * OpenAI Responses API request body targeting the Codex backend. + * + * @param anthropicRequest parsed Anthropic request JSON + * @param sessionId session/conversation identifier, used as {@code prompt_cache_key} + * @return Responses-API-format request JSON + */ + public static ObjectNode translateRequest(JsonNode anthropicRequest, String sessionId) { + ObjectNode responses = MAPPER.createObjectNode(); + + String model = anthropicRequest.path("model").asText(""); + responses.put("model", model); + + // No max_output_tokens / max_tokens field: the Codex backend's + // ResponsesApiRequest (codex-rs/codex-api/src/common.rs) has no such + // field at all and rejects it with HTTP 400 "Unsupported parameter: + // max_output_tokens" — Anthropic's max_tokens is simply dropped here. + + // The Codex backend requires both of these fixed values regardless of what + // the Anthropic client asked for — confirmed against openai/codex's own + // client (codex-rs/core/src/client.rs sets stream: true unconditionally; + // codex-rs/codex-api/src/common.rs's ResponsesApiRequest.store is always + // false for this backend). Requesting otherwise gets HTTP 400 + // {"detail":"Store must be set to false"} / {"...":"Stream must be set to true"}. + // OpenAIProxyServlet is responsible for aggregating the resulting SSE + // stream into a single non-streaming response when the Anthropic client + // itself didn't ask for streaming — the same approach Codex CLI's own + // "non-streaming" callers take. + responses.put("stream", true); + responses.put("store", false); + + // System prompt → instructions + JsonNode systemNode = anthropicRequest.path("system"); + if (!systemNode.isMissingNode() && !systemNode.isNull()) { + String systemText = extractSystemText(systemNode); + if (!systemText.isBlank()) { + responses.put("instructions", systemText); + } + } + + // Reasoning effort — Claude Code's output_config.effort, if present + JsonNode effort = anthropicRequest.path("output_config").path("effort"); + if (!effort.isMissingNode() && !effort.isNull() && !effort.asText().isBlank()) { + responses.putObject("reasoning").put("effort", effort.asText()); + } + + if (sessionId != null && !sessionId.isBlank()) { + responses.put("prompt_cache_key", sessionId); + } + + // Messages → flat input[] items + ArrayNode input = responses.putArray("input"); + JsonNode anthropicMessages = anthropicRequest.path("messages"); + if (anthropicMessages.isArray()) { + for (JsonNode msg : anthropicMessages) { + String role = msg.path("role").asText(); + JsonNode content = msg.path("content"); + if ("user".equals(role)) { + convertUserMessage(content, input); + } else if ("assistant".equals(role)) { + convertAssistantMessage(content, input); + } + } + } + + // Tools + JsonNode tools = anthropicRequest.path("tools"); + if (tools.isArray() && !tools.isEmpty()) { + ArrayNode responsesTools = responses.putArray("tools"); + convertTools(tools, responsesTools); + } + + // tool_choice — Responses API uses the same string/object shape as Chat Completions + JsonNode toolChoice = anthropicRequest.path("tool_choice"); + if (!toolChoice.isMissingNode() && !toolChoice.isNull()) { + responses.set("tool_choice", convertToolChoice(toolChoice)); + } + + return responses; + } + + private static String extractSystemText(JsonNode systemNode) { + if (systemNode.isTextual()) { + return systemNode.asText(); + } + if (systemNode.isArray()) { + StringBuilder sb = new StringBuilder(); + for (JsonNode block : systemNode) { + if ("text".equals(block.path("type").asText())) { + if (sb.length() > 0) sb.append("\n\n"); + sb.append(block.path("text").asText()); + } + } + return sb.toString(); + } + return ""; + } + + private static void convertUserMessage(JsonNode content, ArrayNode input) { + if (content.isTextual()) { + ObjectNode msg = input.addObject(); + msg.put("type", "message"); + msg.put("role", "user"); + ArrayNode parts = msg.putArray("content"); + parts.addObject().put("type", "input_text").put("text", content.asText()); + return; + } + if (!content.isArray()) return; + + ArrayNode textParts = null; + for (JsonNode block : content) { + String type = block.path("type").asText(); + if ("tool_result".equals(type)) { + ObjectNode out = input.addObject(); + out.put("type", "function_call_output"); + out.put("call_id", block.path("tool_use_id").asText()); + out.put("output", extractToolResultContent(block.path("content"))); + } else if ("text".equals(type)) { + if (textParts == null) { + ObjectNode msg = input.addObject(); + msg.put("type", "message"); + msg.put("role", "user"); + textParts = msg.putArray("content"); + } + textParts.addObject().put("type", "input_text").put("text", block.path("text").asText()); + } else if ("image".equals(type)) { + if (textParts == null) { + ObjectNode msg = input.addObject(); + msg.put("type", "message"); + msg.put("role", "user"); + textParts = msg.putArray("content"); + } + JsonNode src = block.path("source"); + String mediaType = src.path("media_type").asText("image/jpeg"); + String data = src.path("data").asText(); + textParts.addObject().put("type", "input_image") + .put("image_url", "data:" + mediaType + ";base64," + data); + } + } + } + + /** + * Extracts tool-result content as plain text, replacing any images with a + * {@code "[image omitted: ]"} placeholder — the Codex backend + * does not accept images inside {@code function_call_output}. + */ + private static String extractToolResultContent(JsonNode contentNode) { + if (contentNode.isTextual()) return contentNode.asText(); + if (contentNode.isNull() || contentNode.isMissingNode()) return ""; + if (contentNode.isArray()) { + StringBuilder sb = new StringBuilder(); + for (JsonNode block : contentNode) { + String type = block.path("type").asText(); + if ("text".equals(type)) { + if (sb.length() > 0) sb.append("\n"); + sb.append(block.path("text").asText()); + } else if ("image".equals(type)) { + if (sb.length() > 0) sb.append("\n"); + String mediaType = block.path("source").path("media_type").asText("image"); + sb.append("[image omitted: ").append(mediaType).append("]"); + } + } + return sb.toString(); + } + return contentNode.toString(); + } + + private static void convertAssistantMessage(JsonNode content, ArrayNode input) { + if (content.isTextual()) { + ObjectNode msg = input.addObject(); + msg.put("type", "message"); + msg.put("role", "assistant"); + msg.putArray("content").addObject().put("type", "output_text").put("text", content.asText()); + return; + } + if (!content.isArray()) return; + + StringBuilder textBuffer = new StringBuilder(); + for (JsonNode block : content) { + String type = block.path("type").asText(); + if ("text".equals(type)) { + textBuffer.append(block.path("text").asText()); + } else if ("tool_use".equals(type)) { + if (textBuffer.length() > 0) { + ObjectNode msg = input.addObject(); + msg.put("type", "message"); + msg.put("role", "assistant"); + msg.putArray("content").addObject().put("type", "output_text").put("text", textBuffer.toString()); + textBuffer.setLength(0); + } + ObjectNode call = input.addObject(); + call.put("type", "function_call"); + call.put("call_id", block.path("id").asText()); + call.put("name", block.path("name").asText()); + JsonNode inputArgs = block.path("input"); + call.put("arguments", inputArgs.isObject() ? inputArgs.toString() : "{}"); + } + // "thinking" blocks are not sent back upstream — Codex reasoning traces + // are provider-internal and not accepted as input. + } + if (textBuffer.length() > 0) { + ObjectNode msg = input.addObject(); + msg.put("type", "message"); + msg.put("role", "assistant"); + msg.putArray("content").addObject().put("type", "output_text").put("text", textBuffer.toString()); + } + } + + /** Anthropic tool {@code name}/{@code type} values that map to Codex's native web_search tool. */ + private static boolean isHostedWebSearchTool(JsonNode tool) { + String type = tool.path("type").asText(""); + return type.startsWith("web_search_"); + } + + private static void convertTools(JsonNode anthropicTools, ArrayNode responsesTools) { + for (JsonNode tool : anthropicTools) { + if (isHostedWebSearchTool(tool)) { + responsesTools.addObject().put("type", "web_search"); + continue; + } + ObjectNode t = responsesTools.addObject(); + t.put("type", "function"); + t.put("name", tool.path("name").asText()); + if (tool.has("description")) { + t.put("description", tool.path("description").asText()); + } + JsonNode schema = tool.path("input_schema"); + if (!schema.isMissingNode()) { + t.set("parameters", schema); + } + } + } + + private static JsonNode convertToolChoice(JsonNode toolChoice) { + if (toolChoice.isTextual()) { + String val = toolChoice.asText(); + return switch (val) { + case "any" -> MAPPER.getNodeFactory().textNode("required"); + case "none" -> MAPPER.getNodeFactory().textNode("none"); + default -> MAPPER.getNodeFactory().textNode("auto"); + }; + } + if (toolChoice.isObject() && "tool".equals(toolChoice.path("type").asText())) { + ObjectNode choice = MAPPER.createObjectNode(); + choice.put("type", "function"); + choice.put("name", toolChoice.path("name").asText()); + return choice; + } + return MAPPER.getNodeFactory().textNode("auto"); + } + + // ------------------------------------------------------------------------- + // Response translation (non-streaming) + // ------------------------------------------------------------------------- + + /** + * Translates a Codex Responses API response to Anthropic Messages response format. + * + * @param codexResponse parsed Responses-API response JSON + * @param model model name to include in response + * @return Anthropic-format response JSON + */ + public static ObjectNode translateResponse(JsonNode codexResponse, String model) { + ObjectNode anthropic = MAPPER.createObjectNode(); + anthropic.put("id", codexResponse.path("id").asText("msg_proxy")); + anthropic.put("type", "message"); + anthropic.put("role", "assistant"); + anthropic.put("model", model); + + ArrayNode content = anthropic.putArray("content"); + String stopReason = "end_turn"; + boolean hadToolCall = false; + + JsonNode output = codexResponse.path("output"); + if (output.isArray()) { + for (JsonNode item : output) { + String type = item.path("type").asText(); + if ("message".equals(type)) { + for (JsonNode part : item.path("content")) { + if ("output_text".equals(part.path("type").asText())) { + String text = part.path("text").asText(); + if (!text.isBlank()) { + content.addObject().put("type", "text").put("text", text); + } + } + } + } else if ("function_call".equals(type)) { + hadToolCall = true; + ObjectNode toolUse = content.addObject(); + toolUse.put("type", "tool_use"); + toolUse.put("id", item.path("call_id").asText()); + toolUse.put("name", item.path("name").asText()); + String argsStr = item.path("arguments").asText("{}"); + try { + toolUse.set("input", MAPPER.readTree(argsStr)); + } catch (Exception e) { + toolUse.putObject("input"); + } + } + // "reasoning" output items are intentionally dropped. + } + } + + String status = codexResponse.path("status").asText("completed"); + stopReason = hadToolCall ? "tool_use" : mapStatus(status); + + anthropic.put("stop_reason", stopReason); + anthropic.putNull("stop_sequence"); + + ObjectNode usage = anthropic.putObject("usage"); + JsonNode codexUsage = codexResponse.path("usage"); + usage.put("input_tokens", codexUsage.path("input_tokens").asInt(0)); + usage.put("output_tokens", codexUsage.path("output_tokens").asInt(0)); + + return anthropic; + } + + static String mapStatus(String status) { + return switch (status) { + case "incomplete" -> "max_tokens"; + default -> "end_turn"; + }; + } + + // ------------------------------------------------------------------------- + // Streaming state machine + // ------------------------------------------------------------------------- + + /** + * Mutable per-request state for the Responses-API streaming translator. + * Create one instance per streaming request. + * + *

The Responses API emits named SSE events ({@code response.output_text.delta}, + * {@code response.output_item.added}, {@code response.function_call_arguments.delta}, + * {@code response.completed}, {@code response.failed}) rather than Chat Completions' + * single repeated chunk shape, so this state machine is independent of + * {@link AnthropicToOpenAITranslator.StreamingState}. + */ + public static final class StreamingState { + + private boolean messageStarted = false; + private boolean doneReceived = false; + private boolean textBlockOpen = false; + private int textBlockIndex = -1; + private int nextBlockIndex = 0; + private String stopReason = "end_turn"; + private int outputTokens = 0; + private int inputTokens = 0; + private String messageId = "msg_proxy"; + private String model = ""; + + // Codex output_index -> block state, for function_call items + private final java.util.Map toolCalls = new java.util.LinkedHashMap<>(); + + private static final class ToolCallState { + String id; + String name; + boolean started; + int blockIndex; + } + + public boolean isMessageStarted() { return messageStarted; } + public boolean isDoneReceived() { return doneReceived; } + + /** + * Processes one named Responses-API SSE event and returns zero or more + * Anthropic SSE events. + * + * @param eventType the SSE {@code event:} name (e.g. {@code response.output_text.delta}) + * @param dataLine the raw {@code data:} JSON payload for that event + * @return string of Anthropic SSE events to write to the client, may be empty + */ + public String processEvent(String eventType, String dataLine) { + JsonNode data; + try { + data = MAPPER.readTree(dataLine); + } catch (Exception e) { + return ""; + } + + StringBuilder out = new StringBuilder(); + ensureMessageStarted(data, out); + + switch (eventType) { + case "response.output_text.delta" -> { + String delta = data.path("delta").asText(""); + if (!delta.isEmpty()) { + if (!textBlockOpen) { + textBlockIndex = nextBlockIndex++; + out.append(AnthropicToOpenAITranslator.sseEvent("content_block_start", + buildTextBlockStart(textBlockIndex))); + textBlockOpen = true; + } + out.append(AnthropicToOpenAITranslator.sseEvent("content_block_delta", + buildTextDelta(textBlockIndex, delta))); + } + } + case "response.output_item.added" -> { + JsonNode item = data.path("item"); + if ("function_call".equals(item.path("type").asText())) { + closeTextBlockIfOpen(out); + int outputIndex = data.path("output_index").asInt(0); + ToolCallState state = new ToolCallState(); + state.id = item.path("call_id").asText(); + state.name = item.path("name").asText(); + state.blockIndex = nextBlockIndex++; + state.started = true; + toolCalls.put(outputIndex, state); + out.append(AnthropicToOpenAITranslator.sseEvent("content_block_start", + buildToolUseBlockStart(state.blockIndex, state.id, state.name))); + } + } + case "response.function_call_arguments.delta" -> { + int outputIndex = data.path("output_index").asInt(0); + ToolCallState state = toolCalls.get(outputIndex); + String delta = data.path("delta").asText(""); + if (state != null && !delta.isEmpty()) { + out.append(AnthropicToOpenAITranslator.sseEvent("content_block_delta", + buildInputJsonDelta(state.blockIndex, delta))); + } + } + case "response.completed", "response.incomplete" -> { + JsonNode usage = data.path("response").path("usage"); + if (!usage.isMissingNode()) { + outputTokens = usage.path("output_tokens").asInt(outputTokens); + inputTokens = usage.path("input_tokens").asInt(inputTokens); + } + stopReason = !toolCalls.isEmpty() ? "tool_use" + : mapStatus(data.path("response").path("status").asText("completed")); + doneReceived = true; + closeTextBlockIfOpen(out); + out.append(buildDoneEvents()); + } + case "response.failed", "error" -> { + doneReceived = true; + closeTextBlockIfOpen(out); + out.append(buildDoneEvents()); + } + default -> { /* ignore other event types (response.created, response.output_item.done, ...) */ } + } + + return out.toString(); + } + + private void ensureMessageStarted(JsonNode data, StringBuilder out) { + if (messageStarted) return; + JsonNode response = data.path("response"); + if (!response.isMissingNode()) { + messageId = response.path("id").asText("msg_proxy"); + model = response.path("model").asText(""); + } + out.append(AnthropicToOpenAITranslator.sseEvent("message_start", buildMessageStart())); + out.append(AnthropicToOpenAITranslator.sseEvent("ping", "{\"type\":\"ping\"}")); + messageStarted = true; + } + + private void closeTextBlockIfOpen(StringBuilder out) { + if (textBlockOpen) { + out.append(AnthropicToOpenAITranslator.sseEvent("content_block_stop", + "{\"type\":\"content_block_stop\",\"index\":" + textBlockIndex + "}")); + textBlockOpen = false; + } + } + + String buildDoneEvents() { + StringBuilder out = new StringBuilder(); + for (ToolCallState state : toolCalls.values()) { + out.append(AnthropicToOpenAITranslator.sseEvent("content_block_stop", + "{\"type\":\"content_block_stop\",\"index\":" + state.blockIndex + "}")); + } + out.append(AnthropicToOpenAITranslator.sseEvent("message_delta", buildMessageDelta())); + out.append(AnthropicToOpenAITranslator.sseEvent("message_stop", "{\"type\":\"message_stop\"}")); + return out.toString(); + } + + private String buildMessageStart() { + ObjectNode msg = MAPPER.createObjectNode(); + msg.put("id", messageId); + msg.put("type", "message"); + msg.put("role", "assistant"); + msg.putArray("content"); + msg.put("model", model); + msg.putNull("stop_reason"); + msg.putNull("stop_sequence"); + msg.putObject("usage").put("input_tokens", inputTokens).put("output_tokens", 0); + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "message_start"); + root.set("message", msg); + return toJson(root); + } + + private static String buildTextBlockStart(int index) { + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "content_block_start"); + root.put("index", index); + root.putObject("content_block").put("type", "text").put("text", ""); + return toJson(root); + } + + private static String buildTextDelta(int index, String text) { + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "content_block_delta"); + root.put("index", index); + root.putObject("delta").put("type", "text_delta").put("text", text); + return toJson(root); + } + + private static String buildToolUseBlockStart(int index, String id, String name) { + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "content_block_start"); + root.put("index", index); + ObjectNode cb = root.putObject("content_block"); + cb.put("type", "tool_use"); + cb.put("id", id); + cb.put("name", name); + cb.putObject("input"); + return toJson(root); + } + + private static String buildInputJsonDelta(int index, String partialJson) { + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "content_block_delta"); + root.put("index", index); + root.putObject("delta").put("type", "input_json_delta").put("partial_json", partialJson); + return toJson(root); + } + + private String buildMessageDelta() { + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "message_delta"); + ObjectNode delta = root.putObject("delta"); + delta.put("stop_reason", stopReason); + delta.putNull("stop_sequence"); + root.putObject("usage").put("output_tokens", outputTokens); + return toJson(root); + } + + private static String toJson(ObjectNode node) { + try { + return MAPPER.writeValueAsString(node); + } catch (Exception e) { + return "{}"; + } + } + } + + // ------------------------------------------------------------------------- + // Stream aggregation (for non-streaming Anthropic clients) + // ------------------------------------------------------------------------- + + /** + * Aggregates a Codex Responses-API SSE stream into a single non-streaming + * Codex-response-shaped {@link JsonNode}, suitable for {@link #translateResponse}. + * + *

The Codex backend requires {@code stream: true} on every request (see + * {@link #translateRequest}), so when the Anthropic client didn't itself + * ask for streaming, {@code OpenAIProxyServlet} still has to open an SSE + * connection to Codex — this class lets it collapse that stream back into + * one response instead of duplicating the non-streaming translation logic. + * Create one instance per request. + */ + public static final class ResponseAggregator { + + private String responseId = "msg_proxy"; + private String status = "completed"; + private int inputTokens = 0; + private int outputTokens = 0; + private final StringBuilder textBuffer = new StringBuilder(); + + // Codex output_index -> in-progress function call + private final java.util.Map functionCalls = new java.util.LinkedHashMap<>(); + + private static final class FunctionCallState { + String callId; + String name; + final StringBuilder arguments = new StringBuilder(); + } + + /** + * Processes one named Responses-API SSE event. + * + * @param eventType the SSE {@code event:} name (e.g. {@code response.output_text.delta}) + * @param dataLine the raw {@code data:} JSON payload for that event + */ + public void processEvent(String eventType, String dataLine) { + JsonNode data; + try { + data = MAPPER.readTree(dataLine); + } catch (Exception e) { + return; + } + + switch (eventType) { + case "response.output_text.delta" -> textBuffer.append(data.path("delta").asText("")); + case "response.output_item.added" -> { + JsonNode item = data.path("item"); + if ("function_call".equals(item.path("type").asText())) { + int outputIndex = data.path("output_index").asInt(0); + FunctionCallState state = new FunctionCallState(); + state.callId = item.path("call_id").asText(); + state.name = item.path("name").asText(); + functionCalls.put(outputIndex, state); + } + } + case "response.function_call_arguments.delta" -> { + int outputIndex = data.path("output_index").asInt(0); + FunctionCallState state = functionCalls.get(outputIndex); + if (state != null) { + state.arguments.append(data.path("delta").asText("")); + } + } + case "response.completed", "response.incomplete" -> { + JsonNode response = data.path("response"); + responseId = response.path("id").asText(responseId); + status = response.path("status").asText("completed"); + JsonNode usage = response.path("usage"); + inputTokens = usage.path("input_tokens").asInt(inputTokens); + outputTokens = usage.path("output_tokens").asInt(outputTokens); + } + default -> { /* ignore other event types (response.created, response.output_item.done, ...) */ } + } + } + + /** + * Builds a Codex-response-shaped JSON object from everything processed so + * far, in the same {@code {"id":...,"output":[...],"usage":{...},"status":...}} + * shape a non-streaming Codex response would have — pass it to + * {@link #translateResponse} to get the final Anthropic response. + * + * @return the aggregated Codex-shaped response + */ + public ObjectNode buildCodexResponse() { + ObjectNode root = MAPPER.createObjectNode(); + root.put("id", responseId); + root.put("status", status); + + ArrayNode output = root.putArray("output"); + if (textBuffer.length() > 0) { + ObjectNode message = output.addObject(); + message.put("type", "message"); + message.putArray("content").addObject().put("type", "output_text").put("text", textBuffer.toString()); + } + for (FunctionCallState state : functionCalls.values()) { + ObjectNode call = output.addObject(); + call.put("type", "function_call"); + call.put("call_id", state.callId); + call.put("name", state.name); + call.put("arguments", state.arguments.toString()); + } + + ObjectNode usage = root.putObject("usage"); + usage.put("input_tokens", inputTokens); + usage.put("output_tokens", outputTokens); + return root; + } + } + + // ------------------------------------------------------------------------- + // Debug summary helpers (no conversation content) + // ------------------------------------------------------------------------- + + /** Returns a one-line summary of an Anthropic request for debug logging. */ + public static String summarizeAnthropicRequest(JsonNode req) { + return AnthropicToOpenAITranslator.summarizeAnthropicRequest(req); + } + + /** Returns a one-line summary of a Codex Responses-API request for debug logging. */ + public static String summarizeCodexRequest(JsonNode req) { + return "model=" + req.path("model").asText("?") + + " stream=" + req.path("stream").asBoolean(false) + + " input=" + req.path("input").size() + + " tools=" + req.path("tools").size(); + } + + /** Returns a one-line summary of a Codex Responses-API response for debug logging. */ + public static String summarizeCodexResponse(JsonNode resp) { + JsonNode usage = resp.path("usage"); + return "status=" + resp.path("status").asText("?") + + " output_items=" + resp.path("output").size() + + " input_tokens=" + usage.path("input_tokens").asInt(0) + + " output_tokens=" + usage.path("output_tokens").asInt(0); + } +} diff --git a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfig.java b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfig.java index 8b65bb60..49f30ebf 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfig.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfig.java @@ -13,22 +13,74 @@ */ public final class OpenAIProxyConfig { + /** Which upstream API shape {@link OpenAIProxyServlet} should translate to/from. */ + public enum Mode { + /** Any OpenAI API-key-based provider, via the Chat Completions API. */ + CHAT_COMPLETIONS, + /** ChatGPT Plus/Pro/Team subscription, via OpenAI's Codex Responses API. */ + CHATGPT_CODEX + } + + private final Mode mode; private final String baseUrl; private final String apiKey; private final ProxyConfiguration proxy; + private final String profileId; + private final String accessToken; + private final String accountId; + /** + * Creates a {@link Mode#CHAT_COMPLETIONS} config for an OpenAI API-key-based provider. + * + * @param baseUrl provider base URL (e.g. {@code https://api.openai.com/v1}) + * @param apiKey provider API key, sent as {@code Authorization: Bearer } + * @param proxy proxy settings from the profile + */ public OpenAIProxyConfig(String baseUrl, String apiKey, ProxyConfiguration proxy) { - this.baseUrl = baseUrl; - this.apiKey = apiKey; - this.proxy = proxy; + this(Mode.CHAT_COMPLETIONS, baseUrl, apiKey, proxy, null, null, null); + } + + /** + * Creates a config for either upstream mode. + * + * @param mode which upstream API shape to use + * @param baseUrl upstream base URL + * @param apiKey provider API key ({@link Mode#CHAT_COMPLETIONS} only; blank otherwise) + * @param proxy proxy settings from the profile + * @param profileId owning profile id ({@link Mode#CHATGPT_CODEX} only; used to re-fetch + * the live profile for token refresh), or {@code null} + * @param accessToken ChatGPT OAuth access token ({@link Mode#CHATGPT_CODEX} only), or {@code null} + * @param accountId {@code chatgpt_account_id} claim ({@link Mode#CHATGPT_CODEX} only), or {@code null} + */ + public OpenAIProxyConfig(Mode mode, String baseUrl, String apiKey, ProxyConfiguration proxy, + String profileId, String accessToken, String accountId) { + this.mode = mode; + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.proxy = proxy; + this.profileId = profileId; + this.accessToken = accessToken; + this.accountId = accountId; } + /** Which upstream API shape this config targets. */ + public Mode getMode() { return mode; } + /** Base URL of the OpenAI-compatible endpoint (e.g. {@code https://api.openai.com/v1}). */ public String getBaseUrl() { return baseUrl; } /** API key sent as {@code Authorization: Bearer }. */ public String getApiKey() { return apiKey; } + /** Owning profile id, for {@link Mode#CHATGPT_CODEX} token-refresh lookups. */ + public String getProfileId() { return profileId; } + + /** ChatGPT OAuth access token, for {@link Mode#CHATGPT_CODEX}. */ + public String getAccessToken() { return accessToken; } + + /** {@code chatgpt_account_id} claim, sent as the {@code ChatGPT-Account-Id} header. */ + public String getAccountId() { return accountId; } + /** * Builds an {@link HttpClient} configured with the proxy settings from this config. * See {@link ProxyConfiguration#applyTo} for details. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServlet.java b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServlet.java index 6230fdcc..21dcbacd 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServlet.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServlet.java @@ -2,8 +2,12 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.github.nbplugins.claudecodegui.chatgptauth.ChatGptTokenManager; +import io.github.nbplugins.claudecodegui.chatgptauth.OAuthException; import io.github.nbplugins.claudecodegui.mcp.MCPSseServer; import io.github.nbplugins.claudecodegui.settings.ClaudeCodePreferences; +import io.github.nbplugins.claudecodegui.settings.ClaudeProfile; +import io.github.nbplugins.claudecodegui.settings.ClaudeProfileStore; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -40,6 +44,7 @@ public final class OpenAIProxyServlet extends HttpServlet { private static final Logger LOG = Logger.getLogger(OpenAIProxyServlet.class.getName()); private final MCPSseServer mcpServer; + private final ChatGptTokenManager tokenManager = new ChatGptTokenManager(); // HttpClient is built per-request from the session's OpenAIProxyConfig, // which contains the proxy settings from the profile. @@ -94,6 +99,11 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I LOG.info(sb.toString()); } + if (config.getMode() == OpenAIProxyConfig.Mode.CHATGPT_CODEX) { + handleCodexRequest(req, resp, config, uuid, anthropicReq, model, streaming, debug); + return; + } + // Translate request ObjectNode openaiReq = AnthropicToOpenAITranslator.translateRequest(anthropicReq); String openaiBody = AnthropicToOpenAITranslator.MAPPER.writeValueAsString(openaiReq); @@ -241,6 +251,195 @@ private void handleStreaming(HttpClient httpClient, HttpRequest httpReq, String writer.flush(); } + // ------------------------------------------------------------------------- + // ChatGPT-subscription (Codex Responses API) path + // ------------------------------------------------------------------------- + + /** + * Handles a request for a {@link OpenAIProxyConfig.Mode#CHATGPT_CODEX} session: + * refreshes the access token if needed, translates via + * {@link AnthropicToCodexTranslator}, and calls the Codex Responses endpoint. + * Model validity is not checked here — an unsupported model just gets + * whatever error the real Codex backend returns, handled by + * {@link #toCodexAnthropicError}. + */ + private void handleCodexRequest(HttpServletRequest req, HttpServletResponse resp, OpenAIProxyConfig config, + String uuid, JsonNode anthropicReq, String model, boolean streaming, boolean debug) throws IOException { + + ClaudeProfile profile = ClaudeProfileStore.findById(config.getProfileId()); + String accessToken; + try { + accessToken = profile != null ? tokenManager.getValidAccessToken(profile) : config.getAccessToken(); + } catch (OAuthException e) { + LOG.warning("OpenAI proxy (Codex): token refresh failed: " + e.getMessage()); + resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + resp.setContentType("application/json"); + resp.getWriter().write("{\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"" + + e.getMessage().replace("\"", "'") + "\"}}"); + return; + } + + ObjectNode codexReq = AnthropicToCodexTranslator.translateRequest(anthropicReq, uuid); + String codexBody = AnthropicToCodexTranslator.MAPPER.writeValueAsString(codexReq); + + String targetUrl = config.getBaseUrl(); + if (!targetUrl.endsWith("/")) targetUrl += "/"; + targetUrl += "responses"; + + if (debug) { + LOG.info("OpenAI proxy (Codex) → Codex request: url=" + targetUrl + + " Authorization=Bearer [REDACTED]" + + " | " + AnthropicToCodexTranslator.summarizeCodexRequest(codexReq)); + } + + HttpRequest httpReq = HttpRequest.newBuilder() + .uri(URI.create(targetUrl)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + accessToken) + .header("ChatGPT-Account-Id", profile != null ? profile.getChatgptAccountId() : config.getAccountId()) + .header("User-Agent", "netbeans-plugin-claude-code-gui") + .header("originator", "codex_cli_rs") + .POST(HttpRequest.BodyPublishers.ofString(codexBody, StandardCharsets.UTF_8)) + .timeout(Duration.ofSeconds(600)) + .build(); + + HttpClient httpClient = config.buildHttpClient(); + if (streaming) { + handleCodexStreaming(httpClient, httpReq, resp, debug); + } else { + // The Codex backend requires stream:true on every request body + // (see AnthropicToCodexTranslator.translateRequest) — there is no + // plain non-streaming request to make. When the Anthropic client + // itself didn't ask for streaming, open the same SSE connection + // anyway and collapse it into one response via ResponseAggregator, + // the same way Codex CLI's own non-streaming callers do. + handleCodexNonStreamingViaAggregatedStream(httpClient, httpReq, model, resp, debug); + } + } + + private void handleCodexNonStreamingViaAggregatedStream(HttpClient httpClient, HttpRequest httpReq, String model, + HttpServletResponse resp, boolean debug) throws IOException { + HttpResponse httpResp; + try { + httpResp = httpClient.send(httpReq, HttpResponse.BodyHandlers.ofInputStream()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + resp.sendError(HttpServletResponse.SC_BAD_GATEWAY, "Proxy request interrupted"); + return; + } + + int status = httpResp.statusCode(); + if (status >= 400) { + String errBody = new String(httpResp.body().readAllBytes(), StandardCharsets.UTF_8); + LOG.severe("OpenAI proxy (Codex): provider returned HTTP " + status + ": " + errBody); + resp.setStatus(status); + resp.setContentType("application/json"); + resp.getWriter().write(toCodexAnthropicError(errBody, status)); + return; + } + + AnthropicToCodexTranslator.ResponseAggregator aggregator = + new AnthropicToCodexTranslator.ResponseAggregator(); + String currentEvent = null; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(httpResp.body(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.startsWith("event: ")) { + currentEvent = line.substring(7).trim(); + } else if (line.startsWith("data: ") && currentEvent != null) { + aggregator.processEvent(currentEvent, line.substring(6).trim()); + } + } + } + + JsonNode codexResp = aggregator.buildCodexResponse(); + if (debug) { + LOG.info("OpenAI proxy (Codex) ← Codex response (aggregated from stream): status=" + status + + " | " + AnthropicToCodexTranslator.summarizeCodexResponse(codexResp)); + } + + ObjectNode anthropicResp = AnthropicToCodexTranslator.translateResponse(codexResp, model); + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("application/json"); + resp.getWriter().write(AnthropicToCodexTranslator.MAPPER.writeValueAsString(anthropicResp)); + } + + private void handleCodexStreaming(HttpClient httpClient, HttpRequest httpReq, + HttpServletResponse resp, boolean debug) throws IOException { + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("text/event-stream"); + resp.setCharacterEncoding("UTF-8"); + resp.setHeader("Cache-Control", "no-cache"); + resp.setHeader("Connection", "keep-alive"); + resp.setHeader("X-Accel-Buffering", "no"); + + PrintWriter writer = resp.getWriter(); + + HttpResponse httpResp; + try { + httpResp = httpClient.send(httpReq, HttpResponse.BodyHandlers.ofInputStream()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + int status = httpResp.statusCode(); + if (status >= 400) { + String errBody = new String(httpResp.body().readAllBytes(), StandardCharsets.UTF_8); + LOG.severe("OpenAI proxy (Codex): provider returned HTTP " + status + " (streaming): " + errBody); + writer.write("event: error\ndata: " + toCodexAnthropicError(errBody, status) + "\n\n"); + writer.flush(); + return; + } + + AnthropicToCodexTranslator.StreamingState state = new AnthropicToCodexTranslator.StreamingState(); + String currentEvent = null; + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(httpResp.body(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.startsWith("event: ")) { + currentEvent = line.substring(7).trim(); + } else if (line.startsWith("data: ") && currentEvent != null) { + String data = line.substring(6).trim(); + String events = state.processEvent(currentEvent, data); + if (!events.isEmpty()) { + writer.write(events); + writer.flush(); + } + } + } + } + + if (state.isMessageStarted() && !state.isDoneReceived()) { + writer.write(state.buildDoneEvents()); + } + if (debug) { + LOG.info("OpenAI proxy (Codex): streaming done"); + } + writer.flush(); + } + + /** + * Converts a Codex provider error body to Anthropic error JSON, with a + * dedicated mapping for {@code codex.rate_limits.limit_reached} (Codex's + * account-wide rate limit, shared across all clients of the upstream + * ChatGPT account) to a clear {@code rate_limit_error}. + */ + static String toCodexAnthropicError(String providerBody, int httpStatus) { + try { + JsonNode json = AnthropicToCodexTranslator.MAPPER.readTree(providerBody); + String errorCode = json.path("error").path("code").asText(""); + if ("codex.rate_limits.limit_reached".equals(errorCode) || httpStatus == 429) { + return "{\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":" + + "\"ChatGPT subscription rate limit reached (shared account-wide across all clients).\"}}"; + } + } catch (Exception ignored) {} + return toAnthropicError(providerBody, httpStatus); + } + /** * Converts a provider error body to Anthropic error JSON string. * Handles both Anthropic-format ({@code {"type":"error","error":{...}}}) @@ -261,6 +460,12 @@ static String toAnthropicError(String providerBody, int httpStatus) { String escaped = msg.replace("\\", "\\\\").replace("\"", "\\\""); return "{\"type\":\"error\",\"error\":{\"type\":\"" + type + "\",\"message\":\"" + escaped + "\"}}"; } + // Codex backend format: {"detail":"..."} (e.g. "Store must be set to false") + if (json.has("detail")) { + String msg = json.get("detail").asText("Provider error " + httpStatus); + String escaped = msg.replace("\\", "\\\\").replace("\"", "\\\""); + return "{\"type\":\"error\",\"error\":{\"type\":\"api_error\",\"message\":\"" + escaped + "\"}}"; + } } catch (Exception ignored) {} return "{\"type\":\"error\",\"error\":{\"type\":\"api_error\",\"message\":\"Provider returned HTTP " + httpStatus + "\"}}"; } diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java index 92f0bea4..b3a89d39 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java @@ -179,8 +179,38 @@ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCl } } + // ChatGPT subscription: pre-emptively refresh the OAuth token, register the + // session, and inject ANTHROPIC_BASE_URL pointing to the internal Codex proxy. + if (profile != null + && profile.computeConnectionType() == ClaudeProfile.ConnectionType.OPENAI_SUBSCRIPTION) { + ClaudeCodeStatusService mcpSvc = Lookup.getDefault().lookup(ClaudeCodeStatusService.class); + if (mcpSvc != null && mcpSvc.isServerRunning()) { + try { + String accessToken = new io.github.nbplugins.claudecodegui.chatgptauth.ChatGptTokenManager() + .getValidAccessToken(profile); + String uuid = UUID.randomUUID().toString(); + mcpSvc.registerChatgptSubscriptionProxy(uuid, profile.getId(), accessToken, + profile.getChatgptAccountId(), + io.github.nbplugins.claudecodegui.settings.ProxyConfiguration.from(profile)); + openAIProxyUuid = uuid; + env.put("ANTHROPIC_BASE_URL", + "http://127.0.0.1:" + mcpSvc.getServerPort() + "/openai-proxy/" + uuid); + env.put("ANTHROPIC_AUTH_TOKEN", "sk-proxy-internal"); + LOG.info("ChatGPT subscription proxy registered: uuid=" + uuid + + ", profile=" + profile.getName()); + } catch (io.github.nbplugins.claudecodegui.chatgptauth.OAuthException e) { + LOG.warning("ChatGPT subscription auth failed at session start: " + e.getMessage()); + throw new IOException("ChatGPT sign-in expired — please re-authenticate in Profile settings: " + + e.getMessage(), e); + } + } else { + LOG.warning("ChatGPT subscription proxy: MCP server not running — proxy cannot be started"); + } + } + boolean apiKeyHelper = profile != null && !profile.getApiKey().isBlank() && profile.getBaseUrl().isBlank() - && profile.computeConnectionType() != ClaudeProfile.ConnectionType.OPENAI_PROXY; + && profile.computeConnectionType() != ClaudeProfile.ConnectionType.OPENAI_PROXY + && profile.computeConnectionType() != ClaudeProfile.ConnectionType.OPENAI_SUBSCRIPTION; LOG.info("Starting Claude: profile=" + (profile != null ? profile.getName() + " (" + profile.computeConnectionType() + ")" : "Default") + ", apiKeyHelper=" + (apiKeyHelper ? "SET" : "NOT SET") + ", ANTHROPIC_AUTH_TOKEN=" + (!env.getOrDefault("ANTHROPIC_AUTH_TOKEN", "").isBlank() ? "SET" : "NOT SET") diff --git a/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfile.java b/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfile.java index acd51275..9611112a 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfile.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfile.java @@ -57,7 +57,15 @@ public enum ConnectionType { * The proxy is started by {@code ClaudeProcess} and injects its own * {@code ANTHROPIC_BASE_URL}; no env vars are set here. */ - OPENAI_PROXY + OPENAI_PROXY, + /** + * ChatGPT Plus/Pro/Team subscription (OAuth login), routed through the + * internal proxy to OpenAI's Codex Responses API at + * {@code chatgpt.com/backend-api/codex/responses}. Like + * {@link #OPENAI_PROXY}, the proxy is started by {@code ClaudeProcess} + * and injects its own {@code ANTHROPIC_BASE_URL}; no env vars are set here. + */ + OPENAI_SUBSCRIPTION } /** @@ -170,6 +178,56 @@ public enum ProxyMode { */ private boolean openaiProxy; + /** + * When {@code true}, this profile uses a ChatGPT Plus/Pro/Team subscription + * (OAuth login) routed through the internal proxy to OpenAI's Codex backend. + * {@link #computeConnectionType()} returns {@link ConnectionType#OPENAI_SUBSCRIPTION}. + * The {@code chatgpt*} fields hold the OAuth token set obtained via + * {@code io.github.nbplugins.claudecodegui.chatgptauth.ChatGptOAuthClient}. + */ + private boolean openaiSubscription; + + /** + * ChatGPT OAuth access token. Short-lived (hours); refreshed automatically + * by {@code ChatGptTokenManager} using {@link #chatgptRefreshToken}. + * + *

Stored as plain text via {@link ClaudeProfileStore}, same as + * {@link #apiKey} and {@link #token} — this codebase has no OS keychain + * integration, so no OAuth credential here receives stronger protection + * than any other credential field. + */ + private String chatgptAccessToken; + + /** + * ChatGPT OAuth refresh token, used to obtain new access tokens once + * {@link #chatgptAccessToken} expires. OpenAI's token endpoint may rotate + * this value on refresh, in which case the new value replaces this field. + * Same plaintext-storage caveat as {@link #chatgptAccessToken}. + */ + private String chatgptRefreshToken; + + /** + * {@code chatgpt_account_id} claim extracted from the OAuth {@code id_token}, + * sent as the {@code ChatGPT-Account-Id} header on every Codex request. + */ + private String chatgptAccountId; + + /** + * Expiry instant of {@link #chatgptAccessToken}, as an ISO-8601 string + * (see {@link java.time.Instant#toString()}). Stored as a string rather + * than a raw {@code long} for human-debuggability of the persisted JSON, + * consistent with this class's plain-String field convention. Blank when + * not signed in. + */ + private String chatgptTokenExpiresAt; + + /** + * {@code email} claim extracted from the OAuth {@code id_token}, if present. + * Used only for the "Signed in as ..." status label; falls back to + * {@link #chatgptAccountId} when blank. + */ + private String chatgptEmail; + // ------------------------------------------------------------------------- // No-arg constructor (Jackson) // ------------------------------------------------------------------------- @@ -190,6 +248,11 @@ public ClaudeProfile() { this.customModels = new ArrayList<>(); this.extraCliArgs = ""; this.storageDir = ""; + this.chatgptAccessToken = ""; + this.chatgptRefreshToken = ""; + this.chatgptAccountId = ""; + this.chatgptTokenExpiresAt = ""; + this.chatgptEmail = ""; } // ------------------------------------------------------------------------- @@ -248,6 +311,9 @@ public boolean isDefault() { * @return the connection type inferred from current field values */ public ConnectionType computeConnectionType() { + if (openaiSubscription) { + return ConnectionType.OPENAI_SUBSCRIPTION; + } if (openaiProxy) { return ConnectionType.OPENAI_PROXY; } @@ -288,7 +354,8 @@ public Map toEnvVars() { env.put("ANTHROPIC_AUTH_TOKEN", blankToEmpty(apiKey)); env.put("ANTHROPIC_BASE_URL", blankToEmpty(baseUrl)); } - case OPENAI_PROXY -> { /* ANTHROPIC_BASE_URL is injected by ClaudeProcess after proxy start */ } + case OPENAI_PROXY, OPENAI_SUBSCRIPTION -> + { /* ANTHROPIC_BASE_URL is injected by ClaudeProcess after proxy start */ } default -> { /* CLAUDE_MANAGED — nothing to inject */ } } @@ -610,6 +677,67 @@ public ClaudeProfile withStorageDir(String storageDir) { /** Sets whether this profile uses the internal OpenAI-compatible proxy. */ public void setOpenaiProxy(boolean openaiProxy) { this.openaiProxy = openaiProxy; } + /** Returns {@code true} if this profile uses a ChatGPT subscription (OAuth) via the internal proxy. */ + public boolean isOpenaiSubscription() { return openaiSubscription; } + + /** Sets whether this profile uses a ChatGPT subscription (OAuth) via the internal proxy. */ + public void setOpenaiSubscription(boolean openaiSubscription) { this.openaiSubscription = openaiSubscription; } + + /** Returns the ChatGPT OAuth access token; blank when not signed in. */ + public String getChatgptAccessToken() { return blankToEmpty(chatgptAccessToken); } + + /** Sets the ChatGPT OAuth access token. */ + public void setChatgptAccessToken(String chatgptAccessToken) { this.chatgptAccessToken = chatgptAccessToken; } + + /** Returns the ChatGPT OAuth refresh token; blank when not signed in. */ + public String getChatgptRefreshToken() { return blankToEmpty(chatgptRefreshToken); } + + /** Sets the ChatGPT OAuth refresh token. */ + public void setChatgptRefreshToken(String chatgptRefreshToken) { this.chatgptRefreshToken = chatgptRefreshToken; } + + /** Returns the {@code chatgpt_account_id} claim, sent as the {@code ChatGPT-Account-Id} header. */ + public String getChatgptAccountId() { return blankToEmpty(chatgptAccountId); } + + /** Sets the {@code chatgpt_account_id} claim. */ + public void setChatgptAccountId(String chatgptAccountId) { this.chatgptAccountId = chatgptAccountId; } + + /** Returns the access-token expiry instant as an ISO-8601 string; blank when not signed in. */ + public String getChatgptTokenExpiresAt() { return blankToEmpty(chatgptTokenExpiresAt); } + + /** Sets the access-token expiry instant as an ISO-8601 string. */ + public void setChatgptTokenExpiresAt(String chatgptTokenExpiresAt) { this.chatgptTokenExpiresAt = chatgptTokenExpiresAt; } + + /** Returns the {@code email} claim from the OAuth id_token, if present. */ + public String getChatgptEmail() { return blankToEmpty(chatgptEmail); } + + /** Sets the {@code email} claim from the OAuth id_token. */ + public void setChatgptEmail(String chatgptEmail) { this.chatgptEmail = chatgptEmail; } + + /** + * Returns {@code true} when this profile holds a usable ChatGPT OAuth + * token set (access + refresh token both present). + * + * @return {@code true} if signed in to ChatGPT + */ + @JsonIgnore + public boolean isSignedIntoChatgpt() { + return !getChatgptAccessToken().isBlank() && !getChatgptRefreshToken().isBlank(); + } + + /** + * Clears all ChatGPT OAuth token fields (access token, refresh token, + * account id, expiry, email), leaving {@link #openaiSubscription} untouched. + * Used by the "Sign out" action and when switching away from the + * {@link ConnectionType#OPENAI_SUBSCRIPTION} connection type. + */ + public void clearChatgptAuth() { + this.chatgptAccessToken = ""; + this.chatgptRefreshToken = ""; + this.chatgptAccountId = ""; + this.chatgptTokenExpiresAt = ""; + this.chatgptEmail = ""; + } + // ------------------------------------------------------------------------- // Object // ------------------------------------------------------------------------- diff --git a/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfileStore.java b/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfileStore.java index 2fdb4e50..21e1c7a6 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfileStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfileStore.java @@ -77,6 +77,12 @@ public static List getProfiles() { def.setModelAliases(stored.getModelAliases()); def.setCustomModels(stored.getCustomModels()); def.setStorageDir(stored.getStorageDir()); + def.setOpenaiSubscription(stored.isOpenaiSubscription()); + def.setChatgptAccessToken(stored.getChatgptAccessToken()); + def.setChatgptRefreshToken(stored.getChatgptRefreshToken()); + def.setChatgptAccountId(stored.getChatgptAccountId()); + def.setChatgptTokenExpiresAt(stored.getChatgptTokenExpiresAt()); + def.setChatgptEmail(stored.getChatgptEmail()); } catch (Exception e) { LOG.warning("Could not parse stored Default profile JSON, using defaults. Cause: " + e); def.setExtraCliArgs(ClaudeCodePreferences.getDefaultExtraCliArgs()); @@ -130,6 +136,24 @@ public static ClaudeProfile findByName(String name) { .orElseGet(ClaudeProfileStore::getDefaultProfile); } + /** + * Looks up a profile by id. + * + *

Unlike {@link #findByName}, this does not fall back to the Default + * profile on a miss — callers that need to distinguish "not found" from + * "found the Default profile" (e.g. token-refresh persistence) should use + * this method. + * + * @param id profile id, or {@code ""} for Default + * @return the matching profile, or {@code null} if no profile has this id + */ + public static ClaudeProfile findById(String id) { + if (id == null || id.isBlank()) { + return getDefaultProfile(); + } + return getProfiles().stream().filter(p -> p.getId().equals(id)).findFirst().orElse(null); + } + // ------------------------------------------------------------------------- // Write // ------------------------------------------------------------------------- diff --git a/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanel.java b/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanel.java index 3c07ced0..873841c6 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanel.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanel.java @@ -65,10 +65,11 @@ public final class ClaudeProfilesPanel extends JPanel { // ------------------------------------------------------------------------- private static final String CONN_MANAGED = "Claude managed"; - private static final String CONN_SUBSCRIPTION = "Subscription"; + private static final String CONN_SUBSCRIPTION = "Claude Subscription"; private static final String CONN_CLAUDE_API = "Claude API"; private static final String CONN_OTHER_API = "Claude-compatible API"; private static final String CONN_OPENAI_PROXY = "OpenAI-compatible API"; + private static final String CONN_CHATGPT_SUBSCRIPTION = "ChatGPT Subscription"; private static final String PROXY_CARD_NONE = "proxy_none"; private static final String PROXY_CARD_CUSTOM = "proxy_custom"; @@ -109,6 +110,20 @@ public final class ClaudeProfilesPanel extends JPanel { private JRadioButton rbOtherApi; /** Radio button for OpenAI-compatible proxy connection. */ private JRadioButton rbOpenAIProxy; + /** Radio button for ChatGPT subscription (OAuth) connection. */ + private JRadioButton rbChatgptSubscription; + /** Status label showing ChatGPT sign-in state ("Not signed in" / "Signed in as ..."). */ + private JLabel chatgptStatusLabel; + /** Button that generates a sign-in link and copies it to the clipboard. Always enabled. */ + private JButton chatgptSignInBtn; + /** Button that clears the stored ChatGPT OAuth tokens. */ + private JButton chatgptSignOutBtn; + /** Field for pasting back the authorization code (or full redirect URL) after signing in in the browser. */ + private JTextField chatgptCodeField; + /** Button that redeems {@link #chatgptCodeField}'s contents for tokens. */ + private JButton chatgptSubmitCodeBtn; + /** The in-progress sign-in attempt, or {@code null} if none. Cancelled/replaced on each "Copy sign in link" click. */ + private io.github.nbplugins.claudecodegui.chatgptauth.ChatGptOAuthClient.PendingSignIn currentChatgptSignIn; /** Info label shown when OpenAI proxy is selected. */ /** Password field for the OAuth token. */ private JPasswordField tokenField; @@ -295,8 +310,10 @@ private JPanel buildConnectionTypeSection() { rbClaudeApi = new JRadioButton(CONN_CLAUDE_API); rbOtherApi = new JRadioButton(CONN_OTHER_API); rbOpenAIProxy = new JRadioButton(CONN_OPENAI_PROXY); + rbChatgptSubscription = new JRadioButton(CONN_CHATGPT_SUBSCRIPTION); ButtonGroup bg = new ButtonGroup(); - for (JRadioButton rb : new JRadioButton[]{rbManaged, rbSubscription, rbClaudeApi, rbOtherApi, rbOpenAIProxy}) { + for (JRadioButton rb : new JRadioButton[]{ + rbManaged, rbSubscription, rbClaudeApi, rbOtherApi, rbOpenAIProxy, rbChatgptSubscription}) { bg.add(rb); } @@ -345,6 +362,21 @@ private JPanel buildConnectionTypeSection() { rbClaudeApi.addActionListener(e -> updateFieldEnablement()); rbOtherApi.addActionListener(e -> updateFieldEnablement()); rbOpenAIProxy.addActionListener(e -> updateFieldEnablement()); + rbChatgptSubscription.addActionListener(e -> updateFieldEnablement()); + + chatgptStatusLabel = new JLabel("Not signed in"); + chatgptSignInBtn = new JButton("Copy sign in link"); + chatgptSignOutBtn = new JButton("Sign out"); + chatgptSignInBtn.addActionListener(e -> onChatgptSignIn()); + chatgptSignOutBtn.addActionListener(e -> onChatgptSignOut()); + + // Hidden until a sign-in link has been generated; user pastes back the code + // (or the full redirect URL) they get after completing sign-in in their own browser. + chatgptCodeField = new JTextField(18); + chatgptCodeField.setVisible(false); + chatgptSubmitCodeBtn = new JButton("Complete Sign-in"); + chatgptSubmitCodeBtn.setVisible(false); + chatgptSubmitCodeBtn.addActionListener(e -> onChatgptSubmitCode()); // Grid: col0=radio, col1=label, col2=field(fill), col3=button JPanel grid = new JPanel(new GridBagLayout()); @@ -384,6 +416,16 @@ private JPanel buildConnectionTypeSection() { // row 4: rbOpenAIProxy — fields (baseUrlField, apiKeyField) are shared with rows 2-3 grid.add(rbOpenAIProxy, inlineRbGbc(0, 4)); + // row 5: rbChatgptSubscription + status label + [Sign in] + [Sign out] + grid.add(rbChatgptSubscription, inlineRbGbc(0, 5)); + grid.add(chatgptStatusLabel, inlineLblGbc(1, 5)); + grid.add(chatgptSignInBtn, inlineBtnGbc(2, 5)); + grid.add(chatgptSignOutBtn, inlineBtnGbc(3, 5)); + + // row 6: pasted code field + Complete Sign-in button, hidden until a link is generated + grid.add(chatgptCodeField, inlineFieldGbc(1, 6)); + grid.add(chatgptSubmitCodeBtn, inlineBtnGbc(2, 6)); + JPanel outer = new JPanel(new BorderLayout(0, 4)); outer.add(new JLabel("Connection Type:"), BorderLayout.NORTH); outer.add(grid, BorderLayout.CENTER); @@ -391,19 +433,34 @@ private JPanel buildConnectionTypeSection() { } private void updateFieldEnablement() { - boolean sub = rbSubscription.isSelected(); - boolean api = rbClaudeApi.isSelected() || rbOtherApi.isSelected() || rbOpenAIProxy.isSelected(); - boolean otherApi = rbOtherApi.isSelected(); - boolean openai = rbOpenAIProxy.isSelected(); + boolean sub = rbSubscription.isSelected(); + boolean chatgptSub = rbChatgptSubscription.isSelected(); + boolean api = rbClaudeApi.isSelected() || rbOtherApi.isSelected() || rbOpenAIProxy.isSelected(); + boolean otherApi = rbOtherApi.isSelected(); + boolean openai = rbOpenAIProxy.isSelected(); tokenField.setEnabled(sub); tokenShowBtn.setEnabled(sub); - apiKeyField.setEnabled(api); - apiKeyShowBtn.setEnabled(api); - baseUrlField.setEditable(otherApi || openai); + apiKeyField.setEnabled(api && !chatgptSub); + apiKeyShowBtn.setEnabled(api && !chatgptSub); + baseUrlField.setEditable((otherApi || openai) && !chatgptSub); if (claudeAiBtn != null) { claudeAiBtn.setVisible(sub); consoleAnthropicBtn.setVisible(rbClaudeApi.isSelected()); } + chatgptStatusLabel.setVisible(chatgptSub); + chatgptSignInBtn.setVisible(chatgptSub); + chatgptSignOutBtn.setVisible(chatgptSub); + if (!chatgptSub) { + chatgptCodeField.setVisible(false); + chatgptSubmitCodeBtn.setVisible(false); + if (currentChatgptSignIn != null) { + currentChatgptSignIn.close(); + currentChatgptSignIn = null; + } + } + if (chatgptSub) { + refreshChatgptStatusLabel(currentFormProfile); + } updateModelAliasesBtn(); } @@ -411,7 +468,111 @@ private void updateModelAliasesBtn() { boolean other = rbOtherApi.isSelected() || rbOpenAIProxy.isSelected(); boolean hasKey = !new String(apiKeyField.getPassword()).isBlank(); boolean hasUrl = !baseUrlField.getText().isBlank(); - modelAliasesBtn.setEnabled(other && hasKey && hasUrl); + if (rbChatgptSubscription.isSelected()) { + modelAliasesBtn.setEnabled(currentFormProfile != null && currentFormProfile.isSignedIntoChatgpt()); + } else { + modelAliasesBtn.setEnabled(other && hasKey && hasUrl); + } + } + + private void refreshChatgptStatusLabel(ClaudeProfile p) { + if (p == null) return; + if (p.isSignedIntoChatgpt()) { + String who = !p.getChatgptEmail().isBlank() ? p.getChatgptEmail() : p.getChatgptAccountId(); + chatgptStatusLabel.setText("Signed in as " + who); + } else { + chatgptStatusLabel.setText("Not signed in"); + } + chatgptSignOutBtn.setEnabled(p.isSignedIntoChatgpt()); + } + + private void onChatgptSignIn() { + ClaudeProfile p = currentFormProfile; + if (p == null) return; + + // Regenerating cancels whatever attempt was pending — no timeout to wait out. + if (currentChatgptSignIn != null) { + currentChatgptSignIn.close(); + currentChatgptSignIn = null; + } + + try { + currentChatgptSignIn = new io.github.nbplugins.claudecodegui.chatgptauth.ChatGptOAuthClient() + .beginSignIn(); + } catch (io.github.nbplugins.claudecodegui.chatgptauth.OAuthException ex) { + JOptionPane.showMessageDialog(this, + "Could not start ChatGPT sign-in: " + ex.getMessage(), + "Sign-in Failed", JOptionPane.ERROR_MESSAGE); + return; + } + + java.awt.Toolkit.getDefaultToolkit().getSystemClipboard() + .setContents(new java.awt.datatransfer.StringSelection(currentChatgptSignIn.authorizeUrl()), null); + chatgptStatusLabel.setText( + "Sign-in link copied — open it in your browser, sign in, then paste the code below."); + chatgptCodeField.setText(""); + chatgptCodeField.setVisible(true); + chatgptSubmitCodeBtn.setVisible(true); + } + + private void onChatgptSubmitCode() { + ClaudeProfile p = currentFormProfile; + if (p == null || currentChatgptSignIn == null) return; + String pasted = chatgptCodeField.getText(); + ProxyConfiguration proxyConfig = new ProxyConfiguration( + rbProxyCustom.isSelected() ? ClaudeProfile.ProxyMode.CUSTOM + : rbProxyNone.isSelected() ? ClaudeProfile.ProxyMode.NO_PROXY + : ClaudeProfile.ProxyMode.SYSTEM_MANAGED, + httpProxyField.getText().trim(), httpsProxyField.getText().trim(), noProxyField.getText().trim()); + io.github.nbplugins.claudecodegui.chatgptauth.ChatGptOAuthClient.PendingSignIn pendingSignIn = + currentChatgptSignIn; + + chatgptSubmitCodeBtn.setEnabled(false); + new javax.swing.SwingWorker() { + @Override + protected io.github.nbplugins.claudecodegui.chatgptauth.ChatGptOAuthClient.TokenSet doInBackground() + throws Exception { + return pendingSignIn.completeWithCode(pasted, proxyConfig); + } + + @Override + protected void done() { + chatgptSubmitCodeBtn.setEnabled(true); + try { + io.github.nbplugins.claudecodegui.chatgptauth.ChatGptOAuthClient.TokenSet tokens = get(); + p.setChatgptAccessToken(tokens.accessToken()); + p.setChatgptRefreshToken(tokens.refreshToken()); + p.setChatgptAccountId(tokens.accountId()); + p.setChatgptEmail(tokens.email()); + p.setChatgptTokenExpiresAt(tokens.expiresAt().toString()); + pendingSignIn.close(); + if (currentChatgptSignIn == pendingSignIn) { + currentChatgptSignIn = null; + } + chatgptCodeField.setText(""); + chatgptCodeField.setVisible(false); + chatgptSubmitCodeBtn.setVisible(false); + refreshChatgptStatusLabel(p); + updateModelAliasesBtn(); + } catch (Exception ex) { + // Leave the pending attempt and code field open — user may just have a typo to fix. + Throwable cause = ex.getCause() != null ? ex.getCause() : ex; + LOG.warning("ChatGPT sign-in code exchange failed: " + cause.getMessage()); + JOptionPane.showMessageDialog(ClaudeProfilesPanel.this, + "Could not complete ChatGPT sign-in: " + cause.getMessage(), + "Sign-in Failed", JOptionPane.ERROR_MESSAGE); + } + } + }.execute(); + } + + private void onChatgptSignOut() { + ClaudeProfile p = currentFormProfile; + if (p == null) return; + p.clearChatgptAuth(); + refreshChatgptStatusLabel(p); + updateModelAliasesBtn(); } private JPanel buildProxySection() { @@ -593,31 +754,45 @@ private void flushFormToCurrentProfile() { p.setApiKey(""); p.setBaseUrl(""); p.setOpenaiProxy(false); + p.setOpenaiSubscription(false); } else if (rbClaudeApi.isSelected()) { p.setApiKey(new String(apiKeyField.getPassword())); p.setToken(""); p.setBaseUrl(""); p.setOpenaiProxy(false); + p.setOpenaiSubscription(false); } else if (rbOtherApi.isSelected()) { p.setApiKey(new String(apiKeyField.getPassword())); p.setBaseUrl(baseUrlField.getText().trim()); p.setToken(""); p.setOpenaiProxy(false); + p.setOpenaiSubscription(false); } else if (rbOpenAIProxy.isSelected()) { p.setApiKey(new String(apiKeyField.getPassword())); p.setBaseUrl(baseUrlField.getText().trim()); p.setToken(""); p.setOpenaiProxy(true); + p.setOpenaiSubscription(false); + } else if (rbChatgptSubscription.isSelected()) { + // chatgpt* token fields are written directly by the sign-in/sign-out button + // handlers, not by this text-field flush — they are opaque credentials, not + // user-editable text, so they don't round-trip through a visible field. + p.setToken(""); + p.setApiKey(""); + p.setBaseUrl(""); + p.setOpenaiProxy(false); + p.setOpenaiSubscription(true); } else { p.setToken(""); p.setApiKey(""); p.setBaseUrl(""); p.setOpenaiProxy(false); + p.setOpenaiSubscription(false); } // Clear model aliases/custom models when switching to a connection type that does not // support them — prevents stale aliases from the old type appearing in the model combo box. - if (!rbOtherApi.isSelected() && !rbOpenAIProxy.isSelected()) { + if (!rbOtherApi.isSelected() && !rbOpenAIProxy.isSelected() && !rbChatgptSubscription.isSelected()) { p.setModelAliases(null); p.setCustomModels(null); } @@ -689,9 +864,11 @@ private void loadProfileIntoForm(ClaudeProfile p) { apiKeyField.setText(p.getApiKey()); baseUrlField.setText(p.getBaseUrl()); } + case OPENAI_SUBSCRIPTION -> rbChatgptSubscription.setSelected(true); default -> rbManaged.setSelected(true); } updateFieldEnablement(); + refreshChatgptStatusLabel(p); // Proxy switch (p.getProxyMode()) { @@ -814,11 +991,21 @@ private void onModelAliases() { for (String id : p.getCustomModels()) { existing.add(new ModelAlias(id, null, "custom")); } - ModelAliasesDialog dlg = new ModelAliasesDialog( - this, - p.getBaseUrl(), - p.getApiKey(), - existing); + ModelAliasesDialog dlg; + if (p.computeConnectionType() == ClaudeProfile.ConnectionType.OPENAI_SUBSCRIPTION) { + ModelAliasesDialog.ModelFetcher fetcher = () -> { + String token = new io.github.nbplugins.claudecodegui.chatgptauth.ChatGptTokenManager() + .getValidAccessToken(p); + java.net.http.HttpClient httpClient = ProxyConfiguration.from(p).applyTo( + java.net.http.HttpClient.newBuilder() + .connectTimeout(java.time.Duration.ofSeconds(30))).build(); + return new io.github.nbplugins.claudecodegui.chatgptauth.ChatGptOAuthClient() + .fetchModels(httpClient, token, p.getChatgptAccountId()); + }; + dlg = new ModelAliasesDialog(this, fetcher, existing); + } else { + dlg = new ModelAliasesDialog(this, p.getBaseUrl(), p.getApiKey(), existing); + } dlg.setVisible(true); java.util.List chosen = dlg.getModels(); if (chosen != null) { diff --git a/src/main/java/io/github/nbplugins/claudecodegui/settings/ModelAliasesDialog.java b/src/main/java/io/github/nbplugins/claudecodegui/settings/ModelAliasesDialog.java index 1d2a5c9f..6cfbbc98 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/settings/ModelAliasesDialog.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/settings/ModelAliasesDialog.java @@ -1,6 +1,8 @@ package io.github.nbplugins.claudecodegui.settings; +import io.github.nbplugins.claudecodegui.ui.common.BasicTextContextMenu; import java.awt.BorderLayout; +import java.awt.Color; import java.awt.Component; import java.awt.Dimension; import java.awt.FlowLayout; @@ -20,13 +22,15 @@ import javax.swing.JButton; import javax.swing.JComboBox; import javax.swing.JDialog; -import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JTable; +import javax.swing.JTextArea; import javax.swing.ListSelectionModel; import javax.swing.SwingWorker; +import javax.swing.UIManager; +import javax.swing.border.EmptyBorder; import javax.swing.table.DefaultTableCellRenderer; import javax.swing.table.DefaultTableModel; @@ -40,17 +44,34 @@ public final class ModelAliasesDialog extends JDialog { private static final String[] ALIASES = {"", "sonnet", "opus", "haiku", "custom"}; - /** The base URL of the Other API endpoint, used when fetching available models. */ - private final String baseUrl; - /** The API key used when fetching available models. */ - private final String apiKey; + /** + * Fetches the list of available model IDs for the "Fetch" button. + * Called off the EDT, inside this dialog's own {@link SwingWorker} — implementations + * may perform blocking network I/O directly. + */ + public interface ModelFetcher { + /** + * Fetches the current list of available model IDs. + * + * @return model IDs; never {@code null} + * @throws Exception on any fetch failure (network, auth, parse, etc.) — + * the message is shown to the user via the dialog's status label + */ + List fetch() throws Exception; + } + + /** Strategy used by the "Fetch" button. */ + private final ModelFetcher fetcher; /** Table model for the model alias rows. */ private DefaultTableModel tableModel; /** Table displaying model aliases. */ private JTable table; - /** Label showing the fetch status or errors. */ - private JLabel statusLabel; + /** + * Read-only status area (fetch progress / errors). Implemented as a selectable + * {@link JTextArea} so the user can copy error text to the clipboard. + */ + private JTextArea statusArea; /** Non-null after the user clicks OK; null if cancelled. */ private List result = null; @@ -60,7 +81,8 @@ public final class ModelAliasesDialog extends JDialog { // ------------------------------------------------------------------------- /** - * Creates a new model aliases dialog. + * Creates a new model aliases dialog that fetches from an OpenAI-compatible + * {@code /v1/models} endpoint using an API key. * * @param parent parent component for centering * @param baseUrl base URL of the Other API endpoint @@ -69,16 +91,64 @@ public final class ModelAliasesDialog extends JDialog { */ public ModelAliasesDialog(Component parent, String baseUrl, String apiKey, List initial) { + this(parent, httpModelFetcher(baseUrl, apiKey), initial); + } + + /** + * Creates a new model aliases dialog with a custom fetch strategy — used for + * connection types (e.g. ChatGPT Subscription) that don't fetch via a plain + * API-key-authenticated {@code /v1/models} endpoint. + * + * @param parent parent component for centering + * @param fetcher strategy invoked by the "Fetch" button + * @param initial pre-existing model aliases to populate the table with + */ + public ModelAliasesDialog(Component parent, ModelFetcher fetcher, List initial) { super(JOptionPane.getFrameForComponent(parent), "Model Aliases", true); - this.baseUrl = baseUrl == null ? "" : baseUrl.trim(); - this.apiKey = apiKey == null ? "" : apiKey.trim(); + this.fetcher = fetcher; initComponents(initial); pack(); - setMinimumSize(new Dimension(600, 350)); + setMinimumSize(new Dimension(600, 400)); setLocationRelativeTo(parent); } + /** + * Default {@link ModelFetcher} for OpenAI-compatible providers: {@code GET + * {baseUrl}/v1/models} with {@code Authorization: Bearer }. + */ + private static ModelFetcher httpModelFetcher(String baseUrl, String apiKey) { + String url = baseUrl == null ? "" : baseUrl.trim(); + String key = apiKey == null ? "" : apiKey.trim(); + return () -> { + if (url.isBlank()) { + throw new IllegalStateException("Base URL is empty — cannot fetch."); + } + String fetchUrl = url.replaceAll("/+$", "") + "/v1/models"; + LOG.fine("Fetch models: GET " + fetchUrl); + + HttpURLConnection conn = (HttpURLConnection) new URL(fetchUrl).openConnection(); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(15_000); + conn.setRequestMethod("GET"); + if (!key.isBlank()) { + conn.setRequestProperty("Authorization", "Bearer " + key); + } + conn.setRequestProperty("Accept", "application/json"); + + int status = conn.getResponseCode(); + String body; + try (InputStream is = status < 400 ? conn.getInputStream() : conn.getErrorStream()) { + body = is == null ? "" : new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + LOG.fine("Fetch models: response body: " + body); + if (status < 200 || status >= 300) { + throw new IOException("Fetch models: failed HTTP " + status); + } + return parseModelIds(body); + }; + } + // ------------------------------------------------------------------------- // Public API // ------------------------------------------------------------------------- @@ -199,11 +269,22 @@ public Component getTableCellRendererComponent(JTable t, Object value, center.add(btnPanel, BorderLayout.EAST); add(center, BorderLayout.CENTER); - // --- Status label --- - statusLabel = new JLabel(" "); - add(statusLabel, BorderLayout.SOUTH); + // --- Status area (wraps long error text; selectable/copyable) and OK / Cancel. + // Both go into one SOUTH panel — BorderLayout.SOUTH and PAGE_END are the + // same slot, so putting them separately made the status line invisible. + statusArea = new JTextArea(" "); + statusArea.setEditable(false); + statusArea.setFocusable(true); + statusArea.setLineWrap(true); + statusArea.setWrapStyleWord(true); + statusArea.setOpaque(false); + statusArea.setBorder(new EmptyBorder(2, 2, 2, 2)); + statusArea.setRows(3); + // Match dialog body font rather than monospaced editor default. + statusArea.setFont(UIManager.getFont("Label.font")); + statusArea.setForeground(UIManager.getColor("Label.foreground")); + BasicTextContextMenu.attach(statusArea, BasicTextContextMenu.createReadOnly(statusArea)); - // --- OK / Cancel --- JButton okBtn = new JButton("OK"); JButton cancelBtn = new JButton("Cancel"); okBtn .addActionListener(e -> onOk()); @@ -213,7 +294,13 @@ public Component getTableCellRendererComponent(JTable t, Object value, JPanel okPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0)); okPanel.add(okBtn); okPanel.add(cancelBtn); - add(okPanel, BorderLayout.PAGE_END); + + JPanel south = new JPanel(new BorderLayout(0, 4)); + south.add(new JScrollPane(statusArea, + JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, + JScrollPane.HORIZONTAL_SCROLLBAR_NEVER), BorderLayout.CENTER); + south.add(okPanel, BorderLayout.SOUTH); + add(south, BorderLayout.SOUTH); } // ------------------------------------------------------------------------- @@ -221,58 +308,48 @@ public Component getTableCellRendererComponent(JTable t, Object value, // ------------------------------------------------------------------------- private void onFetch() { - if (baseUrl.isBlank()) { - statusLabel.setText("Base URL is empty — cannot fetch."); - return; - } - String fetchUrl = baseUrl.replaceAll("/+$", "") + "/v1/models"; - statusLabel.setText("Fetching..."); - LOG.fine("Fetch models: GET " + fetchUrl); - - new SwingWorker() { - @Override protected FetchResult doInBackground() throws Exception { - HttpURLConnection conn = (HttpURLConnection) new URL(fetchUrl).openConnection(); - conn.setConnectTimeout(10_000); - conn.setReadTimeout(15_000); - conn.setRequestMethod("GET"); - if (!apiKey.isBlank()) { - conn.setRequestProperty("Authorization", "Bearer " + apiKey); - } - conn.setRequestProperty("Accept", "application/json"); + setStatus("Fetching...", false); - int status = conn.getResponseCode(); - String body; - try (InputStream is = status < 400 ? conn.getInputStream() : conn.getErrorStream()) { - body = is == null ? "" : new String(is.readAllBytes(), StandardCharsets.UTF_8); - } - LOG.fine("Fetch models: response body: " + body); - return new FetchResult(status, body); + new SwingWorker, Void>() { + @Override protected List doInBackground() throws Exception { + return fetcher.fetch(); } @Override protected void done() { try { - FetchResult r = get(); - if (r.status() >= 200 && r.status() < 300) { - List ids = parseModelIds(r.body()); - applyFetchedIds(ids); - LOG.info("Fetch models: response " + r.status() + ", " + ids.size() + " models returned"); - statusLabel.setText("Fetched " + ids.size() + " models, " - + countAvailable() + " available"); - } else { - String msg = "Fetch models: failed HTTP " + r.status(); - LOG.warning(msg); - statusLabel.setText(msg); - } + List ids = get(); + applyFetchedIds(ids); + LOG.info("Fetch models: " + ids.size() + " models returned"); + setStatus("Fetched " + ids.size() + " models, " + countAvailable() + " available", false); } catch (Exception ex) { - String msg = "Fetch models: failed — " + ex.getMessage(); + Throwable cause = ex.getCause() != null ? ex.getCause() : ex; + String msg = "Fetch models: failed — " + cause.getMessage(); LOG.warning(msg); - statusLabel.setText(msg); + setStatus(msg, true); } } }.execute(); } - private record FetchResult(int status, String body) {} + /** + * Sets the status text. The area is selectable / copyable (Ctrl+C or + * right-click → Copy via {@link BasicTextContextMenu#createReadOnly}) so + * upstream error bodies can be pasted into a bug report. Errors show in red. + * + * @param text status / error text + * @param error {@code true} to render in red + */ + private void setStatus(String text, boolean error) { + statusArea.setText(text == null || text.isBlank() ? " " : text); + statusArea.setCaretPosition(0); + statusArea.setToolTipText(text); + Color fg = error ? Color.RED.darker() : UIManager.getColor("Label.foreground"); + statusArea.setForeground(fg != null ? fg : Color.BLACK); + } + + private void setStatus(String text) { + setStatus(text, false); + } /** Parses {@code data[].id} from an OpenAI-compatible {@code /v1/models} response. */ static List parseModelIds(String json) { @@ -368,12 +445,12 @@ private void onAdd() { if (id == null) return; id = id.trim(); String error = validateModelId(id, -1); - if (error != null) { statusLabel.setText(error); return; } + if (error != null) { setStatus(error); return; } tableModel.addRow(new Object[]{id, null, ""}); int newRow = tableModel.getRowCount() - 1; table.setRowSelectionInterval(newRow, newRow); table.scrollRectToVisible(table.getCellRect(newRow, 0, true)); - statusLabel.setText(" "); + setStatus(" "); } private void onRename() { @@ -387,10 +464,10 @@ private void onRename() { newId = newId.trim(); if (newId.equals(current)) return; String error = validateModelId(newId, sel); - if (error != null) { statusLabel.setText(error); return; } + if (error != null) { setStatus(error); return; } tableModel.setValueAt(newId, sel, 0); tableModel.setValueAt(null, sel, 1); - statusLabel.setText(" "); + setStatus(" "); } String validateModelId(String id, int skipRow) { diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/common/BasicTextContextMenu.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/common/BasicTextContextMenu.java index 4213e48c..a48354a1 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/common/BasicTextContextMenu.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/common/BasicTextContextMenu.java @@ -25,7 +25,9 @@ protected BasicTextContextMenu() {} /** * Creates a read-only JPopupMenu with Select All and Copy. - * Copy is enabled only when text is selected (checked when menu opens). + * If nothing is selected, Copy copies the whole text (handy for status/error + * fields where the user right-clicks without first dragging a selection). + * Copy is enabled when the component has non-blank content. * * @param tc the text component whose actions the menu items delegate to * @return the configured popup menu @@ -39,11 +41,17 @@ public static JPopupMenu createReadOnly(JTextComponent tc) { JMenuItem copy = new JMenuItem("Copy"); copy.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_C, InputEvent.CTRL_DOWN_MASK)); - copy.addActionListener(e -> tc.copy()); + copy.addActionListener(e -> { + if (tc.getSelectionStart() == tc.getSelectionEnd()) { + tc.selectAll(); + } + tc.copy(); + }); menu.addPopupMenuListener(new javax.swing.event.PopupMenuListener() { @Override public void popupMenuWillBecomeVisible(javax.swing.event.PopupMenuEvent e) { - copy.setEnabled(tc.getSelectionStart() != tc.getSelectionEnd()); + String text = tc.getText(); + copy.setEnabled(text != null && !text.isBlank()); } @Override public void popupMenuWillBecomeInvisible(javax.swing.event.PopupMenuEvent e) {} @Override public void popupMenuCanceled(javax.swing.event.PopupMenuEvent e) {} diff --git a/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusService.java b/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusService.java index 72bc1577..eeaa4da0 100644 --- a/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusService.java +++ b/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusService.java @@ -49,6 +49,19 @@ public interface ClaudeCodeStatusService { */ void registerOpenAIProxy(String uuid, String baseUrl, String apiKey, ProxyConfiguration proxy); + /** + * Registers a ChatGPT-subscription proxy session, routed to OpenAI's + * Codex Responses API. Called by {@code ClaudeProcess} at session start. + * + * @param uuid unique session identifier + * @param profileId owning profile id, used to re-fetch the live profile for token refresh + * @param accessToken ChatGPT OAuth access token + * @param accountId {@code chatgpt_account_id} claim + * @param proxy proxy settings from the profile + */ + void registerChatgptSubscriptionProxy(String uuid, String profileId, String accessToken, + String accountId, ProxyConfiguration proxy); + /** * Deregisters an OpenAI-compatible proxy session. * Called by {@code ClaudeProcess} at session stop. diff --git a/src/test/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptOAuthClientTest.java b/src/test/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptOAuthClientTest.java new file mode 100644 index 00000000..4eb8e852 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptOAuthClientTest.java @@ -0,0 +1,269 @@ +package io.github.nbplugins.claudecodegui.chatgptauth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the parts of {@link ChatGptOAuthClient} that don't require a + * real network round-trip: PKCE verifier/challenge generation, authorize URL + * construction, {@code id_token} claim extraction, {@link ChatGptOAuthClient#beginSignIn()}'s + * local listener setup, and the pasted-code extraction logic. + * + *

{@code ChatGptOAuthClient} never opens a browser and never waits for the + * OAuth redirect over the network — the caller (see + * {@code ClaudeProfilesPanel.onChatgptSignIn}/{@code onChatgptSubmitCode}) + * copies the authorize URL for the user to open themselves, and later + * redeems a code the user pastes back. NOT tested here (requires manual + * verification): the actual token exchange against {@code auth.openai.com}. + */ +class ChatGptOAuthClientTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // ------------------------------------------------------------------------- + // generateCodeVerifier + // ------------------------------------------------------------------------- + + @Test + void generateCodeVerifier_meetsRfc7636LengthAndCharsetConstraints() { + String verifier = ChatGptOAuthClient.generateCodeVerifier(); + // RFC 7636 §4.1: 43-128 characters from [A-Z a-z 0-9 - . _ ~] + assertTrue(verifier.length() >= 43 && verifier.length() <= 128, + "verifier length out of RFC 7636 range: " + verifier.length()); + assertTrue(verifier.matches("[A-Za-z0-9\\-._~]+"), + "verifier contains characters outside the unreserved set: " + verifier); + } + + @Test + void generateCodeVerifier_producesDistinctValues() { + assertNotEquals(ChatGptOAuthClient.generateCodeVerifier(), ChatGptOAuthClient.generateCodeVerifier()); + } + + // ------------------------------------------------------------------------- + // deriveCodeChallenge — RFC 7636 Appendix B test vector + // ------------------------------------------------------------------------- + + @Test + void deriveCodeChallenge_matchesRfc7636AppendixBVector() { + String verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + String expectedChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + assertEquals(expectedChallenge, ChatGptOAuthClient.deriveCodeChallenge(verifier)); + } + + // ------------------------------------------------------------------------- + // buildAuthorizeUrl + // ------------------------------------------------------------------------- + + @Test + void buildAuthorizeUrl_containsAllRequiredParams() { + String url = ChatGptOAuthClient.buildAuthorizeUrl("verifier123", "challenge456", "state789", 1455); + + assertTrue(url.startsWith(ChatGptOAuthClient.AUTHORIZE_ENDPOINT + "?")); + assertTrue(url.contains("response_type=code")); + assertTrue(url.contains("client_id=" + ChatGptOAuthClient.CLIENT_ID)); + assertTrue(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback")); + assertTrue(url.contains("code_challenge=challenge456")); + assertTrue(url.contains("code_challenge_method=S256")); + assertTrue(url.contains("state=state789")); + assertTrue(url.contains("scope=" + java.net.URLEncoder.encode(ChatGptOAuthClient.SCOPES, StandardCharsets.UTF_8))); + } + + @Test + void buildAuthorizeUrl_usesGivenCallbackPort() { + String url = ChatGptOAuthClient.buildAuthorizeUrl("v", "c", "s", 1457); + assertTrue(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1457%2Fauth%2Fcallback")); + } + + // ------------------------------------------------------------------------- + // id_token claim parsing (via reflection on the private helper) + // ------------------------------------------------------------------------- + + @Test + void parseIdTokenClaims_extractsAccountIdAndEmail() throws Exception { + String idToken = buildFakeIdToken("acct-123", "user@example.com"); + JsonNode claims = invokeParseIdTokenClaims(idToken); + + assertNotNull(claims); + assertEquals("acct-123", claims.path("https://api.openai.com/auth").path("chatgpt_account_id").asText()); + assertEquals("user@example.com", claims.path("email").asText()); + } + + @Test + void parseIdTokenClaims_malformedToken_returnsNull() throws Exception { + assertNull(invokeParseIdTokenClaims("not-a-jwt")); + } + + private static String buildFakeIdToken(String accountId, String email) { + String header = base64Url("{\"alg\":\"none\"}"); + var payload = MAPPER.createObjectNode(); + payload.put("email", email); + payload.putObject("https://api.openai.com/auth").put("chatgpt_account_id", accountId); + String payloadEncoded = base64Url(payload.toString()); + // No real signature needed — the client intentionally does not verify it + // (the token is obtained directly from auth.openai.com over TLS). + return header + "." + payloadEncoded + ".fakesignature"; + } + + private static String base64Url(String json) { + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } + + private static JsonNode invokeParseIdTokenClaims(String idToken) throws Exception { + Method m = ChatGptOAuthClient.class.getDeclaredMethod("parseIdTokenClaims", String.class); + m.setAccessible(true); + return (JsonNode) m.invoke(null, idToken); + } + + // ------------------------------------------------------------------------- + // beginSignIn + // ------------------------------------------------------------------------- + + @Test + void beginSignIn_returnsPendingSignInWithWellFormedAuthorizeUrl() throws Exception { + ChatGptOAuthClient client = new ChatGptOAuthClient(); + try (ChatGptOAuthClient.PendingSignIn pending = client.beginSignIn()) { + String url = pending.authorizeUrl(); + assertTrue(url.startsWith(ChatGptOAuthClient.AUTHORIZE_ENDPOINT + "?")); + assertTrue(url.contains("client_id=" + ChatGptOAuthClient.CLIENT_ID)); + assertTrue(url.contains("code_challenge_method=S256")); + } + } + + @Test + void beginSignIn_closeIsIdempotent() throws Exception { + ChatGptOAuthClient client = new ChatGptOAuthClient(); + ChatGptOAuthClient.PendingSignIn pending = client.beginSignIn(); + assertDoesNotThrow(() -> { + pending.close(); + pending.close(); + }); + } + + @Test + void beginSignIn_calledTwice_bindsDistinctPorts() throws Exception { + ChatGptOAuthClient client = new ChatGptOAuthClient(); + try (ChatGptOAuthClient.PendingSignIn first = client.beginSignIn(); + ChatGptOAuthClient.PendingSignIn second = client.beginSignIn()) { + assertNotEquals(first.authorizeUrl(), second.authorizeUrl(), + "each attempt should use fresh PKCE parameters / a free port"); + } + } + + // ------------------------------------------------------------------------- + // extractCode — pasted code or full redirect URL + // ------------------------------------------------------------------------- + + @Test + void extractCode_bareCode_returnedAsIs() { + assertEquals("abc123", ChatGptOAuthClient.extractCode(" abc123 ")); + } + + @Test + void extractCode_fullRedirectUrl_extractsCodeParam() { + String url = "http://localhost:1455/auth/callback?code=XYZ789&state=somestate"; + assertEquals("XYZ789", ChatGptOAuthClient.extractCode(url)); + } + + @Test + void extractCode_urlEncodedCode_isDecoded() { + String url = "http://localhost:1455/auth/callback?code=a%2Fb%3Dc&state=s"; + assertEquals("a/b=c", ChatGptOAuthClient.extractCode(url)); + } + + @Test + void extractCode_urlWithoutCodeParam_returnsEmpty() { + String url = "http://localhost:1455/auth/callback?error=access_denied"; + assertEquals("", ChatGptOAuthClient.extractCode(url)); + } + + @Test + void extractCode_blankInput_returnsEmpty() { + assertEquals("", ChatGptOAuthClient.extractCode(" ")); + assertEquals("", ChatGptOAuthClient.extractCode(null)); + } + + // ------------------------------------------------------------------------- + // parseModelIds(JsonNode) — Codex /models response is undocumented, + // so this tolerates a few plausible shapes. + // ------------------------------------------------------------------------- + + @Test + void parseModelIds_dataEnvelopeOfObjects_returnsIds() throws Exception { + JsonNode json = MAPPER.readTree("{\"data\":[{\"id\":\"gpt-5-codex\"},{\"id\":\"gpt-5-codex-mini\"}]}"); + assertEquals(java.util.List.of("gpt-5-codex", "gpt-5-codex-mini"), ChatGptOAuthClient.parseModelIds(json)); + } + + @Test + void parseModelIds_bareArrayOfStrings_returnsIds() throws Exception { + JsonNode json = MAPPER.readTree("[\"gpt-5-codex\",\"gpt-5.1-codex\"]"); + assertEquals(java.util.List.of("gpt-5-codex", "gpt-5.1-codex"), ChatGptOAuthClient.parseModelIds(json)); + } + + @Test + void parseModelIds_bareArrayOfObjects_returnsIds() throws Exception { + JsonNode json = MAPPER.readTree("[{\"id\":\"gpt-5-codex\"}]"); + assertEquals(java.util.List.of("gpt-5-codex"), ChatGptOAuthClient.parseModelIds(json)); + } + + @Test + void parseModelIds_blankIdsSkipped() throws Exception { + JsonNode json = MAPPER.readTree("{\"data\":[{\"id\":\"\"},{\"id\":\"gpt-5-codex\"}]}"); + assertEquals(java.util.List.of("gpt-5-codex"), ChatGptOAuthClient.parseModelIds(json)); + } + + @Test + void parseModelIds_unrecognizedShape_returnsEmpty() throws Exception { + JsonNode json = MAPPER.readTree("{\"status\":\"ok\"}"); + assertTrue(ChatGptOAuthClient.parseModelIds(json).isEmpty()); + } + + /** Confirmed live shape from the real Codex backend (2026-07-18): {@code {"models":[...]}}. */ + @Test + void parseModelIds_modelsEnvelopeOfObjects_returnsIds() throws Exception { + JsonNode json = MAPPER.readTree("{\"models\":[{\"id\":\"gpt-5-codex\"},{\"id\":\"gpt-5.1-codex\"}]}"); + assertEquals(java.util.List.of("gpt-5-codex", "gpt-5.1-codex"), ChatGptOAuthClient.parseModelIds(json)); + } + + @Test + void parseModelIds_modelsEnvelopeEmpty_returnsEmpty() throws Exception { + JsonNode json = MAPPER.readTree("{\"models\":[]}"); + assertTrue(ChatGptOAuthClient.parseModelIds(json).isEmpty()); + } + + @Test + void parseModelIds_modelFieldInsteadOfId_returnsIds() throws Exception { + JsonNode json = MAPPER.readTree("{\"models\":[{\"model\":\"gpt-5-codex\"}]}"); + assertEquals(java.util.List.of("gpt-5-codex"), ChatGptOAuthClient.parseModelIds(json)); + } + + @Test + void parseModelIds_nameFieldInsteadOfId_returnsIds() throws Exception { + JsonNode json = MAPPER.readTree("{\"models\":[{\"name\":\"gpt-5-codex\"}]}"); + assertEquals(java.util.List.of("gpt-5-codex"), ChatGptOAuthClient.parseModelIds(json)); + } + + /** + * The confirmed real shape (verified against {@code openai/codex}'s + * {@code ModelInfo} struct and its own {@code models.rs} test fixture): each + * entry's identifier field is {@code slug}, not {@code id}. + */ + @Test + void parseModelIds_slugField_returnsIds() throws Exception { + JsonNode json = MAPPER.readTree( + "{\"models\":[{\"slug\":\"gpt-5-codex\",\"display_name\":\"GPT-5 Codex\"}]}"); + assertEquals(java.util.List.of("gpt-5-codex"), ChatGptOAuthClient.parseModelIds(json)); + } + + @Test + void parseModelIds_slugTakesPrecedenceOverId() throws Exception { + JsonNode json = MAPPER.readTree("{\"models\":[{\"slug\":\"real-slug\",\"id\":\"unused-id\"}]}"); + assertEquals(java.util.List.of("real-slug"), ChatGptOAuthClient.parseModelIds(json)); + } +} diff --git a/src/test/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptTokenManagerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptTokenManagerTest.java new file mode 100644 index 00000000..ec6d69c3 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptTokenManagerTest.java @@ -0,0 +1,212 @@ +package io.github.nbplugins.claudecodegui.chatgptauth; + +import io.github.nbplugins.claudecodegui.chatgptauth.ChatGptOAuthClient.TokenSet; +import io.github.nbplugins.claudecodegui.settings.ClaudeProfile; +import io.github.nbplugins.claudecodegui.settings.ClaudeProfileStore; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link ChatGptTokenManager}. + * + *

Uses the package-private {@link ChatGptTokenManager.TokenRefresher} + * injection point so no real network call is made — the fake refresher runs + * entirely in-process. + * + *

Note: {@link ClaudeProfileStore#clearProfiles()} is called in + * {@link #setUp()} per the same pattern as {@code ClaudeProfileStoreTest}, + * since successful refreshes persist via {@link ClaudeProfileStore}. + */ +class ChatGptTokenManagerTest { + + @BeforeEach + void setUp() { + try { + ClaudeProfileStore.clearProfiles(); + } catch (Exception ignored) { + // NbPreferences platform not available in plain unit-test environment + } + } + + private static ClaudeProfile signedInProfile(Instant expiresAt) { + ClaudeProfile p = ClaudeProfile.createNamed("chatgpt-test-" + System.nanoTime()); + p.setOpenaiSubscription(true); + p.setChatgptAccessToken("access-old"); + p.setChatgptRefreshToken("refresh-old"); + p.setChatgptAccountId("acct-1"); + p.setChatgptTokenExpiresAt(expiresAt.toString()); + return p; + } + + private static Clock fixedClock(Instant now) { + return Clock.fixed(now, ZoneOffset.UTC); + } + + // ------------------------------------------------------------------------- + // getValidAccessToken — no refresh when fresh + // ------------------------------------------------------------------------- + + @Test + void getValidAccessToken_notNearExpiry_returnsCachedTokenWithoutRefreshing() throws Exception { + Instant now = Instant.parse("2026-01-01T00:00:00Z"); + ClaudeProfile profile = signedInProfile(now.plusSeconds(3600)); // 1h out, well outside 5m skew + + AtomicInteger refreshCalls = new AtomicInteger(); + ChatGptTokenManager manager = new ChatGptTokenManager(fixedClock(now), (client, refreshToken) -> { + refreshCalls.incrementAndGet(); + throw new OAuthException("should not be called"); + }); + + String token = manager.getValidAccessToken(profile); + + assertEquals("access-old", token); + assertEquals(0, refreshCalls.get()); + } + + // ------------------------------------------------------------------------- + // getValidAccessToken — refresh when near expiry + // ------------------------------------------------------------------------- + + @Test + void getValidAccessToken_withinSkewOfExpiry_refreshesAndPersists() throws Exception { + Instant now = Instant.parse("2026-01-01T00:00:00Z"); + ClaudeProfile profile = signedInProfile(now.plus(java.time.Duration.ofMinutes(2))); // within 5m skew + + TokenSet refreshed = new TokenSet("access-new", "refresh-new", "acct-1", "user@example.com", + now.plusSeconds(3600)); + AtomicInteger refreshCalls = new AtomicInteger(); + ChatGptTokenManager manager = new ChatGptTokenManager(fixedClock(now), (client, refreshToken) -> { + assertEquals("refresh-old", refreshToken); + refreshCalls.incrementAndGet(); + return refreshed; + }); + + String token = manager.getValidAccessToken(profile); + + assertEquals("access-new", token); + assertEquals(1, refreshCalls.get()); + assertEquals("access-new", profile.getChatgptAccessToken()); + assertEquals("refresh-new", profile.getChatgptRefreshToken()); + } + + @Test + void getValidAccessToken_noRecordedExpiry_refreshes() throws Exception { + ClaudeProfile profile = ClaudeProfile.createNamed("chatgpt-test-noexp-" + System.nanoTime()); + profile.setOpenaiSubscription(true); + profile.setChatgptAccessToken("access-old"); + profile.setChatgptRefreshToken("refresh-old"); + // chatgptTokenExpiresAt left blank + + TokenSet refreshed = new TokenSet("access-new", "refresh-new", "acct-1", "", + Instant.now().plusSeconds(3600)); + AtomicInteger refreshCalls = new AtomicInteger(); + ChatGptTokenManager manager = new ChatGptTokenManager(Clock.systemUTC(), (client, refreshToken) -> { + refreshCalls.incrementAndGet(); + return refreshed; + }); + + manager.getValidAccessToken(profile); + assertEquals(1, refreshCalls.get()); + } + + // ------------------------------------------------------------------------- + // getValidAccessToken — not signed in + // ------------------------------------------------------------------------- + + @Test + void getValidAccessToken_notSignedIn_throwsOAuthException() { + ClaudeProfile profile = ClaudeProfile.createNamed("chatgpt-test-nosignin-" + System.nanoTime()); + ChatGptTokenManager manager = new ChatGptTokenManager(Clock.systemUTC(), + (client, refreshToken) -> { throw new OAuthException("unexpected"); }); + + assertThrows(OAuthException.class, () -> manager.getValidAccessToken(profile)); + } + + // ------------------------------------------------------------------------- + // Single-flight under concurrency + // ------------------------------------------------------------------------- + + @Test + void refresh_concurrentCallsForSameProfile_onlyOneHttpCallMade() throws Exception { + Instant now = Instant.now(); + ClaudeProfile profile = signedInProfile(now.minusSeconds(1)); // already expired + + AtomicInteger refreshCalls = new AtomicInteger(); + java.util.concurrent.CountDownLatch release = new java.util.concurrent.CountDownLatch(1); + TokenSet refreshed = new TokenSet("access-new", "refresh-new", "acct-1", "", now.plusSeconds(3600)); + + ChatGptTokenManager manager = new ChatGptTokenManager(Clock.systemUTC(), (client, refreshToken) -> { + refreshCalls.incrementAndGet(); + try { + release.await(); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + return refreshed; + }); + + int threadCount = 5; + java.util.concurrent.ExecutorService pool = java.util.concurrent.Executors.newFixedThreadPool(threadCount); + java.util.List> futures = new java.util.ArrayList<>(); + for (int i = 0; i < threadCount; i++) { + futures.add(pool.submit(() -> manager.getValidAccessToken(profile))); + } + Thread.sleep(100); // let all threads reach the in-flight refresh + release.countDown(); + + for (java.util.concurrent.Future f : futures) { + assertEquals("access-new", f.get()); + } + pool.shutdown(); + + assertEquals(1, refreshCalls.get(), "only one HTTP refresh call should be made for concurrent callers"); + } + + // ------------------------------------------------------------------------- + // Refresh HTTP call uses the profile's proxy settings + // ------------------------------------------------------------------------- + + @Test + void refresh_usesHttpClientConfiguredWithProfileProxySettings() throws Exception { + Instant now = Instant.now(); + ClaudeProfile profile = signedInProfile(now.minusSeconds(1)); + profile.setProxyMode(ClaudeProfile.ProxyMode.CUSTOM); + profile.setHttpsProxy("http://proxy.example.com:3128"); + + java.util.concurrent.atomic.AtomicReference capturedClient = + new java.util.concurrent.atomic.AtomicReference<>(); + TokenSet refreshed = new TokenSet("access-new", "refresh-new", "acct-1", "", now.plusSeconds(3600)); + ChatGptTokenManager manager = new ChatGptTokenManager(Clock.systemUTC(), (client, refreshToken) -> { + capturedClient.set(client); + return refreshed; + }); + + manager.getValidAccessToken(profile); + + assertNotNull(capturedClient.get()); + assertTrue(capturedClient.get().proxy().isPresent(), + "HttpClient used for refresh must apply the profile's CUSTOM proxy settings"); + } + + // ------------------------------------------------------------------------- + // Refresh failure + // ------------------------------------------------------------------------- + + @Test + void refresh_httpFailure_throwsOAuthExceptionAndLeavesCachedTokenUnchanged() { + Instant now = Instant.now(); + ClaudeProfile profile = signedInProfile(now.minusSeconds(1)); + + ChatGptTokenManager manager = new ChatGptTokenManager(Clock.systemUTC(), + (client, refreshToken) -> { throw new OAuthException("refresh token revoked"); }); + + assertThrows(OAuthException.class, () -> manager.getValidAccessToken(profile)); + assertEquals("access-old", profile.getChatgptAccessToken()); + } +} diff --git a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslatorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslatorTest.java new file mode 100644 index 00000000..8bb649d0 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslatorTest.java @@ -0,0 +1,366 @@ +package io.github.nbplugins.claudecodegui.openaiproxy; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link AnthropicToCodexTranslator}. + * + *

Reuses several Anthropic-request fixtures from + * {@code AnthropicToOpenAITranslatorTest} (same input format — only the + * upstream shape differs) plus a few {@code codex_*}-prefixed fixtures for + * Codex-specific behaviour (image placeholders, web_search tool, response shape). + */ +class AnthropicToCodexTranslatorTest { + + private static JsonNode load(String name) throws Exception { + String path = "/io/github/nbplugins/claudecodegui/openaiproxy/" + name; + try (InputStream is = AnthropicToCodexTranslatorTest.class.getResourceAsStream(path)) { + assertNotNull(is, "Missing fixture: " + path); + return AnthropicToCodexTranslator.MAPPER.readTree( + new String(is.readAllBytes(), StandardCharsets.UTF_8)); + } + } + + private static AnthropicToCodexTranslator.StreamingState state() { + return new AnthropicToCodexTranslator.StreamingState(); + } + + // ------------------------------------------------------------------------- + // translateRequest + // ------------------------------------------------------------------------- + + @Test + void translateRequest_simpleTextMessage() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest(load("req_simple_text.json"), "sess-1"); + + assertEquals("claude-sonnet-4-5", result.path("model").asText()); + JsonNode input = result.path("input"); + assertEquals(1, input.size()); + assertEquals("message", input.get(0).path("type").asText()); + assertEquals("user", input.get(0).path("role").asText()); + assertEquals("input_text", input.get(0).path("content").get(0).path("type").asText()); + assertEquals("Hello", input.get(0).path("content").get(0).path("text").asText()); + } + + /** + * Regression: the Codex backend's request schema has no {@code max_output_tokens} + * field at all and rejects it with {@code HTTP 400 "Unsupported parameter: + * max_output_tokens"} — Anthropic's {@code max_tokens} must simply be dropped, + * not translated to a Codex field name. + */ + @Test + void translateRequest_doesNotEmitMaxOutputTokens() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest(load("req_simple_text.json"), "sess-1"); + assertFalse(result.has("max_output_tokens")); + assertFalse(result.has("max_tokens")); + } + + /** + * Regression: the Codex backend rejects requests missing this with + * {@code HTTP 400 {"detail":"Store must be set to false"}}. + */ + @Test + void translateRequest_alwaysSetsStoreFalse() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest(load("req_simple_text.json"), "sess-1"); + assertTrue(result.path("store").isBoolean()); + assertFalse(result.path("store").asBoolean()); + } + + /** + * Regression: the Codex backend rejects requests without this, with + * {@code HTTP 400 {"...":"Stream must be set to true"}} — Codex CLI's own + * client (openai/codex core/src/client.rs) always sets it too, even for + * "non-streaming" callers, which instead aggregate the resulting SSE + * stream client-side (see {@link AnthropicToCodexTranslator.ResponseAggregator}). + */ + @Test + void translateRequest_alwaysSetsStreamTrue_evenWhenAnthropicRequestDidNot() throws Exception { + JsonNode nonStreamingReq = load("req_simple_text.json"); + assertFalse(nonStreamingReq.has("stream"), "fixture must not itself request streaming"); + ObjectNode result = AnthropicToCodexTranslator.translateRequest(nonStreamingReq, "sess-1"); + assertTrue(result.path("stream").isBoolean()); + assertTrue(result.path("stream").asBoolean()); + } + + @Test + void translateRequest_promptCacheKeySetFromSessionId() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest(load("req_simple_text.json"), "sess-42"); + assertEquals("sess-42", result.path("prompt_cache_key").asText()); + } + + @Test + void translateRequest_systemPromptBecomesInstructions() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest(load("req_system_prompt.json"), "s"); + assertEquals("You are helpful.", result.path("instructions").asText()); + } + + @Test + void translateRequest_systemAsListOfBlocks_joinedIntoInstructions() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest(load("req_system_as_blocks.json"), "s"); + assertEquals("Block one.\n\nBlock two.", result.path("instructions").asText()); + } + + @Test + void translateRequest_toolUseInAssistantMessage_becomesFunctionCallItem() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest(load("req_tool_use_assistant.json"), "s"); + + JsonNode input = result.path("input"); + assertEquals(1, input.size()); + JsonNode call = input.get(0); + assertEquals("function_call", call.path("type").asText()); + assertEquals("call_1", call.path("call_id").asText()); + assertEquals("read_file", call.path("name").asText()); + assertTrue(call.path("arguments").asText().contains("test.txt")); + } + + @Test + void translateRequest_toolResultInUserMessage_becomesFunctionCallOutputItem() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest(load("req_tool_result_user.json"), "s"); + + JsonNode input = result.path("input"); + assertEquals(1, input.size()); + JsonNode out = input.get(0); + assertEquals("function_call_output", out.path("type").asText()); + assertEquals("call_1", out.path("call_id").asText()); + assertEquals("file content here", out.path("output").asText()); + } + + @Test + void translateRequest_toolResultWithListContent() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest(load("req_tool_result_list_content.json"), "s"); + assertEquals("result text", result.path("input").get(0).path("output").asText()); + } + + @Test + void translateRequest_toolResultWithImage_becomesPlaceholderText() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest( + load("codex_req_tool_result_image.json"), "s"); + + String output = result.path("input").get(0).path("output").asText(); + assertTrue(output.contains("screenshot:")); + assertTrue(output.contains("[image omitted: image/png]"), + "expected image placeholder, got: " + output); + } + + @Test + void translateRequest_toolsConverted() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest(load("req_tools.json"), "s"); + + JsonNode tools = result.path("tools"); + assertEquals(1, tools.size()); + JsonNode tool = tools.get(0); + assertEquals("function", tool.path("type").asText()); + assertEquals("read_file", tool.path("name").asText()); + assertEquals("Reads a file", tool.path("description").asText()); + assertTrue(tool.path("parameters").has("properties")); + } + + @Test + void translateRequest_webSearchTool_mappedToCodexNativeTool() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest(load("codex_req_web_search_tool.json"), "s"); + + JsonNode tools = result.path("tools"); + assertEquals(1, tools.size()); + assertEquals("web_search", tools.get(0).path("type").asText()); + assertFalse(tools.get(0).has("name"), "native web_search tool should not carry a function name"); + } + + @Test + void translateRequest_reasoningEffortFromOutputConfig() throws Exception { + ObjectNode req = (ObjectNode) load("req_simple_text.json"); + req.putObject("output_config").put("effort", "high"); + + ObjectNode result = AnthropicToCodexTranslator.translateRequest(req, "s"); + assertEquals("high", result.path("reasoning").path("effort").asText()); + } + + // ------------------------------------------------------------------------- + // translateResponse + // ------------------------------------------------------------------------- + + @Test + void translateResponse_textOutput() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateResponse(load("codex_resp_text.json"), "gpt-5-codex"); + + assertEquals("message", result.path("type").asText()); + assertEquals("assistant", result.path("role").asText()); + JsonNode content = result.path("content"); + assertEquals(1, content.size()); + assertEquals("text", content.get(0).path("type").asText()); + assertEquals("Hello from Codex", content.get(0).path("text").asText()); + assertEquals("end_turn", result.path("stop_reason").asText()); + assertEquals(12, result.path("usage").path("input_tokens").asInt()); + assertEquals(5, result.path("usage").path("output_tokens").asInt()); + } + + @Test + void translateResponse_toolCall_dropsReasoningAndSetsToolUseStopReason() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateResponse(load("codex_resp_tool_call.json"), "gpt-5-codex"); + + JsonNode content = result.path("content"); + assertEquals(1, content.size(), "reasoning output item must be dropped"); + JsonNode toolUse = content.get(0); + assertEquals("tool_use", toolUse.path("type").asText()); + assertEquals("call_9", toolUse.path("id").asText()); + assertEquals("read_file", toolUse.path("name").asText()); + assertEquals("/tmp/test.txt", toolUse.path("input").path("path").asText()); + assertEquals("tool_use", result.path("stop_reason").asText()); + } + + // ------------------------------------------------------------------------- + // Streaming + // ------------------------------------------------------------------------- + + @Test + void streaming_textDelta_producesContentBlockEvents() { + AnthropicToCodexTranslator.StreamingState s = state(); + + String events = s.processEvent("response.output_text.delta", + "{\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5-codex\"},\"delta\":\"Hi\",\"output_index\":0}"); + + assertTrue(events.contains("message_start")); + assertTrue(events.contains("content_block_start")); + assertTrue(events.contains("text_delta")); + assertTrue(events.contains("\"text\":\"Hi\"")); + assertTrue(s.isMessageStarted()); + assertFalse(s.isDoneReceived()); + } + + @Test + void streaming_functionCall_producesToolUseBlock() { + AnthropicToCodexTranslator.StreamingState s = state(); + + s.processEvent("response.output_item.added", + "{\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"read_file\"}}"); + String argsEvents = s.processEvent("response.function_call_arguments.delta", + "{\"output_index\":0,\"delta\":\"{\\\"path\\\":\\\"x\\\"}\"}"); + + assertTrue(argsEvents.contains("input_json_delta")); + assertTrue(argsEvents.contains("path")); + } + + @Test + void streaming_completedEvent_setsDoneAndBuildsStopEvents() { + AnthropicToCodexTranslator.StreamingState s = state(); + s.processEvent("response.output_text.delta", + "{\"response\":{\"id\":\"resp_1\",\"model\":\"m\"},\"delta\":\"Hi\",\"output_index\":0}"); + + String events = s.processEvent("response.completed", + "{\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}}"); + + assertTrue(s.isDoneReceived()); + assertTrue(events.contains("content_block_stop")); + assertTrue(events.contains("message_delta")); + assertTrue(events.contains("message_stop")); + } + + // ------------------------------------------------------------------------- + // Error mapping (rate limit) + // ------------------------------------------------------------------------- + + @Test + void toCodexAnthropicError_rateLimitCode_mapsToRateLimitError() { + String body = "{\"error\":{\"code\":\"codex.rate_limits.limit_reached\",\"message\":\"too many requests\"}}"; + String result = OpenAIProxyServlet.toCodexAnthropicError(body, 429); + assertTrue(result.contains("rate_limit_error")); + } + + @Test + void toCodexAnthropicError_otherError_fallsBackToGenericMapping() { + String body = "{\"error\":{\"message\":\"boom\",\"type\":\"server_error\"}}"; + String result = OpenAIProxyServlet.toCodexAnthropicError(body, 500); + assertTrue(result.contains("boom")); + } + + /** + * Regression: the Codex backend reports validation errors as + * {@code {"detail":"..."}} (e.g. "Store must be set to false"), a shape + * the generic OpenAI-style {@code {"error":{...}}} mapping didn't + * recognize — it used to fall through to an opaque "Provider returned + * HTTP 400" message instead of the real cause. + */ + @Test + void toCodexAnthropicError_detailShape_surfacesRealCause() { + String body = "{\"detail\":\"Store must be set to false\"}"; + String result = OpenAIProxyServlet.toCodexAnthropicError(body, 400); + assertTrue(result.contains("Store must be set to false"), "expected real cause, got: " + result); + } + + // ------------------------------------------------------------------------- + // ResponseAggregator — collapses a Codex SSE stream into a single + // non-streaming Codex-response-shaped JSON for translateResponse. + // ------------------------------------------------------------------------- + + private static AnthropicToCodexTranslator.ResponseAggregator aggregator() { + return new AnthropicToCodexTranslator.ResponseAggregator(); + } + + @Test + void responseAggregator_textOnly_buildsCodexResponseWithMessageOutput() { + AnthropicToCodexTranslator.ResponseAggregator agg = aggregator(); + agg.processEvent("response.output_text.delta", "{\"delta\":\"Hel\"}"); + agg.processEvent("response.output_text.delta", "{\"delta\":\"lo\"}"); + agg.processEvent("response.completed", + "{\"response\":{\"id\":\"resp_1\",\"status\":\"completed\"," + + "\"usage\":{\"input_tokens\":5,\"output_tokens\":2}}}"); + + JsonNode codexResp = agg.buildCodexResponse(); + assertEquals("resp_1", codexResp.path("id").asText()); + assertEquals("completed", codexResp.path("status").asText()); + assertEquals(5, codexResp.path("usage").path("input_tokens").asInt()); + assertEquals(2, codexResp.path("usage").path("output_tokens").asInt()); + + JsonNode output = codexResp.path("output"); + assertEquals(1, output.size()); + assertEquals("message", output.get(0).path("type").asText()); + assertEquals("Hello", output.get(0).path("content").get(0).path("text").asText()); + } + + @Test + void responseAggregator_functionCall_buildsCodexResponseWithFunctionCallOutput() { + AnthropicToCodexTranslator.ResponseAggregator agg = aggregator(); + agg.processEvent("response.output_item.added", + "{\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"read_file\"}}"); + agg.processEvent("response.function_call_arguments.delta", + "{\"output_index\":0,\"delta\":\"{\\\"path\\\":\"}"); + agg.processEvent("response.function_call_arguments.delta", + "{\"output_index\":0,\"delta\":\"\\\"x\\\"}\"}"); + agg.processEvent("response.completed", + "{\"response\":{\"id\":\"resp_2\",\"status\":\"completed\"," + + "\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"); + + JsonNode output = agg.buildCodexResponse().path("output"); + assertEquals(1, output.size()); + assertEquals("function_call", output.get(0).path("type").asText()); + assertEquals("call_1", output.get(0).path("call_id").asText()); + assertEquals("read_file", output.get(0).path("name").asText()); + assertEquals("{\"path\":\"x\"}", output.get(0).path("arguments").asText()); + } + + /** + * End-to-end: aggregated output feeds directly into {@link + * AnthropicToCodexTranslator#translateResponse}, the same as a real + * non-streaming Codex response would. + */ + @Test + void responseAggregator_feedsIntoTranslateResponse() { + AnthropicToCodexTranslator.ResponseAggregator agg = aggregator(); + agg.processEvent("response.output_text.delta", "{\"delta\":\"Hi\"}"); + agg.processEvent("response.completed", + "{\"response\":{\"id\":\"resp_3\",\"status\":\"completed\"," + + "\"usage\":{\"input_tokens\":4,\"output_tokens\":3}}}"); + + ObjectNode anthropicResp = AnthropicToCodexTranslator.translateResponse(agg.buildCodexResponse(), "gpt-5-codex"); + assertEquals("text", anthropicResp.path("content").get(0).path("type").asText()); + assertEquals("Hi", anthropicResp.path("content").get(0).path("text").asText()); + assertEquals("end_turn", anthropicResp.path("stop_reason").asText()); + assertEquals(4, anthropicResp.path("usage").path("input_tokens").asInt()); + assertEquals(3, anthropicResp.path("usage").path("output_tokens").asInt()); + } +} diff --git a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfigTest.java b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfigTest.java new file mode 100644 index 00000000..c7a85e94 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfigTest.java @@ -0,0 +1,59 @@ +package io.github.nbplugins.claudecodegui.openaiproxy; + +import io.github.nbplugins.claudecodegui.settings.ClaudeProfile.ProxyMode; +import io.github.nbplugins.claudecodegui.settings.ProxyConfiguration; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link OpenAIProxyConfig}. + */ +class OpenAIProxyConfigTest { + + @Test + void threeArgConstructor_buildsChatCompletionsMode() { + OpenAIProxyConfig config = new OpenAIProxyConfig("https://api.example.com/v1", "sk-key", + new ProxyConfiguration(ProxyMode.SYSTEM_MANAGED, "", "", "")); + + assertEquals(OpenAIProxyConfig.Mode.CHAT_COMPLETIONS, config.getMode()); + assertEquals("https://api.example.com/v1", config.getBaseUrl()); + assertEquals("sk-key", config.getApiKey()); + assertNull(config.getProfileId()); + assertNull(config.getAccessToken()); + assertNull(config.getAccountId()); + } + + @Test + void sevenArgConstructor_buildsChatgptCodexMode() { + OpenAIProxyConfig config = new OpenAIProxyConfig(OpenAIProxyConfig.Mode.CHATGPT_CODEX, + "https://chatgpt.com/backend-api/codex", "access-token", + new ProxyConfiguration(ProxyMode.SYSTEM_MANAGED, "", "", ""), + "profile-1", "access-token", "acct-1"); + + assertEquals(OpenAIProxyConfig.Mode.CHATGPT_CODEX, config.getMode()); + assertEquals("profile-1", config.getProfileId()); + assertEquals("access-token", config.getAccessToken()); + assertEquals("acct-1", config.getAccountId()); + } + + @Test + void buildHttpClient_appliesConfiguredProxyForBothModes() { + ProxyConfiguration customProxy = new ProxyConfiguration( + ProxyMode.CUSTOM, "", "http://proxy.example.com:3128", ""); + + OpenAIProxyConfig chatCompletions = new OpenAIProxyConfig("https://api.example.com/v1", "sk-key", customProxy); + assertTrue(chatCompletions.buildHttpClient().proxy().isPresent()); + + OpenAIProxyConfig codex = new OpenAIProxyConfig(OpenAIProxyConfig.Mode.CHATGPT_CODEX, + "https://chatgpt.com/backend-api/codex", "", customProxy, "p", "tok", "acct"); + assertTrue(codex.buildHttpClient().proxy().isPresent()); + } + + @Test + void buildHttpClient_noProxyMode_usesNoProxySelector() { + ProxyConfiguration noProxy = new ProxyConfiguration(ProxyMode.NO_PROXY, "", "", ""); + OpenAIProxyConfig config = new OpenAIProxyConfig("https://api.example.com/v1", "sk-key", noProxy); + assertEquals(java.net.http.HttpClient.Builder.NO_PROXY, config.buildHttpClient().proxy().orElseThrow()); + } +} diff --git a/src/test/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfileTest.java b/src/test/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfileTest.java index a545999e..7de9c389 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfileTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfileTest.java @@ -125,6 +125,67 @@ void computeConnectionType_apiKeyTakesPrecedenceOverToken() { assertEquals(ConnectionType.CLAUDE_API, p.computeConnectionType()); } + @Test + void computeConnectionType_openaiSubscription_isOpenaiSubscription() { + ClaudeProfile p = ClaudeProfile.createNamed("P"); + p.setOpenaiSubscription(true); + assertEquals(ConnectionType.OPENAI_SUBSCRIPTION, p.computeConnectionType()); + } + + @Test + void computeConnectionType_openaiSubscriptionTakesPrecedenceOverOpenaiProxy() { + ClaudeProfile p = ClaudeProfile.createNamed("P"); + p.setOpenaiProxy(true); + p.setOpenaiSubscription(true); + assertEquals(ConnectionType.OPENAI_SUBSCRIPTION, p.computeConnectionType()); + } + + // ------------------------------------------------------------------------- + // ChatGPT OAuth state + // ------------------------------------------------------------------------- + + @Test + void isSignedIntoChatgpt_falseByDefault() { + ClaudeProfile p = ClaudeProfile.createNamed("P"); + assertFalse(p.isSignedIntoChatgpt()); + } + + @Test + void isSignedIntoChatgpt_trueWhenAccessAndRefreshTokenPresent() { + ClaudeProfile p = ClaudeProfile.createNamed("P"); + p.setChatgptAccessToken("access"); + p.setChatgptRefreshToken("refresh"); + assertTrue(p.isSignedIntoChatgpt()); + } + + @Test + void isSignedIntoChatgpt_falseWhenOnlyAccessTokenPresent() { + ClaudeProfile p = ClaudeProfile.createNamed("P"); + p.setChatgptAccessToken("access"); + assertFalse(p.isSignedIntoChatgpt()); + } + + @Test + void clearChatgptAuth_clearsAllTokenFieldsButNotFlag() { + ClaudeProfile p = ClaudeProfile.createNamed("P"); + p.setOpenaiSubscription(true); + p.setChatgptAccessToken("access"); + p.setChatgptRefreshToken("refresh"); + p.setChatgptAccountId("acct-1"); + p.setChatgptTokenExpiresAt("2026-01-01T00:00:00Z"); + p.setChatgptEmail("user@example.com"); + + p.clearChatgptAuth(); + + assertFalse(p.isSignedIntoChatgpt()); + assertEquals("", p.getChatgptAccessToken()); + assertEquals("", p.getChatgptRefreshToken()); + assertEquals("", p.getChatgptAccountId()); + assertEquals("", p.getChatgptTokenExpiresAt()); + assertEquals("", p.getChatgptEmail()); + assertTrue(p.isOpenaiSubscription(), "clearChatgptAuth must not change the connection-type flag"); + } + // ------------------------------------------------------------------------- // toEnvVars — auth // ------------------------------------------------------------------------- @@ -158,6 +219,16 @@ void toEnvVars_claudeApi_doesNotInjectApiKey() { assertFalse(env.containsKey("ANTHROPIC_BASE_URL")); } + @Test + void toEnvVars_openaiSubscription_noAuthVars() { + ClaudeProfile p = ClaudeProfile.createNamed("P"); + p.setOpenaiSubscription(true); + Map env = p.toEnvVars(); + assertFalse(env.containsKey("ANTHROPIC_AUTH_TOKEN")); + assertFalse(env.containsKey("ANTHROPIC_BASE_URL")); + assertFalse(env.containsKey("CLAUDE_CODE_OAUTH_TOKEN")); + } + @Test void toEnvVars_otherApi_injectsAuthTokenAndBaseUrl() { ClaudeProfile p = ClaudeProfile.createNamed("P"); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanelFlushTest.java b/src/test/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanelFlushTest.java index df6f7704..47693f48 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanelFlushTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanelFlushTest.java @@ -139,6 +139,77 @@ void flush_keepsModelAliasesForOtherApiConnection() throws Exception { "customModels must be kept when connection type supports them"); } + @Test + void flush_chatgptSubscription_setsFlagAndClearsOtherAuthFields() throws Exception { + ClaudeProfile work = ClaudeProfile.createNamed("Work"); + work.setApiKey("sk-old"); + work.setBaseUrl("https://old.example.com"); + + ClaudeProfilesPanel panel = new ClaudeProfilesPanel(); + setField(panel, "profiles", new ArrayList<>(List.of(ClaudeProfile.createDefault(), work))); + rebuildCombo(panel); + invoke(panel, "loadProfileIntoForm", ClaudeProfile.class, work); + + JRadioButton rbChatgptSubscription = getField(panel, "rbChatgptSubscription", JRadioButton.class); + rbChatgptSubscription.setSelected(true); + + invoke(panel, "flushFormToCurrentProfile"); + + assertTrue(work.isOpenaiSubscription()); + assertFalse(work.isOpenaiProxy()); + assertEquals("", work.getApiKey()); + assertEquals("", work.getBaseUrl()); + assertEquals("", work.getToken()); + } + + /** + * Regression: {@code flushFormToCurrentProfile()} used to force-set + * {@code customModels} to a hardcoded Codex model list on switching to + * ChatGPT Subscription. Model discovery is now live (via "Model + * Aliases…" → Fetch), so the flush must leave {@code customModels} + * untouched. + */ + @Test + void flush_chatgptSubscription_doesNotAutoPopulateCustomModels() throws Exception { + ClaudeProfile work = ClaudeProfile.createNamed("Work"); + + ClaudeProfilesPanel panel = new ClaudeProfilesPanel(); + setField(panel, "profiles", new ArrayList<>(List.of(ClaudeProfile.createDefault(), work))); + rebuildCombo(panel); + invoke(panel, "loadProfileIntoForm", ClaudeProfile.class, work); + + JRadioButton rbChatgptSubscription = getField(panel, "rbChatgptSubscription", JRadioButton.class); + rbChatgptSubscription.setSelected(true); + + invoke(panel, "flushFormToCurrentProfile"); + + assertTrue(work.getCustomModels().isEmpty(), + "customModels must not be auto-populated with a hardcoded list"); + } + + @Test + void flush_switchingAwayFromChatgptSubscription_clearsFlag() throws Exception { + ClaudeProfile work = ClaudeProfile.createNamed("Work"); + work.setOpenaiSubscription(true); + work.setChatgptAccessToken("access"); + work.setChatgptRefreshToken("refresh"); + + ClaudeProfilesPanel panel = new ClaudeProfilesPanel(); + setField(panel, "profiles", new ArrayList<>(List.of(ClaudeProfile.createDefault(), work))); + rebuildCombo(panel); + invoke(panel, "loadProfileIntoForm", ClaudeProfile.class, work); + + JRadioButton rbManaged = getField(panel, "rbManaged", JRadioButton.class); + rbManaged.setSelected(true); + + invoke(panel, "flushFormToCurrentProfile"); + + assertFalse(work.isOpenaiSubscription()); + // Switching connection type does not sign the user out — chatgpt* tokens + // are only cleared explicitly via the "Sign out" button. + assertTrue(work.isSignedIntoChatgpt()); + } + // ------------------------------------------------------------------------- // Reflection helpers // ------------------------------------------------------------------------- diff --git a/src/test/java/io/github/nbplugins/claudecodegui/settings/ModelAliasesDialogTest.java b/src/test/java/io/github/nbplugins/claudecodegui/settings/ModelAliasesDialogTest.java index be9be2a0..7cd6e3c2 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/settings/ModelAliasesDialogTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/settings/ModelAliasesDialogTest.java @@ -1,11 +1,19 @@ package io.github.nbplugins.claudecodegui.settings; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; /** * Unit tests for {@link ModelAliasesDialog} helpers. + * + *

{@link ModelAliasesDialog} extends {@code JDialog} (a {@code java.awt.Window}), + * which the JDK refuses to construct under {@code java.awt.headless=true} — so, + * consistent with the rest of this file, tests here exercise only its static + * helpers and the {@link ModelAliasesDialog.ModelFetcher} functional contract, + * never the dialog itself. The "Fetch" button's actual wiring is covered by + * manual verification (see the plan/CHANGELOG for this feature). */ class ModelAliasesDialogTest { @@ -75,4 +83,30 @@ void validateModelId_skipRowStillCatchesDuplicateInOtherRow() { // Renaming row 0 to "b" (which is at row 1) → conflict assertNotNull(ModelAliasesDialog.validateModelId("b", List.of("a", "b"), 0)); } + + // ------------------------------------------------------------------------- + // ModelFetcher — the pluggable fetch strategy used for connection types + // (ChatGPT Subscription) that don't fetch via a plain baseUrl+apiKey + // /v1/models endpoint. Only the functional contract is testable here + // without constructing the dialog itself (see class Javadoc). + // ------------------------------------------------------------------------- + + @Test + void modelFetcher_returnsSuppliedList() throws Exception { + AtomicInteger calls = new AtomicInteger(); + ModelAliasesDialog.ModelFetcher fetcher = () -> { + calls.incrementAndGet(); + return List.of("gpt-5-codex", "gpt-5.1-codex"); + }; + + assertEquals(List.of("gpt-5-codex", "gpt-5.1-codex"), fetcher.fetch()); + assertEquals(1, calls.get()); + } + + @Test + void modelFetcher_propagatesFailure() { + ModelAliasesDialog.ModelFetcher fetcher = () -> { throw new java.io.IOException("boom"); }; + Exception ex = assertThrows(java.io.IOException.class, fetcher::fetch); + assertEquals("boom", ex.getMessage()); + } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/common/BasicTextContextMenuTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/common/BasicTextContextMenuTest.java index 78f82a17..6c981a2b 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/common/BasicTextContextMenuTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/common/BasicTextContextMenuTest.java @@ -30,13 +30,36 @@ void createReadOnly_menuHasSelectAllAndCopy() { } @Test - void createReadOnly_copyDisabledWhenNoSelection() { + void createReadOnly_copyDisabledWhenTextIsBlank() { + JTextComponent tc = new JTextField(""); + JPopupMenu menu = BasicTextContextMenu.createReadOnly(tc); + + // Simulate menu becoming visible + javax.swing.event.PopupMenuEvent evt = new javax.swing.event.PopupMenuEvent(menu); + for (javax.swing.event.PopupMenuListener l : menu.getPopupMenuListeners()) { + l.popupMenuWillBecomeVisible(evt); + } + + JMenuItem copy = Arrays.stream(menu.getComponents()) + .filter(c -> c instanceof JMenuItem && "Copy".equals(((JMenuItem) c).getText())) + .map(c -> (JMenuItem) c) + .findFirst().orElseThrow(); + assertFalse(copy.isEnabled()); + } + + /** + * Regression: earlier this required a selection to enable Copy. It now copies + * the whole text when nothing is selected — handy for read-only status/error + * fields (e.g. {@code ModelAliasesDialog}'s fetch-error area) where the user + * right-clicks without first dragging a selection. + */ + @Test + void createReadOnly_copyEnabledWithoutSelection_whenTextPresent() { JTextComponent tc = new JTextField("hello"); tc.setSelectionStart(0); tc.setSelectionEnd(0); JPopupMenu menu = BasicTextContextMenu.createReadOnly(tc); - // Simulate menu becoming visible javax.swing.event.PopupMenuEvent evt = new javax.swing.event.PopupMenuEvent(menu); for (javax.swing.event.PopupMenuListener l : menu.getPopupMenuListeners()) { l.popupMenuWillBecomeVisible(evt); @@ -46,7 +69,7 @@ void createReadOnly_copyDisabledWhenNoSelection() { .filter(c -> c instanceof JMenuItem && "Copy".equals(((JMenuItem) c).getText())) .map(c -> (JMenuItem) c) .findFirst().orElseThrow(); - assertFalse(copy.isEnabled()); + assertTrue(copy.isEnabled()); } @Test diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_req_tool_result_image.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_req_tool_result_image.json new file mode 100644 index 00000000..fcde6f5a --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_req_tool_result_image.json @@ -0,0 +1,15 @@ +{ + "model": "gpt-5-codex", + "max_tokens": 512, + "messages": [{ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "call_3", + "content": [ + {"type": "text", "text": "screenshot:"}, + {"type": "image", "source": {"media_type": "image/png", "data": "AAAA"}} + ] + }] + }] +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_req_web_search_tool.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_req_web_search_tool.json new file mode 100644 index 00000000..a3e9f8e8 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_req_web_search_tool.json @@ -0,0 +1,9 @@ +{ + "model": "gpt-5-codex", + "max_tokens": 512, + "messages": [{"role": "user", "content": "search the web"}], + "tools": [{ + "type": "web_search_20250305", + "name": "web_search" + }] +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_resp_text.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_resp_text.json new file mode 100644 index 00000000..eeb05158 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_resp_text.json @@ -0,0 +1,13 @@ +{ + "id": "resp_abc123", + "object": "response", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello from Codex"}] + } + ], + "usage": {"input_tokens": 12, "output_tokens": 5} +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_resp_tool_call.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_resp_tool_call.json new file mode 100644 index 00000000..106298af --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_resp_tool_call.json @@ -0,0 +1,18 @@ +{ + "id": "resp_def456", + "object": "response", + "status": "completed", + "output": [ + { + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "thinking about it..."}] + }, + { + "type": "function_call", + "call_id": "call_9", + "name": "read_file", + "arguments": "{\"path\":\"/tmp/test.txt\"}" + } + ], + "usage": {"input_tokens": 20, "output_tokens": 8} +}