From 0a3f416e3840f5a9f90c6568739cc1cb95d17447 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Sat, 18 Jul 2026 21:35:41 +0300 Subject: [PATCH 1/3] Added support for signing in with a ChatGPT Plus/Pro/Team subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New profile connection type that routes Claude Code sessions through OpenAI's Codex backend using ChatGPT OAuth instead of an API key. The user copies a sign-in link (PKCE flow against auth.openai.com), signs in in their own browser, and pastes the resulting code back to complete sign-in — the plugin never opens or waits on a browser itself, since the browser may run under different network conditions than the plugin. Once signed in, "Model Aliases…" fetches the live model list from the Codex backend instead of a fixed list. The Codex Responses API request shape (stream:true always required, store:false always required, no max_output_tokens field, {"detail":...} error shape) was reverse engineered against openai/codex's own Rust source. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 + pom.xml | 2 +- .../claudecodegui/ClaudeCodeInstaller.java | 8 + .../chatgptauth/ChatGptOAuthClient.java | 529 +++++++++++++ .../chatgptauth/ChatGptTokenManager.java | 165 ++++ .../chatgptauth/OAuthException.java | 30 + .../claudecodegui/mcp/MCPSseServer.java | 17 + .../AnthropicToCodexTranslator.java | 734 ++++++++++++++++++ .../openaiproxy/OpenAIProxyConfig.java | 58 +- .../openaiproxy/OpenAIProxyServlet.java | 205 +++++ .../claudecodegui/process/ClaudeProcess.java | 32 +- .../claudecodegui/settings/ClaudeProfile.java | 132 +++- .../settings/ClaudeProfileStore.java | 24 + .../settings/ClaudeProfilesPanel.java | 217 +++++- .../settings/ModelAliasesDialog.java | 197 +++-- .../ui/common/BasicTextContextMenu.java | 14 +- .../netbeans/ClaudeCodeStatusService.java | 13 + .../chatgptauth/ChatGptOAuthClientTest.java | 269 +++++++ .../chatgptauth/ChatGptTokenManagerTest.java | 212 +++++ .../AnthropicToCodexTranslatorTest.java | 366 +++++++++ .../openaiproxy/OpenAIProxyConfigTest.java | 59 ++ .../settings/ClaudeProfileTest.java | 71 ++ .../ClaudeProfilesPanelFlushTest.java | 71 ++ .../settings/ModelAliasesDialogTest.java | 34 + .../ui/common/BasicTextContextMenuTest.java | 29 +- .../codex_req_tool_result_image.json | 15 + .../codex_req_web_search_tool.json | 9 + .../openaiproxy/codex_resp_text.json | 13 + .../openaiproxy/codex_resp_tool_call.json | 18 + 29 files changed, 3457 insertions(+), 88 deletions(-) create mode 100644 src/main/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptOAuthClient.java create mode 100644 src/main/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptTokenManager.java create mode 100644 src/main/java/io/github/nbplugins/claudecodegui/chatgptauth/OAuthException.java create mode 100644 src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslator.java create mode 100644 src/test/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptOAuthClientTest.java create mode 100644 src/test/java/io/github/nbplugins/claudecodegui/chatgptauth/ChatGptTokenManagerTest.java create mode 100644 src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslatorTest.java create mode 100644 src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfigTest.java create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_req_tool_result_image.json create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_req_web_search_tool.json create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_resp_text.json create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/codex_resp_tool_call.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c985316..e93b8009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +- 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/pom.xml b/pom.xml index d7baeb89..aa993eae 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.17-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..46607820 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanel.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanel.java @@ -69,6 +69,7 @@ public final class ClaudeProfilesPanel extends JPanel { 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} +} From 772ac1d551b8a7737314c8ce5d4e4ab6814584bf Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Sat, 18 Jul 2026 22:01:00 +0300 Subject: [PATCH 2/3] Renamed the "Subscription" connection type to "Claude Subscription" Disambiguates it from the new "ChatGPT Subscription" connection type. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 1 + pom.xml | 2 +- .../nbplugins/claudecodegui/settings/ClaudeProfilesPanel.java | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e93b8009..8c96746b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- 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) diff --git a/pom.xml b/pom.xml index aa993eae..71b5ba21 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.17-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/settings/ClaudeProfilesPanel.java b/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanel.java index 46607820..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,7 +65,7 @@ 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"; From 1bfa7b220e544a87fec15f89952eaf9d8680e82c Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Sat, 18 Jul 2026 22:01:35 +0300 Subject: [PATCH 3/3] Documented the ChatGPT subscription connection type Added a README feature bullet, a new user-manual section covering the copy-link/paste-code sign-in flow and live model discovery, updated the connection-type table and Model Aliases description to cover it, and refreshed the Profiles tab screenshot to show the current UI. Co-Authored-By: Claude Sonnet 5 --- README.md | 1 + docs/screenshots/profiles-tab.png | Bin 77124 -> 90845 bytes docs/user-manual.md | 36 ++++++++++++++++++++++++++---- 3 files changed, 33 insertions(+), 4 deletions(-) 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 9bdabdf1707f0528e9360536402e27dba32b5161..d676bc269a9cead309bd3ed2c04851918a7ff35b 100644 GIT binary patch literal 90845 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�r0s`OAzMnB-%YSb>9 z*a~vka%t04eOLYo5VKXE7jeC*`Cf_auP)!mVDY(3Gjj6gTu&4%(2rclO7_F|W?>k+ zl%*+xlbzM$=(|{#iMK}c#MbDs%yy_y0M$Xtx9D`$Z>PJFRGjoE{ks`Fqr;y>5xXZT zf%H6U^b9AvUSOXCO?XR-&U?}HwX3sqKMpJBxRZf7sOp@9`Xm>&xs#+T@oC5SRH{)d zA@ux}KO*F#vM$cJ0a;#5NkA>u8^)_%=;Xj_#};fXOd0<=Tyf0dr(!*uXzQF)aL z0$NP_2(o>?EbuQ>d^!l+Y2^Hbl}=Xide>a)=!VRJUKQ6b`-AQPs*Le-e`F2f4n}UGFgu+>>GfgG(Z(ex6R*WqSW6<#KuO>a2&-e zSgAF<0$?Q^^Y)sidelyvAYWmS#zgOMEEMnaKzoA~pMCoZo|FiN!-ZiV=W;+pe z-`wa;m0^4jHWqnk#)=bXX^(rS@z1rS;3hWV>xO$nG4cq%Yoo!}Ah+VP-t?+&w^J)0 z3zDEJTFq$8a&+FTr3REYE!@rq)oQd7E3eK* zG-SrsSI-M${p|cl=kB{}pPkgZ@730MH3jH5lVOsb?LRHe;H?vf*{#PTr=|`4EpafX z0{SyF8hZLQLcgk)V)gjv5fXe0;~dV_7v?$Lcb$*{MYI(meqdh7e$*RDq*rID`BxPQBpof-Je}M z$$a%JF1=^-B-72NOz0H60jbR?J<<(`>~qxYT!6l+RwgjA@O)HbR9%ODO5za~GX@{H zxi!fusZ~@CPL7Ym0uQz>o`rK+AX4@j({%!d+D}msXf^{2|JtH&Adg$Y>l+Tn@F}r` zqUdDRz_Et2X`hOx;89sD_uS{~`%_&5vZPJgzBp5s4h_F_P5QBq_p)C4b)}65ZgYxj zrz>hjh|STzX+Jc^)fFsOK;mX95+Vx~|_(F<5)sWnBwpMrrdnD?}6& zXjeJLRPtI|_4nNyz;##FgRL7U7wIAAHZ#7}v9e9#lL=DI`6lk$=ef)iLaC0TKf97=`MT)o41E(YauqWC znKYfEe5yXu9&XsIFVy=dH+raR&JJAl%oN)w+_MZMZ3YQ~*TzwYN@x4)?9sYhB*l7{mMH6A`Dx)U-79$LPgb@JLoI(ZRKW zY?{=2&PnVDP}#3~u!t(+w+rrIn}O+#cdo?}n^}>^K9F{drkR&Buv0WC#8a&p(MIT( zD08xNq7X-^D`B+VX_kL@D?(8?jA@bN@F`x_vKj~bu6MP|mSEn>M%+r3UF^< zUBE)AsD*`s!72MRuzAt|{(Gi~2UH^7?PdICly#}6yO|>%x?4viOd%&^riywdPkr~8 zfpBA9LB$06b$f|lm;}H~!d%0YGTQ9t4Vft>^y$ue`Ck_JdZ5A)N3~P$8>WTK(4N-U ze?X>hJu~KbpZ>JIcVRL`yBpuaqBSEMLxmZAX7Fi5@O_Hjg+ zixlyJ^OYc`ti$Iu$F7Y;XTh$_YVkvJ?b_<}SpMDwwm7H- z$NAE6tZh6$=|;W(?W&-;^Q(JADUTzw`p>S}Mh{vdyLm=6Sc4rNZcUz+p4}p=3f$!k zt+2E@dhAC@O?=}*O}xXttPaW0b1>!VZuF}pf!9qMO)HLa?t0^nfL8K5dxMhrUK`>pY1lw9wu^)-=vZKibikl~`e+DkHs5*so)n@s zbJvgR!p6qt=HX$qIh3|D+*4&fTxjt0r%*eh0mO({7=%#>@{^pEr8uAEzSQ+b`=$sX z#&NGj4P>4Ar2HAbr}3Z*yDSZHjD2=vR7b`99j{;RcT7{2NXSXJ#`c_ZotXeA6KIZMIj%Og(ur9+=?abC-1R%hcGwuLDtte92v#N`60wXO3BQg~F9x z&TkzJnKUOl|ERqf)GJ#D)v|Sr3copqYms|NSXs#o%J|g#n|4g=bP`#+ob-&m9@Ea% zB3jxZob2~tAFDKO{#|^~HYN4~k?MF<Xd+Pu2Zz4l zVa=?ubWgrx@GBJES5q^GMONR-aPHl^o(_2KCjxUGa~2({J9MP=FJv=iDg<$p9CL$c zQp39MVj*Dc$WNV+eUCaZay@}XR%Q)`x2zI|;1JpVarFa~5ZT5!h@g!X8!=-)ChxwN zxSTfuQK*nq?1gSl8K?&;D1rb;o;dvK{06q;T{;1_pnk*!;ChdZ_~NAt6AR49SGm?D zo*i#)>bpw@D8XX!z$gw9fJkM)s_N?getys0+}z%9aA0q5Z`V7l1Z=h-%40UkpJIucIisZ6jh(4YJ zI@CvF+5BImT%BxIIOsg*a0G-OAuoov1S+f?55C63N&#~Bt7Bu3e=vJ!7wg#-DvQKi zJ2mgwz3ZVeUKirMv~p=7enBk(b4Z5i5lgsbCa+#A^PU9Dq*`9R!l9rXA3%>xm zTp0+}-8^h4@iWc37>?c%dMfoy(v^wzMqhh!C8v!JjVZF-97t1H(i+0$ewoN`A!3gr z+quRYW6IO~6HQ1;*6>dJ`6FF*Y6Iqvlg=73s|Lzr!bSh^PmM&%^#kg zSM6=9{oNEI1i&C|4$p=Xy3jqVvmLJ*2zv_ zA3-1oid$r|i6OnKF&t`Y8cZ`8>FYpal;p#ln=R3u8MLEBR7q3i|>h{>0?Tj-nky$y_*gsv}|+KUjB+6B+;r% zVwJtzZ-->Z0{2w4tA@0t0a&rQqblf2BfDGlejq$C5+Yc74iwrxI4FO|A!Vk}pAoI% zim3F2>ekyonJP1o($K(H$PltQ(xv(v+;OeYVm9fYrR}qRp2gv9U&E1XDepyTQ^m?w z4ZQiuQ#9oyjw%}RTgd%lM^G?#FSvG&MU}j4pHzNZ>fUd*u4;9q=>MS>X5wS!mE9S~nX|Aq}5Ae&_8=n!l0fKTsbBUCnN0UGq03+F=BA zm|bNu2lATPD-E91Swr*U@+V#b)(>r9P@xjg2V=6g?n6LLrM@%(5t_Hn zQu8M%Q1#xj$iE$w@BDgZtb}zDFOj30zT3AL?>Jk|%EBLd8`Wami{6qV1dj#X6H(r? z+Nbl%>eYD+b_$2vcMu56aD+bQGGprBJjp8TdX2h}ny6T4pNkX;tB)RU3KMI?x~)P( zqwvYTVsg{(X>WbUu5qM14=nBs9_Yp9+}_cDey|ii1JIzN;nNT9H#TQW4u;{8)Gl`= zom^vz5vv~6GYPx~CR91(6%Y*$vV69+@JcguV18;y(nOk0zV~H@R>c4sftI ztx!2YNIRY9MCTAl&06|4fdvDs5z(d1M`wtgiE$(Z%F2dJ^s#xIA1*~+EP@sH&6ARn zER=iSBL-pcf)R2|#@b(alYEOHFq}yzWwslR!0Fa%6UGpBcVfgZ4ecNoreE*%4vy)J zEeD;=O}8%N;gTi>=^U*&v>)PiUGs10os%Fv>!q}~;YkfOFr#EK<#ju%>13=k4cJol z*+bFE1+i~!1y}Ed3ZV5_!)kO~i4uGddEKyg??5X%`?9)B`6?IT6WS?_$7X=ncT2dMlxp z90J2>&)Av>J8h}9wsSdo_3cAx$Lou89|H55{K~*^ZhPi_ie1gd&IMzCPGt<|JUvxu-=!}LLOOkwNk~c&H3(+MXwFqoarb2X+xi=Ss!vPEVHx}1inv4Ui*jC% zrOrozo5tU=ti>uCP2{p%D~S>Ny2K-$q3Jig8Km;)>-f}2IoM0JA9J{*D zvsZ+usl|e28WP%~Ob%s}!tW274vofg|lVxHd%3S zTd+Ijj25m7Sv!5m?|Ww9+r4m^&!4TuvrZy0xi**H`Rr8@-rQuYEO6_0bPW4^dy#R6 zB5Hoe^ljnclP=Zb()v83->{^VxO;2qdFA_f$duxX%=Z_Bj>G;dpTm$`ZS{hJE_uW= z>We{~`Q_@`G3hJy)`Fv@^BvF>Jf67ZVA^!=ddF;oT4+CVTLH*B$PU2p1jUzj{RuN8-Z=>r8ozvkW`j8yAzq(CxI_pjuB^x0S->)BV zNvHoB_HuV?ehDi7<#(FKRDPxwe*Z~Y%wh(xj%3A)NkvcJD=O!TKQpXi2JANnACKrP zNkknA@F@65>D^5Z{s2i1Xe6BnA~Obn5eL^|S^;}jPtAM&!qFv897wOxROfyXh0)-s z-DxHgna#x^wM7*{%v60g9UW=7gR+Jy7S2~Rp}7?@Z%i4@{uPq==NN}JvPuVvyif(S zNWmyXN_QoJXBq6~Wt=Uv9wo7EcsvsO3OTZ4k7;GEfY+9g7(on2m5Q)u@B3QP zuCetifB))gB*&2kwoW&?>|9oBjZaJt|?O&hd1hrUD(S34d@C@~6k8I#3!1vf>}+VN3GQxz6jMsYfY z!QIQ4jR!({ja&X&`8$H=^qsQzQZ%j#n$ExBssikMr!pzfz-^%7aqm$jq&eZFo zvH4Jtp0#x)P5aAgn`ONE;oqq*9i|^jjZ~WrZBRyrm9L|UlMjwJk-S&Zo<)kjE%Ne> z_A0{8t2$n(c7DdH&m`_GgGLG|?L7O&7~gVpPK470VskBZ!{9f+M?P5ZC5k0G{E}1C zCcZV_rZlz3gH*Mt;!}39dChdnk7x}Y_E}3O*#OO{NNj>h=Z)pnWY>j8Oc%?OEMy*w z?wtf!2?hMZ5^-yF_H>?E_tSmL36TBjMe*i33KdzTsU@8f_nT!rdsMjZZcRF&EKL7R zmAkl5f4UXb8~CSJS0V`gM=n+aL!e||rog$`z~MdeT+8MUhuOKE#u+E#d+#o}u=_!p zVvrB+6|?EF+|CyA5}y<@K&|oFg{Zxtpa$G+K${EZ*wgfYQy_)0Er!82BWj;hga?FI*)JN?W#vYwbU5_-Eg7Gfml?sg2r9PH2+EgBdZ|?Z zK1uNRJhZk9>I5YpqYn)}`0g-yj`Bx#!K;;JwI^+@55coH*T1|11fiyI`tx{5lk2`p zjPkvwfc^595Ypl*mtBqKqaG4MF3Y%>h_&8G%?l=tVkH+kxy0`WP|u^)?!xqWgI3Q5 zSZ!rwxkyO=bPFY432N!e)H`smsT88{?Gw_Dqbf8ulD85=C0XrzM zT)jRCq&y)EaX`b8;*3@v<;yqMnPp_r3=H3o6_6j$tA3fsc@pK`@+tx_wRY-%?lTrQ zVYq0dxQ?%`v;L#Dtio%Sf@I8|g*3s)?2X&TL)Q=sY=SLo{ZErbem4U>x zOXGEM(ziAeb~393`5zlKs{g4^87Kex0yOKWWcQ&f9F;26#kk}b_dZ#w*h%45R$lbU zdwcP6rc(tW8RqI#iJ@pBVY(GXl=`qBnNqAq=UI5Na%OsM!NE=hRP061*UXz%cLu4l ztK}2~F$h44UelDY^#V>Tl7v?m@#+8T!kG`;Zv-QbAbwW{nb!O71*Bh#%&ojr^8>F` z_cC|+oG`d5dJVsQBTtRAzn9MO`lw2hdg4kot~kk37oF8hLf*a30BU+w9BnpQ={Bo_ zpH-PRT4^{rJdgtoGsM^!N(&VWL@!`rU-z>M9thB_wI8>i*h?=VCc<=7-M2 z3f8A%3AQ}}X%3%Tz!-z!>O6Q&gbYZa{F!*}P^NO~qxTU8Ouf($q&`c<@uN_l7`zB5 z76m0-dE}mhYi(47 zSM*oh_*_Jg6rUx2{U~aB`IaRZ9coHHj}QU|%Ur$e?(SY$ZujMJTo2-Nlj_PbJkKd8 zkGrON4@Yw19sIz@r#(mu(?Zh|qol2ue0q3bjz>K+nU2F#c_`YpA8sM^0i&BHl8Q}p@hBOJw@IG*^1`Q z@z63;(Bj$zx-tY`EiWqwNTc@L`PtM{Y)0O&3)K_UJOPPmW^=hQXg|{(P{2qqVZFz0 z$_v|@g0IAvW&j6jnT3BW^hQNuS|7r%}5f zC5K_n0>zEj8=K=k|A&5Nus68&QPGDI%y*h41Nb=OZyYc^%!&uDb{p49zj!}JmQYa$#vozaS>f5d5D(e-_NTe9b59C|wy9?iDRC#fGG&mW-&{5LsCL(d`g^|D#FK zh=MPD{rYvf1zdN%qIs?yOBBlimQr}Y7hwoI?BfKMeok~?0Q5ljKc3w0K73)Z%KNmq z&OPpOlKk^GP*6q>-da_emXfE91<7XigANQ4zTVTRdMdFZf*+$Y{IX4N8g!709g9rl zpZKm9&;5m|tgM2AZdkXz$ZymLe;BdwfI-8Eb#T3%5+T*a{uBa!q;h)#4M}x22;knd z<~r-prQkzx!EdHrOEwBMjcTY4(~WG(Ec!)U-6g91p^o0R=jN^Uol6^87d1j6xF8{H zpI6fF=_4iPzWw<|7RGMe^H!ONgrtb7>g~nBLJ`DC_~G_MR=OWTbPRjb0CI6r=VX9a z>6%i=VZ}QNn-8Vh&h;C7=7RBy6sYtnEEX)Hm_78G4b_lXjKWr|ZCyA2v<0-+!XEj3oVWH%mMYCGCQ` zkRw?BOiQ=cJlF22SuwId?3!#k+WzSk9!P2YS3ru3jdW8Q#zQ9z%gck>7Lvk-(Wd8) zhrIn{8a#=_75+6O)S;Ha(VYLi@sjaB@%2@^?h4GWeJ;SczQ zsszcj9@|blVCzVr9zv1RQu-fan>Nlht=SFa+2bIYOHIg!!VP#{u1!VUF!vlpItQ{+ zgRw&%iS8+sb|kZU921L(oZL1pcGB@=Ltb4G!bohU+fQ!E0W*L1d(mpCeS-^!9W=VT zV+nA*l&hd(W--@7$1`N`V{z1=_uECl#_{kIX@9BGsN+IJ%XGO-0(Ok%@#_jfqcY$( z>I;S>iztXWXPMFDt?6Cm-qK>9^|D>+#@eswHCZ~1sc}l0_t@k`0_^7L)Q-ot$#9PY zH1)?m=gdj!z$_h^P{;!Kyu+q|e+X)@S6PDSecrjGCmyE)g3_-tvBMh{OC!ZiV}i>C zFvE~;>ejq(HRhK#1HmIH_H0Hh2M5O=ch9>&&#aozv6Qi-e7Q`@R^H@NMVtkD28ewJ z7QP~KGFiPZP5yDrC}->`dIiE$P$&b&>rzB9kEoNpZ!s=2GgFqPf53^C+5GPM3?ZEN zDWuBtg|+M{GF%|EaGIQS@RNh>O?UQ4ddDti#G{*6(yb)WoVdt#dn>NWkQIp!I9K!zZ}a?}XQ1dBo~F zlx3l+gGH5Fd@!_81~8nPbeu_Q0~Y5?R5~}eo>3MiQ)KNakf7^_h;1Fk z=jR`KTzROAv3ICWEvvN4+$pz-SXdY=-tR9N6|Sl5$p}1cTYfeEzB(Taq_a)#2=RE% zOcZ=(aIa}xs93-3xy9oqe`%a5+(1wV=$}wus7ru3ja~C=dVj}QDnnFJ^tWvlVHjzm zEa>Cg@`bv>qKz=zJy+Lul86!fL&HaSQGtp7z>6xMP%`q#qmb`#I}SocZCaa?dR!7J zE9ma-zQf4LiV!7_bM%x(LCXVGBZ_iJ6}wC3^d8DDX~I0u4p*uy1vcb7)%BVHQ5Q5R zesDYQbIofd-{f>zSm#vXyBS2sQLoYVLrP^64>QnzZ`b|$w-`1;{j&cMp031Ekz1>P z+`E4MV8-os_37cOSYI3PdkpC0!?EqWsJvcFA(M?saJ|LcR-%_y<$zvIiz8Patd_&p zU{p&vM3A%R`{%)_w`Zv#TLsxl6gAt?`EfKaSK9A?pc`6Lz8A1YJ2=pan6yFpRe42{ zU0yu%4yUP&Jh%%;@y6(w-*O3-W6G(VYT8BwdATFj!km*6Bs=ECe73143){8mIgYuY zka>hxv3?6NIXSuI&stJ(x;blRgI3aq>#~PJGtt)H9Vk0hy6`T$E2q<%DSg!ojxoI) z=$<-*iwP_o(Y!q;;|K_&HGVt?Z0Hg}BsU9bS5@mwS3}VXE+_JeFn#i!96;W6Bv6Q@?9jnKd>fVr)tI4xQGcQNYt` zUS{q1u;|KPYnQ;9%bOpRA_FULK`Q5ct4851O$AmV=Y}(IHznyd>qR2E z3W$OQJ(6x$g3z{`X(ffEcT47&muKi0^6?h{MqbU$+ari(uwMMtRew|?Nx`4vd zQbRhg{k~UDyH@B6c0x@F#*uslSp%eF zXPa~8%{gjIr=pFnDnx!pJ&Qyc?w7=xcsxk;{=-stP6LXv@|+15x$y%hapkRY zJO>J%OzcF>#<`iLd7B< z{R~58I8{vA4fiR?k7#X|;?*I`SDt7{f#dHjhT^$ugmA*(Xb9$mREC#fJgLc6WMz(g zn*525d3ewLMMP{y1ttW2UwB;FYa8@-WX#uXgGDFMw|D3*7EvYr6V@WH3R=2us#sF| z6;an#W$pgpRfEB#NCe9K134Pqn)S4bRg`o?I?i$9;|hxA32@_`8CbxxwQMt-yT&es zb=$`WszVu!4m0IeTn7?h!Ty8rdV&gcP^ZS)fdYt@S?4Y-+-9fQjKPq>M*b$I)imt> zJJ)Wpe{~#uB^M#?vc&I4^hp~XKZFP&nl)t2?oB8M&>pQl^O?rsIfQhB@ces21ChSI zzU+toFlSy~-SeE=#f#Rw+UZv)D5{)q;fy{I*>(N(>=k`Pm4g{&tFQzS7{w4JRMWl1 zUyc_-Pb2URWt=0FweJn@7zA#PX~cUeCX1M4kB3$_GH3c0?-@s1yuZH45DK-5&RF?2 zT;{-H#Qd6b?UYVXl4gA$ik`0!*1k}QeOD1Zxq+{y-)2e4a#&a6V{<`~b#y>&(ZK4? z2Zr2cC~mWM2Nz%$H97)XeS0dRE8k<@2LX`H^YX1JQqKtu@bD^Iqx` z(BF2jlk21=0AI<<$b%Q2P7M$ABii&8Ti`dNWR0GlF@IBy_#f3h_&Nvfi-b{b3d5(R zHchHWbhZQ;CFEpLVeRtNu)e#yyTNNsYDnxS>H zYxJ3H%g$p(cBc-zi3#mmTS9Ynt^Nr$t)Y}_eq#~@o|JWT{qiMP^MkiNg5A9LY?-xo zBezRoN=h2p;_G`7Oql60rUJ_I@5%31UM;o^Ti3$-<)26kjhx{>5qqL~q@}A85Ylf+ zO9JR;RMOVB2%Fc`nEIYmJ^$EDnrrP!u8uQdzI_VL4Hvzi<+VKQZ=xiRG=Y*fQv*8c zc!+m58Wg+I3$oQnS~8v_Ky);dWB_C16BFBdiHJ`#YS#AISp1KV9cy%T&I@rHK3`U; zqr;+;VrnJPSE7OOm45TpB|-i9`EM^TFZ0<=n2Angc%py>W;%_2dYxS?1abAGRd2A{ zuI6!&>8s9mb`HPHuD(}Gcab~`X6Oqh3QUeQ$iD4jP8Gz8NMic-2QMab=koc$I$VFN zgk^;dUI9;ggET5u0c$HUFB#+=)fjtWKE!dMw&sD$rA$mWq#75C0qF8c0ADh)s}`4_ zdcMhbCzpdpf8pzLHGJCDtl~8k{@ZvSNw}*ZE?f!kJX*LT(i*M?K#+lf z;cHnL8{#`M8y;TX0$&&^CpG-^a8Vo~>G@w9Qf0}L;+}zHj)Opz&rFBWq2b+Yw(z-E zZ9%y6V~`No5@j7qNK{4xfu_ox!@ehv>%r?zlCh`111Ekawps@Q@6xHTvD;f2S-h>3 zD?BLsL6_Mj4agTM2j3Z}(Mp0V>f5+QB@!Utf?)h57lS!aO4wR&hr<-pv=0{{|LFXn zs0HusquB_ zN~Z0B!dF!5b>o*R&}}ODU{0JD!?U!7M+t{IE1;W6S4d-6MK700Bx#L9VF9+i#=&$l zb6#0T3Q8ILCFhuK#V+}$mp)C9S^xO4XwZLB>sW~B$B$q521ID!3r(PKI1~!qK{D3U zk~MM79T>%}EYlCP`&h0lyEE!|R2hFq#bRoFR|6302yVee=*D2dg!9;gd_zJ4F4YOlNEulJJx z6Z~OGPAh;^A+X8d2E%ELEJ{<$HV(oe=5#=XK|>~3fDVs_4vS&=x`oYU_^DfQe;RBI z*IFRIvJRRDtS2Gz6jh6vxmvH_Z4rS+d$psa&~Fo}_M;Ze*k%!u<8h$>mS{&x#XMw} zI3#^C0RWlcPASVIH;L;4TPQS@kd>1;MWF2+D;jq^^|su z$jc*n+Z;`MwJE1yS)LBY%Y_V7gX?gB7zR{P_hgKm2GVE^ZvI;A-9->CAoZU7O8xNN zDf?{Q>_XarBk=f$&c41Lo#Y2`$L;otGQQvL=2_(C%8`>pr4j8;eU5)4AR0V685gKk zZzDi0#T4mz%F@JQiH$NoZhMR&{&+G2G7(;{G!}lCT7ZrS7ZACJV5_I8q@XgCa+xYl zA>fx$g)v7s0E+A7t5VRos-p!!70GouI+u3Y<#j1@_=X}>KEfy-tF3(I@u@JX2>TdAb+{zZ?TXfKQG%$iMaU55M5$fK)B*VT z&F30sm#Gux?|LlvnY@zlrjC`*h?5P%=mnpU#Q`>-HyUm5Up^*l#JHJR-M>iV*q7Jm zbrhx(?W32}HgU*FW}q4ISh0GYr-4U}8wZvMc!QN0s%HyYU^txRLZ6K0NsIaMN&d&3 zGA=7wN=-vE9fE3u(OGZ1HJH7T!5=W`g$Sv7xYVUetJO)0D<{i9e@zsgI{c}97070{ z7y3T7vNi`Cl9E~>A8FWw!+h`$}t;@Q9bR;)Cno?IU4sbxk?f@lbT;(~69EX+eBz&p?C;=SJt@0!v_w(2nq_Y2*l zd8UbuD|rr=Gf8u-*2ZR5hHQI}tAYh)MASKl4}$OdzUu?PIyR8+(fRI-?oyL82B!m+ zrsusq7Z_c1I;h`z7U%q?H-kNii|a}_HPfGerj)n}6D^** zAF$<&)vwhe(6pV!X>#8f6hgRd9yEDd@>Nceum6~`zCPlOhd4}mdqc+pW^WZY#d~c6 z1BNm|wQ}m3Z$?(w_P9M>ULNq?WO&*G(5*u5UzkR<)yZ1 zYz@JvEf)Dbt0f#=@EB5n?~d8BwK+xe6lq&y%U6DyZH z%y+i*`t>1XZT>3)|!8ho_ukV zw!_y5x7a62eKwe(Tr=MDNoMCJ!7qM~q9L8>%e&=*n?1~E&ntJ72se498$4m~)Df^m zG2=nK*LmzTH1kT2YnsKV;cjol=mem?yF%oXS-}KW<()YMx zrV6Yp1%>pgBPA7OZ*+HJlaZB72b2e`Nl?l(JG{kvZBZE+yRl#309Y>;9<(nu^cvYXSXkyy;) z|7-K@bKcUX53 zJsMC<*DNW>3qnNMjn#>A^uQD~VoGl0lU4g8G{u72eG91NDYi$U4D89bm}S5e`v3uJ*hgnuzW<@jdjzz?0t5;=Z467pJU)s~U z`-8MRLeFM+yE1tnOW*(Sg@%fHrwyvYyt-c?bVV@+);pL#89&<;OkB;RX~W(4DX1DK z#9gVSKZgN^^glk)u}CXYP7(#P^=3XCDI-nqx4w;Nb{3Tu)g#%M_r<2*39#s><=$P` zEe@^^Mn<iDsUS zPQ`~cYGP_asgkbX-VrD}2jU9eN+EkseQ<(P_YjKHkG$T5Vaah&;k3ECSE{%EHVzZ? z;27RG6$GT^=v0|>U(#+BPoHvw&ikao@OL8Z^6Us91VHD-@ z80HfNeRrqKdGZ_Ueff~L&PlrL2fEMHO9iT5K{x}WAX_Oi@#w+w;NnMPb8~Z}{M*A@uCxHa@ZRvC zqv689UNKM_ML-5cASTy$ne^I&iJCk(lPowhzmlN-r4UZQ)Bj|JOvHCQeV19rBH%AR zeHXqDo^6fo`G1)l^3_X`7JLb@=i|r<&4ex5ka{wnjti!cc-tyLfrG;~mGn^;S893>r(m~^5=N!>l=d6ljbPuFR!G*(^W`U#`E?27dZLEbAs5q87` z;bS(?^GGb2%h0(0dw?&T+v3>U4GNR=sp=l#fF1O8On3%H>LGjk5w1I|6iO}iPMh+U zcZF@{bu|bRnil?_q%@k{@ILo^HjN}2$x08sw>|C)$;ek3g1{<)D&)oqj%%6sQ9B7m zv(dvx&w)F(WIUIB$c%<}YPDeIx}K5&3~H(R+r0xOab_%xu&%$&A@XxiSHfxM3q9uw zq{YUFi0CIfTn~jQ?Oskf;xE+3ZkWIo#ECH%%!|h7`0UvJrR4K%Ht}>n(-dy;V+1}3 zf%nxw{9@O}dDdvwHN?@l$%IkLrvztlg9KU z7Ku+e>pe+nYH%C!20z`B|95x$UBmpmlJMcxBI}RW95#pQ{CBI&%GJF5sS2ON_kfwv zR1Y&3I-OZlh#YoMl|ziN1by&zpwgW?JIt|zra zLMkatmrN-PPl$c|vW7ac8Skce7uiLnkC$4a4qFSzt>T8I)uW99aK76uV&NZ5@gIQ9eJe-KG#NjRt5xa~q zo?G7yVYA&OsVU_QmdvWjSW4e_x+}_Z@^nFY2qUDP+BHpBCV7(|PzmE|nPK;+SaXB- z{4fa+K0Sn5DvZ=4x;A3ylQ%|`7QIp8(b%o;I<%nu(;Ct6K4&=DZu0E-ivhT}{f|%? zM;lcT0oAF{(9TG54*GNT2f%pe5APw=6axZsxec^l(v)EN(hTAIr{!Tcz2YBr2jSgC z#Vs#kGn*n@LoNntczf-eW)(JK-(fd&Z{+2tKGlmz}KTAT|1L9!5#T0C858< z;tDD^_a{rDFg*M^d2$$(6U1tDP+Kd#n}JZSFm)H$72mxfefu*-QAlClLhOO3CPfKt zYv7`KK$hgV6*pvV1Ts_ev09kEF9Q(Kbu9OC-yr^=_B0+5q;y^*V@!qq7O>w_h zAu7kXK>dQ;*H6BUaXBHpg4?G;Vj!zy&ne~{C+7Dq%F&Y!GLP;k>Ig`WJ>*n2oPV1o zl(__fuWg5qA0R$ZmDbQ!(qTpf^6b-bFhc>c^M*KIaJv@@XW*&ut{LY+Vj$}hYtTBI zp`0X`_gK&2F`IL|pIWjH787-EpRX{DJa5^(sxXa^^>BNKH*z1SYn$pTRGaqQGpEWP zJcpB3oRhjt33OeeVF}k?-}H{qm5JY8YLU5z5>bL@IUippSCQAA-9_*R{ghITjIL>evi|odg8jf4m30Z1pT>|+VYl&5X-)@ zgK4xck4~7i@m6qd0!?>szmGEH)WvmyI1c5Jys^B_QDV|fpPnX>dCG-XGUbwsBEWYw zntr#>>a~&^r0scG4%S7OVI$IPf(>lFQpCq%d*V7*ZvTjBt@a^+8XN1SfRk%W0K6=| z%;DqkXbqk}ZV>BtS0KZ*$1F4|c0@ZpcB24z>Ss=MC9R|@4(hz5Q0gTfBP2Y9eYdyW z`8iJ1KN}yj=J=#6kFGQ!=|BsZkS;juSA?JVvxiCre72~mlbl_aDl+`?;yzg#m8J0Ur+8{^_py*nfl zYFhd^>m{X?pySeTvje7eTg#ETwXC)mZYQ-@3wH#cHT1t_(EOO%?!b<1HCFf%D#_WaxT%#?CIU66?4k z(j*c?fmvheO%@3)DHl~@G&wgcZP4`f4dA`J2a&^)oE#j>bACLXOkMZYetn&J@0}&A z<>97NwGgsVpNXVPQo%97 zOC^t=42k1a!{Nd^7>GvF70I&PF>n+eEMK%*8MZ`>Qm=>UGB-9=n-r>N?Qx*1TLfxFLo?PVTm*F(}kZIFd zrlx!^iOPg$t1em%cIm>j@|@-xW$V`prL1ebO#M3`ZI>l9n|u$O-)_8E0~tnMYQhzw z`W6XlEk`kR$TS^(VqA3rRnOqW-5zhtUa2jEaD6iksd}cv-dLrU&NB9D!QYsDhe~o$ zq659-WP%?p@577{TJRY_-i?Qe_r96iF|y{g+&Q5J#JLKmW4Caf*5?5FlFXlMt%5Yg zuflRbZFZpRcgtFIKPsq|^;{*0i#;XuO5NfES36FF1>_{D-$`GDxUywrIN1QWUMvlx zu9)1E@2O(OXx+m5ClOi3b2}zO^Yh0kA(B7T)R*4n@8ao$Hm?VgWh^& z>yXK+b8}Z4#MFtP#qal5%ltgsNtfZd^Dg1N^YkC^C5)oIKkMsQYztM^@2nN><|ec@ zy-wTO+QCP|rKi3`D24Z|&^nFn)iuaeQz{4UvdYxj*t?Yr5NIfX&dXD2BEqWO-b4<` zOz>5H&vz&x%r+1&%$fVamnO~aFm+R!)l*8n_$&9}rB&fJq}JlKz)Duhi#)7`-Efcf zm=fTEpi9O)Id8;#f0<1)(s^WBZP-A`^hhk&RSqp=J4uj9b4%2k-Z&Bf^-H*tWirC45lip%;2zG6!1F^-{+U76a@F}H&`OG3%Zl;)q-qbNtg`1~R3Z1dHl@_fB@WkCb~j|gbQfo-Zw@jp@I*b+r&4N~ zQ*c_LZx*q!S_DYkukIcbksLIHdY)y;)MR?1RtHd;$~rsE%#=~0C4_R-HCLhcLy;%M z>Ui+Y!DlIJvB=8t)~1L#QccxPe0Rap&uR&eHFU@-9EC*QAUf;}WH~T;@6pbs4ZJ-% zdq6lj^!!<_t7q!e(Ad$u`N>QPr{4NCKZq@BwVWUAUW8U?eoa{Ojf#0(<{&Vwr4-); zgcnMR6BEil6cP4!{n3^OI8>PIEsoyZ59% z@p*rjxJu7uUD6=DNPFH*NHr56JvS5U&Bbbx-r{PrF+M1Pn)QdJ>hvuO z9wzHJy>YG+w|X{id#0)2#0hlla)Z~Q;d&W%6CEbI;Dk*j#;sAzBb|Hel*8*_TH8{d?DJmGwst7 zKXiAOG^epqA(r{{+Tj7sDWUE^T|f2;Y-}zU%Hu8BX3;mv=ri5AF}}ChSTk5##u%H% z9S+XbM{Dt{!2!A7u$JzCJk)sE^>IGp*-ni3*#(lHB}J^3ro_$L6sDKk?5vPV_KmxeH`aG7b-0Db8mm}G zuQt2Lx%FT6wYI+VoKf9ozNbwVX)f56s;^5$35geLpR>xQ4quPalWIgEj(C*7!N7&7 z<-pfTYN4z4po;#MAoV(3AS5xLas6ABcyDF6Nq4~h!=Z0~hSpcHCYpQ6kBS#1elQXp zR_&8YKh-uNlU?RG7O!FN?-E!1(D6Q{&~`Pwft*txvFN+>lCjYpCVS)4KL{Az&%u2R z04Wq^nw+~4s`OR4ndwlKYBneK*e}xC=uRyhSunwn<|IQV^z0q%kQL86zB8Wb>b4{_ zeuU%F8bW|zA;-s_)-Tx?R1u5o=~iud)jOSUOcwg3S{K)6Qc&*67s{rUd+ZfnYV?F_ zsEOVkVAk=_%gu1u;$ox1NzAki)(=?3w}*W>)Ub)d9^h-oOea zyP21}=Rc{1B21$=RGUdqvQu--M;;rkekYGdp$Bg3-YZfyADD$4hl7`ce^E1Aft{V= zf(3c~%NY}okFpk8J@cpmqt@#zllN|vSP@G;TkTz3;n%tPK5svDW8t)PDaI4<@(hfM<_rn1&CrsEl@X>^ubB9j*)MkzuY1|Hj#w9j? zB-^dN(|HK*p6I9nc~}ZeRNRgctKaPlyF2Q(U*jatV}_9iU~N|6;M&6W-G*cFXnDo^gdaM(Qpc(LV4Xpp|+Or#2y2B~~dXrMG;| zu_?#b`2H%ew#Y1mdogJFt2utt9Z*x)ykR1Zohjw}NkjqD&GvB1E%PW3s!Da&Q$n>B zyL2Vs5;n=Rhyg&>zx8dGZt+O;^c=Z0-Lp|Of9(LW6_T6oK7!t;z(^HTQ>D)~&$|b~ zT0-k`uY-=c_TYca#_6wjeci4~R*~Ed>6TREEO!(W}l|IOa zqsgw$j?2$n^QIpiltaTCLd13j)77FtM-CGkMXK2QOSdqb@8!xjJSA2Yv(RIc<5Dq( z?4Z!6!@IEeLG2^_5K1MSZg^(iF9)K|3&N|qzBjqdP0bAXuY+QeB)}-cN7awk$5jYJNVo*|AQd{5Nz_e&?&H=zkP|Eb3_+5ek6Tsk2ie;suZ)GMm!X)tW9kXo)sxGc|r|pBH%ZncOYm8 z^p~jL*`DK@-JC+VM=VE+4^SJi>@u-v*83H)S^{m{C=Bjx-nmM&;4L}Xz@eqohua-b z9dzpXKH8xLNVLv#Z_?%B4h!C$)M`0)U{_4svW7};q_ zFT6h!jGCbg_n}>)fo^Bl9)?tTtNx}C4!=`~kX&K`89#-)y?6Nl2M(MEqngF{2t;pE zT4hBlFmU(0mjqcXN)s{ zJo@OCXgT8PoSO~ytLSI>n?5syKi5SB$oynb-i(_A8e)9*I&{YhZREt6urWo+cRj)q zs%0h&OG3Y(R@Q`TXEWNzNwBZ^aZvhGj{hdGWG^Bg((U&O2{f&bqI{eL$L*AiZ^x>#e>VvF{nsIJta1K_shq+YHZdEBNz4G|v+@0B)B2%l$--Ki?_1yYkP?`y$HT z{GYzbXqa2vOFiSEBJ2*V*l_ zl_GC3{~A-cuL3-QH$?xEGW5+~+vtSJDvsNhbKEFD?$}5i5&^90s|O>63yZJQwzP*SDs+XaN01Sgpk!p{J|NsELJ#9C7Vb-}1==F_|l zN}9(*O@#ZEx-L$n87}0asoICD8{Xsn*P7&D_UCh}4@TA@R1h|z(CBm(VHV34sX!$` zxE{qPe|ZKd+@Sw3p0y4a!}S9#D^CiV59@kuXrnK7CpS7DNm*F5suU7fGUba5W1J)K z-or)2$HI03bvzj1`F8Y)aC$)6`>U&Suwqf`&ijTPo<1uxmL**$Ke)PaBK|RrY|Wny zHaN@^ek6^~HR(zHgL?d=FEp$(eG4lyF$wa(OJa;u2fK88!ng03%%3(PG?l>1YQm|F zw+^=3JiOL+u?G>U8v35kXXRiXO-@oD*f}mWS|B1Je>&RE_yI$M)a}ZOGwDz*iPKPCE`&#j{%@4pq8Nwc&34HI zeWQgM$t!M_LFV!ihTVO_a64$;9}wV~QePU~9m8bn6?LhUl*XFVxJ*ijZ79!%Yj+n+ zVJtOcnkd@)Oz(cKaTox(8a{yNqp zmC@3QUZpB^HRJlK<3YUAD1;h^6@;RL&Wnwk4AS1_IaVu3F{xk=>AF|@&^v`WjM}*^ zfzVVvc~-~3+a1h^_n&@WgR}(iX-RuJy$C*4yZ|J;!p|KZtw22!$oH2LT@ySH+dpMP zxll-ioQ>4nd}HWEdI#&i!9SvVpAZmQ&6e78OP6S~e%WgZEk0w)*4HQ0cO*g+Nco~8 zNJ3-&fD+bU%lD;tR#7A=wg+h%0Nad4{E^dxCe_h0jQqxByLUc>;8)COd26W#Ii@g2 z{d;`oi8cX-SGpuT{xBt^tI^hKxBi)$Ifh4z0v(p;EQMXCF9cbv1$teD6l5`svr zufDvJ2byJ-#_rRYpoFp_7kBY-`;TGFh!&Y1TC$af@XvSQUKRf2Ej+u!j61sqo7nh~ z-Csr`S&xufL8op_!)ek9os!1j11I-OdNn289`=En*>>x!VIl_=V_~*xSXf>-zXgc< z3Id62%IXP4v&xEGE#}wnlxbtMhpMQSoY?SS|N6UZvp>Emq1luU&)NlMnFuQ`XC(j~ zK5Imsvf;zk0Cz7B9S*#D>MzH)-CQbxZVRvqH>KXc)~&j2mq|7)ZMQy5+U$qvuURJT z#&*d4oZQ@6@O^msfKexNr%{=!KOH_ILs^bLZ5X&*aJ6r*#e~%`6G=0#aA54_O3drI z=9nTgZ1g%c$?siEqBg*qe?ELe-l`&Hejx)Bx8hL1=O@q_sKJD9TwuM5|iY%r) zeQr3#yARpO#aAeWk8U$xe;!~Vna``o zTB&sI7iasXF466a0PqP!)O_L7x9ZnYBtE^!3wN&J^7Z=@ylsPg@}5Q91$G}>UI-PT zgr=2}f=#L8+xbTca!>2;;W6%atMW3i`1ttTONlU6g*-2kx}cmplOtQ)%ct#de>#6G zTj@b(g2uF#zPxmBJ43Kx$#nK#J_bLt;prm00UOtPo8m=shk?BXN#7d02XgOp-t z?ow{mBrk4BOB9ws-apl@@fA%XI?t>^x2 z=$zYoksG9N)3Kgf6!8IHKc_yz*Zj3T1T@3FqGv^`eyp*?V&;3H^(0w?Z`fhiU3srHC^fJlZ z_ShabzjRLy<>pp13-uvo?m)P%om_%(0UAFt!>~pBilT)Pms_hq;*)DB?iv2Z$7$Z( zEjdvc4_tMk`X_o>3)GCnZ9ORkWK*z!bziS2XNb9+=9l!4;0z`4jwPQwyIXPM;fu*?;y3lNOx@Dm+HcSpF0QmX9*%MO9T)`4>}J*yVeN%0$__ z(-J)4>>BRJqofN|^SZF$XtrCym_8|~R#7ogMYZ`KJ%aHESi>2i^uviTnME~lB?{H# z1|dUwhUwOH70n}M0HA=|9fO#ULqG8cpzwkpP(o?baIm5st6kyd`MdK5Y{K1Fm}tjbp`!zQ7| z3zlXiq=QZ3b(5Z1^`-|!_OalYXHlgZkHH?V%ALy$4Jp)?6=wV*Ejr86E9sd_Z9pTX z=uR@BdabAvPd(rGp1;xWLz@P~$TtqB%tM^L;UrHPg78ZHxM?`Pg~X*%6SvX(9IYH zW9|uC)Q`WVQskOo^6QO(zfKF0c+c5CG$e%I&W_1qsp)N@)q8rB3KaU7o`h(wzuORn z^+Mh1pCi1uM|kJ~fJSer61}HEGwM zLT`gxN*0C0!T4L@0hSXBDCFfnURNO5_Zm!ySMH;hVeK4U?L594F%sgdK@oqf7}z{} z3MVpAxveh+5IKI%CK~Z22E^j$m)=TFEq4{ZKCSA=O8j&>FY9L?{E8p^mX-#-tg*I5 zR_ODE*08`Kbd-~MDZu1K{-_@G1(D&$V;ipV)A8W(DyEV~N_FNXXp`BOmSLLV@l4K3 znZ()yGgrorxm_o&Z&R=SE_yZuyl5us5Y3Cu6cpYkyR_a;RTaNRKv3d3d%z+}$d2(f zo-=8Gdw&?}3smMV=H}vZ)i(aYRd#Q7Wr=uqQ&O$0iIbaCagZRgWwkz%ustz|y@cD+ z{vFQwA@k!~-sbGjwe>qN#+UE7j-TI@2VQ&yx@#%{ID2T`3*sdAc&?r=i;N3?nF8BK z0o7{06$WJ7c3NsP@c0Mj3v8ofy>^_biMOG^#>`XHb-Faj zSDAuWErS{Z4Uq>1qhF8cSU;jc+t~ZtzM+3L4q-{n%uF!^PTy;`#~F0Xw}E-m%geB; zC8T$|++NzABnguXqKj_~J=Snp<91TTxapufNYUPDlEG;dQfXbHV&Kb&M;tuEY@7UYy3;q?mOjQrsdp@^d)CsgS znGK}YMXmh?JX#ZDhqvqLAa2|7{BjRSXJbu-8Cj_j2)X2a{O>(_`X=Fs4w8cN-0#K= z^%=qg0y6IyZQ=aYKxIz#qR@PxskXcY_=P!MDvZ*_EyAu$^}eeCQU3M<4dp9sG63Fo z1pJLsaTk9vR3Y=8e_%@7_n1`ws$!#1)10ZPRaP9S{!jm!m1pml?kK@~K}?J3_lEHU zm!}tgH2;UZJSUz!K6Y*=exZb`Y3Z6j!^~Mt9u(>Rf-tEr^Ybz>TcN3;!I}sT&-ZjS z54fyEwmw1C`QRY^IcwWgfsGYyHa$SM*Q$~7vB)5M*)1m{%<4#0$2YB!zIjEvS;sZ8 z9{fXj{>9In$A?Tz;=80$QF4>#j@f3n70E8?`MVnOvl9gbEolW5ZGL1z0Aqfm`g+wP(vd-Q61R{a`-@&mPAyF*P4T1POEmTI-{qzrV`8nM~)oW4@WIOrp!D z$wru^N4eD6=!&9y0UTSo{z7A8C3RS#KJn$>fKETw1s6ZsbX8NsIpgGnqR`gS6x(+< z+un1R4%##^HuByXg~32Qs?pKcR23R+I=O5cjQ zk4!4@X8QB*PF`u;xSXBBb_OIdR;(>j7%_n880@dz`32XEPF~p+0bU_Xk;N71-C;^Q z1)>^wS>hKn55--Is;V%N{VIPlR(O1}zuE;Qq*lY@^KARE#$gYwt@WfL zFZ)??d|t4Ck=*qRVR^lh(ib(BW&HvyG>3*h@UBQ&J3U$O5O|P*Q)9x@z?PrrnnOl# zyn0m>@!)rK5Qemp>t}c_bbKHs^qtsTYJbXo;AT$F>O6z_Y zgU1nCwGWQLdF~oxp53ia?+otkWKW+KdY*OCDKK08!uc+f?fl!UrhOA5Io!xh0ZfS+ z>ZHjG!s)wBDT0h9ZvOe8oV0YgsKoruj1bFDbPbZTABPf5vr<`djUsE8Z7BRug}-+J zTr^@cm2v`(uHQwmSv(+m@f>P{WoFkaTxX_g1Laa!XKhLoHy=jz((=37aAfifFiuX{ z$WvSLr!IKolhCCmI2KCdT(rKYdCIR*Yda6l6&7toqfbZp{77!`c@Bip%;0cJm28X| zLLWAeabd`5EtZ-cM@J2DoQBDGBpUUpZudX{f?xAX=WuZoPMjhL75n z_T32itinRV#|Ai~eFgjL$abckkPO9g7!a`6R2?GqPb>t(vO1m18A0YNs2o_7-8F-( z)Lq-HqO{r>eqN-)J!?vUF}+F0ZTwk8!grw99O|B;)DpU#fXBAl2gJQv<(|Z2!!N(g z7QhAW0C$*WSv=JO<7<2M2Avjp+D37)shagDYX@Y=S{r`g>aD)9A_$KWV!s-1yP9mw zWv;u)#D13hb+rb^Dpbh#Se*lr1Y=`H5*U54UFIVX_+ zT<6Ra{NS1VNz{NMd}!wtZ3JrtA1^}De9^7w`OiEzAb8(h5Wpc6_zFUNL`wSMwH_aX zk$<~yW}wh-k{@lvpLbzbQSIjtEk zltXITluqG1>vMDzR_VH6)FY4>pky4KFQ5?=96%0z->v(muKErIoA$v6kj%7qm5JHU ziW|a4-ZQjqAIN2eIb&nA?wi&2n?4 zyG!Eg%jE+5NL6UZdaUYe&m&Tr<^EsQ@ny%!mM?BB8wkKt0V+ukvakMRl9N&PD98&} znc5uz5}Q?AzU9)6Ob4~l0j1k`7(W@Uhz&R$Qs@S~95)(=txB};t9RkMdE6gRsJ2&w zR#qDFAAXS(ZKz^+I+G=s2hd;0lq{tON}?ci$pmh%Lp{!T4q(seE%R51s)bv$RkrGW zk1QRCbHlN1xLM1Ps_6q6s9sWKYw`pw4z!PDm0}livqJTpb;ng#hUHHZxU%{)<%fbR z=?U&T!;h5bI2U+ae#LuEgL}&F?0IGbzi2#EF&(?qmc*0APzUmN0w0W~iy`r@sVF?1 zHyuKq3#pg1+!SM5x67T^IGJF6rP2atnr2SnA^UBYVHibKNpgwu?_fNTJr~Y{n;0AJ zB6&ZWE{_UYnQ*@~481NJ{1qh`L9k|5bMw6SirkFx^UZHppqZl0Nq26yz}cAdoB;-q zNZi&qAu!6+k_y?gD5gY5HL;tT^O%zhNOj-i3%Xdh|AV2?dp1t*7BD>RF2y|A^6A4j zx%L&>?}AzSALKsBIF?cx0!ZI=eVQytcIE=M3JU3R4S~$EG+Azg@9whRTY7PoI!v7} zbGSo27h&Y4vu^&;Cn(B7{#iA0LT4 zme(wS4KpWET@BB)O|b6OTO+k+grx8B4nVzJ#f2FzNRv|Wp=`TRd1eM%0m^JxD3^aHvC|Saaaf7j9lBBPOd$=NChb5x@M!P8t#h zdv?H}oVxqb*C@l|w4fqu3d_#S^UBi&ot$?KRaM)5S4Ram`J09-O7GBO}~85%V!XA9OkjyJlK1W_A1MPseGyc|O`&1C_Wk5y=QokhFO z17EP4Mjc~Bc<9?FbD+?ITK8Q9mfYOwwTk3w>#wh)T6=yRz>6*-nX5|F{?KB{XaM{8EyQ0bK=(3CHKa6M|)ne47i!lJ7C%jU3u>?O| zbn4}-!H-aCh*eW2YF31AwRODHCiV+4h?hNO_XZ7u6}|tJ!h-|Rx(Bg?D5tyy_uC2> z3ex%6?&^D3JMW87DO53jq;c!G$Uzqg{fyGclh z#keGXWfAa^eKK97mcNU}$K!^Y28*QYU6K)3q>Zr6^cKy{TOxCh%RBeX_Yh)m$9rdC zB3vU<{AziE7zK|J5EFH?GPKn#Izxy|v8Gs-IdvTJN>S#8cu)?Bu@2pnH)pud@JDuW z#toeDkGvHQZvy+|F{@dE_5P?c*(=0|pyT+)0$w-n)Xq+gxMQhsVL5=N4_4Hp&6HjG1d779;)=$btn~4Pia?P zMk$zbe*_Oc{kScN=TlI(7o<3?K70rSYgstHIX!EJO;g4Jma zfGEIEW*;C94q<*G)ZC+bUx_63VTG8W=Fl&J+d1kV9C=9`Od=2-g;^fegdZAT{A&y( z(ob2uV)z1EGSlH?GfI(MD@^M#De1;*H0gW6m0Y77h#Z`9;iun`Bd8smHI1tS!iue z$Sng&D>BxT8!&$%`yZgyi-g!{*Xe@5Ejp(I z#47r0QOEHI{IR?t{v-Q|Ox0$K_$ntdJ_*gaS}X_^ZutGu@LJN)&xTQD6(hVU1CNJ2o{o8Tc8EexmG4pRMypUg<c$Y&c(*d0Ht-elF?ipu;sZDhOl2;+LQ zsp?Ya@sO$YS-%3H9u^=P*nY%fk$>$eQ%5+W`kyW$qI|0V)Ha7_4TIEth*&wU%_E@D zOpO2}q2wSb1Ke}Sc13?%c@=!$CrixKWx4~~d5Cx45CGMxPP=(3H;_<2*RMtY3-|*2 zSYH&@|AtZ>YD}CgQ@XB62LD3`3C8|!@L8kdVfRoH>qZNMW`Op9VIV;YzKT=b}35CKxP*A{tN5qo)M!I5I({Q?+kufkZ zPz^hR-?ho+e#7K&xMW_jbRQh(7Z?->57ft2JLYgaNN#z=<#LLg{nRPa8B$S&Q0?>^ za@7CB&^Z+F)pkB!696>3qJzmsmkpiu;>i=85G{9Z=;-JH+uKHfCTF>pd3Q=ASR`a5 z?Cyb@$u(lQoG4N$&?BRCsvs4T_50)Z)=_!%v9bAGA;f{;Inm-dI=st^9Xd@oQL;@&B&cvXb>!5P&N>wzbzVKN z(P(Kl+;QqWFUrR9xmS{9oRH%F4=aWVtM31X{9>IHi+RS>BlqozArswtNZaHdQu2X};?hxr2gjCGljwnIi8$ z3(FqjDBj#mru93=QMv4#eBu3)V|c-Ai8yB|!e!5A6>2)9fOjl+PURH#p_U;uSOgK@ zT+HIU02ewbJ3Ba>G!49Q)VLhqnEYr zZmm0xQL=cO)qLxjd(is)Tc)8SL4940AgJ|u`3H)Ci_^^!8e0T08ubo&S>9O;#RV6Y z7YPY@8OQUO(<+pjOn>_5|7+X!u_jfj14fTHBeuz`Y4GT6kEm2jX6_+__LdI5`1e&B zRq|E|!|26!9fyd0m_v6q|Wb56WusK@ceXa(q{!$gIYp?Y}RyQ zN{uDnzX)cAGMN6h*pQ60p52%4LzEDW1C-G}_La3BWR&WtUwBJI<3!Uva@!TG={rvc z-Q25=0QxCi!){Gf6dJ_KW8}RdJ6#HpI_l>x)&wxasmJgl@=CEfJoou|ImCS?TpzCB zP%HbVPgl0*p-mUt?Ngc_rQ5C&GU0OEoUBOpWFBU1JRg@p%i zYFn&A&AK6Cq41$zd+%^IO{4vjA0iI&a(K6sj-+&gW&p~>_wV<%2Wbu1&*OerF7+XR#KJ`Ak3&HPt-jT93@cQqf z@l2cs?6hYxaFpG9_Bc01nMP=$yhVV`473ZzS5@3dnHM!pYL}RJ$fn>hc2sOU9;EO3 zbS)8MLp7!^y+oqwl;Ce{&%jc{o3O}+YoIS9q!{LS6#5zEM39fM}&7o`qrys&BN$r^=q9~ zJ>Asw*ECePivYv^f?0F6gdZLyft-5^`tI`U=kIAnCe;V?ai78FME)-L3?LM8$Pg-z zpS5IUhVs8mTQDSYKT?Sf+Ug?r;vGbSa^u#&GbgIFw>w&vpo6`eUZ?3<%PL<1ny%Q3EoPe5 z*;V?wN;F}BtUOL6{X5>vt@j9v0~1zq6w#s^tJO`Sadh$f8wdC;;!cPY7oG)DR?A%w z__W$}_+2&GWwo;J*1j({+#%OUj>%LS%`>&*i-4QECmP&p%7$)PsT| z$gpiOdkNBeonV%>)(|Ky+r&VCG@lQHo+r60)*a_}bzSJg_H{pT@ zTX_sQ_NG{U!j{BgwP(a{#7JzM8mf5~ARxL=85gZLl>}n6r!9GNgmZQX-%xE+v$$aS6fV}c2CO;!($||i$n(bL zHNy$f8_)8skvO>vl_9{^lCGYX8O=7Ws{@0Ka_~@ze5XB5>`G8rFyt?~{B$6?gvni^ zwS)YSQFO1A3{^FgWzYgn@x5AwWq;XdGyd0_j2Qh@S!W5?gD7l(V!(VNgqLI#$N^UO{XbiQv($5_wL2LP!tsj=5H; zS?Z+mtfzf7&e`H1Y`^sGUaFRz?}5V(*k&<6FzZ!dq(KUV-ZI~o*c(LaYgu-@a_QC` z-|sh&)+iSzdT&WX?u&&LQ}ZQ}8M&7O7MQd4YSec0)vhnSL%p`cMpDx8+=%rH-N7JC z!2Ry>+%81*r=T#nzn6UX>F|xln4e0@Do|=BdqKrdgNG1}6x67d7u@u9+VFgmvlwZx z$-Nad&HdSqyZlqyl7F|soRf9rGzdQ6Nkg=Y;o1+IG_koN%dFf{eMERPxES-A$ zPV4ZAL!ecuk5cKepOxmNMB?C7NmoP2x@%vd%uD%Wo55vU!RPwC*kKsro%OsQpFpnH zsoK?JqJ+G))I3s~;+HOEX5ub9RrC3PgT`-eW{g~J+tvLwPPX@jf^~+OPCY?QA8v_V z%BZmLE))b0_buCpm-7cq@O!4tUUE~GH!+>BSNO^6><1)y!v1K!kv)@;K(+0vNb(L=OdV8r&KiWom5Y=(gW8To!A808(anfPhNpC-b zE{@MSq7@JlcwRop1 zzfAssnDW}3*YyT+s-k#j;A~P#ESUzV2BszGqo*hxurfT~KQ<U5SDuT&*TV z>l1-K6TTf>fSh##9DlP_uXdgGZ(YxjU_S219px-g^!)K2EpK#Cy!bWDc04XHZa>~) zng#c6|U(sTUWr*nA#^}PRt{CP-6NV6#YqDHnzSA8e!I!?;C~blTii>yV07Ikb z&1hM`8Xv1>po2dy3CIl&xTq@(%9aw|XY}~s07INx)|C(f-?y-XCMDoEELrCZh3Cc) z6<|!g#a|p6f-c}QQQMessX?Fpi)6IcxQNHv!h071vPsE7#!HkYzUlsoSrYuJu%yd) z4=RSq!cDK8cO2H7ZCt~cyL3FpGGvf!NpA3f)=W=B=qRScA+?V4N+T-J`l5VSQd?FG znF$BmUv7M%gZ`c{dfU910ZBWZRm|lPT@XTFuv(njF`jhq_!DEL7&^i%##R%beW&@< zT^WeCugXx73iBUbmcOfAC(pv+1CoCi<+$1ii-@L$U7Y6?gvCa1Omll0Zf6m*ouPhb zry1^z9!IEZD%Iv6GNpgUaPgLPf09ZA?nNdDH*x1jnamMohr`?38;&b=@n*!9iK2Vw&du8*QQrL)ZRIEaUGfGU>k4F_g?X8zNJD!w5d{;eTo>h-P6E)Y) zmRHp~Ydu{$7gUURNX0>&0TJhie9?ZDCxS6}A=u7#el^J>I|}Ne@f~&nmr7#H-}NRc zQ{+8I{<%Bkzm^3l#3&5y>^+TWi>`l`@X#`tcq*8X9DbMhsz55m(6c8*%?!(@2G@~w z#jirNn-}sK^jz4Qz!D8V#;y02a_C#AgN2Jno8R|ngBRGN@Cic-zki-v4elR1u@-nL zAy(?tKB-G2YAFX;VOu%$^6{2xP_kZG1et8e^WaQ0dPSwS zg<2oSuHgeLpa0Bfmuo-mb3vpM4rDXIq_Dgl)4n&eJy$E&d_GJapV z&ymN_f5DO$U@9){;@qfOF^ULR8E+LGH7$3ajG$fbE#1rys_}x?Kw*+&pxJkm;QvT3 z&)A4=o{lzui<>l3CG<$=^3cY7DvG^f-{^;e~L#|$S>mIC_@xyWJ zgyL7N2S{WzqQ^Q>OW3JZ6s4g!#{2>T0`l%8yWg#NeIoKQ920(6%{O_AHDjhq`zPAl z(&fqJPbf`O(cE6H*oho=yR+%)#{>o?S#3sDr~p38jvXQER1S%;Z0_jdVTPc>C<_ z{wbN+{M$)^c!65w&Wpl-A5#RfgZCc^sU=t09dKV5WG(MMF$wLLzotM*Z~r{o-G`a` zVW0j?>+WOZ^sxJn!w3u=J?w-3<2V`X{xwp+udxBI9>c+F{z)e5rT5R;BSmfLR{ui?uHL_ zwpzlM^%8nqP#b%Jmx45c63ebR#muV5R$dPeB?GixLh!wx0BvT_p!G%lo2&o9uh2C5jFJ7jbGRJ|qJFF(KhmKzGP&0CQt6|S?mUxapU zb}OXyoCZH5@?rVR;I23Yyg$30cC8llEqI})xtKNrVfFlokklCccj+}(4x;g9_G zCfm-Jud}JtVIrIJricQ~$nz;id9j6wFjtSUU_EZEhJrR<4wd)?pnAl~_BAa5z?*`f z^Jnw-PD>FoqFw6lY@vVOx&ajr)M4qYOj*2W_H331>w4E}y!T6(sEdb6BkSz=riA{pR@%+@3-sodKK%h$Dpaz?F+2V1_24Z36OVy~nossgI@@vO}3nbRK$q zN#WFx(&^2xtGLpsi{C2DslW$SqLNJx6goO~n9?H+jA+%pokirN@A=48M+BhC!X#`| zCISjs_u3Ht%n`oEcQr#Fy+>JQLr=Pk6h|%%H*0O!E#cx(g3L^Vj}1mKJZENoBqTHY zEo#uGa8n{wcAPO+koQ~K+fC$+&`<_a5ZZEoM-UVmC zP%*4l@wK3luqYdDdpmJ}K_Quo8;NtkN;+#Wg?yP5(SZ4xOr_4%^RAa>o=*oCzp3W& ztJraU#?~y)K$6;U#JBa12nV!C8bDsLJ4?46tCzdEb3z#%@oaa4~NF z;!6zsObD1^_p^68ZYBd$F5PB}`BGyxZ(5 zN zGPED6xX2k5#=lQYk)i^s`k&j@@^v0s_xD=kkBDvhRZ=a^7=a|^&~z^iY%cY4F6Z=) zPEUmz=0u{Z=@4+`68VVr?QhXTI)2NDZ{qsl2nX@%ck#QApA5~`NTWe6a$(zmr=hZ3 zoBrTXQ}Wa)8_;nwIN}^1AIw7&>>2?!UHFBYd8%b~3A-ngbi57ZrKo@0f2FxWCi<^v3TPe@wl%irA`y$C z^=-{7v^S;4wWUE=Q<=PtVu6Su_oAkI; z%D&B^?#)qd)BTybp!JXJxvJ1wPDO=u6wm~m60NU0w^e+|j(I|=jMPJp7gduNe>jZS zQEa8>p1yKDSY)wO3_kjlFhk(cc0Gg}0$hRG-e0N9vZ%hnKW{3uIow>GJ9J<=z_`dU z2l0Nf8ZR8;e%U}MZgxn_!>(P}{UaLgz#Z0qV6!MF7!GH;lS|@5y}$C!GCK71G1F7b zp=$b$d@P>p$}EYkO^dGSQpE3HJ?FBG0vkN&bYud~p(bsDY-dI^PiLs@k*%%ZYJq#RD0Q%3Ly>~2<&rq#h%mv2ydm=n zYaCt^mEd+i3+ezs;B@Aww00?|CtY4Hwah1z01#s!sSO-c`b5iApLclnvbCM9;zf1@ zjgUZ#4sct?l5H~1lRRI}Cuq06DfygP$!>>Zvj*YIL)3!glE(1!!^-+CSM0p5NZa_9~uF0S?!EXkYGD24+!8-rPb=69|>V?ZTK zs$J}eu34|mGzLdM{B=*PI*KBV@}9{>VXnQuU`Gqr{?{RsHu_~DCM3pD}qq^z9ZquJwGC578YRg2a08{Q z44TCbo>EwSYgjmJsPS(i6a3ro z-9m%&(+b%<;ZzOJ1!I+V)@3%9k<6Ol6b9{@fq}LKrzE{)K>@^525NM&Mdr5AR^?+Gv7?(+)E~H`qXsRxohK)~7g0>AWb<`-?u{YFdh`bH7F-t?(2k-> zsc=#k&f4v;u$H+^UjnYsy+RHOqXvuIz%tb%3lwYYpJwqw{6S|VbLZG<4DF}gPQ0e| zN*sKbj@k{?&Y`Iz&C)V{?{OUI*)oMbkD?qa6Z;@-d-(E5W*d&C>okt_JJ{=cnA9+< zYO+;bckZ?J}920?a`C3p2b@kOvqjL!M)Xb~*8^H@`S4e{qTW=)jG@xwY5Me6t)4 zFriv+!JJ_79zC$A%T)*d$*jL>ms0Ws7f<2r0R`5E(RVkjJwy;?cut1UZ>Ooq?&{CYFN zRjeSb0<64jPSCtu;Rw?^5Bwt=C&Wn*8_s|(}(DDKzT>euoHqou3~QMjH*8+$+Nl2XSc zpRI`F2kdOiOP||*D1R!Yqb<7KAedN$0)&|Xh1BVY3ok|$MwRX89)GV5e5J2#(~Gnx zbG}&ma)oNoWH+&iK_RZ~w@Bk!@_~<31F3_y27W{cGNW%q_yWtYvbd}GR9Y5ax-WSC zu+m)!t8-F*(-OZFf@M$m@XX!0=+9KHfa@QZ6f_H!?Q^p>wHIrdBFnW`#_Hhqj(y6d zo?N&_X~AE{$&z+#kGpvzl$}?1zJ1YrU zq-)Nxy)gL3!xmp-${FXMP%{DF&EyIkT}0jK>?VGkv=$|tTx{=vf;nQ;+OQf49Sl9K z3BTNvovuLiQoR_}1{D!&*RnVtmyEiXUji`z2@Sr;WsRDr?W1r10Nv!37RXxpJQo@b zEn!5=c5C--%#xCJKmiHv8C1OTeH*9~8s-cO7(AC}S%?7mMA+sVUpO`@#+9=5_9xDI zKUYXfUDK?9S|y0e%1!zM7f0xe*lCW>KdIq_yv!``&fj#ra|rD3cBVEUOfc#j=rtGD(pK# z>F|?GdNup&G|78Z%SNkcYV#nG=oxkF;1l`JBqjx97rmcaUdBJws}wmih++lEdq-T@ zbTOLoo?Sz$W%s3U-NNzUt#39!deT;JvnNO%^2WKwA)3QI$ zB4K;2Chms#-V8!~Nh~q8Y0RZudAn>YYm z_|O^-d}Fj?X&o-Q;qVob^GUezxQIZMVqU0n%9ctHAx7&tIXO$hCC));ks;KHru68| zc!~PBG3*~KoU5Hf)FS7m%^E>F<5R4+P#U~Q_q#E5Q-@GykXi(w;(QAJ5)vu9K0-0kp_zK@J||6A!7& z+4)SB7nn*gGu#lU*J9c^Tes#ZjHN$p7C9Y}=i*!=_(e}2_sc>Gs)MB#@s-PUKd|rc zjG6@<(jZQl$5Z}PPKettq>cw}?fuhGPJQ5*x)hq$$J&?q7ww6JW}GI)uB!D1@=qeR za_Lr1KSiH^+4q!TdH%(4x++9UyMD9(b3WvZ?;5%5+A?u-UwNE{eztUZaNKNGNaK4A zpFj&;q+{3Hq5`z9%F%?iL~IZjdII};CHFF@TKwY~lrafI{Mz#ij@$L{XK@wk`vyKa zA5t&WUB40|g`~gBpWhIT2kS*hA=6cUZRQ*$aVoT|$E5jq6r+3YpWL?6j@4rxiRpWr0{iT?j%ZoYb;tp}aKn%Y zQS(P;--C(y--GBFsAlhJI8Ez{i;g7;!9T7@cO_@S?}5c)W`S&w+h72)j?wN%IEi8h zCw%6tg^Kz``}#yrxqw6MJDy9%dI16$i=U-9O9eJjMU#{Fe=yJR%3GpzQzr6Q-dB=9 z9xo+y-~BhP1(LPiO%&-a4M(jc6YhB(kAXeyjH~j3&=e^AMDf>dhV7dQU zZf&*(sC5JHVE-Tgyyx{Fg4MdWujq~8JE!a_?Ww6n^?!mY+7mea-N#RI!Xl9pukj<&4?8bo`HLjjUUyHad56f!$G{VMZc3ikHY?HS%a z5vsunE?LOBK^I5H4XLb-(3Z78aZxZuW!n>^7NyA3cYk|SX&H6w;tqY5HHXCnoi9uP zj?X&n@#kOj(y}qm{+gw(gy`gs$5K0u=SIUlH{Rwly!b14dEoMl^i4m`WsU$L;PY!2 zW#b@#-LQ`MBU!f2?L%U-NmA2S{ocieGkJ8)otPyh$H^rgoGbC+6NnbOT+~Fkj_7eY zmXPjFj2Sjyg{C=$5Sf%oJ*jtz5i@x2b|6tv8_~K*$2=BC0aF8(8ydRA*Q7 z+%5XqNu$#(^AGT9B>VQB3A_C_CQQT2d3C>4%ls&|3L(1fbR=>GaslHj;3bCF%ad>F zhK4u*7MsNIcK%M$DdkJ*8kn9&?<|G~)~d~Bvcds`Db0OBfwUUQuNmR#x_i>>3dLs$ zFkh`WHiRQOWlP(ol^;WX+!ZU>yMxM#NF&w5M+ah1R`}INrWOPGn9#crD;rq$NFO!tBG4_s1g*;{OsQ$=T+n3ylWi;MfyQww_!Y9+S3B3{nuza2@^ zOPv@Sxq0gH!-&3?XJ5qptfmO{?eOK`D>}FAJRHp$OsVoUkRwib8EA5&_L22(pT2XK zQ}iWW!7Uab;k|PlZ=Tl-&^H9>#2rPsN5(T8kzHlG|We-M$8M_ppVmzk{m0sNIx7nsc*<&tLdo2|0ipVen4<3NvCk6`;!fm*CoV{{0Chu(m5>rt0Er&Td z-wh>SkWgSH*b1BSSX#5UVLW?bWF^oKdj}Y!bYPVkMa1}0rq}l49Nw%5a%UmdlZnmx zFW${p^yIp*v{U^!nwu76oEYhO8^OHkq$P=1Fg4X!Dn{Ih7~X8(L-nWXb#k1l-a6eL z!ifeiGUHl0mn~m1L5yz*6`-iWKLqr^<|u(BykP52RqFV zzy-UCm^|BNxhYquwPjFKUHn0t_pz$ePH?}$?@MxWhDe-exvm_Cn&I{iAxs+71muHqnGW!#(XJdveVqP7itG-VZi}6lc3Hpx;FKrsPFVi9XxKVO4yP;}a79 z#2n-Sho&U0SRQz~7E&HT-QX}d*vy$Uy7+p`rEufK5)Yiq?PtghwLgW+!yWf9`*!ua zlGGG)$uFPwU^jdXQqoY>Y;~`Qfva%%&HF+U#1D^?JQStu(@C76-r4MY>EH~|bA?y3 z{zoZm`1?aWX;|rB^SBD_;og6i((oUPTaQ-o|E1XUH|0NldTh#27r{U}r11G&&S3{f ze*bmNeLzW|+d0QS9#NkEzMxo?loyG;H*;#_VkhmncwV0jxw{Ej2lX~!G+F&9(DK0c z(Pj9xi$DD0wE)Wb$cgNm&$p2j89BzN*E(^Fje78Du;-+2WM+1t{a^1P$Aq9eR&(mm zJlbPS&_f9>rG;j8cUx{0p8EFm_L3;B@_g2>`igF_exX#)f}(A*N}-0F8RoqP{#U~I z>$%(m{?!Ejy@3T_hL(Y&c>(6*{3Y(HCg8pS8J#>)%T4=X*^Kl0sIu}YrbImq$g?WZ z=+r1n)zKvVw{W^A+Ue|7O8nrajTj%&sStQhd7s5mcV_R^tDO!hOZr1$uX+|Z_25afQCGH^nIA7aOC+Z+O=d=>)H~@Z*x>52 z%HZ(NNH9~;mFhL8Hx-jftPy5huU;SSKUBPAdLfr{BRoc=D*K0Yl;C(IL{|PP@;r%C z-|V_vLD^8`C{Qp6YqO(VA-Wt9{tdSKNnD3!;@b3Y*@CAF+I>WataTHY5HSXRwbYPCx2o^*1H z7gQJEuF(W*WFPBpeuh+cdiD1u3?=F>VzG?)#8kn+{D0W9jnqRGp5u3>5@1D0rvglR zb`^Ad8KvSAA;^Y*&Kk5nN)SJB@gemx9@Gmc3A#>smIWxD4Up zH`!4+#q&*bV$r{b#HTHMBmFOvtF#6rw1AOIKg>!=eJ~#;8}f>{IHTA*b$*5*YxQ%) zzwV9rx-!O-w)vNdJ7;t0F}9s7bzc=B)CdJ4K1kVCqW<%tv4H}{(4Rtk(i-2Js!tD`}V@^Wne;mcioA5t^@(4MF`Ry^5;f|Ry3(wCsrlPDu6Jonz%&6*W4c>> zTt>|=7P}eXNO5_`BL{tS~1p?6k zD$3reEAtlho5=OqEGp_&k~aT+9q#k8u9Ef{R%Ca=WBO-THS@U#g1(oq+8brhhTu;q zC4tMov}|M?o5|)-Yv}2a#K5p3Pqp3#fQ05+Jv{afkA*=Wdl!qxq=SkaoGLD+V8n5b zi8(GVh=2A%3+%)NYh!+lzXAM(R1M%VC`H0G^^Pbz9d}YKHA611%yW3=K%K_(F5yYu zKN!+TCqhsy;4%L8*OK1H-*J>tF+>MrXT#h%S;Ifk0GnIGO+JbYW=%(tme9hm7d&iJdrz zFz65z|Ni};QBL)%QWR#?($LXP^+$#y)EJFu>wx9{F^qVA^h^q{MSlMdm8)nxyqYIK zBOoDQy5zdUcYj>M{M2FxB8l#CD>ISks_8-&-ybQ>&d%KzxBvSW{}3xZ_hnp&Yzl^9 z^n->5;lRLv#aiF%me$tj8;pFxYR+E44?|;9=gs%oTRR4Md^tpZ7R@>O5Tc8ce*gY$ zakM7$D=jTKBLlAf>HwjIJ@q-Xlvw<>L5_@U=US{!?dU|W7>Z+q;eE0Hs7Y|+x<52l4ewv44QfQjg+YH4-Y z?N-WH(YFps;lcLE*NS=;wOC>gPWdW}lK!Rqo-LygDa z{B|V^z1(fKo8&I{cqJP4Ik`LwudVwUX%&8`&sZ$<@=#+-goZB#=8wL}+jzf3$^J@D z?%fa!6s4K|{8)%X+$d#GDM?5w+6?y_b8|`~6DI}l7liP9Tnbsxn?UwV?4~+Di6&HGvDwfFZC~B?HEmUFJY4tyq zMhu$KY=*$f5=n_#TSr)i)6%51@o_AkZK4K0)|AlC7k$|iH-D-5ix%>k?mS=a8@OSB z1j$xmeeF+kMD(ZWuONl~+jm>|)hth6b606iWtn(i+Ys8{(39QSr`2=F3566_l6ky? z=ljR3%isW>Ppb}emh{vbC2H674c00DX(1U`$D^9dmA|_GHfu``|NE>B@o($Z+5DY< zo3(YfW?qk15Io9;zLbsBO^m}17M6V3=$Eo&tH-}M;A-hb^ciiCjzcAS13M!$Noc;< zKoh_3D*Eku@M9b~`er;B%d@uMe1ldu%eL{ zLwyncmUB7L4jxLtU>b5B6&&8y>*|34tLsi>rY5lR_uX%LE}GrBXv#c1DDm%11!a!M z6!*@Xo&9J1G-16q>Sn?3%*`N7 zq3dW0jW;eNVSI)f7zwn}VMR$HNV zT;&m`frs^gSl2V4V3Fo#Z*R)EB1`*_AiehRcPbw)mjMo zGqQ=%h!6IJR zgjS;+Y5GaWFR6ow*@E9lBX?_xuS(=Gd`Xw2ob8*(MP#TNslA?eNX~kKb$iB)X~Lsd z$EgT;2T(*C@#kTyH_Nf*?u&c4Ub|dm)ft_ZlKH_1cRVe`ef^Nj|0ml8fi=a5^8prY z2j<=zvuAoG4Kn462l5$4fN&(@k>P=b|Hiy2 zUOLf{dqo^`z=ZGb{zRC#JO-zOsh?@Yx}$Y3j!pkZ_+?f;xBW$C2Hto(?-w-gO&ZfR6uDd>w_sr~pGbslR8SVC5~_U=$F{a}O!Bn<){hxh62v@V#- zS-yUan;nKgoDd#brTvs89(}NzV(U8;ZYm4yLv{g4#^VPM-tf3(Jil;r;yby8md||A zf3z<*w|feW`D^JzE}E-I)IcF6H2PApuTx%l@JLhZFa-{(L$^c{nv5$x7gN{?ANI@m9Uj{ zxP1q=V|2fiyfszpFpTvs@Y~K;FE7nS{)@4$Fjc);`nBEmd(U92_tokpNr_re1r;Xn zp@{z4t!D!CR&4Q`Y2`}^4i@j+R))bt;yj((BJwv&xE9NosebHE2tHJn%DC3(f)mzb zgmp;Z>_ohNi(o+PXUYE!s!=d!p8xi@sHTtcZ&3~X-=UiRWGBf^1ZB1^WKTZ3xh8`6 z{Zb_RB00;Ath$jpT-vw5-hHZyOiUnCMM4!~uN_juki-71^4K45@6ce}4*RK#=6$!J zqgLC85}dP2N6F5v=CMm_TG5v8?VUv^hgQN|XY-bePU~>YJC|SVPf5gbeUp+=^cfF= zY-Qnka`-x9PTw0G7GCDU_H#HO^+d14khf)}t0tfFf_@G;)ZhkxEW5dr19hi5R?2t5 zMnCF-s0oDzj~dlqdiND+fTfRFZ3tq?V3G&Vj@ucI$M1c*JT*2a?(PoyZSQv%-gt04 zZ`VC>rRFi<7%LlVZD?U7hdqK{_p}vuU)d)jEs^#0v=xsygYe4LHk*ExmN*EZ8te7S zn&G0I;$pi5FqRh088aWe|NgSjMuE{xxwd{0x1Oat@{w79NcuwvxdXm_edfrLS>b1S zPdB}%DLUv21o1?r%k@uxhL?lomepUUboME=SkhX{M0 zn%gDmxrQXItj~wH!ZIv>)C>w@1IB<-cIsbng7f~9@okT%_=6dky^_`_X*H^Y8G|d@ zg?tCDLV=jv<3&;BoyND@Gy$omP&>jT-T z+|QS>ze{<-ny?6PHn9$e-1({!riaTO)nm0IJ_G{b<%EBIZC+I;85c6@Zs9haMPKu1JRYR&tlH7U591xJ|{yy zr%uOkOj#3@!|HsGgU&w157q7F+U0*UObCMAU%KnD=nX4pl{zhB=?;8!*)3|}3nRPL zDlHC$2`;L9A_L+_inh%am9z6|9=J}X6jtstf!vvb24zZsEPI_WclxieHZ8H2)cGX2 z1Xk;!MHUu4*d?Z{IL<>W41(DSKHvfiwQ%$AxM?zOWJP^NM`SK1mp#}A9okccxX!!( zdD@Jzp?1_H+fJeksF5K32LlE<7bnhfG?=cCp16n8lbX`PZ`TqRZeRQW38>!FJAV*6 zig858P;}+n?mO5>K;EC4OMo-j^t4llU08k#{>c(N>DggQ*g{4|Jt7h9Pkd|tjl7n& zbIk8se{EmKD6IO0M9TqPI&nKm$_Q>Hi>Rk3$%|4VGLfe`itt^Y~_j}HtUrN%s zKXapV@m$#8@uoTq7@ZRV@kF_fTvhFS3BpabbR)yuG-5=#woy7tC_?te1pc@+D?;JP zVHTn(c8~%>UX`AJjT0R#YWj@N$)>SFVZ7+Hnset$Tk`IlEx|)bsvi@$0_O1`$IOW-){_8J3g^y94{mj&zr$IVaZCQjW~xIR{7_>8+`|VGyr|P zEn}KV{OjWw4sE=ST8y;AGYwx9*m7~XUNkk#!CbZTA*D9rwL%;?v0gwe)up3V zsx>1{A8qq~pa?lO8a$xRPU~-Jdf%GT-lZnFzrPpvbpjC;F;wb7(Yce&pnt+kzyrLj z;$*%~-2|uTDHu>(aD%m7MHK3vTAG&%r)R!U+%;y~f8q4pn^=WRLusy>l|!dt>%DA! z>y`h%cFFwC9?j<@CKYC$*=?yYOaBSmmzm4zbY)6srgAcQQ7waa`)-o=3rM!xx-#MH z7psfLxZkVnQ;WF&jKDDcn!*P$7Sw?fO_eUpN2M2n29-}xHOc0z3Nc|^ajaee3y4?z zY}}mjz$3z1$2Vf+BO6Iny}}bCddgLsHS6q1t-Nl*&ts*ccSDBNTk=vF{8UQuMb1~Y zXW43C<~e&mQq7XVve&ZEKI8`jGLp3ejI6Jkxd|Ir_5W0b7)z*|=G*JF#p`$0hDKm1 z!&w34a1b`JKOF@?812yj!Qfq*3U7_CFcFOGnC=$+BEO(A>#Or0V?lX z6vET@PE`SsQ4IKBMw ziV6lZ&i0O9C1`Q6x`@Q=YpclM4TGdsnd}T^W`$hn$VC7+%gPVzm zsWm9F8o$XRYO40oj+vSg$COH^S}x`jY_F4!l?2Liurz?PZ;(RV~d^e0%A;c;BiEneXnlo!O7(H)|HM zt|zW3y1vc$3x#8itNy}jzz(SAz;vQ-sEtH}MYz7!30_^xBZZ_lp z9wMP_Y}s{0oR~dUPYwT~5=QOZjY216=IgvDencC-VCV^cnQwlx$t9Yu^w@#9l*}GY7S-}^k#KZ8>@w55SGF#FUA!%!=1E=9xHa>4oMsbrDlg&Hg8-L zjGmPm);+S6di2hg@R5#ey5z{cepIz*9kW&o{$j}6DG>3)QL#KjZ%vXwkA-8Z|} zwBd&ga&k&O^0N!^4@*7c*q_gj_KaUdy|*NB$mgca)zLF31nJp;5aProb1(zT|+7($k_smg~shj10tFv{|} ze&A-d`=3hx$zj5lk^~P2vzLj4_hIfxHSJGrVFbk|IlIDPo8>pt!ZTa~(L=dHcE(qh zN-eF-c6^<`RoTjDpJgx7&Xu)srR!}(=qaB}Lz?*`gfP;}NnD00b8?W#TcI2=f!#6r zDDmHnOLk0P1uzESDF1e~UCm!vwQ0zg9n zwk9kZ!J`-nw*QB>_l{~R4ZlV$qmB*60w_{O9h4Rrq<0%4T{?uOA|)Wb6B2BQf`AGH zX`vZv2m+x8A|gdXO@K&^N(m4kLI?puxj{gk8Sn3Y>)!8v`Gd7!k7(m)9@ccDL@`oBEG_ z6$`w%hev;I$k9u$%O4mFECa@nE2D8X+LsWErWnrY^pyJo6?tJ(hhKG6>^?^oES>b) z`#1Bs*`Zlp0r2 zt^WgzefsbG2-ni`=vqdhlb>HYt6YE1*YBG{)0QQdpmZj1QJZpI0y~0M`tIH3k&%&$ zdEG#Ig^2z^{bTy40#d5Bi{H->&(*AKiF*Moaqnp;XSlnrrLiDC<(pPFuyns|Yg_rA zz5IWz(!Yvgy<{hLtBLYU6+Ha!Y5iRtq5HQ>{a-j6E`QIvuCRok-z>H(@<%G)Qw>+F z>#3$gru^Ft8|jPr-ialaM0d<|1SG|s=j#di!VrdH=N6WKbT@N;bknbWwKbH*y_CnA z4PC?XMy^5CHcx;^zBG`P5*HVWMoaN1c>nI<;gPwa+0KkZR<7Pj`KMGF`4~9D&9k~1 zwwVLU7@3=&WYI28%j|D{tt1LTmNu16eOj9L(3oSk8mhLFRIr%>{4Z;XSaXNMuip*_ zfR>t?f>*nE1plY|vT!?q^yPpi_`Y5DhKgV6$|0^9QTvQ5G*#U})jw~gqk{Yg2EBMf zm|Ieb84(H-XzaOdSUHqC*kWzPD(g$Xa{KxpN;4__kS`enJh6VZ zntknTokx#Tl~3VER1|wqzt3gHPQA~`qmAy3PRQcqD~>ko+D~FtX92t^N>fiiDCSxd zReVWo_x=4xQVRc`2+UtQqJgQoW?QDjkXzY&E)ejx_h@e@~mW!zeW4FWLQ7_}L_3;_|O`yJCKtrfieBm``+O5y= zyx55jg9x31+~e=i!s)2wuQR6us_d{2eN3tE|60HK8~gYZS%$gwEepk0grAQxXtfP9 zQ9c}X>vOphW9V%JZ!?j1x zW*4<3J_XAk>W=VamjX|!J7(X0j@y3_)3 z5e)QQ2+?Ei`}<3)<7*BJKAd|?Z{Z1X#=(2dRlHEfNS55IrL*sBPCY_3tU7J^#IDd6 z(|#YU{9Sy0<`6$Aa$I_u|4D21{#~&YkKO&GnZsBZ-`Bet7p!_^H`LAQTwqvA)iCwN zL4=Fn<$}g5ZwdqZODIs;5+O_4p%u9KLjo@49_b({wY*aA^eYkThv$`-Z!VbAoUCU|iXedT7#`M=4}`_Q_pk-dc-_}6y;@CE@Q0eEK>?ERni ztp-!AXeGDOVtBaMJVRvDkTM}Cp*~uGT@(H(`@z`xcMacqlxcyDGN1m6{KZiR=_~~X zB=@l6zDq}NAEQZ#cq#c<6ytI3Cv#q)_0`3{IhI3DFh#rtm8*8OFYddoiNRYY{V7sh2c{gzhHFAF5&K)!m8+cqVyS>tW`JICe}_ zEwB184mT#@(=+k5-?4w#g{K@L34I0olbQ8UPG7Jmhbp0X6v&+_RP$`H{aRYx73ycl z5R8p)(036PNdM2CYj^H4Zt3IFm7^x2OKfpIoRB>q7KN90ykv;wZ~!NNk==UfOHQ{I zf5d*)`c{YDt@y(C44Xdd8Ts61xAMkL`V6Z?Nze)4n`Ip>rc}(U5|*dJ<)ZNP9okH^ zFR<^+ZD}0c$t`2?_LWxt^u&5fwc`EATUG7^hy%Qz<&o2$8Nr^dbY;vP4F5=K>MU&xzD4|c;$ z3?iP|MyPy{D6`l7NH zuTHgJbZtbW<6;b@FdAs%1rqA@ z>CaAbcSESYWl<*mI~qUUEtmkG&o;an3>lG!^b3~cPrn+oEXWfK`f5vz4{BKGSWnz`1E-h4GrJgA++4+EOeG1vzApydykeJ~7*;QFBHf zcnM^_GkAS*=ML7#i7lAap9^%VC>LY5L9aR>TH?Pf##7<0#I(_3De(0EX)A^L473u0 zs*;r6OPVsaPt#EgXpF7AnVA_}K?taKEL?rmQzNTU97iR98dn|=_Mtsz?&!!_0N0Sg z)vCRP7+dHI+qDlyttm2w8v9xppTab8+KrxW6F93q!fm9?J%+0IhT>%HC4}9rW$3g5 z6u3vGYAzDl13sqe4e=0-L;oqDnagX=VwLir7ZeH^HM~wEo;lvh%WvA5|S> zZp=m3W_dNcR;L7t*8DkLP6B0z*(V7q*ACY`uo~6N7mP4-9$OLuU?KXQ0tiG!u-(Xw z`~}N0vNFK*kfDFR!hxbnu`J&Mxmkf&re)PRU-9IH#a%=*s!wcL#1x~Oh*vXATAz() z97Wv{(Iit(lFVWEX_}2>=<79@Yl3g~ouF56o0*E_(d8>z zx;@XA1t<$AENTmdXlvmIW0j} z{6bLSIz;KYepdXc0_!AU-2DJ5GcOT+Us0UX8w!_PqHquyXx2=0lI8be7=Rq86_!xuvAPC z07#}O$CWv+o4SDl@jC;!BH>ew#Bu6V()jaYc0v3QfE)b|hTgu$@$TR_u|(!XU9Ob@aOOmY?|p_dTs_13Yww@k zVnTq{-e@2G&Uao*S1K0t>zf%OZmI^jZ2aPA?@7x7&H!URe=@?5UaWSy5wHQLQcZV169t)}96-$T%y!NwO4Qn;C|22_6drUfDZltKj zJIGJ0%pE;(5w9JQBBe&i)bWZ}(jRt^@0q~xS_dHP;B0PYnE}NPpye*1wrOo1)hoDU zUX8*7x-72-mJZ8MHtZlD8NL(mnvmi0?l-GYc*#t^JQ~1+X4&Frf=zl>CJY z#F#h}zkbkt`O`HQ6(i3jR2D$f9hp~mkToW@(aP(ON~aYjKT?$w@X8+p3UI5vV{llG zCZ?<~J;izwdQZz(Q!9MZC%`x_Qf|oWv&X7elTYq3*=veZtU1@N2K(IuV(0UVc&Uig zw=*THMHCqZ+H&o-diS(kbGtI2eflHMdZX2WN`QVNCX(3m$ZXHxdYW$X6$w8NP26R> zyX!`J)~4!(88W6pNelUeB5IUiUdJrhHDT<~lP;eEmyU`WhvAWQQqHX|<%G(G=bk*l z3vyk1{O)hf??W(sgix@+31O7vMceamYpUpnlg_f#>b3l;h&2RqPIaZ{c%f(fCm8)mlqyCB8Yx^g zh>D3hj>yUa`$a=-8{%i}<*7au$0t9T4$u1+n1mfTZ17zA>fr=G{F{)>*Rz7{b7hmN z`xSuyI`0(UK`nY;PBHM;kt~8h=Bc*qqFm0lXz1c9SbPO zPaZ{Pm+qFfoptn~u|)Vn4sM#GX?<5&Gk4#`R)v8a4N5iDc--JkaGLc@zb@;&;H!rt z4VXj|x%T5Fyjnfu_dT4BIvKB?%lBz~Bj4w_Q1irC2w*F9r|;?@b@mapeW9GX)BMA1 zlbN(ZwS~-$H`13Msz~T{E(ik>8H8sAB%}3HXYHi*&J~QaBzeA5hTKHM^$OJhZ7(gb zmTdP#+WRXt^~qUhQ&jTGGcOf&MHqzE7MBp)Ppmf&5iSS{njMkw2>e&p_C%)pdXI%8BoP6Ek2>byMTF1dSbbtH(J_Zbf+Ch zukkC0nX_s_`mEgyNZ#11rWcVyYn5?nZdXFhYZGP{#R0|Gb)#t2p={9th-#s4={fO_ zxfoAPTzayNlfjpx)6K#$)q?7cGGYc_e)9vxdzgWPr5KgotaEmAT5V#KPFFuz6>&Ss z^vF+He_GRdJCE0-D(_-YK z>Am)<$~VTa^qPP-x|!?vqVn-aG5~l+eDrze=4Zi) z-9!X~tAO|!qqz|Z?}C79ZQ78vEB3vlR+T_3>On5FCX?#LfX2YMnvcI2JETeDPpxnm zjhA#tQbR0(tP>~fRYL5Hm*DaH{pT1_{2TpE9sA0t&d)_~in07=+mTg#uh)mN3R6Tt zQ(Jh*~q?e`3*1DvMY5Xsc;-WzNpY)GZF#bV3{oNHs>Jkd2CJhRUt?h)y5#1*^xNj|H&(a*`R4CNssu&_SXEPN^Vc2S z?f+XYpS%60e_A1MKAUy4XNi3)w}60vd~gEMzA~O#eQ5Jv-80ypY++ehQz0RtzH*NY zR?&j@Sj)-yS1Ds9@vdWu9z(cYn_qXA*z5=9kIW~Q)qy2=0wGz=ESOHY)Y=)xYt^)#jEUud2}lgLEY68@h~v}T&W+~` zxg6)^L#B2-u3X66831#p0jgAC0Ru-VkR2m)cvBZa#sZ*;NFE8vySudoHcYh+IOiPyPnKu$Jqn}UsjB6prFpBP0G83 z-i5-JbVfvbK0T4E_j%GKX%nj~DK$J;Wj7&X9*T*pxI5ywcGl!@lIgeu{Fdg2$APyZ z7~btm#-ae6G75z+YD8aYBdVE$fbyA9?{i@aqAx60N9S~r0-)CI`s-{0@rKt zFHT}qhK0*g#PfG``+N~g3CQA7MeXfTM|YUtjK?GMj&f>u@6JCZB=?tr?EG5B*t9U9 z_OAL%sks_6wGcCoK6E(z`amk7Ex^8ahMPI6eK^uN_Ydb+>53}ZITT6UUFv% zK+~rck1xMfZ=Qc;Y93(XEvK=6_i&eJi?H+Rxykvg(Pl|P6sqgvC9Cct{U;CV3XG#1 z?1iL@@5mFO5$5x-z38Zy(%CgAEX@sALA6Ox%8pKW^jjWO+fP|GIAzMA(KP4~8z4Z3 z;p>`{{dZ%Lj)K!$G&ROB$iPO42u$0_xu>T@q1}mR1q25}1k_$HDPL=Edo^P1cV*BW zj8RdpnkhoqEj9L;E;W|i1x>^(!DrCu?(u09F_zcw+X}lUa{v42fsNMYaNLQ@W(-<6MA_x{_1lQ6jC*h6&v-=`bv4a8{|;lbw>s)kjHS+ut{d z8S#%g=T|H&v*yg(CT_5>;ODJ(75(^JBED7B!lG6dn?CYTzKCP>wNF7qj_-inw0u>y zluo6PWfY*<#A8fJ`Hw!s;>eg8t@_bk-RfTX>(il-9EiMHksW^z{EB(x1XH?1{f%u3 zOt@8|`V(0vkh$h4&4amkvTf9o$cW6rdK&H%7(@`;E4C-*@cy+ zP6EnOa1w$hL$JR9n##zjGl>^^uqd_?uwdts3Y%k)LpU)WHiSSlR?;F_?`@Imro2jZ zY4ZU`=8v0!`4ht;^VC5g@f$szt-wW3*$^pyl+PZN1PA~2Fh1NKMu1I?C3bB&&rjU^ z*49D{K9O@u)X!-_Xk#1;jDup-9;!$(Zb>1`@A0k3WqCu#EC;PbOon~sp(J^e>D{bM z3Hj1)T9f-I>T#V(d0ak%snR4?HJExH-Ll$OGf_~P2Ez1k#uJx-%igaQo(=i>28E3F zE_6Db`t;GalsI2~tV`$(R%I@!#wDh&$5-2HCd(PCL?Zl$@c1(Oa+ZT=7d2{tHkZF(1f_Zc$fvH*JoY&7Af+SRLa(rl1esBs1RC>fcN=0s3!o~Rka35QTL)+^7U_rAMZZ7IRARzn9WopNJdT8vh zQn5$L<7==O7$~6wQG~}QMNEEAfI0Y_*<<-kx1(d-b{+_g&I%1h6N|kD_1N1rh;0Q8 zlWh&RCZ(%KCO&6M6=z$=nU+jmM@@w^(%VlHb94tzOuG+JJ<87c95l{aRO^FOHqBOE z&tiA&8oMG82aB@-@_o;;7Mz_gBOlDH-^dCR5os?KOoJ%<_}_}hd)_S~r*xEBsoRMU zWe5#EOlDLLq`1t0;&b1FPl+r~Ao$pSS0LewR%~y)x%mbKntRR3>(wH1o%q5t@u&JE@f#+@4%o~( z)k|!GkMg7On;fmRpyAa7Nx@bGX$&6g(3a%E89SC0;F6iR#AR*9UE?5N4UyZsd5!6r zt#J>pwVZRAF$VD!75@aH0+-?*rHCI;jNF{0x3M4ow_vvavxpHFUh7&$8Sl){)Y0kw zj?#;X6CZc%xcyAy>LsHMykEn(usI|EsH#_%5I=u382L2eD;WsO{>a81#cBwaCGYZWLqN=_v=53 z;r(#piWF&e-scvTS$1Qna1+(P4cP+6NMWx6O|J>Y()({30ImtRS_HI6%prY5^u2(e zwYXIp8HCl=lCd9u*IdQZ8vH)@KJ5TbyS-oPl5D87K|!2q50-rm8C0bcaB^SlG95S# zkg~Nz*hPUtlZL~K@MuqM{*dlqZ7okFBKB%jw+o6Wd%*e>>&7|x^_D({Lq3)~( zWY*F-WUtwAGosJAkxzOwo?(Jig+(grR1L5~rC(UT6z$y{q~{0hbDH;U_lS*wSsd%C z>={e|_q8jLi-;2iiAtII!R9(=;Y4AwGp)vUe(ISqvL1`}!ElxG-4QD%Osa-8{`p(2Btf>=WfAPRCu;~mv zL`c{-^=Po10QlDZ15+D7a^WXz>{Lpon!$ts3}>gPoAnLi#tF7ndsN-<{S5WI&!6@* z%PE$YK5&DoeHaaj+I$&ycYIY_GwQzDS@MpW_nv220XMKus~-`;sbr~}Dne{szYda}z5ZbH7b7=1{vs=B43vg!_aEfPVV z+m_=h23<0`N=t2lCLhdb^7e6UvMIJ5$ABjGs+*AkrLvV`8~fK$O26*SP4oOqxrQQ|XL}L?pkXrPg)dSbDGc8* zX}46h>7%7`#0jr}avSmuAgoC>*N3W6&p%yhDq3SQP44Y8LxRM9FGu zvB^(+A%L(;IOjbp)>k^|=Hiq?$j(>kXXEhF4w ztt?+Z*j44Vl!Ap?10DdKI=bA@l&qk}g)qBz<+S%z;;tYNsmer4hNnYMUM-TiXW3&| zo9=cTaqGjLNFr0J#w+!wf;!ZE$y07$z4P@_Z-fQqv_HSl>8`X; zT?G{inTYFxKo+VR5Cs|2J5P*zI<8AoI!xUq@%8Ezt#|sxH-MYuoDtXmFC^msSwQ-~n*p6Hw2F46qC9ej2%Ed{y-A0Sr`}H* z20Iy;6{WV5_}d4k5@D&L_&)l{^!IetrITQxUZ2Ip-wsdlWGwgm8S-kiayp<@kH$w9 zp;T2QVCzjLq&5RvYUVcxP!*_!<0G)i+i=+-K%=iz4&<%Bg0Tps>PNaQ-GlVo^CK&e z=EDBiU|}5+K(nXqvZ^hvsdGgg$t_p#<|SzJUKd;KHg}^R)Yce1Fv2k~@2DY{P>qFT z$XoP^COF`dTp7 zBD$(uzaXBkUs#v$NbLTk{P4}*)xLz(o|2iK0m2ZIa$E7`>lciAVd3|=9hp}1SHJ8{ z7E{R)hdbwY>{vaQWONdX1SFgVf{jRmNM+ki;;J$L3f3lB~%w-oo|NQ?^W z^3`k7hxW6$ABOW3>v42cxJ+pc>c0e7Hnb@TXESwohp3fRU$N~OVM#+FUo50duzuA; zr#8EPWB;^XTEWFgUM#~)?RUAN%MUL z@2IgIg>Y`hQn3mt2KDX&6QdqoWzO5b4h*G*$5gNK4jwy_pZ1|5GGL!nO>sR1sToYb zXyN(})GTob@VC6kA3vWDmews9ZU9AjO}R!y#nD`2~E_v-U>1@@kg1$d*InN*&< zh5M`Xw?34HZS!G^?$UR*iHZ%`R-ZC}cc3=B1x=6Z&<$QGEfRu40N$d~;4}%n z!Q3&OK1v!^6b~hv=Wac9-QQZP4_uxz1SVV-|HKFR6oA$6VsC!Ca~b&>si5BZk^%Ektqnu8~DOc|8OP3dywDM-*OjJ z`)Qe4o->iNa<)AOT52xJ94Y}8eZB{67V^Z1bwr(V$PLaAJgg=5yrOaBwQJhsaqRUf zy~HIo1&Diw41dvw0X^C_o|%4SedDIs6^87zlBO0@M&NyqVVtSyq*;yCQX^3$&D4Ej z^fR*u)XPwhv4h`*CuYj;KI1d!e9GuWj{&3o? z=o zWjNm^H++xwO)}bu9o@rC$bRDj@*&aE%qwhRy&;a|DB z`;>HtZTJPqShsdSU+EVS7nKCWZI)c$17Gj@k7I#SKbTXT0Z}$L$>MTh*PrP zK*&9(FRRMc!rSU5OAobq{^*a5mr1jJRLrNLU!#A!!KSIT^_SDH122O#K>Jg9Vqn`} zyH0ZTLs|!TlI=ak|8~=)Wkgh znsRq(mqBvsOJZ4eaNgV)y?ZJeMc-$6Y$S=3yH|U9`H=pX<&@^ZIImL{lKQ}-+fC>` zrpdCRSn9VS(DO+(+jL4n7AC-sXcH5g8Ed;Nh+MM;z8tPzS$dWq|IcQP+8F@*rhR#p z1C4TWa)3X9v%waI^j998CFKAnuHY_G2vB#NL-EE_)uflz zZ!YdxSWW0VYgRa`6$>+b{UAklVw#!J?f!L_og6Vf=e2`{;N*)X1%0&PF;~}Ai*s&< zFKmZCRIC~o44;@5>s@Or467a!Yn7i^9E9b5Sal^n35I{Aso>QA*<@#b5GbsvJI_>v zm2WVMMUF|5o@JC{o*~0buM&;`smiMlVydI=KS=Q6VV;8KTa(5W zDE>bMOmZat<@WN;f!_f3e8S^dWN=$568T2-Da-xA#2O8t>PUY%-Xx#sF54^gUg`K~ zDOp;z6+BRZTXsc_xfL#HV;3z)mHs{=a80$F^77Kx`kx!v&LB9f_%7MITKVsW73HN8D%@90cBIJGgeYvdjPZvA zK0@iaY@bg%ASRwmOS4OXf|G&DM@Tk@EwHcYaCW+fD5<8_5Lmh_AnnXuP@*=}ucS(5 zlK8it=|3)u=?Vgz-9ao`-FC)2KxI>?_2g7#VN=WY3r#}#U-rHF$mZ0UvYcz>UH;%9 zMWk|?m%DiEO^rXQ^sx> z%C>q-vmP$wx5Xyq)OzS}usBig#(mO0kTH2t3*-tUg%Z;5W^|=-O~%y^+t8+UgFWg? z;R?eBIbf9{T*Ekwvg=pPEn8PH(h&mSV6e#5B)qcOhBo|9$Dm4Ns8p5B>psj}zGrxF z*Liknyic&@XOI1h5&}b<`6w%k@dj1!ir>xeu@<_J5%kYJv}kV}Y>Y3Kbtq#v!@*=* zzdeXGz1$e+G(c`&4l=Afpgd;0xFdSBT?kq41zhubVasc-ut<&V8wv8HZ4i;O3Ixwz z3*qT4$BjzyF5>2E{tUaJt;1&v2rsN?HbmQh0V9{{nMnlux@SJd#+_#u>$d31-SP&KBf- z{2Un&Y3Iduo)U{P*<%01CGr3dT~=$y1Bd8H!DK zug6eP^R;=NLAwg$r;XJM{Z`O;7hVXHZOp@{Xp&;Iv^zBQJd=#!Tq3n~a* zE4t21W^m3M0#Tb#ePpR?GBq%lQPn;we6UXXuG$22YRkbM*S>mv6)-3@{hXl^2ZPne z)8;R}v2CfnoxD79T6$d7RFw-H2m9GhpRCu0+M+{in4d9Gw^>l%sJAF@n4{PIRgeEn zA{ini61cv`_ctwZfG{@+3;ufS@ZGM9Q2^oUf%VHb{t@vvrq8+y3|wyNnX2LRIZ-`G zU*_SvY48J+dk!-2dFTNZpbJGMJw$+ATd0m<@JX1pngW1xMvwZ=|0PoB_Zq&4_ZiD{ z0D-jla-Vc4N_ni_8kHk8IvtLf7X_>;t<+lqP0EocO_|VEn%pqjz4j){CCLv?7REE!+<@AylwUMBQ^M<32ej4HjoLs9@R*Pax$ z7&dqjq#Q23uHtFws`A^HP1hm`SCwDivu6xH_ruN%Do#F=tWvI@$lg0rAc_Qn%#rCn z>uL5c4tKvX2USd(135)cAe|GGa?~0rX601&5gPjMhmDJu7Rg9NQ(|QGT0( zN7l7`3^el7YJaKY9cEw{lW?H1NM@9G-NbGF(5xA!bm!6faHT{u?Onc$flUDFJ_-kEn*6mYJ{+mU&a%UX-&2DWtx)LavmyVT) z)k%xnc3-Ev_Fk;*`Tg+nUv(wPwjxqvov5%Jw^G8}!sLxyK2it^Q5 zuC2NIYO}Ia+OI6X@~ke3JHvOV=+(JO>g_1Y-A)r&Y@&Y!6>BE1sM6VCO_W!8cvG!} zH~VC`dfq97UBtg&*G6O;85>ie>YJN;fLI6`&zWW_xme7`%J8=uqz!ts zME)a*Fx~Y}bRBRq2KKmOq<&p&>{?_=WEza=3zlrh*W(~!-4Z<9pc zu|>J5Z_(~MQkS=sgDq2GHgqfF*-T@0#fqA!YmazWqs(V`R zgSfIb(+e8c>m}TG*mgQjY^+LdBY(K(X=DUcSgZL8zxrb>{;`4ncW|Wsm<2Hi3p8io z@xKtpx5V&rZoBbh6;Apq-zMi}zK99O&fWZ@3Q)ann?9gj2{+xS-$2Xffu=PneJ#H^ zk!V`1SJ}hQvLbo`9q&k%P$aKDr38DrHU0xPwqU=JjM$XXf-L)gw0ZKQq|aluwY!sdFj|C>llc;jC>$0~ozB|m#8EM}Ru z$QjX19ek+5)cCPES}wB?`PW|{a^s`sx8Z~^WD#xQ8C8^GsI?+$7>^r1O3DjQxzI5o zD+rk0)2vWwe$Zwn^Z8g^3F@r`_A6Mx8=2JrSwptVBJw&MlF8+K4lJ`*_Q(60E1zSg zLiPy1p%T0JYQrLIa?wd=I94zkZY2Kv#}By#>nhr`1(f?Q>qoT^pQa}#xO?p$ybzEW z#zWI>n=T0CZFKbeu;3SIJSFYNdShlIwbV#w#;@wy3hhxl)Zj;8ic{Hq{#itS95iLi z`)MhlZxs@>w##O5Vk!WZaFS-#vvWE#W0-hAsYrMT}$|{gxC7&&#fs zlsw>HGcs3U8gn89wn4n-#1)Wvot75uIj>iH^R|OF@2{fCoSbNH!;Zs&0thr_P7EMY z`5VzMS$gECO$ww6xU{651brw8fHZ(V{48EFd_^pxmWq#kHfyu(%k@{g%GT}F?$Jxl zD?o&2`YVl-JD$N0ym>@n;FQWh|KSXqGaLc?{@b=Kb)M;!x24XbUS`Agii_eGGzRTl zXbCbSRb#OLfJoJEMDX_(#+vRmxx%uU=J!GXlYa+R5$BtGhO^KE8;-u2&V6tOPKwcS zh$po@DR_t{vYmKEpGbMSxaRb6SsZ}yev>-8aLPcR?86B z$3S(zJiKq-Fmkxv+~YoUK+bUWlp4s)^FHxo7;%_YJc9{k+0(kAALIH`Tn&D_p+AAA ztu=~SbN~XS+^y4SB`yr9!K0j69u@~m+-?)`_&FZ&TDm1UfJ2X5z7`(vl-iBJw2$Cp zV5RhE*5RdIYLCtA4o4Ujvq7X+9`ZL2T%lX`3^Upz&DRU>u$02Ab7>~sZg`;Ip~MV8 zTTwp>QoJmP8Km?*C-bG$Tx6GZ%5;FO2Kg=-()J2B;q{08z{s-gQj?`2`+Gk!_6@=b z`57@}C^)BhVF#ZwCe3J{C?6qrxLgk^b|udCI(=Mww|f{9*_BNyYz z?Ejsv@b!83c09heMvnki6P8&Bi~<8qeD|ZM3hjAlr1z>Z<~>*T)wNnZsW?$yQ!G}3XE%l)bN4b;Dh zz@JjRjh))=us9%sWym5c3yX+|wFa@|S%!6E>{yWa?4I1Pe~t&{kfv7Mx4I6miLz~g zk-7yix^I}7j2^W~&HY!iaj(%*EdrzspZsY>=N_cKfx%1KhHZ|C{cA0K zPJaHGS=@vQ|F7#NEKka~4s7~(a-T97^SN85-{0KrSpV=lH#mY1{S_CvN&3|T!KpXg zP#?OB!@Z?zes#KNfen5lPox*%3u-$m0>Qd-~j0xJFkQ>;I(T zcI5t>Yhp#V<1en%H1*^@TpwQTjeQ#L)}U``J$+$bmp@#TbyljHNpAqS!4F2y5#S?) zd5WN7nJjxkA z$=`0v^qtniSf#mG6CSVNUC65xy~@JR50~c~GnU({9{ALytRXH(L5f{;Z&*-V0?DZi zl@wOJ$~K9*!Q+}g)Y9_$z!0$+jS&)EPg$p}JNN6Y=e=HCpJo;Q#JzteIV@6Xq+~nH zQ@wbTtHkkm?w`)t+$B@#p+n2MOV9>*9+Eodw8oj(?rpTskh90w%RdjPB|@EW7x+u`UX9z^omT157@0S{l6mYVp|!Rm+0q4H+=X> zy`)Kgou|x=RB*B^B**X_19iLTX7n0&|8;hVXG_T-|Ix0tz|b|1*LSdZ>9XMLzLkKC zu%Lydm0uET30hb!&-730gs*l1ZA^36-vzDnFE>3+C(ZM0Oq`u1>8GlU6!9zqqowBs zH4gNl;ZRB;SxlYAU+DBnX}$kF!oK-(9y~!oLw3(#s;Plzdz**_(6z?~xF?tL-b#fc zSH0*V{f3o&&UkiWix963$H0{Yyh%n`oTREVYNiz3ne9R@gD{8UE2S~v=VqFSutKZj zKI@?hEE9bxY*hu=`uLnk`>cZ7^zYJc^W`vmg)t7)jiB&`Nyf~j6qh8C|4blm*4eY< zbE8cXe~f$vzUs}qsF+q;xzuJ2h1d|HlXsVQ9ZqO8$+&{WlvnEp4~|HP^oOk@me|T5|Vz`lqkX-NW(bn$l`7y#_a`hW!2)ib+(M zPs8Gi-h2<@^m25iHYmWq(zO&#{Fpq1Q#`(1;<4HyQq1co^zU*9Yg+iUaMM7+h-_d^ zZA5`!{ZxB^3kfwy2oz2?^z(T(QxFvy7@@ktPTx<-EL%W;`tom84!ihifV8?tpk>bQ zRrMBKhk5?HGT6*x@>kY9l)WAL+t(?ECV>}dI$Na^QEgoSn2F}cFIBv8nwq#Dq+8g* zV(+gck&=l=C;cuCD_UUM&;tcI$`3eBVU0lYYQl>F(L*(=z{C5*Z#?#@5dt`iAc?0m zvKAW)Yl{fJ-P*o4|)1-rfYjQl@J0nbass3o9AKOOB}0^Z-drti1w2svkE#e`U|e;U-Sf%c%(`BauI4L_S(BGs*U9YN z1t_&!SmwL-RhjUZd1zG{#D9#*ZNIEtxGx2YEwF*K8OkV@Ol$ZYk;b z=y6Y!!h(#(VWx5{j8~;#dI&m`|_jol1w&kvinU-+fI_)Az?is7^6SYxZ zZEYl#%7>!L4&#k4XS2xTVz_)wrI`wVQJXO*EQqgkkl?Llmek-P)K&A;N=s|wfQCW5 zuC1@XIQ7Ox5eK|69;QMIBJ*Fz==6Mi(PudV54P@a?=o$dGR;%PjO!# z^!UAIl*u%`3lOr^j{iO`tP&AK@n6CfDXFSZO`MyJk+%j*WBj+zPEmaK0*e%5sTbc) ze+XV+PC8Gd3xUQ;SnA3KEf(a(nQHe5gP*P7&%q)RSpt{YzP3xfczgheK5u=1Dsu^! zHDJK15(swTNG+H3TST7q`h2sypjjCpl3-J=Ur>6$fck#-%o4d~)zD`Bk^~W(S`tZY zRDs?&BKxdEAq67o;P7~2X>^Dne&|Q|HBe^Zqgoa|`q{tN93brd63!OcnYS*M4BI&A zU|Jst6Wl&l04bD&mr4sa1y$HS>)W$&dI?yxZ0l$Phfa_t?)Ulv;bc8fK%svejBNpu zFeGA7SV&~5C@a0Lh9^^?fquG`zEEisKN&W|f(bHX-rc5#rUzMibf%6UzYosp8+%NjVzxQIB13{fmS& zTcz$pdt_HlbU+bTe++F{%tiH%SlM9KZ1Qdn<`>~)(IOE>lT-B&;Ccz`S%gr~Q_Qo? z7Z?XybO=d1UsTSN9KH}YQ?p%|A0BRW|3<~f7Nq{E|Y&wvacDp*N5XGe{-^Plv)L%AF3(l5#^JW>;K6>ZME?UD0D%cDLPIMtnN8g9^Komb{G_{XkY z4>b2o`&^oQ;8>XYXkt=!TgN_wahR<+nTO&r-}2P5^T3-#dP@)?NJj1i;oG zEd@z5PCaEqGO;iBwq^Nn9aKmGC$ENfMaKEq1!2AXOG>fOvPFOMu>N`C8B9V2 zn9%Y^01;cng7;wSG1uvj#&cszW9tb~trKHGd$rOhBRanc>i2H`4eYtsiNCWp(vLA2 z&EUUF)N#ARg@q%Quw%Afm5h`}$2!v*el*CriFKx6XVj0YF946eP=5ydhFyPq*!i;a z$*tbF z08S}kN^BjB#yTKR|7(18d*A8_!1uR%3NE$`IZQ)YH{bpo53;8g`>I~k+C0sj_49yA zF~huypYefgIz1kQ=In3W1KFQx2 zUC>hPVaT>xyuBE~vXr*Ga%Zy_uWxQl#iy_DHYV^7p0dshFZ-YM)0aMMcH3MLn0~7| zI7gjRPR~!ql=V7L!rS;Z!YbSbqBgft19bcUYVW(ln##I%x35|EaR4OH4lm6k~FL?E1&j|W|uqmdDIK ztx2Ab8C7QlMIg59BeK@7eOrxD0|euBZ264Oq;FOV%|r~*Vv3>De!B=4o)Y-2O+|%| zZ_+z*m@X+*eYI*k*RwRwz{O(*cdi^U?G5e8ZMH}d@2Q7_p}Gs=PN@wQ7|!Ls!-2!} z7Ew*F2H8oNLo=ZQu=bX(OslA#d3$BPPc}cpK&(4o26E!(%6oCsI+vA{2E8N1oRp3t zS*k<9^B0XS#(*s{64kDDXnU!lQ}ITL>w&j`b?!NLh3}W5P9D@*{H^wf02kM*jaHlp zc|o#hcB-s`AfpEN1ocq>)v-{`8iQ?ZKdI|; zFLLpIfOdCROn94-=Hw5t%Ohd?M#qm8KDn$VVbVSNod*`68qi+^*_!55D@&JleZNmY)HtqyjE^UZ5H{b^cBc*m^a=ITbNpop{4 zY!;hET(0X1eKnnTYrQKyOIYnQdVCdUdva)DHfUcr!Z03?}?ehx{)6AfO_9Mv&_Q5=UHNhQiKXF>R;0N z_H5_4`Y#6l8GemhhQ6ClshaVC$YR1kH6%JW+gqz59|4C2*G%XbOn%4h3yDpE!=Ek9 z*;f#}$G9h@^ zHtR|oy+$gS&a9RxoTG+msDY#HbdRlo^X`V7`f)aVTV)L8r?XE>R9)~Ddb?KJJP`WfFynAOTcraQ#-XePR+g5@`*B-cq>VzJX<4HAM z9igs1a?)Q%VHyz>D-F}ZNrrGXDvMZOhm5=o`p(sXV7Mb=TxzL>;qOcz*-5EC zXhy-L!Tq7`YARyeArjCicA&JF#P8`F(|S|F{cBq7UMjYsQ)O|XszV_-tuHLrJ}t3F zY3z#Vft%Toz7~tdb8wOo%i)N00lSEto z3V;fT8mMhHk=wWaAgG>r44IW(cyE0Gh+A5(GQz7*&*A=LrKPkYD-g*r5fy{+a&#`= z%*?7T3V$LPrf)qP_tl}2DwOryN}=0KWLF~MahJn9YmSoe%@H{tlpk7Ai*qGk-P9^k zeF)C6beICQG9NokAoE>POuOH~43BHJ#LIgE_{NA@7p7FtYT@DZO*xAm9I8E+^y1uxij3kpH&teMB}>eI1Hv< zC$$&~5BU^~RJYC@-*!EI@cxwsuSkmWJBokJE1p+HTyR7l>mGe79|3@O$T=S?U1|b^ zjtg*8z68Soo5zKjnO`@E-q*x%`yQ2Jx<_MJCtllPLL4zx8KJYePS<9`aGLoUiY-gW zWRP=Tqf^b-fO=*k>ovj(poMkU@c~vV#%-DOuEPBd2cszbvnG;r$)fI7B8Su4loQX! z!l~f^`z*}!v1&CAyQaMzu+qdj$(b4Jgm(&SIVNA=7WjV||T>AvBC zSlt!Y6k^bl@&-(pJej3pVsvd(vBXZ)o1ta_^O3t*VLckkwmz;6rz zfjN(sgU#`%Lrf9xyZ--hBd6U?3j2y?L9xJcL%+hNC0oG@H?vf^x zN=9=SDLpf;S)rWEox}E5w4V{i1JxX_s0pZvvfy z=714TLG!;m4-HrWc68R5ZTrztD9D$$OIG6;0oYOA-fgE}dz~+%slQMG_|TRbO*N)x^+bts43h_xiXdSG-@(4be=Qm70J+k3Qns&aFwVv~IipeeRLpA72LG z8TBzKXnJ`s*R^e9ef+VtJ`yMkr;0c2tqv?l>#`sS<^pvhxQ}q}<_348s*xm?J1QJy z01&{|k0Ux)%-~@nbq*6=`J3KiiWh&!%=iz)ILkf4 za|irF{dvr2GO8tbYMV^|I4?b=#%>4KyDwqqDzz3hsI*wy)%_gll_|v)MCQtZEf8~qb3of<`iIWkhY=@ei^(ZIifiY$oxU%0t7Szib7$yN^On=a z(?e)iX3IAN&105B^pi07?v{ch_`+@Xb^eJ95^f*g*o~;8e}Zwm$7hak(K*oU!gcJq z_28j4_vAMh9u#N6TR+%_t}N6l;@=FOGZo?0;OoybbPr@3Yhf>+vxt63*U|Qno{^pv5#PrgLctq%E?9Wp&E3@wf zsIL#b`a7T#qVFFHE({Bgr=OjEKSPB7y$+%!09~6n8R9gq z-07r+`3NsJI+NePox7Db!;@!z(f}1@T4h4iwo+cisoa*#XWUZ`mW7{_0rK=w&+J7x`@REY_C)lkVEWv(v?^BrZ zi*#;2C?@5l%GTMcoR?nMQSu9-BQ|eDTKOaG{|kBM6v-=6W8}CnK-r3rwr$+_pCS8= zruTRM6V_jRwzo}d>%hNJ!{0FXUlQJEdb!=}ADG(W)JNbiy!gAtzp%NDmfMp2rS}|e z=&XmL(OaR|-b;^~95=6Rdy@38^uBRD|L>S+L-v-x&K>yJ^w`+n5#Ry9@yEYxB4pw9 zv&ZZ9oc>z`^Iw|kVq<5S3_S9upMEkD0xz~F_)|c$F@!|=Cy|xB`hQI-|Em=u6z%U5 zzW>JZ*w8^>eeA!tHZ~M`9`%ofu|*9Ct=7O8DU9ep5((Q5865zuUc>&nvHFBRzaP7X zU5xr-9o(gK$r%`bZv4~#Z~XrS_U{=<@e=9WCz6QL*3kL#`u}F4=wTpff6I|Awy4BQ zllO<-7QJ$D?_`I}{!N2^QhQy|#wE8lsSct2 z55!QaAO^(F&=RxEDcSxD^YimNrgcJ-iOdwhbRtvL%^cCtUD`FiOm_d^bhH#o{8jCt znFW!1w3N<3yB2aHP`du`28``;T{Jc4T2{`wp=`0UOM%-1;+VzE7QeXhP@?-mz|gBd z9};`>N=M+WFYQy=yUWXXb5>DHwNv~LAP#lKbBrlG%0@1nDqqgnH_N99I+hMWfxY8+;B#c@)Qc2n>)$2s9 zhq^8m+iNuS93?LG{)zHR?wRn6g@TKyK11cvp;)ky|81$d*%G25B4@^*PEgi)n_m&v z7!VhfqJ$ak}PDZTG(&tEHn2Fk9LaF}s+ZI|5IZ;P-pL7rI>au66Etje;o9!OyR*y6Ct|Gwz?eQYi^k7KS6%x&aW6giA0Wp-S3dIBKSwzm;P715s&h3L?TsOYnjL^!V zsAOcJeS#-)o%+vV=wo~+_r7UKa5n6%&5ByTl05_nQIsi0nP^Snj=H#8*RreH%r>!; zkcCrs#Fo9Hbb0JjEticbn0lo~;aJv1WzMQW)PlM<3UK`#}4K)hJr|Mmz&dh$N- zxYS}cBeXZJSBU|f!bB4t@DZB(BGALRPWpXw3#%=l%bj+#60vhij^k(5stt)bRVhss zQHbEkIPZv@l_4$LNlnsp-osB$zeY0GN!5VZad9fuzij)irpo{zExTa{8d`4=pLGy{ z?LhJ*)Qo|Kddv#*Ae1I6VyKJegjEon;~jJ`%vxVaTgam8`RzlZVYyDNeGY4UfMDup zcm?u-e57`?GLO1eBBu-)82f@$2mo3i{9?Tx)`e8`F12)0=PTl|%^&QVdocNN%P)30 z?L*h;lQSK_GP6q;27h2DC0YvOh*M1J8nB83PtUA`ey2ID<};J*Zb|mSD)g-MVXWPA z9RoQNJZv~)_rAU3C;5X;kSZYm9dDVKO-xEohCiJdndZm>PPVKq(0#5wyRPGFp_e59 z%^i1ixfSG0qJHCYBxx;VX`w%TWf|f$Cb1gEH_{0U36KfkY3~~+_q`Dz^?Lb~De(f# z`?tX4S+7^Fwy{FZ_hDnebCi=c;hu78lLD8{!D|t-D`2=2u@-l2s$e8ugWP21Qs+~0 zcgY#IlHchs^HngS*l)G*q*}|PpDch=U4{j2Jx9Fk4T;a6+n0y(_pEZu#FG+wF2s(A zQx_+^aU3<3Z&Sk2`XHAyobF@F8&Ny<&Nbc^@Q z#><_kqcOX(zi@^dHz$>+A$7nDwL+b?j2Kd zGB+Q;^4RfuTrB%$T8HCPQE7G8<%Kawt*7KZr?`8X^00Wb5Sl}aZ~SCKR!xK5Xh}3?NrkwY|+E4w=E2p`Hjg5{bX|4K_4~hqPW)^1Lq|H_2 z5Ef}9TIod4c*;gXYeP((-8aoc#_KiXj}WS-n&M7wX5Y-~XI%E>DDdkyMk+`vVXK~K zn|}MDo}SU?7>aScn5?C~1VCVGd=Dy0j zb{Xldl^cH3R->_^qy!ag>EO3qW=4S>8D3@$mE&i?O)akLk9bhMQ*ZA0(H| z11mln!TamjK!R(CYlw`rEH8{5dT9=>Q0z~5^)WxT(+3uOUMQTKpCx`8f-BX@9fLHh zIOi2$0#UMQO>riya2ozH(tXhW4#*D$(}cxOHpe-6?j0ITu5LfLHBhIUA)&K^GgKXA z*v_3hcW4FEf=OmE59M0IZG8&nf>8(ez=B_v93;n*d6vXT_(Sizh9%4WX0FL>@vnap zA$NTONY#A4O-LLoVR}~+!4sezpu$l+%4s1b^#dDZB~dRTIwOr<-aTN9FwX73Ws;O14#CJ47#dEi=~*d)Ve|KtOYup$$S zoOG;~-ZylC&5qp&1WWsqsXm-_ZLmkOu9y|ZLZ-F#?sa3p)pzL=tdT;yNNw-oMjLTO z&s@_wywY^Gzt#CLY{%O51cOT}=cFYr_1p*Jdq{VfX?_!$5%r7W%`0^jDc$fS6v7QB zfpifwMvycZT@n!jUUsM-V&Za*egHb`l2S)N95@QqS6Mv8So_IC4QmI@@+I#_kxOXW z3|(l@RHg8vtBY^(Cc)6vp448Io5r=bvB7HvUln4lEzUXC%p&q%DQYjhgYjF?`Gy!x zeM2?HPz8EkY^Y$)u$4SDgm+4jo$a$OzPQuKd(cutyZWUR*<;<+_Nf}Q8YYRMyy#ZV znF!*J3wBM{N&+E~jiNTlWX&y4LHy8UXk2`@dhHFY7$0J(#Vx12?#`I(IBMhWFds5b zx@@@9MK_OJ%c86e(Wh6n8YftS=2K>FoxWza^iT{c@Q6~!SfIzCy$zgr>u{x zH$MxHaLf;CHele}`fj0l?sK<9Lj^tcf1>wXsbNg2LwdoC;rNMY8Iaq$7*TDeW4n>N zf8^|nZ#8#j{i3uythgX{-L5(sOdJUNw|C*kgxU)xML^C}iZ~g_W_c@C+$9%&Es#{0U z96YD#fzI}ZI_b08xjm>w+w|JSFxEhwLx{*6xNgN|G7T`EnNWUcelbkVr8|BAuVjc` z9otI!fzp8KBu&%O$RJBvKO@Nteff-e1Ot&jKR83iWPNr-<}v_$$d#inp-=X+vs?~{ zsGd^=(WJXVnp^IzPh$I+ga|G+qirt>Lek^}CNGXEna2@%fkhk6 zS9ey)CY-P7ptQ7R{6Q%YoY9LDo*JA>D1Sl+os*?y5Iq9#IHw~qQYGTkupIC?Ili<< zS5H2SGT9ox9{Wu?+{3cwpek4`0vqmmCB3VwU45qV4@y_eK>Vtn!}wbV@RP+lxxON| zlU4|9*@XI)RqOG$k3Uw@yeOSWOF5z*uVn{kq)&~fB17|T z6c9+VY#iX7y3G8oQGPO~9wk*x34a&l@-Wt~T#I+1Xd_BSDnfD>?+#(6 zy6RykmXW`)bUu5nDc1(4OwxU=i!W@n$j27gAh=0$6&dS^WMA^4pShT->QjM8Y^jEg zoCM}*2)C(L>`~PlZ+&AAYF6c8vxPgBOu5d|rM5jQ;LwS_JddgS=IJf%bIaU%t0&aC z7^--;%`yBTJrz`VQ@R7Fo4>IUAS*`(&0KV9yFwxXX6V>PeP*y*VM?rc@*i(581`JG zq9q=)+kL)+=v+EV<(C(vA;~g%1th{6ck*N6+``m#BXQ0%Ynb~gnYoD}Y;@3Mto><| zcri(c_uESu3(4*ADf@=Dm*T(J;;$JHy?mxhdKmB*GN~m_g&>dj&yH?O5rA(IH2*jwGraOQj@D6N>g{gL4HT5DUmU&6Ba&@C53wX zo2Ay+iv^XHS{fud07QmcOieac&V|{sTkhz$#)nhGniH;Zq6b{ls;v?YwM+ZcY}?zb z?6N1#bSsbu$92isADgR=joNw+g1U|M`F9SZ}^lG!!uTHIi4OxG+|0)biIizf{i;+pj*TqkeN$O0h+pprO_ zVz&oL_+==UtLn#aPzlgluzy2`-n&oY%XOl7jip^ntWhN|bL5$>wiK_YE2SNkr$3fg zMS>}=4Yo?Ku5(#)tM-k_<$V|1XY>M?BA{av|KDX&|0PPRe^ zvU0`jNKm~cJ7wmR)NHw9;rH!&U9=)H%YbKO6aTCmTk%FAn{QP=c@jYP^e$FR4f(!FwmVFQSi%(gGK2|nPiWAJ zve!VG61(hOmYs49B041G)rfIaU z)7=yr)u3{)+zu_3PmHFI;qpz-@OC#Ro3D_1_|@aC6KY3GP5f`CV<+?2+fA%io*n%W zM^9VO4Y?8#=T-3X?l_Li(Vk?+!}}`B;r+%LP3sZ&H{hct%C120TOl?UY=6fJF8v`# z8Hyl%$aVSx6LDSL4A7l&aXqmSz+!mfx2;F(-kdIk=JVw-mnXGE&lnPa*`YqwYMfrm zsqraUV(t7z0hl}H+!>;7vVNt$SrB5UOQlEYVwUYX4yoGsAc1uqS}MqzHrW(GrW_C= z;Z7LoOP0uXdG--4D^0sZ@YZ~x^u}C^LEP|ta0!oN7_IB42;DYe@t-2E91x^;7ex}V zdSiR`Lzwg_+Va>SaQ#Sr^C>esVZEI@j3ED(uv+;sw2Emyfs`(D{|jZkE=GL^vZ9df zD=sYH4%>{~v{~h2))-P=!oygb`ORbYF)6dxJ+KP=OKt604z*2yXwJ&Sqx33Imj-sp zVT_|?GGVnQYctNXrAF?O968DC3jsF_UWHqwIXZsKn8sudXjOa{pzT$|`y%dxvZMih z(=S}2JeC+{(_^+LvCXt)YGPmIPOcx)^JBE;sD!V3HdU(ppT=y_S(Ga0|0sf_zdsF8 zgkUd(ag$oiMTBuS@t_8?a`dK+iT>y5X*55AYLm)IYi-NEjHjDX`)0)LCJf};+uDu? zue?WT$_xKxrZHbnhw5*H@(p0hRw(aqDL!SVA60cLp3ltg}tym;4c8||W<6~{T#BCjVwSk=aB zmts?j^=klAk8^-~;pyxhhd)>1rl)O0@#Gn#^A6Rps~s^Zq9b3_1n(~0j78rIhatCJ z7or|_GC~E_tH1h7#c0OdnBdu`Wc>Xi=gl;|3LoA76-(p&Y5vtix%oQwk#Q;G`fB0~ zqhXTvA{;Q-njFq;MolaL1{~z?d)dQqPd+?03X$ zrw;IM)88aqHXqd~J8=-u>3!f4rR#ecKo;^B;kFsM_Mlz|k{-Mp_;Xy}Up2=iYMT=6 zSaXR_$wo>EZv?KZY6oJqZ|oCl`Ut$`PQ9&VCmmDxGP&t{rk_41aP8`6by%rv7A&W& zl4pK*7E^6}f)3t|>7^(yn!Owvjv=gx`|+VKSsDyC2dnrT)7X=?S34~YO~zY+orLVd zPiom??s>x}*qI=r(>WuotoD!tugGh28zUQ=QTYMbvITUcw0Ca);51}_T&qfdys9`d zUYzahCf5~|#GQ>J(Xv9xTCRKJV?=t4sC>-W07{Bnq28Pu->A%R= zfen09uzXEVWykNIMSGjr(BH3@uVZ7QdPO*UqPrYG6#pWhz4;iOtC4Y!Ni*M^{cIeR z&IanexFqgnNuUi*=Y_KSo%}ta*`)Vfh?2s9`ao$V){NYnw1{n=`#*-$ti!!|)J4*? z!HK5qTeZDuWnf(AVjmit8pIWofJ}&)e(wC)7;Sb&c&k_0VrZn(c|+qDA@5-?@&|89 z0^BbM4Mdw$1U)UMi=!>&sUtgdi#F>+sTKY literal 77124 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 **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.