From 8c8e2e51317cb1d8e0e5ed680840a3e76b909fba Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 20 Jul 2026 10:03:10 +0300 Subject: [PATCH 1/2] Bumped version to 1.3.20-SNAPSHOT Starting work on OpenAI proxy rate-limit error classification and prompt-caching improvements. Co-Authored-By: Claude Sonnet 5 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c948cd18..9fd4d8c0 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.19-SNAPSHOT + 1.3.20-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. From ccae202b9261ce1f9db6b1377659f0f218b6d374 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 21 Jul 2026 21:54:48 +0300 Subject: [PATCH 2/2] Improved ChatGPT/OpenAI-compatible connection rate-limit handling, caching, and diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles the ChatGPT-subscription rate-limit investigation and follow-up work: - Classified OpenAI-compatible rate-limit/quota errors as rate_limit_error so Claude Code reports them immediately instead of retrying up to 10 times - Keyed prompt_cache_key to Claude Code's own stable session id (instead of an internal id that rotated on every restart) so caching survives Continue/Resume session, and enabled it for the OpenAI-compatible connection type, which previously sent no cache key at all - Added an experimental per-model "Explicit Cache" option in Model Aliases for GPT-5.6-family models, defaulting on for that family; older GPT models get extended 24h cache retention instead - Detected empty completions from the provider (e.g. when a conversation grows too large for the model's context/output limit) and substituted an explanatory notice, including the size of the triggering request, instead of silently returning to the idle prompt - Added a "Session Statistics…" context menu item (prompt area and terminal) showing per-model cumulative token/cache usage and last-request size, shown only for sessions using the OpenAI-compatible or ChatGPT Subscription connection types - Clarified in the user manual that /usage never reflects ChatGPT subscription quota, since no account-wide usage endpoint is queried Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 7 + claude-launch-tests/README.md | 20 ++ claude-launch-tests/test_429_retry_headers.py | 143 +++++++++++++ .../test_session_id_header_stability.py | 101 +++++++++ .../test_system_prompt_prefix_stability.py | 112 ++++++++++ .../test_usage_command_endpoints.py | 117 ++++++++++ docs/user-manual.md | 8 +- pom.xml | 2 +- .../claudecodegui/ClaudeCodeInstaller.java | 13 +- .../controller/ClaudeSessionController.java | 7 + .../claudecodegui/mcp/MCPSseServer.java | 19 +- .../AnthropicToCodexTranslator.java | 166 +++++++++++++- .../AnthropicToOpenAITranslator.java | 182 +++++++++++++++- .../openaiproxy/OpenAIProxyConfig.java | 107 ++++++++++ .../openaiproxy/OpenAIProxyServlet.java | 202 ++++++++++++++++-- .../claudecodegui/process/ClaudeProcess.java | 20 +- .../claudecodegui/settings/ClaudeProfile.java | 45 ++++ .../settings/ClaudeProfileStore.java | 1 + .../settings/ClaudeProfilesPanel.java | 10 +- .../claudecodegui/settings/ModelAlias.java | 67 +++++- .../settings/ModelAliasesDialog.java | 22 +- .../claudecodegui/ui/ClaudePromptPanel.java | 24 ++- .../claudecodegui/ui/ClaudeSessionTab.java | 34 ++- .../ui/SessionStatisticsDialog.java | 126 +++++++++++ .../ui/ZoomableJediTermWidget.java | 37 +++- .../AnthropicToCodexTranslatorTest.java | 144 +++++++++++++ .../AnthropicToOpenAITranslatorTest.java | 159 ++++++++++++++ .../openaiproxy/OpenAIProxyConfigTest.java | 87 ++++++++ .../openaiproxy/OpenAIProxyServletTest.java | 193 +++++++++++++++++ .../settings/ModelAliasTest.java | 77 +++++++ .../ui/ClaudePromptPanelSendTest.java | 2 +- .../openaiproxy/req_system_prompt_gpt56.json | 6 + 32 files changed, 2200 insertions(+), 60 deletions(-) create mode 100644 claude-launch-tests/test_429_retry_headers.py create mode 100644 claude-launch-tests/test_session_id_header_stability.py create mode 100644 claude-launch-tests/test_system_prompt_prefix_stability.py create mode 100644 claude-launch-tests/test_usage_command_endpoints.py create mode 100644 src/main/java/io/github/nbplugins/claudecodegui/ui/SessionStatisticsDialog.java create mode 100644 src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServletTest.java create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_system_prompt_gpt56.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a408089..ea4b52e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +- Fixed the OpenAI-compatible and ChatGPT Subscription connection types silently ending a prompt with no visible response when the provider returned an empty completion (typically because the conversation grew too large for the model's context/output limit) — Claude Code now shows an explanatory message, including the size of the request that triggered it, instead of just returning to the idle prompt +- Added a "Session Statistics…" context menu item (right-click the prompt area or the terminal) showing cumulative prompt-cache and token-usage statistics plus the size of the last request, broken down per model used during the session; only available for sessions using the OpenAI-compatible or ChatGPT Subscription connection types +- Added an experimental "Explicit Cache" option per model in the Model Aliases dialog (for the OpenAI-compatible and ChatGPT Subscription connection types), enabled by default for GPT-5.6-family models, to reduce token costs on long conversations where that model family's automatic caching is unreliable; older GPT models get an extended 24-hour cache retention instead +- Fixed prompt caching for the OpenAI-compatible and ChatGPT Subscription connection types not surviving "Continue last session"/"Resume session", by keying the cache to Claude Code's own session id instead of an internal id that changed on every restart; also enabled prompt caching for the OpenAI-compatible connection type, which previously had none +- Clarified in the user manual that the `/usage` command shows only local, per-session statistics and never reflects ChatGPT subscription quota, since it does not query any account-wide usage endpoint +- Fixed the OpenAI-compatible and ChatGPT Subscription connection types not clearly reporting rate-limit/quota errors from the provider — Claude Code now shows the actual cause immediately instead of silently retrying up to 10 times when a ChatGPT subscription's usage limit is exhausted for an extended period + # 1.3.5 (2026-07-19) - Fixed Ctrl++/− terminal and Markdown Preview zoom shortcuts not working when using the numpad +/− keys diff --git a/claude-launch-tests/README.md b/claude-launch-tests/README.md index 75b805fa..4eeb7e37 100644 --- a/claude-launch-tests/README.md +++ b/claude-launch-tests/README.md @@ -18,6 +18,10 @@ All scripts unset CLAUDECODE and CLAUDE_CODE_ENTRYPOINT to avoid the | `test_term_env.py` | Does presence/absence of TERM affect output buffering? | | `test_stdin_close_after_output.py` | Can we send a response via stdin if we close it after receiving output? | | `test_interactive_choices.py` | Can claude ask clarifying questions and receive answers via stdin in --print mode? | +| `test_429_retry_headers.py` | Does a response header stop Claude Code CLI's built-in 429 retry loop? | +| `test_usage_command_endpoints.py` | Does the `/usage` slash command hit a live network endpoint? | +| `test_session_id_header_stability.py` | Does `X-Claude-Code-Session-Id` stay stable across `--continue`? | +| `test_system_prompt_prefix_stability.py` | Does the `system` field stay byte-identical turn-to-turn (needed for cache breakpoints)? | ## Conclusion: interactivity in --print mode @@ -32,3 +36,19 @@ All scripts unset CLAUDECODE and CLAUDE_CODE_ENTRYPOINT to avoid the of the CLI, not a PTY issue. PTY is only needed for permission prompts when running without `--dangerously-skip-permissions`. For the plugin, `--dangerously-skip-permissions` is an acceptable default (with a warning in the UI), deferring PTY support to a later optional stage. + +## Conclusion: 429 retry suppression + +| Response header | Requests before giving up | Wall time | +|---|---|---| +| none (baseline) | 8+ (still retrying at 90s test cap) | 90s+ (would reach ~10min for all 10 attempts) | +| `Retry-After: 0` | 8+ (behaves like baseline) | 90s+ | +| `Retry-After: 999999` | 1 | ~0.1s | +| `X-Should-Retry: false` | 1 | ~0.1s | + +**A large `Retry-After` value or an explicit `X-Should-Retry: false` header both make Claude Code +CLI give up immediately** instead of retrying up to 10 times, surfacing +`{"error":"rate_limit","is_error":true,"api_error_status":429,...}` right away. A small +`Retry-After` value does not suppress retries. Applied in +`OpenAIProxyServlet.setRetryAfterIfHardLimit()` — only for genuine hard-quota-exhaustion errors +that carry a `resets_in_seconds` field, not for ordinary transient 429s. diff --git a/claude-launch-tests/test_429_retry_headers.py b/claude-launch-tests/test_429_retry_headers.py new file mode 100644 index 00000000..31b48cf2 --- /dev/null +++ b/claude-launch-tests/test_429_retry_headers.py @@ -0,0 +1,143 @@ +""" +Hypothesis: Claude Code CLI's built-in retry behavior on HTTP 429 responses can be +influenced by response headers (Retry-After, or a should-retry-style convention), +so a proxy could tell the CLI "don't bother retrying, this is a hard quota limit" +instead of the CLI blindly retrying up to 10 times with its own backoff schedule. + +Background: the NetBeans plugin's OpenAI-compatible proxy already returns a clean +Anthropic-format `rate_limit_error` on 429 (see OpenAIProxyServlet.toCodexAnthropicError), +but captured logs from a real session still showed the CLI retrying +"attempt 1/10" .. "attempt 10/10" with 1s-39s backoff before giving up — even though +the underlying quota wouldn't reset for ~29 days. This test probes whether any +response header changes that behavior, run against a local fake server standing in +for ANTHROPIC_BASE_URL (same mechanism the plugin's proxy uses). + +Run: python3 claude-launch-tests/test_429_retry_headers.py + +Result: CONFIRMED — both mechanisms work. +- baseline (no extra headers): 8 requests over 90s, then still hadn't finished + (timed out at our 90s test cap; the real backoff schedule is 1s/2s/4s/8s/16s/32s/39s(capped) + and would keep going up to attempt 10/10, matching the ~10min real-session logs) +- Retry-After: 999999 (a value far larger than any sane retry budget): 1 request, + ~0.1s, CLI immediately surfaces `{"error":"rate_limit","is_error":true, + "api_error_status":429,...}` and exits — no retries at all +- Retry-After: 0: behaved like baseline (8+ requests, still retrying) — a small + value does NOT suppress retries, only a large one does +- x-should-retry: false: 1 request, ~0.1s, same immediate-surface behavior as + the large Retry-After case + +Conclusion: Claude Code CLI's SDK (Stainless-generated) honors both a large +`Retry-After` value and an explicit `X-Should-Retry: false` header to skip its +own retry loop and surface the error immediately. Applied in +OpenAIProxyServlet.setRetryAfterIfHardLimit(): when a Codex `usage_limit_reached` +error body carries `resets_in_seconds` (a genuine, long-lived hard quota limit), +the proxy now forwards it as `Retry-After` on the HTTP response — but only for +that specific hard-limit case, not for ordinary transient 429s (which have no +`resets_in_seconds` field), so those still get the CLI's normal short-backoff +retry behavior. This only applies to non-streaming responses (where the proxy +sets the real HTTP status/headers); the streaming (SSE) error paths in +OpenAIProxyServlet already commit to `HTTP 200` before the upstream error is +known, so there's no way to retroactively attach a Retry-After header there — +the error is only conveyed via the SSE `event: error` body in that case. +""" + +import http.server +import json +import os +import subprocess +import threading +import time + +CLAUDE = '/usr/local/bin/claude' +CWD = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ENV_BASE = {k: v for k, v in os.environ.items() + if k not in ('CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', + 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY')} + +RATE_LIMIT_BODY = json.dumps({ + "type": "error", + "error": {"type": "rate_limit_error", "message": "Rate limit test — should not resolve"}, +}).encode('utf-8') + +CMD_TAIL = ['--print', 'say hi', '--output-format', 'stream-json', '--verbose', + '--dangerously-skip-permissions'] + + +class CountingHandler(http.server.BaseHTTPRequestHandler): + """Always answers /v1/messages with 429 + RATE_LIMIT_BODY, optionally with + extra headers. Records every request's arrival time on the class-level list + supplied via `server.hits`.""" + + extra_headers = {} + + def log_message(self, fmt, *args): + pass # keep test output focused on our own summary + + def do_POST(self): + self.server.hits.append(time.monotonic()) + self.send_response(429) + self.send_header('Content-Type', 'application/json') + for k, v in self.extra_headers.items(): + self.send_header(k, v) + self.send_header('Content-Length', str(len(RATE_LIMIT_BODY))) + self.end_headers() + self.wfile.write(RATE_LIMIT_BODY) + + +def run_variant(label, extra_headers, timeout=90): + handler_cls = type('Handler', (CountingHandler,), {'extra_headers': extra_headers}) + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), handler_cls) + server.hits = [] + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + env = dict(ENV_BASE) + env['ANTHROPIC_BASE_URL'] = f'http://127.0.0.1:{port}' + env['ANTHROPIC_AUTH_TOKEN'] = 'sk-test-fake' + + print(f'\n=== {label} (headers={extra_headers or "none"}) ===') + start = time.monotonic() + try: + proc = subprocess.run([CLAUDE] + CMD_TAIL, env=env, cwd=CWD, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + timeout=timeout) + elapsed = time.monotonic() - start + timed_out = False + except subprocess.TimeoutExpired as e: + elapsed = time.monotonic() - start + timed_out = True + proc = None + + server.shutdown() + thread.join(timeout=5) + + hit_count = len(server.hits) + gaps = [round(b - a, 2) for a, b in zip(server.hits, server.hits[1:])] + print(f'requests to fake server: {hit_count}') + print(f'gaps between requests (s): {gaps}') + print(f'wall time: {elapsed:.1f}s{" (TIMED OUT)" if timed_out else ""}') + if proc is not None: + tail = proc.stdout.decode('utf-8', errors='replace')[-500:] + print(f'stdout tail: {tail!r}') + err_tail = proc.stderr.decode('utf-8', errors='replace')[-300:] + if err_tail.strip(): + print(f'stderr tail: {err_tail!r}') + return hit_count, elapsed, timed_out + + +if __name__ == '__main__': + results = {} + results['baseline (no extra headers)'] = run_variant( + 'baseline (no extra headers)', {}) + results['Retry-After: 999999 (far future)'] = run_variant( + 'Retry-After: 999999 (far future)', {'Retry-After': '999999'}) + results['Retry-After: 0'] = run_variant( + 'Retry-After: 0', {'Retry-After': '0'}) + results['x-should-retry: false'] = run_variant( + 'x-should-retry: false', {'x-should-retry': 'false'}) + + print('\n\n=== SUMMARY ===') + print(f'{"variant":40s} {"requests":>9s} {"seconds":>9s}') + for label, (hits, elapsed, timed_out) in results.items(): + print(f'{label:40s} {hits:9d} {elapsed:9.1f}{" TIMEOUT" if timed_out else ""}') diff --git a/claude-launch-tests/test_session_id_header_stability.py b/claude-launch-tests/test_session_id_header_stability.py new file mode 100644 index 00000000..755aa7bb --- /dev/null +++ b/claude-launch-tests/test_session_id_header_stability.py @@ -0,0 +1,101 @@ +""" +Hypothesis: Claude Code CLI's `X-Claude-Code-Session-Id` request header stays +identical across a `--continue` restart of the same conversation, since it's +the same identifier used for the `~/.claude/projects//.jsonl` +file that `--continue` reopens. If true, this header is a better source for the +OpenAI proxy's `prompt_cache_key` than the plugin's own per-process UUID (which +is regenerated on every PTY restart, defeating prompt-cache continuity across +"Continue last session"). + +Method: run `claude --print` twice from the same working directory against a +local fake HTTP server that echoes a valid Anthropic message response and +records the `X-Claude-Code-Session-Id` header of every request — first as a +new session, then with `--continue`. + +Run: python3 claude-launch-tests/test_session_id_header_stability.py + +Result: CONFIRMED. Both runs sent the identical session id header +(e.g. `3ff9518a-bfbd-4de7-92fe-13c419eba9f5` in one run), matching the +`session_id` field in the CLI's own `system/init` stream-json event. Applied in +OpenAIProxyServlet: `prompt_cache_key` is now sourced from this header (falling +back to the proxy's internal per-process UUID when absent), so prompt caching +survives "Continue last session" / "Resume session" instead of starting a fresh +cache key on every restart. +""" + +import http.server +import json +import os +import subprocess +import threading + +CLAUDE = '/usr/local/bin/claude' +CWD = '/tmp/claude_session_id_stability_test' +ENV_BASE = {k: v for k, v in os.environ.items() + if k not in ('CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', + 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY')} + +RESPONSE_BODY = json.dumps({ + "id": "msg_test", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5", + "content": [{"type": "text", "text": "hi there"}], + "stop_reason": "end_turn", "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, +}).encode('utf-8') + + +class RecordingHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + pass + + def do_POST(self): + session_header = self.headers.get('X-Claude-Code-Session-Id', '') + self.server.session_ids.append(session_header) + length = int(self.headers.get('Content-Length', 0)) + self.rfile.read(length) + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(RESPONSE_BODY))) + self.end_headers() + self.wfile.write(RESPONSE_BODY) + + +def run(env, prompt, extra_args=()): + cmd = [CLAUDE, '--print', prompt, '--output-format', 'stream-json', + '--verbose', '--dangerously-skip-permissions', *extra_args] + proc = subprocess.run(cmd, env=env, cwd=CWD, capture_output=True, timeout=30) + return proc.stdout.decode('utf-8', errors='replace') + + +def main(): + os.makedirs(CWD, exist_ok=True) + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), RecordingHandler) + server.session_ids = [] + port = server.server_address[1] + threading.Thread(target=server.serve_forever, daemon=True).start() + + env = dict(ENV_BASE) + env['ANTHROPIC_BASE_URL'] = f'http://127.0.0.1:{port}' + env['ANTHROPIC_AUTH_TOKEN'] = 'sk-test-fake' + + print('=== First run (new session) ===') + out1 = run(env, 'say hi') + ids_after_first = list(server.session_ids) + print(f'session ids after first run: {ids_after_first}') + + print('\n=== Second run (--continue) ===') + out2 = run(env, 'say hi again', extra_args=('--continue',)) + ids_after_second = list(server.session_ids) + print(f'session ids after second run: {ids_after_second}') + + server.shutdown() + + first_unique = set(ids_after_first) + second_new = set(ids_after_second) - set(ids_after_first) + print(f'\nfirst-run session id(s): {first_unique}') + print(f'session id(s) seen only in second run: {second_new}') + stable = first_unique and second_new <= first_unique + print(f'\nSTABLE ACROSS --continue: {stable}') + + +if __name__ == '__main__': + main() diff --git a/claude-launch-tests/test_system_prompt_prefix_stability.py b/claude-launch-tests/test_system_prompt_prefix_stability.py new file mode 100644 index 00000000..679297cc --- /dev/null +++ b/claude-launch-tests/test_system_prompt_prefix_stability.py @@ -0,0 +1,112 @@ +""" +Hypothesis: the Anthropic request's `system` field (Claude Code CLI's system +prompt) stays byte-identical turn-to-turn within a session, which is a +prerequisite for explicit prompt-cache breakpoints (prompt_cache_breakpoint) to +actually help — if the "stable" prefix isn't actually stable, anchoring a +breakpoint there is pointless. + +Method: run `claude --print` against a local fake HTTP server that echoes a +valid Anthropic message response and records every request body's `system` +field — once as a fresh session (two near-simultaneous requests within that one +process), then again via `--continue` (a second, separate process). + +Run: python3 claude-launch-tests/test_system_prompt_prefix_stability.py + +Result: MIXED — stable within a continuous process, NOT stable across +`--continue`/`--resume` process restarts. +- Within one `claude` process invocation, all captured requests had an + identical `system` field (byte-for-byte) — e.g. two requests both had + system_len=6083 in the first run, both 27038 in the second. +- Across a `--continue` restart (a fresh process), the `system` field changed + substantially (6083 -> 27038 chars in one observed run) — the CLI rebuilds + the system prompt on every process start, and it's *not* the same content as + a brand-new session's first turn. + +Conclusion: explicit prompt-cache breakpoints anchored on the system +prompt/instructions are safe and effective for the common case — a single +long-running interactive session with many turns (the exact pattern that +caused the original fast quota burn: a 140-message, ~465KB conversation +resent every turn without ever restarting the process). They are NOT expected +to carry a warm cache across `--continue`/`--resume` restarts, since the +prefix itself changes — but that's consistent with (and no worse than) the +already-expected cache-key reset on restart documented separately +(test_session_id_header_stability.py fixes the *key* staying stable across +restarts; it doesn't and can't fix the prefix *content* changing). +""" + +import http.server +import json +import os +import subprocess +import threading + +CLAUDE = '/usr/local/bin/claude' +CWD = '/tmp/claude_prefix_stability_test' +ENV_BASE = {k: v for k, v in os.environ.items() + if k not in ('CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', + 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY')} + +RESPONSE_BODY = json.dumps({ + "id": "msg_test", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5", + "content": [{"type": "text", "text": "hi there"}], + "stop_reason": "end_turn", "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, +}).encode('utf-8') + + +class RecordingHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + pass + + def do_POST(self): + length = int(self.headers.get('Content-Length', 0)) + self.server.bodies.append(self.rfile.read(length)) + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(RESPONSE_BODY))) + self.end_headers() + self.wfile.write(RESPONSE_BODY) + + +def run(env, prompt, extra_args=()): + cmd = [CLAUDE, '--print', prompt, '--output-format', 'stream-json', + '--verbose', '--dangerously-skip-permissions', *extra_args] + subprocess.run(cmd, env=env, cwd=CWD, capture_output=True, timeout=30) + + +def main(): + os.makedirs(CWD, exist_ok=True) + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), RecordingHandler) + server.bodies = [] + port = server.server_address[1] + threading.Thread(target=server.serve_forever, daemon=True).start() + + env = dict(ENV_BASE) + env['ANTHROPIC_BASE_URL'] = f'http://127.0.0.1:{port}' + env['ANTHROPIC_AUTH_TOKEN'] = 'sk-test-fake' + + run(env, 'say hi') + run_1_count = len(server.bodies) + run(env, 'say hi again', extra_args=('--continue',)) + + server.shutdown() + + systems = [] + for i, raw in enumerate(server.bodies): + req = json.loads(raw) + system = req.get('system') + systems.append(system) + print(f'request {i}: messages={len(req.get("messages", []))} ' + f'system_len={len(str(system)) if system else 0}') + + within_run1_stable = len(set(map(str, systems[:run_1_count]))) <= 1 + within_run2_stable = len(set(map(str, systems[run_1_count:]))) <= 1 + across_runs_stable = systems and systems[0] == systems[-1] + + print(f'\nstable within run 1 (same process, no restart): {within_run1_stable}') + print(f'stable within run 2 (same process, no restart): {within_run2_stable}') + print(f'stable across --continue restart: {across_runs_stable}') + + +if __name__ == '__main__': + main() diff --git a/claude-launch-tests/test_usage_command_endpoints.py b/claude-launch-tests/test_usage_command_endpoints.py new file mode 100644 index 00000000..32d39ca2 --- /dev/null +++ b/claude-launch-tests/test_usage_command_endpoints.py @@ -0,0 +1,117 @@ +""" +Hypothesis: the `/usage` slash command (interactive, PTY-only — not available in +--print mode) hits some Anthropic API endpoint other than /v1/messages, which a +proxy sitting at ANTHROPIC_BASE_URL (as the plugin's OpenAI-compatible proxy does) +could intercept and use to surface ChatGPT-subscription quota info. + +Method: run `claude` interactively under a PTY (via pexpect) with ANTHROPIC_BASE_URL +pointed at a local fake HTTP server that logs every request path it receives (any +method), for a session that never even reaches a real backend. Send "/usage" and +see which path(s), if any, get hit. + +Run: python3 claude-launch-tests/test_usage_command_endpoints.py + +Result: REFUTED — even more conclusively than expected. +`/usage` makes ZERO network requests. The fake server recorded no hits at all, +before or after sending `/usage`, yet the command still opened a fully populated +panel: + + Settings Status Config Usage Stats Session + Total cost: $0.0000 + Total duration (API): 0s + Total duration (wall): 5s + Total code changes: 0 lines added, 0 lines removed + Usage: 0 input, 0 output, 0 cache read, 0 cache write + +Conclusion: `/usage` is a purely local view over the CURRENT session's own +running totals (token counts already returned in each /v1/messages response, +code-diff stats the CLI tracks itself, wall-clock time) — it is not a query to +any live account-wide usage/quota/billing endpoint, for any connection type or +auth mode. This isn't a matter of the plugin's proxy needing to intercept a +different path; there is no such request to intercept. So `/usage` can never +show ChatGPT-subscription-wide remaining quota or reset time, regardless of any +proxy-side changes — that information only exists in the 429 error body +(`resets_in_seconds`) when the limit is actually hit. +""" + +import http.server +import os +import threading +import time + +import pexpect + +CLAUDE = '/usr/local/bin/claude' +CWD = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ENV_BASE = {k: v for k, v in os.environ.items() + if k not in ('CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', + 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY')} + + +class LoggingHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + pass + + def _handle(self): + self.server.hits.append((self.command, self.path)) + body = b'{"error":"not found (test stub)"}' + self.send_response(404) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + self._handle() + + def do_POST(self): + self._handle() + + +def drain(child, buf, seconds): + """Actively pull PTY output for `seconds` — pexpect only reads when asked + to, so a plain time.sleep() would leave the child blocked on a full PTY + buffer without ever making progress.""" + end = time.time() + seconds + while time.time() < end: + try: + buf.append(child.read_nonblocking(size=4096, timeout=0.2)) + except pexpect.exceptions.TIMEOUT: + pass + except pexpect.exceptions.EOF: + break + + +def main(): + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), LoggingHandler) + server.hits = [] + port = server.server_address[1] + threading.Thread(target=server.serve_forever, daemon=True).start() + + env = dict(ENV_BASE) + env['ANTHROPIC_BASE_URL'] = f'http://127.0.0.1:{port}' + env['ANTHROPIC_AUTH_TOKEN'] = 'sk-test-fake' + + child = pexpect.spawn(CLAUDE, ['--dangerously-skip-permissions'], + env=env, cwd=CWD, timeout=1, encoding='utf-8') + buf = [] + try: + drain(child, buf, 4) # let the startup banner settle + before = list(server.hits) + print(f'requests before /usage: {before}') + + child.send('/usage') + drain(child, buf, 1) + child.send('\r') + drain(child, buf, 4) + + after = list(server.hits) + print(f'requests after /usage: {after}') + print(f'\nNEW requests triggered by /usage: {after[len(before):]}') + finally: + child.close(force=True) + server.shutdown() + + +if __name__ == '__main__': + main() diff --git a/docs/user-manual.md b/docs/user-manual.md index a8845ef4..292dcb39 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -501,13 +501,16 @@ Click **Model Aliases…** to open the Model Aliases dialog. ![Model Aliases dialog](screenshots/model-aliases.png) -The dialog shows a table with three columns: +The dialog shows a table with four columns: | Column | Description | |--------|-------------| | **ID** | The model identifier as returned by the provider | | **Available** | ✓ if the model was found at the endpoint during the last Fetch; ✗ otherwise | | **Alias** | The standard alias to map this model to: `sonnet`, `opus`, `haiku`, `custom`, or blank (no alias) | +| **Explicit Cache** | Experimental: send explicit prompt-cache control to the provider for this model (see below) | + +**Explicit Cache column (experimental):** GPT-5.6-family models changed how automatic prompt caching works, making it unreliable for long conversations with a stable system prompt and a growing message history — exactly the pattern of an ongoing coding session (see [ChatGPT Subscription rate limits](#chatgpt-subscription) below for background). Checking this column sends explicit cache-control fields to the provider for that model: GPT-5.6+ models get an explicit cache breakpoint (limited to a 30-minute cache lifetime by the provider); older GPT models get a 24-hour cache retention hint instead. It's checked by default for GPT-5.6-family model IDs and has no effect for non-GPT models (e.g. Grok, Gemini) — their own caching, if any, is unaffected. Since this relies on provider behavior that isn't fully documented, treat it as experimental — uncheck it for a model if you notice no improvement or unexpected errors. **Buttons:** @@ -617,7 +620,8 @@ You can click **Copy sign in link** again at any time to cancel a stuck attempt **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. +- Usage is subject to your ChatGPT subscription's own rate limits, shared across all Codex/ChatGPT clients on that account. When the limit is reached, Claude Code now reports it immediately as a rate-limit error instead of retrying; the message doesn't currently include the exact reset time reported by the backend. +- The `/usage` command shows only local, per-session statistics (tokens/cost for the current conversation) — it does not query any live account-wide usage or quota endpoint, so it never reflects your ChatGPT subscription's remaining quota or reset time, for any connection type. - 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 diff --git a/pom.xml b/pom.xml index 9fd4d8c0..45cb35e3 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.20-SNAPSHOT + 1.3.28-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 5071fe29..f2f8212d 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ClaudeCodeInstaller.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ClaudeCodeInstaller.java @@ -258,8 +258,19 @@ public boolean isLockFileValid() { @Override public void registerOpenAIProxy(String uuid, String baseUrl, String apiKey, io.github.nbplugins.claudecodegui.settings.ProxyConfiguration proxy) { + registerOpenAIProxy(uuid, baseUrl, apiKey, proxy, null); + } + + /** + * Registers an OpenAI-compatible proxy session, additionally recording the + * owning profile id (not part of the {@code ClaudeCodeStatusService} + * legacy interface — callers that have a profile id available should + * prefer this overload; see {@code ClaudeProcess.start()}). + */ + public void registerOpenAIProxy(String uuid, String baseUrl, String apiKey, + io.github.nbplugins.claudecodegui.settings.ProxyConfiguration proxy, String profileId) { if (mcpServer != null) { - mcpServer.registerOpenAIProxy(uuid, baseUrl, apiKey, proxy); + mcpServer.registerOpenAIProxy(uuid, baseUrl, apiKey, proxy, profileId); } } diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 89a7e00a..5f027573 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -289,6 +289,13 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr /** Returns the last command attempted to start the process. */ public String getLastAttemptedCommand() { return claudeProcess.getLastCommand(); } + /** + * Returns the UUID of the active OpenAI-compatible proxy session, or + * {@code null} if the current session isn't using the OpenAI-compatible or + * ChatGPT Subscription connection type. See {@link io.github.nbplugins.claudecodegui.process.ClaudeProcess#getOpenAIProxyUuid()}. + */ + public String getOpenAIProxyUuid() { return claudeProcess.getOpenAIProxyUuid(); } + public void stopProcess() { if (statusTimer != null) { statusTimer.stop(); 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 fb451171..baf25e0c 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/mcp/MCPSseServer.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/mcp/MCPSseServer.java @@ -165,7 +165,24 @@ public void stop() { * @param proxy proxy settings from the profile (used by the servlet's HttpClient) */ public void registerOpenAIProxy(String uuid, String baseUrl, String apiKey, ProxyConfiguration proxy) { - openAIProxies.put(uuid, new OpenAIProxyConfig(baseUrl, apiKey, proxy)); + registerOpenAIProxy(uuid, baseUrl, apiKey, proxy, null); + } + + /** + * Registers an OpenAI-compatible proxy session, additionally recording the + * owning profile id so the servlet can look up per-model experimental + * prompt-caching settings ({@link io.github.nbplugins.claudecodegui.settings.ClaudeProfile#isExplicitPromptCachingEnabled}). + * + * @param uuid unique session identifier + * @param baseUrl provider base URL + * @param apiKey provider API key + * @param proxy proxy settings from the profile (used by the servlet's HttpClient) + * @param profileId owning profile id, or {@code null} + */ + public void registerOpenAIProxy(String uuid, String baseUrl, String apiKey, ProxyConfiguration proxy, + String profileId) { + openAIProxies.put(uuid, new OpenAIProxyConfig(OpenAIProxyConfig.Mode.CHAT_COMPLETIONS, + baseUrl, apiKey, proxy, profileId, null, null)); LOGGER.log(Level.FINE, "OpenAI proxy registered: uuid={0}", uuid); } diff --git a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslator.java b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslator.java index 266d7c7f..52e63720 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslator.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslator.java @@ -40,10 +40,42 @@ */ public final class AnthropicToCodexTranslator { + private static final java.util.logging.Logger LOG = + java.util.logging.Logger.getLogger(AnthropicToCodexTranslator.class.getName()); + static final com.fasterxml.jackson.databind.ObjectMapper MAPPER = AnthropicToOpenAITranslator.MAPPER; private AnthropicToCodexTranslator() {} + /** + * Text substituted for a response that finished with no text and no tool + * calls (e.g. Codex hit a context/output-length limit and returned nothing + * usable) — otherwise Claude Code silently returns to its idle prompt with + * no indication anything went wrong. {@code reason} and {@code status} are + * whatever Codex reported, when available; {@code requestMessageCount}/ + * {@code requestSizeBytes} describe the request that produced this empty + * response (0 when unknown), to help judge whether context length is the + * likely cause without needing to dig through logs. + */ + static String emptyResponseNotice(String status, String reason, + int requestMessageCount, long requestSizeBytes) { + StringBuilder sb = new StringBuilder( + "[Proxy] The provider returned an empty response (no text, no tool calls)"); + if (status != null && !status.isBlank()) { + sb.append(", status=").append(status); + } + if (reason != null && !reason.isBlank()) { + sb.append(", reason=").append(reason); + } + if (requestMessageCount > 0 || requestSizeBytes > 0) { + sb.append(". Last request: ").append(requestMessageCount).append(" messages, ") + .append(AnthropicToOpenAITranslator.formatBytes(requestSizeBytes)); + } + sb.append(". This usually means the request was too large for the model's context/output" + + " limit. Try /compact, or start a new session."); + return sb.toString(); + } + // ------------------------------------------------------------------------- // Request translation // ------------------------------------------------------------------------- @@ -57,6 +89,36 @@ private AnthropicToCodexTranslator() {} * @return Responses-API-format request JSON */ public static ObjectNode translateRequest(JsonNode anthropicRequest, String sessionId) { + return translateRequest(anthropicRequest, sessionId, false); + } + + /** + * 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} + * @param explicitPromptCaching experimental (see {@link io.github.nbplugins.claudecodegui.settings.ModelAlias}): + * when the resolved model parses as a GPT-family id older than + * 5.6, sends {@code prompt_cache_retention: "24h"}. GPT-≥5.6 + * explicit breakpoints ({@code prompt_cache_options}/ + * {@code prompt_cache_breakpoint}) are intentionally NOT + * implemented here yet: unlike the Chat Completions API's + * well-documented {@code text} content-part shape, whether the + * Responses API's {@code instructions} field (a plain top-level + * string, separate from {@code input[]} items) accepts the same + * content-part-array structuring needed to attach a breakpoint is + * unverified — even {@code openai/codex}'s own client doesn't do + * this yet (see the ChatGPT-subscription rate-limit + * investigation). Sending {@code prompt_cache_options.mode: + * "explicit"} without a matching breakpoint would disable + * automatic breakpoint placement without providing a replacement, + * which risks making caching *worse*, not better — so this is + * deliberately left as a follow-up pending live verification. + * @return Responses-API-format request JSON + */ + public static ObjectNode translateRequest(JsonNode anthropicRequest, String sessionId, + boolean explicitPromptCaching) { ObjectNode responses = MAPPER.createObjectNode(); String model = anthropicRequest.path("model").asText(""); @@ -99,6 +161,15 @@ public static ObjectNode translateRequest(JsonNode anthropicRequest, String sess responses.put("prompt_cache_key", sessionId); } + // Experimental explicit prompt caching (see the Javadoc above for why only the + // pre-5.6 prompt_cache_retention path is implemented here, not explicit breakpoints). + if (explicitPromptCaching) { + Double gptVersion = io.github.nbplugins.claudecodegui.settings.ModelAlias.parseGptVersion(model); + if (gptVersion != null && gptVersion < 5.6) { + responses.put("prompt_cache_retention", "24h"); + } + } + // Messages → flat input[] items ArrayNode input = responses.putArray("input"); JsonNode anthropicMessages = anthropicRequest.path("messages"); @@ -305,13 +376,31 @@ private static JsonNode convertToolChoice(JsonNode toolChoice) { // ------------------------------------------------------------------------- /** - * Translates a Codex Responses API response to Anthropic Messages response format. + * Translates a Codex Responses API response to Anthropic Messages response + * format, without request-size context for the empty-response notice (see + * the four-arg overload). * * @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) { + return translateResponse(codexResponse, model, 0, 0); + } + + /** + * 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 + * @param requestMessageCount number of messages in the request that produced this + * response, for the empty-response notice; 0 if unknown + * @param requestSizeBytes size in bytes of the request that produced this + * response, for the empty-response notice; 0 if unknown + * @return Anthropic-format response JSON + */ + public static ObjectNode translateResponse(JsonNode codexResponse, String model, + int requestMessageCount, long requestSizeBytes) { ObjectNode anthropic = MAPPER.createObjectNode(); anthropic.put("id", codexResponse.path("id").asText("msg_proxy")); anthropic.put("type", "message"); @@ -355,6 +444,14 @@ public static ObjectNode translateResponse(JsonNode codexResponse, String model) String status = codexResponse.path("status").asText("completed"); stopReason = hadToolCall ? "tool_use" : mapStatus(status); + if (content.isEmpty()) { + String reason = codexResponse.path("incomplete_details").path("reason").asText(null); + LOG.warning("Codex response had no content: status=" + status + + " reason=" + reason + " raw=" + codexResponse); + content.addObject().put("type", "text").put("text", + emptyResponseNotice(status, reason, requestMessageCount, requestSizeBytes)); + } + anthropic.put("stop_reason", stopReason); anthropic.putNull("stop_sequence"); @@ -392,13 +489,18 @@ public static final class StreamingState { private boolean messageStarted = false; private boolean doneReceived = false; private boolean textBlockOpen = false; + private boolean hadContent = false; private int textBlockIndex = -1; private int nextBlockIndex = 0; private String stopReason = "end_turn"; private int outputTokens = 0; private int inputTokens = 0; + private int cachedTokens = 0; + private int cacheWriteTokens = 0; private String messageId = "msg_proxy"; private String model = ""; + private final int requestMessageCount; + private final long requestSizeBytes; // Codex output_index -> block state, for function_call items private final java.util.Map toolCalls = new java.util.LinkedHashMap<>(); @@ -410,8 +512,28 @@ private static final class ToolCallState { int blockIndex; } + /** Creates a state with no request-size context for the empty-response notice. */ + public StreamingState() { + this(0, 0); + } + + /** + * @param requestMessageCount number of messages in the request being streamed, for + * the empty-response notice; 0 if unknown + * @param requestSizeBytes size in bytes of the request being streamed, for the + * empty-response notice; 0 if unknown + */ + public StreamingState(int requestMessageCount, long requestSizeBytes) { + this.requestMessageCount = requestMessageCount; + this.requestSizeBytes = requestSizeBytes; + } + public boolean isMessageStarted() { return messageStarted; } public boolean isDoneReceived() { return doneReceived; } + public int getInputTokens() { return inputTokens; } + public int getOutputTokens() { return outputTokens; } + public int getCachedTokens() { return cachedTokens; } + public int getCacheWriteTokens() { return cacheWriteTokens; } /** * Processes one named Responses-API SSE event and returns zero or more @@ -436,6 +558,7 @@ public String processEvent(String eventType, String dataLine) { case "response.output_text.delta" -> { String delta = data.path("delta").asText(""); if (!delta.isEmpty()) { + hadContent = true; if (!textBlockOpen) { textBlockIndex = nextBlockIndex++; out.append(AnthropicToOpenAITranslator.sseEvent("content_block_start", @@ -449,6 +572,7 @@ public String processEvent(String eventType, String dataLine) { case "response.output_item.added" -> { JsonNode item = data.path("item"); if ("function_call".equals(item.path("type").asText())) { + hadContent = true; closeTextBlockIfOpen(out); int outputIndex = data.path("output_index").asInt(0); ToolCallState state = new ToolCallState(); @@ -475,14 +599,40 @@ public String processEvent(String eventType, String dataLine) { if (!usage.isMissingNode()) { outputTokens = usage.path("output_tokens").asInt(outputTokens); inputTokens = usage.path("input_tokens").asInt(inputTokens); + cachedTokens = usage.path("input_tokens_details").path("cached_tokens").asInt(cachedTokens); + cacheWriteTokens = usage.path("cache_write_tokens").asInt(cacheWriteTokens); } - stopReason = !toolCalls.isEmpty() ? "tool_use" - : mapStatus(data.path("response").path("status").asText("completed")); + String statusStr = data.path("response").path("status").asText("completed"); + if (!hadContent) { + String reason = data.path("response").path("incomplete_details").path("reason").asText(null); + LOG.warning("Codex stream completed with no content: status=" + statusStr + + " reason=" + reason + " raw=" + dataLine); + String notice = emptyResponseNotice(statusStr, reason, requestMessageCount, requestSizeBytes); + textBlockIndex = nextBlockIndex++; + out.append(AnthropicToOpenAITranslator.sseEvent("content_block_start", + buildTextBlockStart(textBlockIndex))); + out.append(AnthropicToOpenAITranslator.sseEvent("content_block_delta", + buildTextDelta(textBlockIndex, notice))); + textBlockOpen = true; + } + stopReason = !toolCalls.isEmpty() ? "tool_use" : mapStatus(statusStr); doneReceived = true; closeTextBlockIfOpen(out); out.append(buildDoneEvents()); } case "response.failed", "error" -> { + LOG.warning("Codex stream " + eventType + " event: raw=" + dataLine); + if (!hadContent) { + String reason = data.path("message").asText( + data.path("error").path("message").asText(null)); + String notice = emptyResponseNotice(eventType, reason, requestMessageCount, requestSizeBytes); + textBlockIndex = nextBlockIndex++; + out.append(AnthropicToOpenAITranslator.sseEvent("content_block_start", + buildTextBlockStart(textBlockIndex))); + out.append(AnthropicToOpenAITranslator.sseEvent("content_block_delta", + buildTextDelta(textBlockIndex, notice))); + textBlockOpen = true; + } doneReceived = true; closeTextBlockIfOpen(out); out.append(buildDoneEvents()); @@ -616,6 +766,8 @@ public static final class ResponseAggregator { private String status = "completed"; private int inputTokens = 0; private int outputTokens = 0; + private int cachedTokens = 0; + private int cacheWriteTokens = 0; private final StringBuilder textBuffer = new StringBuilder(); // Codex output_index -> in-progress function call @@ -667,6 +819,12 @@ public void processEvent(String eventType, String dataLine) { JsonNode usage = response.path("usage"); inputTokens = usage.path("input_tokens").asInt(inputTokens); outputTokens = usage.path("output_tokens").asInt(outputTokens); + // cached_tokens/cache_write_tokens: not documented in openai/codex's own + // client, best-effort extraction mirroring OpenAI's Responses API usage + // shape (input_tokens_details.cached_tokens, top-level cache_write_tokens) + // for the Session Statistics dialog; default to 0 if absent. + cachedTokens = usage.path("input_tokens_details").path("cached_tokens").asInt(cachedTokens); + cacheWriteTokens = usage.path("cache_write_tokens").asInt(cacheWriteTokens); } default -> { /* ignore other event types (response.created, response.output_item.done, ...) */ } } @@ -702,6 +860,8 @@ public ObjectNode buildCodexResponse() { ObjectNode usage = root.putObject("usage"); usage.put("input_tokens", inputTokens); usage.put("output_tokens", outputTokens); + usage.putObject("input_tokens_details").put("cached_tokens", cachedTokens); + usage.put("cache_write_tokens", cacheWriteTokens); return root; } } diff --git a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java index 0f13a54b..304bc10f 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java @@ -36,24 +36,97 @@ */ public final class AnthropicToOpenAITranslator { + private static final java.util.logging.Logger LOG = + java.util.logging.Logger.getLogger(AnthropicToOpenAITranslator.class.getName()); + static final ObjectMapper MAPPER = new ObjectMapper(); private static final int MAX_TOKENS_NON_CLAUDE = 16384; private AnthropicToOpenAITranslator() {} + /** + * Text substituted for a response that finished with no text and no tool + * calls — otherwise Claude Code silently returns to its idle prompt with + * no indication anything went wrong. See {@code AnthropicToCodexTranslator}'s + * identical helper for the Codex-path counterpart of this issue. + * {@code requestMessageCount}/{@code requestSizeBytes} describe the request + * that produced this empty response (0 when unknown), to help judge whether + * context length is the likely cause without needing to dig through logs. + */ + static String emptyResponseNotice(String finishReason, int requestMessageCount, long requestSizeBytes) { + StringBuilder sb = new StringBuilder( + "[Proxy] The provider returned an empty response (no text, no tool calls)"); + if (finishReason != null && !finishReason.isBlank()) { + sb.append(", finish_reason=").append(finishReason); + } + if (requestMessageCount > 0 || requestSizeBytes > 0) { + sb.append(". Last request: ").append(requestMessageCount).append(" messages, ") + .append(formatBytes(requestSizeBytes)); + } + sb.append(". This usually means the request was too large for the model's context/output" + + " limit. Try /compact, or start a new session."); + return sb.toString(); + } + + static String formatBytes(long bytes) { + if (bytes >= 1024 * 1024) { + return String.format(java.util.Locale.ROOT, "%.1f MB", bytes / (1024.0 * 1024.0)); + } + if (bytes >= 1024) { + return String.format(java.util.Locale.ROOT, "%.1f KB", bytes / 1024.0); + } + return bytes + " bytes"; + } + // ------------------------------------------------------------------------- // Request translation // ------------------------------------------------------------------------- /** * Translates an Anthropic {@code POST /v1/messages} request body to an - * OpenAI {@code POST /chat/completions} request body. + * OpenAI {@code POST /chat/completions} request body, without a + * {@code prompt_cache_key} (see the two-arg overload). * * @param anthropicRequest parsed Anthropic request JSON * @return OpenAI-format request JSON */ public static ObjectNode translateRequest(JsonNode anthropicRequest) { + return translateRequest(anthropicRequest, null); + } + + /** + * Translates an Anthropic {@code POST /v1/messages} request body to an + * OpenAI {@code POST /chat/completions} request body, without experimental + * explicit prompt caching (see the three-arg overload). + * + * @param anthropicRequest parsed Anthropic request JSON + * @param promptCacheKey stable per-conversation cache-routing key, sent + * as {@code prompt_cache_key}; {@code null}/blank + * omits the field + * @return OpenAI-format request JSON + */ + public static ObjectNode translateRequest(JsonNode anthropicRequest, String promptCacheKey) { + return translateRequest(anthropicRequest, promptCacheKey, false); + } + + /** + * Translates an Anthropic {@code POST /v1/messages} request body to an + * OpenAI {@code POST /chat/completions} request body. + * + * @param anthropicRequest parsed Anthropic request JSON + * @param promptCacheKey stable per-conversation cache-routing key, sent + * as {@code prompt_cache_key}; {@code null}/blank + * omits the field + * @param explicitPromptCaching experimental (see {@link io.github.nbplugins.claudecodegui.settings.ModelAlias}): + * when the resolved model parses as a GPT-family id, sends + * {@code prompt_cache_options}/{@code prompt_cache_breakpoint} + * (GPT-≥5.6) or {@code prompt_cache_retention: "24h"} (older + * GPT models); no-op for non-GPT/unparseable model ids + * @return OpenAI-format request JSON + */ + public static ObjectNode translateRequest(JsonNode anthropicRequest, String promptCacheKey, + boolean explicitPromptCaching) { ObjectNode openai = MAPPER.createObjectNode(); // Model — pass as-is; user configures ANTHROPIC_DEFAULT_*_MODEL aliases @@ -85,6 +158,27 @@ public static ObjectNode translateRequest(JsonNode anthropicRequest) { } } + // prompt_cache_key — stable per-conversation key improving cache-hit routing. + // Confirmed as a cross-provider convention (not OpenAI-exclusive): xAI's own + // /v1/chat/completions reference documents this identical field/purpose, so + // it's sent unconditionally regardless of which OpenAI-compatible backend + // the profile targets. + if (promptCacheKey != null && !promptCacheKey.isBlank()) { + openai.put("prompt_cache_key", promptCacheKey); + } + + // Experimental explicit prompt caching (see ModelAlias#explicitPromptCaching). + // gptVersion is null for non-GPT/unparseable model ids — those are always a no-op here, + // regardless of the flag, since these are GPT-specific fields. + Double gptVersion = explicitPromptCaching + ? io.github.nbplugins.claudecodegui.settings.ModelAlias.parseGptVersion(model) : null; + boolean explicitBreakpoint = gptVersion != null && gptVersion >= 5.6; + if (explicitBreakpoint) { + openai.putObject("prompt_cache_options").put("mode", "explicit").put("ttl", "30m"); + } else if (gptVersion != null) { + openai.put("prompt_cache_retention", "24h"); + } + // Messages ArrayNode messages = openai.putArray("messages"); @@ -95,7 +189,18 @@ public static ObjectNode translateRequest(JsonNode anthropicRequest) { if (!systemText.isBlank()) { ObjectNode sysMsg = messages.addObject(); sysMsg.put("role", "system"); - sysMsg.put("content", systemText); + if (explicitBreakpoint) { + // Mark the end of the stable prefix (system prompt) with an explicit + // breakpoint, per OpenAI's documented Chat Completions content-part + // shape: {"type":"text","text":"...","prompt_cache_breakpoint":{"mode":"explicit"}} + ArrayNode contentParts = sysMsg.putArray("content"); + ObjectNode part = contentParts.addObject(); + part.put("type", "text"); + part.put("text", systemText); + part.putObject("prompt_cache_breakpoint").put("mode", "explicit"); + } else { + sysMsg.put("content", systemText); + } } } @@ -331,13 +436,31 @@ private static JsonNode convertToolChoice(JsonNode toolChoice) { // ------------------------------------------------------------------------- /** - * Translates an OpenAI Chat Completions response to Anthropic Messages response format. + * Translates an OpenAI Chat Completions response to Anthropic Messages + * response format, without request-size context for the empty-response + * notice (see the four-arg overload). * * @param openaiResponse parsed OpenAI response JSON * @param model model name to include in response * @return Anthropic-format response JSON */ public static ObjectNode translateResponse(JsonNode openaiResponse, String model) { + return translateResponse(openaiResponse, model, 0, 0); + } + + /** + * Translates an OpenAI Chat Completions response to Anthropic Messages response format. + * + * @param openaiResponse parsed OpenAI response JSON + * @param model model name to include in response + * @param requestMessageCount number of messages in the request that produced this + * response, for the empty-response notice; 0 if unknown + * @param requestSizeBytes size in bytes of the request that produced this + * response, for the empty-response notice; 0 if unknown + * @return Anthropic-format response JSON + */ + public static ObjectNode translateResponse(JsonNode openaiResponse, String model, + int requestMessageCount, long requestSizeBytes) { ObjectNode anthropic = MAPPER.createObjectNode(); anthropic.put("id", openaiResponse.path("id").asText("msg_proxy")); @@ -394,6 +517,15 @@ public static ObjectNode translateResponse(JsonNode openaiResponse, String model stopReason = mapFinishReason(choice.path("finish_reason").asText("stop")); } + if (content.isEmpty()) { + String finishReason = choices.isArray() && !choices.isEmpty() + ? choices.get(0).path("finish_reason").asText(null) : null; + LOG.warning("OpenAI response had no content: finish_reason=" + finishReason + + " raw=" + openaiResponse); + content.addObject().put("type", "text").put("text", + emptyResponseNotice(finishReason, requestMessageCount, requestSizeBytes)); + } + anthropic.put("stop_reason", stopReason); anthropic.putNull("stop_sequence"); @@ -430,13 +562,18 @@ public static final class StreamingState { private boolean thinkingBlockOpen = false; private int thinkingBlockIndex = -1; private boolean textBlockOpen = false; + private boolean hadContent = false; private int textBlockIndex = -1; private int nextBlockIndex = 0; private String stopReason = "end_turn"; private int outputTokens = 0; private int inputTokens = 0; + private int cachedTokens = 0; + private int cacheWriteTokens = 0; private String messageId = "msg_proxy"; private String model = ""; + private final int requestMessageCount; + private final long requestSizeBytes; // Per OpenAI tool_call index: {id, name, started} private final java.util.Map toolCalls = new java.util.LinkedHashMap<>(); @@ -448,8 +585,28 @@ private static final class ToolCallState { int blockIndex; } + /** Creates a state with no request-size context for the empty-response notice. */ + public StreamingState() { + this(0, 0); + } + + /** + * @param requestMessageCount number of messages in the request being streamed, for + * the empty-response notice; 0 if unknown + * @param requestSizeBytes size in bytes of the request being streamed, for the + * empty-response notice; 0 if unknown + */ + public StreamingState(int requestMessageCount, long requestSizeBytes) { + this.requestMessageCount = requestMessageCount; + this.requestSizeBytes = requestSizeBytes; + } + public boolean isMessageStarted() { return messageStarted; } public boolean isDoneReceived() { return doneReceived; } + public int getInputTokens() { return inputTokens; } + public int getOutputTokens() { return outputTokens; } + public int getCachedTokens() { return cachedTokens; } + public int getCacheWriteTokens() { return cacheWriteTokens; } /** * Process one OpenAI SSE data line and return zero or more Anthropic SSE events. @@ -460,7 +617,18 @@ private static final class ToolCallState { public String processChunk(String dataLine) { if ("[DONE]".equals(dataLine)) { doneReceived = true; - return buildDoneEvents(); + StringBuilder out = new StringBuilder(); + if (messageStarted && !hadContent) { + LOG.warning("OpenAI stream completed with no content: stop_reason=" + stopReason); + textBlockIndex = nextBlockIndex++; + out.append(sseEvent("content_block_start", buildTextBlockStart(textBlockIndex))); + out.append(sseEvent("content_block_delta", + buildTextDelta(textBlockIndex, + emptyResponseNotice(stopReason, requestMessageCount, requestSizeBytes)))); + textBlockOpen = true; + } + out.append(buildDoneEvents()); + return out.toString(); } JsonNode chunk; @@ -487,6 +655,8 @@ public String processChunk(String dataLine) { if (!usage.isMissingNode()) { outputTokens = usage.path("completion_tokens").asInt(outputTokens); inputTokens = usage.path("prompt_tokens").asInt(inputTokens); + cachedTokens = usage.path("prompt_tokens_details").path("cached_tokens").asInt(cachedTokens); + cacheWriteTokens = usage.path("cache_write_tokens").asInt(cacheWriteTokens); } return out.toString(); } @@ -505,6 +675,8 @@ public String processChunk(String dataLine) { if (!usage.isMissingNode()) { outputTokens = usage.path("completion_tokens").asInt(outputTokens); inputTokens = usage.path("prompt_tokens").asInt(inputTokens); + cachedTokens = usage.path("prompt_tokens_details").path("cached_tokens").asInt(cachedTokens); + cacheWriteTokens = usage.path("cache_write_tokens").asInt(cacheWriteTokens); } // Reasoning (thinking) delta — DeepSeek/OpenCode style @@ -528,6 +700,7 @@ public String processChunk(String dataLine) { if (!contentNode.isNull() && !contentNode.isMissingNode()) { String text = contentNode.asText(); if (!text.isEmpty()) { + hadContent = true; // Close thinking block before opening text block if (thinkingBlockOpen) { out.append(sseEvent("content_block_delta", @@ -560,6 +733,7 @@ public String processChunk(String dataLine) { if (tcName != null) state.name = tcName; if (!state.started && state.id != null && state.name != null) { + hadContent = true; // Close thinking block if open if (thinkingBlockOpen) { out.append(sseEvent("content_block_delta", 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 49f30ebf..ce8d8058 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfig.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfig.java @@ -3,6 +3,11 @@ import io.github.nbplugins.claudecodegui.settings.ProxyConfiguration; import java.net.http.HttpClient; import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; /** * Snapshot of OpenAI-compatible endpoint configuration taken at session start. @@ -29,6 +34,44 @@ public enum Mode { private final String accessToken; private final String accountId; + // Cumulative usage totals, per model, for the life of this proxy session (i.e. + // one PTY process) — surfaced via the Session Statistics dialog. Not persisted; reset + // to zero on every new session. Keyed by model, since a session can switch + // models mid-conversation (via /model) and per-model breakdown is more useful + // than one blended total. Insertion order (LinkedHashMap-backed reads via + // ModelUsage.snapshot) reflects first-seen order, which is also recency-ish + // since models are typically appended, not interleaved. + private final Map usageByModel = new ConcurrentHashMap<>(); + private volatile String lastModel; + + /** Mutable per-model usage accumulator; not exposed directly — see {@link UsageSnapshot}. */ + private static final class ModelUsage { + final AtomicLong inputTokens = new AtomicLong(); + final AtomicLong outputTokens = new AtomicLong(); + final AtomicLong cachedTokens = new AtomicLong(); + final AtomicLong cacheWriteTokens = new AtomicLong(); + final AtomicLong requests = new AtomicLong(); + // Not cumulative — overwritten on every request, so the Session Statistics dialog + // can show the size of the *last* request for this model (useful for judging + // whether an empty-response failure was caused by context/output-length limits). + volatile int lastRequestMessageCount = 0; + volatile long lastRequestSizeBytes = 0; + } + + /** + * Immutable snapshot of one model's cumulative usage, returned by + * {@link #getUsageByModel()}. + * + * @param lastRequestMessageCount number of messages in the most recent request for + * this model (not cumulative) + * @param lastRequestSizeBytes size in bytes of the most recent request for this + * model (not cumulative) + */ + public record UsageSnapshot(long inputTokens, long outputTokens, + long cachedTokens, long cacheWriteTokens, long requests, + int lastRequestMessageCount, long lastRequestSizeBytes) { + } + /** * Creates a {@link Mode#CHAT_COMPLETIONS} config for an OpenAI API-key-based provider. * @@ -90,4 +133,68 @@ public HttpClient buildHttpClient() { HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)) ).build(); } + + /** + * Accumulates usage totals from one successful response, keyed by model. + * Thread-safe — called from {@link OpenAIProxyServlet}'s request-handling threads. + * + * @param model the model id this response was for + * @param inputTokens uncached input tokens billed for this response + * @param outputTokens output tokens generated for this response + * @param cachedTokens input tokens served from cache (0 if not reported) + * @param cacheWriteTokens input tokens newly written to cache (0 if not reported) + */ + public void addUsage(String model, long inputTokens, long outputTokens, + long cachedTokens, long cacheWriteTokens) { + String key = model == null || model.isBlank() ? "(unknown)" : model; + ModelUsage usage = usageByModel.computeIfAbsent(key, k -> new ModelUsage()); + usage.inputTokens.addAndGet(inputTokens); + usage.outputTokens.addAndGet(outputTokens); + usage.cachedTokens.addAndGet(cachedTokens); + usage.cacheWriteTokens.addAndGet(cacheWriteTokens); + usage.requests.incrementAndGet(); + lastModel = key; + } + + /** + * Records the size of a request about to be sent for {@code model}, so the + * Session Statistics dialog can show it even if the request ultimately fails or + * returns an empty response (i.e. before {@link #addUsage} would otherwise + * be the only place a model gets registered). Thread-safe. Overwrites any + * previously recorded size for the same model — this is "last", not cumulative. + * + * @param model the model id this request is for + * @param messageCount number of messages in the request + * @param requestSizeBytes size of the (translated, upstream-format) request body in bytes + */ + public void recordRequest(String model, int messageCount, long requestSizeBytes) { + String key = model == null || model.isBlank() ? "(unknown)" : model; + ModelUsage usage = usageByModel.computeIfAbsent(key, k -> new ModelUsage()); + usage.lastRequestMessageCount = messageCount; + usage.lastRequestSizeBytes = requestSizeBytes; + lastModel = key; + } + + /** + * Returns the model id of the most recently recorded usage, or {@code null} + * if no usage has been recorded yet. + */ + public String getLastModel() { return lastModel; } + + /** + * Returns a snapshot of cumulative usage per model, in first-seen order. + * + * @return unmodifiable map, model id → usage snapshot; empty if nothing recorded yet + */ + public Map getUsageByModel() { + Map snapshot = new LinkedHashMap<>(); + for (Map.Entry e : usageByModel.entrySet()) { + ModelUsage u = e.getValue(); + snapshot.put(e.getKey(), new UsageSnapshot( + u.inputTokens.get(), u.outputTokens.get(), + u.cachedTokens.get(), u.cacheWriteTokens.get(), u.requests.get(), + u.lastRequestMessageCount, u.lastRequestSizeBytes)); + } + return Collections.unmodifiableMap(snapshot); + } } 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 21dcbacd..dde985e7 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServlet.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServlet.java @@ -86,6 +86,11 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I boolean streaming = anthropicReq.path("stream").asBoolean(false); String model = anthropicReq.path("model").asText("unknown"); + String cacheKey = resolvePromptCacheKey(req, uuid); + boolean explicitPromptCaching = isExplicitPromptCachingEnabled(config, model); + int requestMessageCount = anthropicReq.path("messages").size(); + long requestSizeBytes = body.getBytes(StandardCharsets.UTF_8).length; + config.recordRequest(model, requestMessageCount, requestSizeBytes); if (debug) { StringBuilder sb = new StringBuilder("OpenAI proxy ← Anthropic request: "); @@ -100,12 +105,13 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I } if (config.getMode() == OpenAIProxyConfig.Mode.CHATGPT_CODEX) { - handleCodexRequest(req, resp, config, uuid, anthropicReq, model, streaming, debug); + handleCodexRequest(req, resp, config, cacheKey, explicitPromptCaching, anthropicReq, model, streaming, + requestMessageCount, requestSizeBytes, debug); return; } // Translate request - ObjectNode openaiReq = AnthropicToOpenAITranslator.translateRequest(anthropicReq); + ObjectNode openaiReq = AnthropicToOpenAITranslator.translateRequest(anthropicReq, cacheKey, explicitPromptCaching); String openaiBody = AnthropicToOpenAITranslator.MAPPER.writeValueAsString(openaiReq); String targetUrl = config.getBaseUrl(); @@ -128,14 +134,15 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I HttpClient httpClient = config.buildHttpClient(); if (streaming) { - handleStreaming(httpClient, httpReq, model, resp, debug); + handleStreaming(httpClient, httpReq, model, resp, config, requestMessageCount, requestSizeBytes, debug); } else { - handleNonStreaming(httpClient, httpReq, model, resp, debug); + handleNonStreaming(httpClient, httpReq, model, resp, config, requestMessageCount, requestSizeBytes, debug); } } private void handleNonStreaming(HttpClient httpClient, HttpRequest httpReq, String model, - HttpServletResponse resp, boolean debug) throws IOException { + HttpServletResponse resp, OpenAIProxyConfig config, + int requestMessageCount, long requestSizeBytes, boolean debug) throws IOException { HttpResponse httpResp; try { httpResp = httpClient.send(httpReq, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); @@ -152,6 +159,7 @@ private void handleNonStreaming(HttpClient httpClient, HttpRequest httpReq, Stri LOG.severe("OpenAI proxy: provider returned HTTP " + status + ": " + errBody); resp.setStatus(status); resp.setContentType("application/json"); + setRetryAfterIfHardLimit(resp, errBody, status); resp.getWriter().write(toAnthropicError(errBody, status)); return; } @@ -173,7 +181,9 @@ private void handleNonStreaming(HttpClient httpClient, HttpRequest httpReq, Stri LOG.info(sb.toString()); } - ObjectNode anthropicResp = AnthropicToOpenAITranslator.translateResponse(openaiResp, model); + ObjectNode anthropicResp = AnthropicToOpenAITranslator.translateResponse( + openaiResp, model, requestMessageCount, requestSizeBytes); + recordUsage(config, model, openaiResp.path("usage")); if (debug) { LOG.info("OpenAI proxy → Anthropic response: stop_reason=" @@ -188,7 +198,8 @@ private void handleNonStreaming(HttpClient httpClient, HttpRequest httpReq, Stri } private void handleStreaming(HttpClient httpClient, HttpRequest httpReq, String model, - HttpServletResponse resp, boolean debug) throws IOException { + HttpServletResponse resp, OpenAIProxyConfig config, + int requestMessageCount, long requestSizeBytes, boolean debug) throws IOException { resp.setStatus(HttpServletResponse.SC_OK); resp.setContentType("text/event-stream"); resp.setCharacterEncoding("UTF-8"); @@ -216,7 +227,7 @@ private void handleStreaming(HttpClient httpClient, HttpRequest httpReq, String } AnthropicToOpenAITranslator.StreamingState state = - new AnthropicToOpenAITranslator.StreamingState(); + new AnthropicToOpenAITranslator.StreamingState(requestMessageCount, requestSizeBytes); int chunkCount = 0; boolean firstChunk = true; @@ -244,10 +255,16 @@ private void handleStreaming(HttpClient httpClient, HttpRequest httpReq, String if (state.isMessageStarted() && !state.isDoneReceived()) { writer.write(state.buildDoneEvents()); } - - if (debug) { - LOG.info("OpenAI proxy: streaming done, total chunks=" + chunkCount); + if (state.isMessageStarted()) { + config.addUsage(model, state.getInputTokens(), state.getOutputTokens(), + state.getCachedTokens(), state.getCacheWriteTokens()); } + + LOG.fine("OpenAI proxy: streaming done, total chunks=" + chunkCount + + " input_tokens=" + state.getInputTokens() + + " output_tokens=" + state.getOutputTokens() + + " cached_tokens=" + state.getCachedTokens() + + " cache_write_tokens=" + state.getCacheWriteTokens()); writer.flush(); } @@ -264,7 +281,8 @@ private void handleStreaming(HttpClient httpClient, HttpRequest httpReq, String * {@link #toCodexAnthropicError}. */ private void handleCodexRequest(HttpServletRequest req, HttpServletResponse resp, OpenAIProxyConfig config, - String uuid, JsonNode anthropicReq, String model, boolean streaming, boolean debug) throws IOException { + String cacheKey, boolean explicitPromptCaching, JsonNode anthropicReq, String model, + boolean streaming, int requestMessageCount, long requestSizeBytes, boolean debug) throws IOException { ClaudeProfile profile = ClaudeProfileStore.findById(config.getProfileId()); String accessToken; @@ -279,7 +297,7 @@ private void handleCodexRequest(HttpServletRequest req, HttpServletResponse resp return; } - ObjectNode codexReq = AnthropicToCodexTranslator.translateRequest(anthropicReq, uuid); + ObjectNode codexReq = AnthropicToCodexTranslator.translateRequest(anthropicReq, cacheKey, explicitPromptCaching); String codexBody = AnthropicToCodexTranslator.MAPPER.writeValueAsString(codexReq); String targetUrl = config.getBaseUrl(); @@ -305,7 +323,7 @@ private void handleCodexRequest(HttpServletRequest req, HttpServletResponse resp HttpClient httpClient = config.buildHttpClient(); if (streaming) { - handleCodexStreaming(httpClient, httpReq, resp, debug); + handleCodexStreaming(httpClient, httpReq, resp, config, model, requestMessageCount, requestSizeBytes); } else { // The Codex backend requires stream:true on every request body // (see AnthropicToCodexTranslator.translateRequest) — there is no @@ -313,12 +331,14 @@ private void handleCodexRequest(HttpServletRequest req, HttpServletResponse resp // 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); + handleCodexNonStreamingViaAggregatedStream(httpClient, httpReq, model, resp, config, + requestMessageCount, requestSizeBytes, debug); } } private void handleCodexNonStreamingViaAggregatedStream(HttpClient httpClient, HttpRequest httpReq, String model, - HttpServletResponse resp, boolean debug) throws IOException { + HttpServletResponse resp, OpenAIProxyConfig config, + int requestMessageCount, long requestSizeBytes, boolean debug) throws IOException { HttpResponse httpResp; try { httpResp = httpClient.send(httpReq, HttpResponse.BodyHandlers.ofInputStream()); @@ -334,6 +354,7 @@ private void handleCodexNonStreamingViaAggregatedStream(HttpClient httpClient, H LOG.severe("OpenAI proxy (Codex): provider returned HTTP " + status + ": " + errBody); resp.setStatus(status); resp.setContentType("application/json"); + setRetryAfterIfHardLimit(resp, errBody, status); resp.getWriter().write(toCodexAnthropicError(errBody, status)); return; } @@ -359,14 +380,17 @@ private void handleCodexNonStreamingViaAggregatedStream(HttpClient httpClient, H + " | " + AnthropicToCodexTranslator.summarizeCodexResponse(codexResp)); } - ObjectNode anthropicResp = AnthropicToCodexTranslator.translateResponse(codexResp, model); + ObjectNode anthropicResp = AnthropicToCodexTranslator.translateResponse( + codexResp, model, requestMessageCount, requestSizeBytes); + recordUsage(config, model, codexResp.path("usage")); 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 { + HttpServletResponse resp, OpenAIProxyConfig config, String model, + int requestMessageCount, long requestSizeBytes) throws IOException { resp.setStatus(HttpServletResponse.SC_OK); resp.setContentType("text/event-stream"); resp.setCharacterEncoding("UTF-8"); @@ -393,7 +417,8 @@ private void handleCodexStreaming(HttpClient httpClient, HttpRequest httpReq, return; } - AnthropicToCodexTranslator.StreamingState state = new AnthropicToCodexTranslator.StreamingState(); + AnthropicToCodexTranslator.StreamingState state = + new AnthropicToCodexTranslator.StreamingState(requestMessageCount, requestSizeBytes); String currentEvent = null; try (BufferedReader reader = new BufferedReader( @@ -416,9 +441,15 @@ private void handleCodexStreaming(HttpClient httpClient, HttpRequest httpReq, if (state.isMessageStarted() && !state.isDoneReceived()) { writer.write(state.buildDoneEvents()); } - if (debug) { - LOG.info("OpenAI proxy (Codex): streaming done"); + if (state.isMessageStarted()) { + config.addUsage(model, state.getInputTokens(), state.getOutputTokens(), + state.getCachedTokens(), state.getCacheWriteTokens()); } + LOG.fine("OpenAI proxy (Codex): streaming done" + + " input_tokens=" + state.getInputTokens() + + " output_tokens=" + state.getOutputTokens() + + " cached_tokens=" + state.getCachedTokens() + + " cache_write_tokens=" + state.getCacheWriteTokens()); writer.flush(); } @@ -452,11 +483,15 @@ static String toAnthropicError(String providerBody, int httpStatus) { if (json.has("type") && json.has("error")) { return providerBody; } - // OpenAI format: {"error":{"message":"...","type":"..."}} + // OpenAI format: {"error":{"message":"...","type":"...","code":"..."}} if (json.has("error")) { JsonNode err = json.get("error"); String msg = err.path("message").asText("Provider error " + httpStatus); - String type = err.path("type").asText("api_error"); + String rawType = err.path("type").asText(""); + String rawCode = err.path("code").asText(""); + String type = isQuotaOrRateLimitError(httpStatus, rawType, rawCode) + ? "rate_limit_error" + : (rawType.isBlank() ? "api_error" : rawType); String escaped = msg.replace("\\", "\\\\").replace("\"", "\\\""); return "{\"type\":\"error\",\"error\":{\"type\":\"" + type + "\",\"message\":\"" + escaped + "\"}}"; } @@ -470,6 +505,127 @@ static String toAnthropicError(String providerBody, int httpStatus) { return "{\"type\":\"error\",\"error\":{\"type\":\"api_error\",\"message\":\"Provider returned HTTP " + httpStatus + "\"}}"; } + /** + * Sets a {@code Retry-After} response header when the provider error body + * indicates a hard, long-lived quota exhaustion (Codex's + * {@code usage_limit_reached} error carries a machine-readable + * {@code resets_in_seconds} field for this). Confirmed empirically + * (see {@code claude-launch-tests/test_429_retry_headers.py}) that Claude + * Code CLI's SDK honors {@code Retry-After} and gives up immediately + * instead of retrying up to 10 times when the value is large — so this is + * only set for genuine hard limits, never for ordinary transient 429s + * (which have no {@code resets_in_seconds} field and should still get the + * CLI's normal retry/backoff behavior). + */ + private static void setRetryAfterIfHardLimit(HttpServletResponse resp, String providerBody, int httpStatus) { + Long retryAfterSeconds = extractHardLimitRetryAfterSeconds(providerBody, httpStatus); + if (retryAfterSeconds != null) { + resp.setHeader("Retry-After", String.valueOf(retryAfterSeconds)); + } + } + + /** + * Extracts the {@code resets_in_seconds} field from a hard-quota-exhaustion + * error body (currently only Codex's {@code usage_limit_reached} error + * carries this), or {@code null} if the error doesn't indicate a + * long-lived hard limit. + */ + static Long extractHardLimitRetryAfterSeconds(String providerBody, int httpStatus) { + if (httpStatus != 429) { + return null; + } + try { + JsonNode json = AnthropicToCodexTranslator.MAPPER.readTree(providerBody); + JsonNode resetsIn = json.path("error").path("resets_in_seconds"); + if (resetsIn.isNumber()) { + return resetsIn.asLong(); + } + } catch (Exception ignored) {} + return null; + } + + /** + * Recognizes OpenAI-format rate-limit/quota-exhaustion errors so they can be + * mapped to Anthropic's canonical {@code rate_limit_error} type, mirroring + * what {@link #toCodexAnthropicError} already does for the Codex backend. + * HTTP 429 always qualifies; otherwise matches OpenAI's documented + * {@code insufficient_quota} / {@code rate_limit_exceeded} error codes/types. + */ + private static boolean isQuotaOrRateLimitError(int httpStatus, String rawType, String rawCode) { + if (httpStatus == 429) { + return true; + } + String type = rawType.toLowerCase(java.util.Locale.ROOT); + String code = rawCode.toLowerCase(java.util.Locale.ROOT); + return type.contains("insufficient_quota") || code.contains("insufficient_quota") + || type.contains("rate_limit") || code.contains("rate_limit"); + } + + /** + * Resolves the value to send upstream as {@code prompt_cache_key}. + * + *

Prefers Claude Code CLI's own {@code X-Claude-Code-Session-Id} request + * header — confirmed stable across {@code --continue}/{@code --resume} + * restarts of the same conversation (same value used for the + * {@code ~/.claude/projects//.jsonl} file being reopened; + * see {@code claude-launch-tests/test_session_id_header_stability.py}) — + * over the plugin's own per-process proxy UUID, which is freshly generated + * on every {@code ClaudeProcess.start()} call and would otherwise start a + * new prompt-cache key on every session restart, discarding any warm cache. + * Falls back to the proxy UUID when the header is absent (e.g. non-Claude-Code + * clients, or older CLI versions that don't send it). + */ + private static String resolvePromptCacheKey(HttpServletRequest req, String uuid) { + String sessionHeader = req.getHeader("X-Claude-Code-Session-Id"); + return sessionHeader != null && !sessionHeader.isBlank() ? sessionHeader : uuid; + } + + /** + * Extracts usage totals from a raw provider {@code usage} object and + * accumulates them into {@code config} for the Session Statistics dialog. + * Handles both usage-field naming schemes seen across the two upstream + * shapes this servlet translates: Chat Completions + * ({@code prompt_tokens}/{@code completion_tokens}/ + * {@code prompt_tokens_details.cached_tokens}) and Responses/Codex + * ({@code input_tokens}/{@code output_tokens}/ + * {@code input_tokens_details.cached_tokens}); {@code cache_write_tokens} + * is a shared top-level field name on both. Missing fields default to 0. + * + *

Called from the non-streaming response paths; the streaming (SSE) paths + * accumulate the same totals directly from their {@code StreamingState} + * (see {@code handleStreaming}/{@code handleCodexStreaming}), which tracks + * cached/cache-write token details the same way once the stream completes. + */ + static void recordUsage(OpenAIProxyConfig config, String model, JsonNode usage) { + if (usage.isMissingNode()) { + return; + } + long input = usage.has("prompt_tokens") + ? usage.path("prompt_tokens").asLong(0) : usage.path("input_tokens").asLong(0); + long output = usage.has("completion_tokens") + ? usage.path("completion_tokens").asLong(0) : usage.path("output_tokens").asLong(0); + JsonNode cachedDetails = usage.has("prompt_tokens_details") + ? usage.path("prompt_tokens_details") : usage.path("input_tokens_details"); + long cached = cachedDetails.path("cached_tokens").asLong(0); + long cacheWrite = usage.path("cache_write_tokens").asLong(0); + config.addUsage(model, input, output, cached, cacheWrite); + } + + /** + * Resolves whether experimental explicit prompt caching is enabled for the + * given model, per the owning profile's {@code ModelAlias} settings. + * {@code false} when the config carries no profile id (e.g. older callers + * of the {@code CHAT_COMPLETIONS}-mode registration, or the profile was + * since deleted) — same as if the model were never opted in. + */ + private static boolean isExplicitPromptCachingEnabled(OpenAIProxyConfig config, String model) { + if (config.getProfileId() == null || config.getProfileId().isBlank()) { + return false; + } + ClaudeProfile profile = ClaudeProfileStore.findById(config.getProfileId()); + return profile != null && profile.isExplicitPromptCachingEnabled(model); + } + private static String readBody(HttpServletRequest req) throws IOException { StringBuilder sb = new StringBuilder(); try (BufferedReader reader = req.getReader()) { 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 b3a89d39..553786e0 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java @@ -165,8 +165,16 @@ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCl ClaudeCodeStatusService mcpSvc = Lookup.getDefault().lookup(ClaudeCodeStatusService.class); if (mcpSvc != null && mcpSvc.isServerRunning()) { String uuid = UUID.randomUUID().toString(); - mcpSvc.registerOpenAIProxy(uuid, profile.getBaseUrl(), profile.getApiKey(), - io.github.nbplugins.claudecodegui.settings.ProxyConfiguration.from(profile)); + io.github.nbplugins.claudecodegui.settings.ProxyConfiguration proxyConfig = + io.github.nbplugins.claudecodegui.settings.ProxyConfiguration.from(profile); + if (mcpSvc instanceof io.github.nbplugins.claudecodegui.ClaudeCodeInstaller installer) { + // Prefer the profile-id-aware overload so the proxy can look up + // per-model experimental prompt-caching settings. + installer.registerOpenAIProxy(uuid, profile.getBaseUrl(), profile.getApiKey(), + proxyConfig, profile.getId()); + } else { + mcpSvc.registerOpenAIProxy(uuid, profile.getBaseUrl(), profile.getApiKey(), proxyConfig); + } openAIProxyUuid = uuid; env.put("ANTHROPIC_BASE_URL", "http://127.0.0.1:" + mcpSvc.getServerPort() + "/openai-proxy/" + uuid); @@ -294,6 +302,14 @@ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCl /** Returns the last command attempted to start, as a space-joined string. */ public String getLastCommand() { return lastCommand; } + /** + * Returns the UUID of the active OpenAI-compatible proxy session (see + * {@code /openai-proxy/{uuid}/...}), or {@code null} if the current session + * isn't using the OpenAI-compatible or ChatGPT Subscription connection type. + * Used by the Session Statistics dialog to look up cumulative usage stats. + */ + public String getOpenAIProxyUuid() { return openAIProxyUuid; } + /** * Appends extra CLI args and session-mode flags to {@code cmd}. * 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 9611112a..08530c60 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfile.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfile.java @@ -158,6 +158,16 @@ public enum ProxyMode { */ private List customModels; + /** + * Model IDs opted into experimental explicit prompt caching (see + * {@link ModelAlias#explicitPromptCaching}), for the OpenAI-compatible and + * ChatGPT Subscription connection types. Only entries differing from + * {@link ModelAlias#defaultExplicitPromptCaching(String)} need to be + * stored, but for simplicity every model shown in the Model Aliases dialog + * has its resolved flag written here on save. + */ + private Map explicitPromptCachingModels; + /** * Extra CLI arguments passed to the {@code claude} process on startup. * E.g. {@code "--verbose --dangerously-skip-permissions"}. @@ -246,6 +256,7 @@ public ClaudeProfile() { this.extraEnvVars = new ArrayList<>(); this.modelAliases = new HashMap<>(); this.customModels = new ArrayList<>(); + this.explicitPromptCachingModels = new HashMap<>(); this.extraCliArgs = ""; this.storageDir = ""; this.chatgptAccessToken = ""; @@ -626,6 +637,40 @@ public void setCustomModels(List ids) { this.customModels = ids != null ? new ArrayList<>(ids) : new ArrayList<>(); } + /** + * Returns the per-model experimental explicit-prompt-caching flags. + * + * @return unmodifiable map (model ID → flag); never {@code null} + */ + public Map getExplicitPromptCachingModels() { + return explicitPromptCachingModels != null + ? Collections.unmodifiableMap(explicitPromptCachingModels) : Collections.emptyMap(); + } + + /** + * Replaces the per-model experimental explicit-prompt-caching flags. + * + * @param flags model ID → flag map; {@code null} clears the map + */ + public void setExplicitPromptCachingModels(Map flags) { + this.explicitPromptCachingModels = flags != null ? new HashMap<>(flags) : new HashMap<>(); + } + + /** + * Resolves whether explicit prompt caching is enabled for the given model + * ID, falling back to {@link ModelAlias#defaultExplicitPromptCaching(String)} + * when no explicit override has been stored for it. + * + * @param modelId model identifier + * @return {@code true} if explicit prompt caching should be sent for this model + */ + public boolean isExplicitPromptCachingEnabled(String modelId) { + if (explicitPromptCachingModels != null && explicitPromptCachingModels.containsKey(modelId)) { + return explicitPromptCachingModels.get(modelId); + } + return ModelAlias.defaultExplicitPromptCaching(modelId); + } + /** * Returns the extra CLI arguments string (e.g. {@code "--verbose"}). * 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 21e1c7a6..e29ef1bc 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfileStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfileStore.java @@ -76,6 +76,7 @@ public static List getProfiles() { def.setExtraCliArgs(stored.getExtraCliArgs()); def.setModelAliases(stored.getModelAliases()); def.setCustomModels(stored.getCustomModels()); + def.setExplicitPromptCachingModels(stored.getExplicitPromptCachingModels()); def.setStorageDir(stored.getStorageDir()); def.setOpenaiSubscription(stored.isOpenaiSubscription()); def.setChatgptAccessToken(stored.getChatgptAccessToken()); 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 873841c6..c3393459 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanel.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/settings/ClaudeProfilesPanel.java @@ -795,6 +795,7 @@ private void flushFormToCurrentProfile() { if (!rbOtherApi.isSelected() && !rbOpenAIProxy.isSelected() && !rbChatgptSubscription.isSelected()) { p.setModelAliases(null); p.setCustomModels(null); + p.setExplicitPromptCachingModels(null); } // Proxy @@ -986,10 +987,12 @@ private void onModelAliases() { // Reconstruct display list from stored alias map (sonnet/opus/haiku) and custom list java.util.List existing = new ArrayList<>(); for (java.util.Map.Entry e : p.getModelAliases().entrySet()) { - existing.add(new ModelAlias(e.getValue(), null, e.getKey())); + existing.add(new ModelAlias(e.getValue(), null, e.getKey()) + .withExplicitPromptCaching(p.isExplicitPromptCachingEnabled(e.getValue()))); } for (String id : p.getCustomModels()) { - existing.add(new ModelAlias(id, null, "custom")); + existing.add(new ModelAlias(id, null, "custom") + .withExplicitPromptCaching(p.isExplicitPromptCachingEnabled(id))); } ModelAliasesDialog dlg; if (p.computeConnectionType() == ClaudeProfile.ConnectionType.OPENAI_SUBSCRIPTION) { @@ -1011,15 +1014,18 @@ private void onModelAliases() { if (chosen != null) { java.util.Map aliasMap = new java.util.LinkedHashMap<>(); java.util.List customList = new ArrayList<>(); + java.util.Map cachingFlags = new java.util.LinkedHashMap<>(); for (ModelAlias m : chosen) { if ("custom".equals(m.alias())) { customList.add(m.id()); } else if (m.alias() != null && !m.alias().isBlank()) { aliasMap.put(m.alias(), m.id()); } + cachingFlags.put(m.id(), m.explicitPromptCaching()); } p.setModelAliases(aliasMap); p.setCustomModels(customList); + p.setExplicitPromptCachingModels(cachingFlags); } } diff --git a/src/main/java/io/github/nbplugins/claudecodegui/settings/ModelAlias.java b/src/main/java/io/github/nbplugins/claudecodegui/settings/ModelAlias.java index 275bc707..f1af8a21 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/settings/ModelAlias.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/settings/ModelAlias.java @@ -6,12 +6,17 @@ *

{@code available} is transient — it reflects the result of the last * "Fetch" call and is not persisted. * - * @param id model identifier as returned by {@code /v1/models} - * @param available {@code null} = unknown, {@code true} = reachable, {@code false} = not reachable - * @param alias CC standard alias to map to this model: {@code ""}, {@code "sonnet"}, - * {@code "opus"}, or {@code "haiku"} + * @param id model identifier as returned by {@code /v1/models} + * @param available {@code null} = unknown, {@code true} = reachable, {@code false} = not reachable + * @param alias CC standard alias to map to this model: {@code ""}, {@code "sonnet"}, + * {@code "opus"}, or {@code "haiku"} + * @param explicitPromptCaching experimental: send explicit {@code prompt_cache_options}/ + * {@code prompt_cache_breakpoint} (GPT-≥5.6 models) or + * {@code prompt_cache_retention: "24h"} (older GPT models) for this + * model, via the OpenAI-compatible or ChatGPT Subscription connection + * types. See {@link #defaultExplicitPromptCaching(String)}. */ -public record ModelAlias(String id, Boolean available, String alias) { +public record ModelAlias(String id, Boolean available, String alias, boolean explicitPromptCaching) { /** Validates and normalizes the record components. */ public ModelAlias { @@ -19,6 +24,14 @@ public record ModelAlias(String id, Boolean available, String alias) { if (alias == null) alias = ""; } + /** + * Convenience constructor defaulting {@code explicitPromptCaching} from the + * model id (see {@link #defaultExplicitPromptCaching(String)}). + */ + public ModelAlias(String id, Boolean available, String alias) { + this(id, available, alias, defaultExplicitPromptCaching(id)); + } + /** * Returns a copy with the given availability. * @@ -26,7 +39,7 @@ public record ModelAlias(String id, Boolean available, String alias) { * @return new {@code ModelAlias} with updated availability */ public ModelAlias withAvailable(Boolean available) { - return new ModelAlias(id, available, alias); + return new ModelAlias(id, available, alias, explicitPromptCaching); } /** @@ -36,7 +49,47 @@ public ModelAlias withAvailable(Boolean available) { * @return new {@code ModelAlias} with updated alias */ public ModelAlias withAlias(String alias) { - return new ModelAlias(id, available, alias); + return new ModelAlias(id, available, alias, explicitPromptCaching); + } + + /** + * Returns a copy with the given explicit-prompt-caching flag. + * + * @param explicitPromptCaching whether to opt this model into explicit prompt-cache control + * @return new {@code ModelAlias} with the flag updated + */ + public ModelAlias withExplicitPromptCaching(boolean explicitPromptCaching) { + return new ModelAlias(id, available, alias, explicitPromptCaching); + } + + /** + * Parses a GPT-family model id's {@code major.minor} version + * (e.g. {@code "gpt-5.6-terra"} → {@code 5.6}), or {@code null} if the + * id doesn't match a recognizable {@code gpt-.} pattern + * (custom/unknown ids, non-GPT models like {@code grok-4.5}, etc.). + */ + public static Double parseGptVersion(String id) { + if (id == null) return null; + java.util.regex.Matcher m = java.util.regex.Pattern + .compile("(?i)(?:^|[^a-z0-9])gpt-(\\d+(?:\\.\\d+)?)").matcher(id); + if (!m.find()) return null; + try { + return Double.parseDouble(m.group(1)); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * Default value for {@link #explicitPromptCaching}: on for parsed GPT + * versions ≥ 5.6 (the family whose implicit caching regressed — see + * the ChatGPT-subscription rate-limit investigation), off for anything + * else (older GPT models, non-GPT models, or unparseable ids) — those + * either don't need it or aren't confirmed to accept the 5.6-only fields. + */ + public static boolean defaultExplicitPromptCaching(String id) { + Double version = parseGptVersion(id); + return version != null && version >= 5.6; } /** 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 6cfbbc98..8911d2a3 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/settings/ModelAliasesDialog.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/settings/ModelAliasesDialog.java @@ -171,10 +171,10 @@ private void initComponents(List initial) { getRootPane().setBorder(javax.swing.BorderFactory.createEmptyBorder(8, 8, 8, 8)); // --- Table --- - tableModel = new DefaultTableModel(new String[]{"ID", "Available", "Alias"}, 0) { - @Override public boolean isCellEditable(int row, int col) { return col == 2; } + tableModel = new DefaultTableModel(new String[]{"ID", "Available", "Alias", "Explicit Cache"}, 0) { + @Override public boolean isCellEditable(int row, int col) { return col == 2 || col == 3; } @Override public Class getColumnClass(int col) { - return col == 1 ? Boolean.class : String.class; + return col == 1 || col == 3 ? Boolean.class : String.class; } }; table = new JTable(tableModel); @@ -182,6 +182,7 @@ private void initComponents(List initial) { table.getColumnModel().getColumn(0).setPreferredWidth(240); table.getColumnModel().getColumn(1).setPreferredWidth(70); table.getColumnModel().getColumn(2).setPreferredWidth(90); + table.getColumnModel().getColumn(3).setPreferredWidth(60); // Available column renderer: null→"", true→✓ green, false→✗ red table.getColumnModel().getColumn(1).setCellRenderer(new DefaultTableCellRenderer() { @@ -208,6 +209,9 @@ public Component getTableCellRendererComponent(JTable t, Object value, JComboBox aliasCombo = new JComboBox<>(ALIASES); table.getColumnModel().getColumn(2).setCellEditor(new DefaultCellEditor(aliasCombo)); + // Explicit Cache column: experimental explicit prompt caching (GPT-≥5.6: prompt_cache_options/ + // breakpoint; older GPT models: 24h retention). Auto-enabled for GPT-5.6+ model IDs. + // Drag-and-drop reordering table.setDragEnabled(true); table.setDropMode(javax.swing.DropMode.INSERT_ROWS); @@ -215,7 +219,7 @@ public Component getTableCellRendererComponent(JTable t, Object value, // Populate for (ModelAlias m : initial) { - tableModel.addRow(new Object[]{m.id(), m.available(), m.alias()}); + tableModel.addRow(new Object[]{m.id(), m.available(), m.alias(), m.explicitPromptCaching()}); } JScrollPane scroll = new JScrollPane(table); @@ -392,7 +396,7 @@ private void applyFetchedIds(List fetchedIds) { // Append new rows not already present for (String id : fetchedIds) { if (!existing.contains(id)) { - tableModel.addRow(new Object[]{id, Boolean.TRUE, ""}); + tableModel.addRow(new Object[]{id, Boolean.TRUE, "", ModelAlias.defaultExplicitPromptCaching(id)}); } } } @@ -421,7 +425,7 @@ private void onSort() { rows.sort((a, b) -> a.id().compareToIgnoreCase(b.id())); tableModel.setRowCount(0); for (ModelAlias m : rows) { - tableModel.addRow(new Object[]{m.id(), m.available(), m.alias()}); + tableModel.addRow(new Object[]{m.id(), m.available(), m.alias(), m.explicitPromptCaching()}); } } @@ -446,7 +450,7 @@ private void onAdd() { id = id.trim(); String error = validateModelId(id, -1); if (error != null) { setStatus(error); return; } - tableModel.addRow(new Object[]{id, null, ""}); + tableModel.addRow(new Object[]{id, null, "", ModelAlias.defaultExplicitPromptCaching(id)}); int newRow = tableModel.getRowCount() - 1; table.setRowSelectionInterval(newRow, newRow); table.scrollRectToVisible(table.getCellRect(newRow, 0, true)); @@ -523,8 +527,10 @@ private List collectModels() { String id = (String) tableModel.getValueAt(i, 0); Boolean avail = (Boolean) tableModel.getValueAt(i, 1); String alias = (String) tableModel.getValueAt(i, 2); + Boolean cache = (Boolean) tableModel.getValueAt(i, 3); if (id != null && !id.isBlank()) { - models.add(new ModelAlias(id, avail, alias == null ? "" : alias)); + models.add(new ModelAlias(id, avail, alias == null ? "" : alias, + Boolean.TRUE.equals(cache))); } } return models; diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanel.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanel.java index fd72ea24..bb943274 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanel.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanel.java @@ -80,6 +80,10 @@ public final class ClaudePromptPanel extends JPanel { private final Runnable onStartNewSession; /** Callback for "Switch to Session…" context menu item; may be {@code null}. */ private final Runnable onSwitchSession; + /** Callback for the "Session Statistics…" context menu item. */ + private final Runnable onShowSessionStatistics; + /** Returns whether Session Statistics is available for the current session's connection type. */ + private final Supplier sessionStatisticsAvailable; /** Current position in the history list; {@code -1} = newest (empty field). */ private int historyIndex = -1; @@ -108,6 +112,12 @@ public final class ClaudePromptPanel extends JPanel { * may be {@code null} * @param onSwitchSession called when "Switch to Session…" context menu item is clicked; * may be {@code null} + * @param onShowSessionStatistics called when the "Session Statistics…" context menu item + * is clicked, to show the Session Statistics dialog + * (cache/usage stats) + * @param sessionStatisticsAvailable returns whether Session Statistics is available for the + * current session (only OpenAI-compatible/ChatGPT + * Subscription connection types); queried on demand */ public ClaudePromptPanel(Consumer onSend, Runnable onCancel, @@ -115,7 +125,9 @@ public ClaudePromptPanel(Consumer onSend, Supplier> promptHistorySupplier, Supplier workingDirSupplier, Runnable onStartNewSession, - Runnable onSwitchSession) { + Runnable onSwitchSession, + Runnable onShowSessionStatistics, + Supplier sessionStatisticsAvailable) { super(new BorderLayout()); this.onSend = onSend; this.onCancel = onCancel; @@ -124,6 +136,8 @@ public ClaudePromptPanel(Consumer onSend, this.workingDirSupplier = workingDirSupplier; this.onStartNewSession = onStartNewSession; this.onSwitchSession = onSwitchSession; + this.onShowSessionStatistics = onShowSessionStatistics; + this.sessionStatisticsAvailable = sessionStatisticsAvailable; inputArea = new DecoratedTextArea(3, 40, workingDirSupplier); inputArea.setLineWrap(true); @@ -348,6 +362,9 @@ private void attachContextMenu(DecoratedTextArea area) { JMenuItem switchItem = new JMenuItem("Switch to Session\u2026"); switchItem.addActionListener(e -> { if (onSwitchSession != null) onSwitchSession.run(); }); + JMenuItem sessionStatsItem = new JMenuItem("Session Statistics\u2026"); + sessionStatsItem.addActionListener(e -> { if (onShowSessionStatistics != null) onShowSessionStatistics.run(); }); + menu.addPopupMenuListener(new javax.swing.event.PopupMenuListener() { @Override public void popupMenuWillBecomeVisible(javax.swing.event.PopupMenuEvent e) { List h = promptHistorySupplier.get(); @@ -355,6 +372,9 @@ private void attachContextMenu(DecoratedTextArea area) { nextPrompt.setEnabled(historyIndex > -1); startNewItem.setEnabled(onStartNewSession != null); switchItem.setEnabled(onSwitchSession != null); + sessionStatsItem.setEnabled(onShowSessionStatistics != null + && sessionStatisticsAvailable != null + && Boolean.TRUE.equals(sessionStatisticsAvailable.get())); } @Override public void popupMenuWillBecomeInvisible(javax.swing.event.PopupMenuEvent e) {} @Override public void popupMenuCanceled(javax.swing.event.PopupMenuEvent e) {} @@ -366,6 +386,8 @@ private void attachContextMenu(DecoratedTextArea area) { menu.addSeparator(); menu.add(startNewItem); menu.add(switchItem); + menu.addSeparator(); + menu.add(sessionStatsItem); } // ------------------------------------------------------------------------- diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index 22eda07e..80944783 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -327,7 +327,9 @@ public ClaudeSessionTab(File dir) { return wd != null ? wd.getAbsolutePath() : null; }, () -> onSaveAndSwitch("", SessionMode.NEW, null), - () -> openSwitchDialog(SessionMode.RESUME_SPECIFIC)); + () -> openSwitchDialog(SessionMode.RESUME_SPECIFIC), + this::showSessionStatistics, + this::isSessionStatisticsAvailable); // --- layout --- setLayout(new BorderLayout()); @@ -819,7 +821,8 @@ private void startSession(File dir, String profileName, String extraCliArgs, sessionTag = "[" + dir.getName() + "] "; settingsProvider = new NetBeansSettingsProvider(); settingsProvider.setZoomDelta(termZoomDelta); - ZoomableJediTermWidget widget = new ZoomableJediTermWidget(settingsProvider, this); + ZoomableJediTermWidget widget = new ZoomableJediTermWidget(settingsProvider, this, + this::showSessionStatistics, this::isSessionStatisticsAvailable); Color termBg = UIManager.getColor("EditorPane.background"); if (termBg == null) termBg = UIManager.getColor("Panel.background"); if (termBg != null) { @@ -1180,6 +1183,33 @@ private void scrollBottomOfComponents(java.awt.Container c) { * * @param initialMode the session mode to pre-select in the dialog */ + /** + * Whether Session Statistics is available for the current session — only + * true while the session is using the OpenAI-compatible or ChatGPT + * Subscription connection type (the only ones the plugin's proxy sees + * traffic for; see {@link #showSessionStatistics()}). + */ + private boolean isSessionStatisticsAvailable() { + return controller.getOpenAIProxyUuid() != null; + } + + /** + * Shows the Session Statistics dialog (cumulative cache/usage stats), sourced + * from the current session's {@code OpenAIProxyConfig} if it's using the + * OpenAI-compatible or ChatGPT Subscription connection type. + */ + private void showSessionStatistics() { + String uuid = controller.getOpenAIProxyUuid(); + java.util.function.Supplier supplier = () -> { + if (uuid == null) return null; + io.github.nbplugins.claudecodegui.ClaudeCodeInstaller installer = + org.openide.util.Lookup.getDefault().lookup(io.github.nbplugins.claudecodegui.ClaudeCodeInstaller.class); + return installer != null ? installer.getOpenAIProxyConfig(uuid) : null; + }; + java.awt.Frame mainFrame = org.openide.windows.WindowManager.getDefault().getMainWindow(); + new SessionStatisticsDialog(mainFrame, supplier).setVisible(true); + } + public void openSwitchDialog(SessionMode initialMode) { File workingDir = model.getWorkingDirectory(); if (workingDir == null) return; diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/SessionStatisticsDialog.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/SessionStatisticsDialog.java new file mode 100644 index 00000000..cb1f9c3d --- /dev/null +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/SessionStatisticsDialog.java @@ -0,0 +1,126 @@ +package io.github.nbplugins.claudecodegui.ui; + +import io.github.nbplugins.claudecodegui.openaiproxy.OpenAIProxyConfig; +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Window; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.table.DefaultTableModel; + +/** + * Read-only dialog showing cumulative prompt-cache/usage statistics for the + * current session, broken down per model (a session can switch models + * mid-conversation via {@code /model}), sourced from + * {@link OpenAIProxyConfig#getUsageByModel()}. + * + *

Only meaningful for sessions using the OpenAI-compatible or ChatGPT + * Subscription connection types — those are the only ones the plugin's proxy + * sees traffic for. + */ +public final class SessionStatisticsDialog extends JDialog { + + private static final String[] COLUMNS = {"Model", "Requests", "Input tokens", "Output tokens", + "Cached tokens", "Cache write tokens", "Last request"}; + + private final DefaultTableModel tableModel; + private final Supplier configSupplier; + + /** + * Creates the dialog. Call {@link #setVisible(boolean)} to show it. + * + * @param owner parent window (for modality/centering) + * @param configSupplier supplies the live {@link OpenAIProxyConfig} on each refresh + * ({@code null} if the current connection type isn't proxied — + * shown as an empty table with an explanatory note) + */ + public SessionStatisticsDialog(Window owner, Supplier configSupplier) { + super(owner, "Session Statistics", ModalityType.MODELESS); + this.configSupplier = configSupplier; + + setLayout(new BorderLayout(8, 8)); + getRootPane().setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + + tableModel = new DefaultTableModel(COLUMNS, 0) { + @Override public boolean isCellEditable(int row, int col) { return false; } + }; + JTable table = new JTable(tableModel); + table.setFillsViewportHeight(true); + JScrollPane scroll = new JScrollPane(table); + scroll.setPreferredSize(new Dimension(560, 160)); + add(scroll, BorderLayout.CENTER); + + add(buildNotePanel(), BorderLayout.SOUTH); + + JButton refreshBtn = new JButton("Refresh"); + JButton closeBtn = new JButton("Close"); + refreshBtn.addActionListener(e -> refresh()); + closeBtn.addActionListener(e -> dispose()); + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + buttons.add(refreshBtn); + buttons.add(closeBtn); + add(buttons, BorderLayout.PAGE_END); + + refresh(); + pack(); + setMinimumSize(getSize()); + setLocationRelativeTo(owner); + } + + private JPanel buildNotePanel() { + JLabel note = new JLabel("Totals are cumulative for this running session (reset on" + + " restart/resume); \"Last request\" reflects only the most recent request. One row" + + " per model used during this session. Only available for the OpenAI-compatible and" + + " ChatGPT Subscription connection types."); + note.setFont(note.getFont().deriveFont(note.getFont().getSize2D() - 1f)); + JPanel panel = new JPanel(new BorderLayout()); + panel.add(note, BorderLayout.CENTER); + panel.setBorder(BorderFactory.createEmptyBorder(4, 0, 0, 0)); + return panel; + } + + private void refresh() { + tableModel.setRowCount(0); + OpenAIProxyConfig config = configSupplier.get(); + if (config == null) { + return; + } + Map byModel = config.getUsageByModel(); + List> rows = new ArrayList<>(byModel.entrySet()); + for (Map.Entry e : rows) { + OpenAIProxyConfig.UsageSnapshot u = e.getValue(); + tableModel.addRow(new Object[]{ + e.getKey(), u.requests(), u.inputTokens(), u.outputTokens(), + u.cachedTokens(), u.cacheWriteTokens(), + describeLastRequest(u.lastRequestMessageCount(), u.lastRequestSizeBytes()) + }); + } + } + + private static String describeLastRequest(int messageCount, long sizeBytes) { + if (messageCount <= 0 && sizeBytes <= 0) { + return "n/a"; + } + return messageCount + " messages, " + formatBytes(sizeBytes); + } + + private static String formatBytes(long bytes) { + if (bytes >= 1024 * 1024) { + return String.format(java.util.Locale.ROOT, "%.1f MB", bytes / (1024.0 * 1024.0)); + } + if (bytes >= 1024) { + return String.format(java.util.Locale.ROOT, "%.1f KB", bytes / 1024.0); + } + return bytes + " bytes"; + } +} diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ZoomableJediTermWidget.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ZoomableJediTermWidget.java index 1ff52c11..0eefde2e 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ZoomableJediTermWidget.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ZoomableJediTermWidget.java @@ -8,11 +8,13 @@ import com.jediterm.terminal.ui.settings.SettingsProvider; import io.github.nbplugins.claudecodegui.ui.common.Zoomable; import io.github.nbplugins.claudecodegui.ui.common.ZoomSupport; +import java.util.function.Supplier; +import javax.swing.JMenuItem; import javax.swing.JPopupMenu; /** - * A {@link JediTermWidget} that splices a "Zoom" submenu into the terminal's - * native right-click context menu. + * A {@link JediTermWidget} that splices a "Zoom" submenu (and, optionally, a + * "Session Statistics…" item) into the terminal's native right-click context menu. * *

JediTerm builds its context menu internally: {@code TerminalPanel} has its * own mouse listener that calls the protected @@ -32,6 +34,8 @@ public class ZoomableJediTermWidget extends JediTermWidget { private final Zoomable zoomable; + private final Runnable onShowSessionStatistics; + private final Supplier sessionStatisticsAvailable; private ZoomTerminalPanel zoomPanel; /** @@ -41,8 +45,28 @@ public class ZoomableJediTermWidget extends JediTermWidget { * and {@link #refreshFont()} operate on */ public ZoomableJediTermWidget(SettingsProvider settingsProvider, Zoomable zoomable) { + this(settingsProvider, zoomable, null, null); + } + + /** + * @param settingsProvider terminal settings (font, colors); also supplies the + * zoom-adjusted font size + * @param zoomable the surface whose font the context-menu Zoom submenu + * and {@link #refreshFont()} operate on + * @param onShowSessionStatistics called when the context menu's "Session Statistics…" + * item is clicked; {@code null} omits that item entirely + * @param sessionStatisticsAvailable returns whether Session Statistics is available for + * the current session (only OpenAI-compatible/ChatGPT + * Subscription connection types); queried on every + * right-click since availability can change once the + * session starts; {@code null} treated as unavailable + */ + public ZoomableJediTermWidget(SettingsProvider settingsProvider, Zoomable zoomable, + Runnable onShowSessionStatistics, Supplier sessionStatisticsAvailable) { super(settingsProvider); this.zoomable = zoomable; + this.onShowSessionStatistics = onShowSessionStatistics; + this.sessionStatisticsAvailable = sessionStatisticsAvailable; } /** @@ -81,6 +105,15 @@ private final class ZoomTerminalPanel extends TerminalPanel { protected JPopupMenu createPopupMenu(TerminalActionProvider provider) { JPopupMenu menu = super.createPopupMenu(provider); ZoomSupport.appendZoomMenu(menu, zoomable); + if (onShowSessionStatistics != null && sessionStatisticsAvailable != null + && Boolean.TRUE.equals(sessionStatisticsAvailable.get())) { + if (menu.getComponentCount() > 0) { + menu.addSeparator(); + } + JMenuItem sessionStats = new JMenuItem("Session Statistics…"); + sessionStats.addActionListener(e -> onShowSessionStatistics.run()); + menu.add(sessionStats); + } return menu; } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslatorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslatorTest.java index 8bb649d0..e7ba9934 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslatorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslatorTest.java @@ -100,6 +100,49 @@ void translateRequest_systemPromptBecomesInstructions() throws Exception { assertEquals("You are helpful.", result.path("instructions").asText()); } + // ------------------------------------------------------------------------- + // explicit prompt caching (experimental) — only prompt_cache_retention for + // pre-5.6 GPT models; 5.6+ breakpoints intentionally not implemented (see + // the three-arg translateRequest Javadoc). + // ------------------------------------------------------------------------- + + @Test + void translateRequest_explicitCachingDisabled_noRetentionField() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest( + load("req_system_prompt.json"), "s", false); // model=gpt-4o + + assertFalse(result.has("prompt_cache_retention")); + } + + @Test + void translateRequest_explicitCachingEnabled_olderGptModel_setsRetention() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest( + load("req_system_prompt.json"), "s", true); // model=gpt-4o + + assertEquals("24h", result.path("prompt_cache_retention").asText()); + } + + @Test + void translateRequest_explicitCachingEnabled_gpt56_doesNotSetRetentionOrOptions() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest( + load("req_system_prompt_gpt56.json"), "s", true); + + // Deliberately no prompt_cache_retention (that's the <5.6 mechanism) and no + // prompt_cache_options (breakpoints unimplemented for this backend, see Javadoc). + assertFalse(result.has("prompt_cache_retention")); + assertFalse(result.has("prompt_cache_options")); + assertEquals("You are helpful.", result.path("instructions").asText()); + } + + @Test + void translateRequest_explicitCachingEnabled_nonGptModel_noEffect() throws Exception { + ObjectNode result = AnthropicToCodexTranslator.translateRequest( + load("req_simple_text.json"), "s", true); // model=claude-sonnet-4-5 + + assertFalse(result.has("prompt_cache_retention")); + assertFalse(result.has("prompt_cache_options")); + } + @Test void translateRequest_systemAsListOfBlocks_joinedIntoInstructions() throws Exception { ObjectNode result = AnthropicToCodexTranslator.translateRequest(load("req_system_as_blocks.json"), "s"); @@ -213,6 +256,39 @@ void translateResponse_toolCall_dropsReasoningAndSetsToolUseStopReason() throws assertEquals("tool_use", result.path("stop_reason").asText()); } + @Test + void translateResponse_emptyOutput_substitutesNoticeInsteadOfSilentSuccess() throws Exception { + JsonNode emptyResp = AnthropicToCodexTranslator.MAPPER.readTree( + "{\"id\":\"resp_1\",\"status\":\"incomplete\",\"output\":[]," + + "\"incomplete_details\":{\"reason\":\"max_output_tokens\"}," + + "\"usage\":{\"input_tokens\":900000,\"output_tokens\":0}}"); + + ObjectNode result = AnthropicToCodexTranslator.translateResponse(emptyResp, "gpt-5.6-terra"); + + JsonNode content = result.path("content"); + assertEquals(1, content.size()); + assertEquals("text", content.get(0).path("type").asText()); + String text = content.get(0).path("text").asText(); + assertTrue(text.contains("empty response"), text); + assertTrue(text.contains("max_output_tokens"), text); + assertTrue(text.contains("incomplete"), text); + } + + @Test + void translateResponse_emptyOutput_withRequestSize_includesItInNotice() throws Exception { + JsonNode emptyResp = AnthropicToCodexTranslator.MAPPER.readTree( + "{\"id\":\"resp_1\",\"status\":\"incomplete\",\"output\":[]," + + "\"incomplete_details\":{\"reason\":\"max_output_tokens\"}," + + "\"usage\":{\"input_tokens\":900000,\"output_tokens\":0}}"); + + ObjectNode result = AnthropicToCodexTranslator.translateResponse( + emptyResp, "gpt-5.6-terra", 936, 1746582L); + + String text = result.path("content").get(0).path("text").asText(); + assertTrue(text.contains("936 messages"), text); + assertTrue(text.contains("1.7 MB"), text); + } + // ------------------------------------------------------------------------- // Streaming // ------------------------------------------------------------------------- @@ -258,6 +334,74 @@ void streaming_completedEvent_setsDoneAndBuildsStopEvents() { assertTrue(events.contains("content_block_stop")); assertTrue(events.contains("message_delta")); assertTrue(events.contains("message_stop")); + assertEquals(3, s.getInputTokens()); + assertEquals(2, s.getOutputTokens()); + } + + @Test + void streaming_incompleteEvent_noContent_substitutesNoticeInsteadOfSilentSuccess() { + AnthropicToCodexTranslator.StreamingState s = state(); + + // No output_text.delta or function_call ever arrives before completion — the + // exact scenario observed in production (936-message request, Codex returned + // an empty stream, Claude Code just idled with no explanation). + String events = s.processEvent("response.incomplete", + "{\"response\":{\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"}," + + "\"usage\":{\"input_tokens\":900000,\"output_tokens\":0}}}"); + + assertTrue(s.isDoneReceived()); + assertTrue(events.contains("content_block_start"), events); + assertTrue(events.contains("message_stop"), events); + assertTrue(events.contains("empty response"), events); + assertTrue(events.contains("max_output_tokens"), events); + } + + @Test + void streaming_incompleteEvent_noContent_withRequestSize_includesItInNotice() { + AnthropicToCodexTranslator.StreamingState s = + new AnthropicToCodexTranslator.StreamingState(936, 1746582L); + + String events = s.processEvent("response.incomplete", + "{\"response\":{\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"}," + + "\"usage\":{\"input_tokens\":900000,\"output_tokens\":0}}}"); + + assertTrue(events.contains("936 messages"), events); + assertTrue(events.contains("1.7 MB"), events); + } + + @Test + void streaming_failedEvent_substitutesNotice() { + AnthropicToCodexTranslator.StreamingState s = state(); + + String events = s.processEvent("response.failed", "{\"message\":\"internal error\"}"); + + assertTrue(s.isDoneReceived()); + assertTrue(events.contains("empty response"), events); + assertTrue(events.contains("internal error"), events); + } + + @Test + void streaming_completedEvent_withTextContent_doesNotSubstituteNotice() { + 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}}}"); + + assertFalse(events.contains("empty response"), events); + } + + @Test + void streaming_completedEvent_capturesCachedAndCacheWriteTokens() { + AnthropicToCodexTranslator.StreamingState s = state(); + + s.processEvent("response.completed", + "{\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":100,\"output_tokens\":20," + + "\"input_tokens_details\":{\"cached_tokens\":30},\"cache_write_tokens\":40}}}"); + + assertEquals(30, s.getCachedTokens()); + assertEquals(40, s.getCacheWriteTokens()); } // ------------------------------------------------------------------------- diff --git a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java index 8bdbde48..e705569e 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java @@ -45,6 +45,81 @@ void translateRequest_simpleTextMessage() throws Exception { assertEquals("Hello", messages.get(0).path("content").asText()); } + @Test + void translateRequest_noCacheKey_omitsPromptCacheKey() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest(load("req_simple_text.json")); + + assertFalse(result.has("prompt_cache_key")); + } + + @Test + void translateRequest_withCacheKey_setsPromptCacheKey() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest( + load("req_simple_text.json"), "sess-42"); + + assertEquals("sess-42", result.path("prompt_cache_key").asText()); + } + + // ------------------------------------------------------------------------- + // explicit prompt caching (experimental) + // ------------------------------------------------------------------------- + + @Test + void translateRequest_explicitCachingDisabled_noExtraFields() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest( + load("req_system_prompt_gpt56.json"), "sess-1", false); + + assertFalse(result.has("prompt_cache_options")); + assertFalse(result.has("prompt_cache_retention")); + assertEquals("You are helpful.", + result.path("messages").get(0).path("content").asText()); + } + + @Test + void translateRequest_explicitCachingEnabled_gpt56_setsBreakpointOnSystemMessage() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest( + load("req_system_prompt_gpt56.json"), "sess-1", true); + + assertEquals("explicit", result.path("prompt_cache_options").path("mode").asText()); + assertEquals("30m", result.path("prompt_cache_options").path("ttl").asText()); + assertFalse(result.has("prompt_cache_retention")); + + JsonNode sysContent = result.path("messages").get(0).path("content"); + assertTrue(sysContent.isArray()); + assertEquals("text", sysContent.get(0).path("type").asText()); + assertEquals("You are helpful.", sysContent.get(0).path("text").asText()); + assertEquals("explicit", sysContent.get(0).path("prompt_cache_breakpoint").path("mode").asText()); + } + + @Test + void translateRequest_explicitCachingEnabled_olderGptModel_setsRetentionNotBreakpoint() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest( + load("req_system_prompt.json"), "sess-1", true); // model=gpt-4o + + assertEquals("24h", result.path("prompt_cache_retention").asText()); + assertFalse(result.has("prompt_cache_options")); + // System message content stays a plain string — no breakpoint restructuring for <5.6. + assertEquals("You are helpful.", + result.path("messages").get(0).path("content").asText()); + } + + @Test + void translateRequest_explicitCachingEnabled_nonGptModel_noEffect() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest( + load("req_simple_text.json"), "sess-1", true); // model=claude-sonnet-4-5 + + assertFalse(result.has("prompt_cache_options")); + assertFalse(result.has("prompt_cache_retention")); + } + + @Test + void translateRequest_blankCacheKey_omitsPromptCacheKey() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest( + load("req_simple_text.json"), " "); + + assertFalse(result.has("prompt_cache_key")); + } + @Test void translateRequest_systemPromptPrependedAsFirstMessage() throws Exception { ObjectNode result = AnthropicToOpenAITranslator.translateRequest(load("req_system_prompt.json")); @@ -167,6 +242,35 @@ void translateResponse_toolCalls() throws Exception { assertEquals("/tmp/x.txt", content.get(0).path("input").path("path").asText()); } + @Test + void translateResponse_emptyOutput_substitutesNoticeInsteadOfSilentSuccess() throws Exception { + JsonNode emptyResp = AnthropicToOpenAITranslator.MAPPER.readTree( + "{\"id\":\"chatcmpl-1\",\"choices\":[{\"message\":{\"role\":\"assistant\"}," + + "\"finish_reason\":\"length\"}],\"usage\":{\"prompt_tokens\":900000,\"completion_tokens\":0}}"); + + ObjectNode result = AnthropicToOpenAITranslator.translateResponse(emptyResp, "gpt-4o"); + + JsonNode content = result.path("content"); + assertEquals(1, content.size()); + assertEquals("text", content.get(0).path("type").asText()); + String text = content.get(0).path("text").asText(); + assertTrue(text.contains("empty response"), text); + assertTrue(text.contains("length"), text); + } + + @Test + void translateResponse_emptyOutput_withRequestSize_includesItInNotice() throws Exception { + JsonNode emptyResp = AnthropicToOpenAITranslator.MAPPER.readTree( + "{\"id\":\"chatcmpl-1\",\"choices\":[{\"message\":{\"role\":\"assistant\"}," + + "\"finish_reason\":\"length\"}],\"usage\":{\"prompt_tokens\":900000,\"completion_tokens\":0}}"); + + ObjectNode result = AnthropicToOpenAITranslator.translateResponse(emptyResp, "gpt-4o", 936, 1746582L); + + String text = result.path("content").get(0).path("text").asText(); + assertTrue(text.contains("936 messages"), text); + assertTrue(text.contains("1.7 MB"), text); + } + @Test void translateResponse_finishReasonMapping() { assertEquals("end_turn", AnthropicToOpenAITranslator.mapFinishReason("stop")); @@ -337,6 +441,61 @@ void streaming_singleChunkFullResponseProducesCompleteStream() throws Exception JsonNode msgDelta = AnthropicToOpenAITranslator.MAPPER.readTree( extractSseDataLine(done, "message_delta")); assertEquals(3, msgDelta.path("usage").path("output_tokens").asInt()); + assertEquals(10, s.getInputTokens()); + assertEquals(3, s.getOutputTokens()); + } + + @Test + void streaming_finishesWithNoContent_substitutesNoticeInsteadOfSilentSuccess() throws Exception { + var s = state(); + + // Message starts (role delta) but no text/tool_calls ever arrive before [DONE] — + // the exact scenario observed in production for the Codex path (see the + // AnthropicToCodexTranslatorTest equivalent), reproduced here for the plain + // OpenAI Chat Completions path for consistency. + s.processChunk("{\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"delta\":{\"role\":\"assistant\"}," + + "\"finish_reason\":\"length\",\"index\":0}]}"); + + String done = s.processChunk("[DONE]"); + + assertTrue(done.contains("content_block_start"), done); + assertTrue(done.contains("message_stop"), done); + assertTrue(done.contains("empty response"), done); + } + + @Test + void streaming_finishesWithNoContent_withRequestSize_includesItInNotice() { + var s = new AnthropicToOpenAITranslator.StreamingState(936, 1746582L); + + s.processChunk("{\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"delta\":{\"role\":\"assistant\"}," + + "\"finish_reason\":\"length\",\"index\":0}]}"); + String done = s.processChunk("[DONE]"); + + assertTrue(done.contains("936 messages"), done); + assertTrue(done.contains("1.7 MB"), done); + } + + @Test + void streaming_finishesWithTextContent_doesNotSubstituteNotice() { + var s = state(); + s.processChunk("{\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"delta\":{" + + "\"role\":\"assistant\",\"content\":\"Hi\"},\"index\":0}]}"); + + String done = s.processChunk("[DONE]"); + + assertFalse(done.contains("empty response"), done); + } + + @Test + void streaming_usageChunk_capturesCachedAndCacheWriteTokens() throws Exception { + var s = state(); + + s.processChunk("{\"id\":\"c1\",\"model\":\"m\",\"choices\":[]," + + "\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":20," + + "\"prompt_tokens_details\":{\"cached_tokens\":30},\"cache_write_tokens\":40}}"); + + assertEquals(30, s.getCachedTokens()); + assertEquals(40, s.getCacheWriteTokens()); } @Test diff --git a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfigTest.java b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfigTest.java index c7a85e94..f60297d7 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfigTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyConfigTest.java @@ -56,4 +56,91 @@ void buildHttpClient_noProxyMode_usesNoProxySelector() { OpenAIProxyConfig config = new OpenAIProxyConfig("https://api.example.com/v1", "sk-key", noProxy); assertEquals(java.net.http.HttpClient.Builder.NO_PROXY, config.buildHttpClient().proxy().orElseThrow()); } + + // ------------------------------------------------------------------------- + // Usage accumulation (Session Statistics dialog) + // ------------------------------------------------------------------------- + + @Test + void addUsage_freshConfig_noUsageRecorded() { + OpenAIProxyConfig config = new OpenAIProxyConfig("https://api.example.com/v1", "sk-key", + new ProxyConfiguration(ProxyMode.SYSTEM_MANAGED, "", "", "")); + + assertTrue(config.getUsageByModel().isEmpty()); + assertNull(config.getLastModel()); + } + + @Test + void addUsage_accumulatesAcrossMultipleCallsForSameModel() { + OpenAIProxyConfig config = new OpenAIProxyConfig("https://api.example.com/v1", "sk-key", + new ProxyConfiguration(ProxyMode.SYSTEM_MANAGED, "", "", "")); + + config.addUsage("gpt-5.6-terra", 100, 20, 5, 0); + config.addUsage("gpt-5.6-terra", 50, 10, 0, 15); + + OpenAIProxyConfig.UsageSnapshot usage = config.getUsageByModel().get("gpt-5.6-terra"); + assertEquals(150, usage.inputTokens()); + assertEquals(30, usage.outputTokens()); + assertEquals(5, usage.cachedTokens()); + assertEquals(15, usage.cacheWriteTokens()); + assertEquals(2, usage.requests()); + assertEquals("gpt-5.6-terra", config.getLastModel()); + } + + @Test + void addUsage_multipleModels_trackedSeparately() { + OpenAIProxyConfig config = new OpenAIProxyConfig("https://api.example.com/v1", "sk-key", + new ProxyConfiguration(ProxyMode.SYSTEM_MANAGED, "", "", "")); + + config.addUsage("gpt-5.6-terra", 100, 20, 5, 0); + config.addUsage("gpt-4o", 40, 8, 0, 0); + + assertEquals(2, config.getUsageByModel().size()); + assertEquals(100, config.getUsageByModel().get("gpt-5.6-terra").inputTokens()); + assertEquals(40, config.getUsageByModel().get("gpt-4o").inputTokens()); + assertEquals("gpt-4o", config.getLastModel()); + } + + @Test + void addUsage_blankModel_recordedAsUnknown() { + OpenAIProxyConfig config = new OpenAIProxyConfig("https://api.example.com/v1", "sk-key", + new ProxyConfiguration(ProxyMode.SYSTEM_MANAGED, "", "", "")); + + config.addUsage(null, 10, 2, 0, 0); + config.addUsage("", 5, 1, 0, 0); + + assertEquals(1, config.getUsageByModel().size()); + assertEquals(15, config.getUsageByModel().get("(unknown)").inputTokens()); + } + + // ------------------------------------------------------------------------- + // recordRequest (last-request size, for the Session Statistics dialog) + // ------------------------------------------------------------------------- + + @Test + void recordRequest_registersModelEvenWithoutAddUsage() { + OpenAIProxyConfig config = new OpenAIProxyConfig("https://api.example.com/v1", "sk-key", + new ProxyConfiguration(ProxyMode.SYSTEM_MANAGED, "", "", "")); + + config.recordRequest("gpt-5.6-terra", 936, 1746582L); + + OpenAIProxyConfig.UsageSnapshot usage = config.getUsageByModel().get("gpt-5.6-terra"); + assertEquals(936, usage.lastRequestMessageCount()); + assertEquals(1746582L, usage.lastRequestSizeBytes()); + assertEquals(0, usage.requests(), "recordRequest alone must not count as a completed request"); + assertEquals("gpt-5.6-terra", config.getLastModel()); + } + + @Test + void recordRequest_overwritesPreviousValue_notCumulative() { + OpenAIProxyConfig config = new OpenAIProxyConfig("https://api.example.com/v1", "sk-key", + new ProxyConfiguration(ProxyMode.SYSTEM_MANAGED, "", "", "")); + + config.recordRequest("gpt-5.6-terra", 100, 5000L); + config.recordRequest("gpt-5.6-terra", 936, 1746582L); + + OpenAIProxyConfig.UsageSnapshot usage = config.getUsageByModel().get("gpt-5.6-terra"); + assertEquals(936, usage.lastRequestMessageCount()); + assertEquals(1746582L, usage.lastRequestSizeBytes()); + } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServletTest.java b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServletTest.java new file mode 100644 index 00000000..0491b2b3 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServletTest.java @@ -0,0 +1,193 @@ +package io.github.nbplugins.claudecodegui.openaiproxy; + +import com.fasterxml.jackson.databind.JsonNode; +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 OpenAIProxyServlet}'s provider-error-to-Anthropic-error + * translation, in particular the classification of rate-limit/quota-exhaustion + * errors so Claude Code CLI recognizes them as {@code rate_limit_error} instead + * of falling back to a generic {@code api_error}. + */ +class OpenAIProxyServletTest { + + @Test + void toAnthropicError_openai429InsufficientQuota_mapsToRateLimitError() { + String body = "{\"error\":{\"message\":\"You exceeded your current quota\"," + + "\"type\":\"insufficient_quota\",\"code\":\"insufficient_quota\"}}"; + + String result = OpenAIProxyServlet.toAnthropicError(body, 429); + + assertTrue(result.contains("\"type\":\"rate_limit_error\""), result); + assertTrue(result.contains("You exceeded your current quota"), result); + } + + @Test + void toAnthropicError_openai429WithoutRecognizedCode_stillMapsToRateLimitError() { + // Any HTTP 429 should be treated as a rate limit signal, even if the + // body's error type/code don't match a known quota/rate-limit string. + String body = "{\"error\":{\"message\":\"Too many requests\",\"type\":\"server_error\"}}"; + + String result = OpenAIProxyServlet.toAnthropicError(body, 429); + + assertTrue(result.contains("\"type\":\"rate_limit_error\""), result); + } + + @Test + void toAnthropicError_non429RateLimitCode_mapsToRateLimitError() { + String body = "{\"error\":{\"message\":\"Rate limit reached\"," + + "\"type\":\"rate_limit_exceeded\",\"code\":\"rate_limit_exceeded\"}}"; + + String result = OpenAIProxyServlet.toAnthropicError(body, 400); + + assertTrue(result.contains("\"type\":\"rate_limit_error\""), result); + } + + @Test + void toAnthropicError_unrecognizedError_passesThroughOriginalType() { + String body = "{\"error\":{\"message\":\"Invalid request\",\"type\":\"invalid_request_error\"}}"; + + String result = OpenAIProxyServlet.toAnthropicError(body, 400); + + assertTrue(result.contains("\"type\":\"invalid_request_error\""), result); + assertFalse(result.contains("rate_limit_error"), result); + } + + @Test + void toAnthropicError_alreadyAnthropicFormat_passedThroughUnchanged() { + String body = "{\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"busy\"}}"; + + String result = OpenAIProxyServlet.toAnthropicError(body, 529); + + assertEquals(body, result); + } + + @Test + void toCodexAnthropicError_codexRateLimitCode_mapsToRateLimitError() { + String body = "{\"error\":{\"code\":\"codex.rate_limits.limit_reached\"," + + "\"message\":\"The usage limit has been reached\"}}"; + + String result = OpenAIProxyServlet.toCodexAnthropicError(body, 429); + + assertTrue(result.contains("\"type\":\"rate_limit_error\""), result); + assertTrue(result.contains("ChatGPT subscription rate limit reached"), result); + } + + @Test + void toCodexAnthropicError_non429NonRateLimitError_fallsBackToGenericTranslation() { + String body = "{\"detail\":\"Store must be set to false\"}"; + + String result = OpenAIProxyServlet.toCodexAnthropicError(body, 400); + + assertTrue(result.contains("\"type\":\"api_error\""), result); + assertTrue(result.contains("Store must be set to false"), result); + } + + // ------------------------------------------------------------------------- + // extractHardLimitRetryAfterSeconds + // + // Confirmed empirically (claude-launch-tests/test_429_retry_headers.py) that + // a large Retry-After value makes Claude Code CLI surface the error + // immediately instead of retrying up to 10 times. + // ------------------------------------------------------------------------- + + @Test + void extractHardLimitRetryAfterSeconds_codexUsageLimitReached_returnsResetsInSeconds() { + String body = "{\"error\":{\"type\":\"usage_limit_reached\"," + + "\"message\":\"The usage limit has been reached\",\"plan_type\":\"go\"," + + "\"resets_at\":1787035344,\"eligible_promo\":null,\"resets_in_seconds\":2509946}}"; + + Long result = OpenAIProxyServlet.extractHardLimitRetryAfterSeconds(body, 429); + + assertEquals(2509946L, result); + } + + @Test + void extractHardLimitRetryAfterSeconds_ordinaryRateLimitWithoutResetsField_returnsNull() { + String body = "{\"error\":{\"message\":\"Rate limit reached\",\"type\":\"rate_limit_exceeded\"}}"; + + Long result = OpenAIProxyServlet.extractHardLimitRetryAfterSeconds(body, 429); + + assertNull(result); + } + + @Test + void extractHardLimitRetryAfterSeconds_non429Status_returnsNullEvenWithResetsField() { + String body = "{\"error\":{\"type\":\"usage_limit_reached\",\"resets_in_seconds\":2509946}}"; + + Long result = OpenAIProxyServlet.extractHardLimitRetryAfterSeconds(body, 400); + + assertNull(result); + } + + // ------------------------------------------------------------------------- + // recordUsage + // ------------------------------------------------------------------------- + + private static OpenAIProxyConfig newConfig() { + return new OpenAIProxyConfig("https://api.example.com/v1", "sk-key", + new ProxyConfiguration(ProxyMode.SYSTEM_MANAGED, "", "", "")); + } + + @Test + void recordUsage_chatCompletionsShape_accumulatesTotals() throws Exception { + OpenAIProxyConfig config = newConfig(); + JsonNode usage = AnthropicToOpenAITranslator.MAPPER.readTree( + "{\"prompt_tokens\":100,\"completion_tokens\":20," + + "\"prompt_tokens_details\":{\"cached_tokens\":30},\"cache_write_tokens\":40}"); + + OpenAIProxyServlet.recordUsage(config, "gpt-4o", usage); + + OpenAIProxyConfig.UsageSnapshot u = config.getUsageByModel().get("gpt-4o"); + assertEquals(100, u.inputTokens()); + assertEquals(20, u.outputTokens()); + assertEquals(30, u.cachedTokens()); + assertEquals(40, u.cacheWriteTokens()); + assertEquals(1, u.requests()); + } + + @Test + void recordUsage_responsesApiShape_accumulatesTotals() throws Exception { + OpenAIProxyConfig config = newConfig(); + JsonNode usage = AnthropicToOpenAITranslator.MAPPER.readTree( + "{\"input_tokens\":100,\"output_tokens\":20," + + "\"input_tokens_details\":{\"cached_tokens\":30},\"cache_write_tokens\":40}"); + + OpenAIProxyServlet.recordUsage(config, "gpt-5.6-terra", usage); + + OpenAIProxyConfig.UsageSnapshot u = config.getUsageByModel().get("gpt-5.6-terra"); + assertEquals(100, u.inputTokens()); + assertEquals(20, u.outputTokens()); + assertEquals(30, u.cachedTokens()); + assertEquals(40, u.cacheWriteTokens()); + } + + @Test + void recordUsage_missingUsageNode_doesNotIncrementRequests() throws Exception { + OpenAIProxyConfig config = newConfig(); + JsonNode empty = AnthropicToOpenAITranslator.MAPPER.readTree("{}"); + + OpenAIProxyServlet.recordUsage(config, "gpt-4o", empty.path("usage")); + + assertTrue(config.getUsageByModel().isEmpty()); + } + + @Test + void recordUsage_noCacheFields_defaultsToZero() throws Exception { + OpenAIProxyConfig config = newConfig(); + JsonNode usage = AnthropicToOpenAITranslator.MAPPER.readTree( + "{\"prompt_tokens\":5,\"completion_tokens\":2}"); + + OpenAIProxyServlet.recordUsage(config, "gpt-4o", usage); + + OpenAIProxyConfig.UsageSnapshot u = config.getUsageByModel().get("gpt-4o"); + assertEquals(5, u.inputTokens()); + assertEquals(2, u.outputTokens()); + assertEquals(0, u.cachedTokens()); + assertEquals(0, u.cacheWriteTokens()); + } +} diff --git a/src/test/java/io/github/nbplugins/claudecodegui/settings/ModelAliasTest.java b/src/test/java/io/github/nbplugins/claudecodegui/settings/ModelAliasTest.java index 1d796923..3af0e419 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/settings/ModelAliasTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/settings/ModelAliasTest.java @@ -51,4 +51,81 @@ void validateAliasUniqueness_customMixedWithStandard_returnsNull() { new ModelAlias("openai/gpt-4-turbo", null, "custom")); assertNull(ModelAlias.validateAliasUniqueness(models)); } + + // ------------------------------------------------------------------------- + // parseGptVersion / defaultExplicitPromptCaching + // ------------------------------------------------------------------------- + + @Test + void parseGptVersion_gpt56Terra_returns56() { + assertEquals(5.6, ModelAlias.parseGptVersion("gpt-5.6-terra")); + } + + @Test + void parseGptVersion_gpt56Sol_returns56() { + assertEquals(5.6, ModelAlias.parseGptVersion("gpt-5.6-sol")); + } + + @Test + void parseGptVersion_futureGpt6_returns6() { + assertEquals(6.0, ModelAlias.parseGptVersion("gpt-6-sol")); + } + + @Test + void parseGptVersion_olderGpt4o_returns4() { + assertEquals(4.0, ModelAlias.parseGptVersion("gpt-4o")); + } + + @Test + void parseGptVersion_nonGptModel_returnsNull() { + assertNull(ModelAlias.parseGptVersion("grok-4.5")); + assertNull(ModelAlias.parseGptVersion("gemini-2.5-pro")); + assertNull(ModelAlias.parseGptVersion("claude-sonnet-4-5")); + } + + @Test + void parseGptVersion_nullId_returnsNull() { + assertNull(ModelAlias.parseGptVersion(null)); + } + + @Test + void defaultExplicitPromptCaching_gpt56AndNewer_true() { + assertTrue(ModelAlias.defaultExplicitPromptCaching("gpt-5.6-terra")); + assertTrue(ModelAlias.defaultExplicitPromptCaching("gpt-5.7-sol")); + assertTrue(ModelAlias.defaultExplicitPromptCaching("gpt-6-luna")); + } + + @Test + void defaultExplicitPromptCaching_olderGptModels_false() { + assertFalse(ModelAlias.defaultExplicitPromptCaching("gpt-5.5-sol")); + assertFalse(ModelAlias.defaultExplicitPromptCaching("gpt-4o")); + } + + @Test + void defaultExplicitPromptCaching_nonGptOrUnparseable_false() { + assertFalse(ModelAlias.defaultExplicitPromptCaching("grok-4.5")); + assertFalse(ModelAlias.defaultExplicitPromptCaching("gemini-2.5-pro")); + assertFalse(ModelAlias.defaultExplicitPromptCaching(null)); + } + + @Test + void threeArgConstructor_autoDefaultsExplicitPromptCachingFromId() { + ModelAlias gpt56 = new ModelAlias("gpt-5.6-terra", null, ""); + ModelAlias grok = new ModelAlias("grok-4.5", null, "custom"); + + assertTrue(gpt56.explicitPromptCaching()); + assertFalse(grok.explicitPromptCaching()); + } + + @Test + void withExplicitPromptCaching_overridesFlagWithoutChangingOtherFields() { + ModelAlias m = new ModelAlias("gpt-4o", null, "custom"); + + ModelAlias enabled = m.withExplicitPromptCaching(true); + + assertFalse(m.explicitPromptCaching()); + assertTrue(enabled.explicitPromptCaching()); + assertEquals(m.id(), enabled.id()); + assertEquals(m.alias(), enabled.alias()); + } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanelSendTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanelSendTest.java index 9574facc..93722402 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanelSendTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanelSendTest.java @@ -39,7 +39,7 @@ private void buildPanel(String workingDir) throws Exception { Runnable noop = () -> {}; SwingUtilities.invokeAndWait(() -> { - panel = new ClaudePromptPanel(send, noop, noop, hist, wd, null, null); + panel = new ClaudePromptPanel(send, noop, noop, hist, wd, null, null, noop, () -> false); }); } diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_system_prompt_gpt56.json b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_system_prompt_gpt56.json new file mode 100644 index 00000000..f3f97362 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_system_prompt_gpt56.json @@ -0,0 +1,6 @@ +{ + "model": "gpt-5.6-terra", + "max_tokens": 512, + "system": "You are helpful.", + "messages": [{"role": "user", "content": "Hi"}] +}