From fc465f74bf3b8f445c9b7be14a391436900e103d Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 6 Oct 2026 22:54:12 +0300 Subject: [PATCH] Fixed prompt caching for ChatGPT Subscription and model aliases for subagents Codex backend cache routing needs the session_id header besides prompt_cache_key; the proxy now sends it, gives every subagent its own cache key (session id + x-claude-code-agent-id) and no longer sends the explicit caching fields that Codex rejects with HTTP 400 (prompt_cache_options, prompt_cache_retention). - Kept role:"system" messages sent by Claude Code 2.1.288 inside messages[] (Codex: developer item, Chat Completions: in place). - Parsed cache_write_tokens nested in input/prompt_tokens_details and logged cached_tokens for aggregated Codex responses. - Applied Model Aliases to ChatGPT Subscription sessions so subagents and background requests use a supported model. - Listed models mapped to sonnet/opus/haiku by their own id in the model combo, in the order of the Model Aliases dialog (modelOrder; derived getComboModelIds is @JsonIgnore'd). - Added PromptPrefixTracker (per cache key and model) to log how much of the prompt prefix is unchanged between requests. - Added claude-launch-tests for prefix stability, Codex cache routing and router sticky/explicit cache experiments (results in docstrings). Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 5 + .../test_codex_prompt_cache.py | 124 +++++++++ .../test_prompt_prefix_stability.py | 260 ++++++++++++++++++ .../test_router_openai_explicit_cache.py | 101 +++++++ .../test_router_sticky_cache.py | 110 ++++++++ docs/user-manual.md | 6 +- pom.xml | 2 +- .../controller/ClaudeSessionController.java | 2 +- .../AnthropicToCodexTranslator.java | 77 +++--- .../AnthropicToOpenAITranslator.java | 32 ++- .../openaiproxy/OpenAIProxyServlet.java | 73 +++-- .../openaiproxy/PromptPrefixTracker.java | 112 ++++++++ .../claudecodegui/settings/ClaudeProfile.java | 78 +++++- .../settings/ClaudeProfileStore.java | 1 + .../settings/ClaudeProfilesPanel.java | 16 +- .../AnthropicToCodexTranslatorTest.java | 83 ++++-- .../AnthropicToOpenAITranslatorTest.java | 16 ++ .../openaiproxy/OpenAIProxyServletTest.java | 54 ++++ .../openaiproxy/PromptPrefixTrackerTest.java | 67 +++++ .../settings/ClaudeProfileTest.java | 96 +++++++ .../req_mid_conversation_system.json | 12 + 21 files changed, 1230 insertions(+), 97 deletions(-) create mode 100644 claude-launch-tests/test_codex_prompt_cache.py create mode 100644 claude-launch-tests/test_prompt_prefix_stability.py create mode 100644 claude-launch-tests/test_router_openai_explicit_cache.py create mode 100644 claude-launch-tests/test_router_sticky_cache.py create mode 100644 src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/PromptPrefixTracker.java create mode 100644 src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/PromptPrefixTrackerTest.java create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/openaiproxy/req_mid_conversation_system.json diff --git a/CHANGELOG.md b/CHANGELOG.md index a82f448..771dc9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +- Fixed subagents and background requests failing with "model is not supported when using Codex with a ChatGPT account" for ChatGPT Subscription profiles: model aliases from the Model Aliases dialog (e.g. haiku, sonnet) are now applied to ChatGPT Subscription sessions too +- Changed the model selector to also list models mapped to sonnet, opus or haiku in Model Aliases by their own ID, so they stay selectable directly; all these models are listed in the same order as in the Model Aliases dialog, which now also keeps its row order when reopened +- Fixed prompt caching almost never working for ChatGPT Subscription connections, so long sessions used up the subscription quota much faster than needed; subagents now get their own cache instead of sharing the main conversation's +- Fixed ChatGPT Subscription and OpenAI Compatible connections losing part of Claude Code's context (environment info and system reminders) with Claude Code 2.1.288 and newer +- Fixed requests failing with "Unsupported parameter: prompt_cache_retention" for ChatGPT Subscription models older than GPT-5.6 when Explicit Cache was checked # 1.3.25 (2026-10-05) - Added support for long choice dialogs (e.g. /model with more than 9 items): all items are listed with a scrollbar when the list is taller than the new "Choice menu max height" setting (Tools → Options → Claude Code → General, default 320 px), items 10 and above can be selected, and the model list at startup and the model combo include every model diff --git a/claude-launch-tests/test_codex_prompt_cache.py b/claude-launch-tests/test_codex_prompt_cache.py new file mode 100644 index 0000000..b9865b0 --- /dev/null +++ b/claude-launch-tests/test_codex_prompt_cache.py @@ -0,0 +1,124 @@ +""" +Hypothesis: the ChatGPT-subscription Codex backend +(https://chatgpt.com/backend-api/codex/responses) reports cached_tokens=0 for +the plugin's requests not because the prompt prefix changes, but because the +proxy omits the `session_id` request header that Codex CLI sends — the body's +`prompt_cache_key` alone does not keep consecutive requests on the same cache +shard. Also checks whether the backend accepts explicit caching fields. + +Method: send the same request (~7k-token instructions, or the real Claude Code +system prompt + tools when a captured request body is passed) several times +with one prompt_cache_key, with and without a `session_id` header, and with +`prompt_cache_options` / `prompt_cache_retention`. Reads the ChatGPT access +token from the plugin's NetBeans profile ("ChatGPT" by default) — never +printed, never refreshed (refreshing would rotate the refresh token behind the +plugin's back). Consumes a few requests of the subscription quota. + +Run: python3 claude-launch-tests/test_codex_prompt_cache.py [captured_request.json] + (captured_request.json: a request saved by test_prompt_prefix_stability.py) +Env: NB_PREFS (path to nbclaudecodegui.properties), NB_PROFILE (profile name), + CODEX_MODEL (default gpt-5.6-terra) + +Result (2026-10-06, gpt-5.6-terra / gpt-5.6-luna / gpt-5.5): CONFIRMED. +- no session_id header: cached_tokens=0 on 4/4 repeats of a 29k-token + real-shape request; with a 7k prompt hits were random (0, 6912, 6912, 0, 0) +- session_id header = prompt_cache_key: cached_tokens=28160/28977 from the + 2nd request on, every time +- prompt_cache_options → HTTP 400 "prompt_cache_options is not supported on this model" +- prompt_cache_retention → HTTP 400 "Unsupported parameter: prompt_cache_retention" +Applied: OpenAIProxyServlet.buildCodexHttpRequest sends `session_id`; +AnthropicToCodexTranslator never sends explicit caching fields. +""" + +import json +import os +import sys +import time +import urllib.error +import urllib.request +import uuid + +URL = 'https://chatgpt.com/backend-api/codex/responses' +NB_PREFS = os.environ.get('NB_PREFS', os.path.expanduser( + '~/.netbeans/29/config/Preferences/io/github/nbclaudecodegui.properties')) +NB_PROFILE = os.environ.get('NB_PROFILE', 'ChatGPT') +MODEL = os.environ.get('CODEX_MODEL', 'gpt-5.6-terra') + + +def load_profile(): + raw = None + with open(NB_PREFS, encoding='latin1') as f: + for line in f: + if line.startswith('profiles='): + raw = line[len('profiles='):].rstrip('\n') + raw = raw.encode('latin1').decode('unicode_escape').replace('\\:', ':').replace('\\=', '=') + for p in json.loads(raw): + if p.get('name') == NB_PROFILE: + return p + raise SystemExit(f'profile {NB_PROFILE!r} not found in {NB_PREFS}') + + +PROFILE = load_profile() +_proxy = PROFILE.get('httpsProxy') or PROFILE.get('httpProxy') +OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({'https': _proxy} if _proxy else {})) + + +def prompt_parts(): + if len(sys.argv) > 1: + body = json.load(open(sys.argv[1]))['body'] + instr = '\n\n'.join(b['text'] for b in body['system'] if b.get('type') == 'text') + tools = [{"type": "function", "name": t['name'], "description": t.get('description', ''), + "parameters": t.get('input_schema')} + for t in body.get('tools', []) if not t.get('type', '').startswith('web_search')] + return instr, tools + instr = "You are a helpful coding assistant. " + " ".join( + f"Rule {i}: always keep answers short, precise and correct, rule number {i}." for i in range(400)) + return instr, [] + + +INSTR, TOOLS = prompt_parts() + + +def call(key, session_header, extra=None, n=0): + body = {"model": MODEL, "stream": True, "store": False, "instructions": INSTR, + "prompt_cache_key": key, + "input": [{"type": "message", "role": "user", + "content": [{"type": "input_text", "text": f"Reply with just OK {n}"}]}]} + if TOOLS: + body["tools"] = TOOLS + if extra: + body.update(extra) + headers = {"Content-Type": "application/json", "Accept": "text/event-stream", + "Authorization": "Bearer " + PROFILE['chatgptAccessToken'], + "ChatGPT-Account-Id": PROFILE['chatgptAccountId'], "originator": "codex_cli_rs"} + if session_header: + headers['session_id'] = key + req = urllib.request.Request(URL, data=json.dumps(body).encode(), method='POST', headers=headers) + try: + resp = OPENER.open(req, timeout=180) + except urllib.error.HTTPError as e: + return f"HTTP {e.code}: {e.read()[:200].decode(errors='replace')}" + event = None + for line in resp: + line = line.decode().rstrip('\n') + if line.startswith('event:'): + event = line[6:].strip() + elif line.startswith('data:') and event == 'response.completed': + u = json.loads(line[5:])['response']['usage'] + return f"input={u['input_tokens']} cached={u.get('input_tokens_details', {}).get('cached_tokens')}" + return 'no response.completed event' + + +if __name__ == '__main__': + print(f"model={MODEL} instructions={len(INSTR)} chars tools={len(TOOLS)}") + variants = [ + ('no session_id header', False, None, 4), + ('session_id header', True, None, 4), + ('prompt_cache_options', True, {"prompt_cache_options": {"mode": "explicit", "ttl": "30m"}}, 1), + ('prompt_cache_retention', True, {"prompt_cache_retention": "24h"}, 1), + ] + for name, header, extra, repeats in variants: + key = str(uuid.uuid4()) + for i in range(repeats): + print(f"{name} #{i}: {call(key, header, extra, i)}", flush=True) + time.sleep(3) diff --git a/claude-launch-tests/test_prompt_prefix_stability.py b/claude-launch-tests/test_prompt_prefix_stability.py new file mode 100644 index 0000000..8d97517 --- /dev/null +++ b/claude-launch-tests/test_prompt_prefix_stability.py @@ -0,0 +1,260 @@ +""" +Hypothesis: with Claude Code 2.1.288 the OpenAI/Codex proxy never gets a prompt +cache hit (cached_tokens=0 on every request, although prompt_cache_key is +stable) because the request prefix — `tools` or the beginning of `system` — +differs between consecutive requests of the same conversation. + +Method: run `claude --print` (then `--print --continue`) against a local fake +Anthropic server that records every request body. The first main-model reply is +a tool_use (Bash `echo hi`) so one turn produces several requests. Consecutive +main-model requests are then compared: tools, system blocks, non user/assistant +messages, and the first differing byte of the prefix serialized the way +AnthropicToCodexTranslator builds it (tools, then instructions, then input). + +Run: python3 claude-launch-tests/test_prompt_prefix_stability.py + +Result (Claude Code 2.1.288, both --print and an interactive PTY run with +--model gpt-5.6-terra): hypothesis REFUTED for the Anthropic-side request. +`tools` (same order and definitions) and all top-level `system` blocks — +including the `x-anthropic-billing-header` block, whose fingerprint stays fixed +within the conversation — are byte-identical between consecutive main-model +requests; the first difference is deep inside `messages`, plus moving +`cache_control` markers, which the Codex translator ignores anyway. +Side finding: 2.1.288 now sends `role:"system"` entries inside `messages[]` +(the "# Environment ..." block right after the first user prompt, and +system-reminder-like blocks after tool results; anthropic-beta +mid-conversation-system-2026-04-07). AnthropicToCodexTranslator drops every +message whose role is not user/assistant, so that content never reaches the model. +""" + +import hashlib +import http.server +import json +import os +import shutil +import subprocess +import threading + +CLAUDE = shutil.which('claude') or '/usr/local/bin/claude' +CWD = '/tmp/claude_prefix_stability_test' +OUT_DIR = os.environ.get('PREFIX_TEST_OUT', os.path.join(CWD, 'captured')) +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', + 'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy')} + + +def sse(event, data): + return f"event: {event}\ndata: {json.dumps(data)}\n\n" + + +def text_stream(text): + return (sse('message_start', {"type": "message_start", "message": { + "id": "msg_t", "type": "message", "role": "assistant", "model": "m", + "content": [], "stop_reason": None, "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 0}}}) + + sse('content_block_start', {"type": "content_block_start", "index": 0, + "content_block": {"type": "text", "text": ""}}) + + sse('content_block_delta', {"type": "content_block_delta", "index": 0, + "delta": {"type": "text_delta", "text": text}}) + + sse('content_block_stop', {"type": "content_block_stop", "index": 0}) + + sse('message_delta', {"type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 1}}) + + sse('message_stop', {"type": "message_stop"})) + + +def tool_stream(call_id): + return (sse('message_start', {"type": "message_start", "message": { + "id": "msg_t", "type": "message", "role": "assistant", "model": "m", + "content": [], "stop_reason": None, "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 0}}}) + + sse('content_block_start', {"type": "content_block_start", "index": 0, + "content_block": {"type": "tool_use", "id": call_id, + "name": "Bash", "input": {}}}) + + sse('content_block_delta', {"type": "content_block_delta", "index": 0, + "delta": {"type": "input_json_delta", + "partial_json": json.dumps( + {"command": "echo hi", + "description": "Print hi"})}}) + + sse('content_block_stop', {"type": "content_block_stop", "index": 0}) + + sse('message_delta', {"type": "message_delta", + "delta": {"stop_reason": "tool_use", "stop_sequence": None}, + "usage": {"output_tokens": 1}}) + + sse('message_stop', {"type": "message_stop"})) + + +def is_main(body): + return len(body.get('tools') or []) > 5 + + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + pass + + def do_POST(self): + length = int(self.headers.get('Content-Length', 0)) + raw = self.rfile.read(length) + try: + body = json.loads(raw) + except Exception: + body = {} + srv = self.server + with srv.lock: + idx = len(srv.requests) + srv.requests.append({'path': self.path, 'body': body, + 'session': self.headers.get('X-Claude-Code-Session-Id')}) + with open(os.path.join(OUT_DIR, f'req_{idx:02d}.json'), 'w') as f: + json.dump({'path': self.path, 'headers': dict(self.headers), 'body': body}, f, indent=1) + main = is_main(body) + give_tool = False + if main and srv.tool_calls == 0: + # only the very first main-model request gets a tool call (a trailing + # role:"system" message makes "is the last message a tool_result?" unreliable) + give_tool = True + srv.tool_calls += 1 + if 'count_tokens' in self.path: + payload = json.dumps({"input_tokens": 10}).encode() + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + if body.get('stream'): + payload = (tool_stream(f'toolu_{srv.tool_calls:04d}') if give_tool + else text_stream('done')).encode() + self.send_response(200) + self.send_header('Content-Type', 'text/event-stream') + self.send_header('Content-Length', str(len(payload))) + self.end_headers() + self.wfile.write(payload) + else: + payload = json.dumps({ + "id": "msg_t", "type": "message", "role": "assistant", "model": "m", + "content": [{"type": "text", "text": "Title"}], + "stop_reason": "end_turn", "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}}).encode() + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +def sha(s): + return hashlib.sha1(s.encode('utf-8')).hexdigest()[:10] + + +def system_blocks(body): + s = body.get('system') + if isinstance(s, str): + return [{'type': 'text', 'text': s}] + return s or [] + + +def codex_prefix(body): + """Approximates the Codex request prefix order: tools, instructions, input.""" + tools = json.dumps([{'name': t.get('name'), 'description': t.get('description'), + 'parameters': t.get('input_schema')} for t in body.get('tools', [])], + sort_keys=False) + instr = '\n\n'.join(b.get('text', '') for b in system_blocks(body) if b.get('type') == 'text') + msgs = json.dumps(body.get('messages', [])) + return tools + '\x00' + instr + '\x00' + msgs, len(tools), len(instr) + + +def first_diff(a, b): + n = min(len(a), len(b)) + for i in range(n): + if a[i] != b[i]: + return i + return n + + +def describe(i, r): + b = r['body'] + print(f"\n--- request #{i} path={r['path']} session={r['session']} model={b.get('model')} " + f"stream={b.get('stream')} tools={len(b.get('tools') or [])} messages={len(b.get('messages') or [])}") + for j, blk in enumerate(system_blocks(b)): + txt = blk.get('text', '') + print(f" system[{j}] len={len(txt)} sha={sha(txt)} cache_control={blk.get('cache_control')} " + f"head={txt[:90]!r}") + for j, m in enumerate(b.get('messages') or []): + if m.get('role') not in ('user', 'assistant'): + print(f" !! messages[{j}] role={m.get('role')}") + c = m.get('content') + if isinstance(c, list): + for k, blk in enumerate(c): + if blk.get('type') not in ('text', 'tool_use', 'tool_result', 'image', 'thinking'): + print(f" !! messages[{j}].content[{k}] type={blk.get('type')}") + if blk.get('cache_control'): + print(f" messages[{j}].content[{k}] cache_control={blk.get('cache_control')}") + for k in b: + if k not in ('model', 'messages', 'system', 'tools', 'stream', 'max_tokens', 'metadata', + 'thinking', 'temperature', 'output_config', 'context_management', 'tool_choice'): + print(f" extra top-level key: {k}={json.dumps(b[k])[:200]}") + + +def compare(a, b, ia, ib): + ba, bb = a['body'], b['body'] + print(f"\n=== compare #{ia} -> #{ib}") + ta = [t.get('name') for t in ba.get('tools', [])] + tb = [t.get('name') for t in bb.get('tools', [])] + if ta == tb: + changed = [t['name'] for t, u in zip(ba['tools'], bb['tools']) + if json.dumps(t, sort_keys=True) != json.dumps(u, sort_keys=True)] + print(f" tools: same order; changed definitions: {changed or 'none'}") + else: + print(f" tools: DIFFERENT. added={sorted(set(tb) - set(ta))} removed={sorted(set(ta) - set(tb))} " + f"reordered={set(ta) == set(tb)}") + sa, sb = system_blocks(ba), system_blocks(bb) + for j in range(max(len(sa), len(sb))): + x = sa[j].get('text', '') if j < len(sa) else None + y = sb[j].get('text', '') if j < len(sb) else None + if x != y: + if x is None or y is None: + print(f" system[{j}] present only in one request") + else: + d = first_diff(x, y) + print(f" system[{j}] DIFFERS at char {d}: {x[max(0, d - 40):d + 60]!r} -> {y[max(0, d - 40):d + 60]!r}") + pa, ltools, linstr = codex_prefix(ba) + pb, _, _ = codex_prefix(bb) + d = first_diff(pa, pb) + where = 'tools' if d < ltools else ('instructions' if d < ltools + 1 + linstr else 'messages') + print(f" codex-order prefix: first diff at char {d} of {len(pa)} ({where}); " + f"context: {pa[max(0, d - 60):d + 60]!r}") + + +def run(args): + env = dict(ENV_BASE) + env['ANTHROPIC_BASE_URL'] = f'http://127.0.0.1:{PORT}' + env['ANTHROPIC_AUTH_TOKEN'] = 'sk-test' + env['NO_PROXY'] = '127.0.0.1,localhost' + p = subprocess.run([CLAUDE, '--print', '--permission-mode', 'bypassPermissions'] + args, + cwd=CWD, env=env, capture_output=True, text=True, timeout=180) + print(f"$ claude {' '.join(args)} -> rc={p.returncode} out={p.stdout[:200]!r} err={p.stderr[:300]!r}") + + +if __name__ == '__main__': + os.makedirs(CWD, exist_ok=True) + shutil.rmtree(OUT_DIR, ignore_errors=True) + os.makedirs(OUT_DIR) + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + server.requests = [] + server.lock = threading.Lock() + server.tool_calls = 0 + PORT = server.server_address[1] + threading.Thread(target=server.serve_forever, daemon=True).start() + + run(['Run echo hi using Bash, then say done']) + run(['--continue', 'Run echo hi again, then say done']) + server.shutdown() + + reqs = server.requests + print(f"\nCaptured {len(reqs)} requests into {OUT_DIR}") + for i, r in enumerate(reqs): + describe(i, r) + mains = [i for i, r in enumerate(reqs) if is_main(r['body'])] + print(f"\nMain-model requests: {mains}") + for a, b in zip(mains, mains[1:]): + compare(reqs[a], reqs[b], a, b) diff --git a/claude-launch-tests/test_router_openai_explicit_cache.py b/claude-launch-tests/test_router_openai_explicit_cache.py new file mode 100644 index 0000000..0ce7f29 --- /dev/null +++ b/claude-launch-tests/test_router_openai_explicit_cache.py @@ -0,0 +1,101 @@ +""" +Does the OpenAI-compatible router (llm-router, profile WorkLlmRouter) accept and +benefit from OpenAI's caching fields on Chat Completions, as the plugin sends them +when "Explicit Cache" is checked for a model? +Variants per model (same ~8.5k-token request repeated N times, unique prompt per variant): +- prompt_cache_key only (the plugin's default) +- explicit: prompt_cache_options {mode:explicit, ttl:30m} + prompt_cache_breakpoint on the + system content part (GPT >= 5.6 shape from AnthropicToOpenAITranslator) +- retention: prompt_cache_retention "24h" (older GPT shape) +API key is read from the NetBeans profile and never printed. + +Run: python3 claude-launch-tests/test_router_openai_explicit_cache.py +Env: NB_PREFS, NB_PROFILE (WorkLlmRouter), ROUTER_MODELS (comma list, default + openai/gpt-6-luna,openai/gpt-5.5), REPEATS (6) + +Result (2026-10-06, openai/gpt-6-luna via llm-router = LiteLLM -> OpenRouter, 6 x 8.1k-token +identical requests per variant): the router accepts all three field sets (no 400) but none +gives stable hits. +- key-only: hits on 2/5 repeats (hits on #3, #4) +- explicit: 0/5 (one hit on the very first request, then none) — NOT better than key-only +- retention: 2/5 (hits on #2, #4) +openai/gpt-5.5 could not be measured: HTTP 500 (OpenRouter error) / 429 (no deployments). +Conclusion: the router's cache is random regardless of the OpenAI caching fields. +""" + +import json +import os +import time +import urllib.error +import urllib.request +import uuid + +NB_PREFS = os.environ.get('NB_PREFS', os.path.expanduser( + '~/.netbeans/29/config/Preferences/io/github/nbclaudecodegui.properties')) +NB_PROFILE = os.environ.get('NB_PROFILE', 'WorkLlmRouter') +MODELS = os.environ.get('ROUTER_MODELS', 'openai/gpt-6-luna,openai/gpt-5.5').split(',') +REPEATS = int(os.environ.get('REPEATS', '6')) + + +def load_profile(): + raw = None + with open(NB_PREFS, encoding='latin1') as f: + for line in f: + if line.startswith('profiles='): + raw = line[len('profiles='):].rstrip('\n') + raw = raw.encode('latin1').decode('unicode_escape').replace('\\:', ':').replace('\\=', '=') + for p in json.loads(raw): + if p.get('name') == NB_PROFILE: + return p + raise SystemExit(f'profile {NB_PROFILE!r} not found') + + +PROFILE = load_profile() +URL = PROFILE['baseUrl'].rstrip('/') + '/v1/chat/completions' +OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) # router host is in noProxy + + +def system_text(tag): + return f"[{tag}] You are a helpful coding assistant. " + " ".join( + f"Rule {i}: always keep answers short, precise and correct, rule number {i}." for i in range(450)) + + +def call(model, variant, key, tag, n): + text = system_text(tag) + body = {"model": model, "max_tokens": 8, "stream": False, "prompt_cache_key": key} + if variant == 'explicit': + body["prompt_cache_options"] = {"mode": "explicit", "ttl": "30m"} + sysmsg = {"role": "system", "content": [{"type": "text", "text": text, + "prompt_cache_breakpoint": {"mode": "explicit"}}]} + else: + sysmsg = {"role": "system", "content": text} + if variant == 'retention': + body["prompt_cache_retention"] = "24h" + body["messages"] = [sysmsg, {"role": "user", "content": f"Reply with just OK {n}"}] + req = urllib.request.Request(URL, data=json.dumps(body).encode(), method='POST', headers={ + "Content-Type": "application/json", "Authorization": "Bearer " + PROFILE['apiKey']}) + try: + resp = json.load(OPENER.open(req, timeout=180)) + except urllib.error.HTTPError as e: + return None, f"HTTP {e.code}: {e.read()[:160].decode(errors='replace')}" + u = resp.get('usage', {}) + d = u.get('prompt_tokens_details') or {} + cached = d.get('cached_tokens', u.get('cached_tokens')) + return cached, f"prompt={u.get('prompt_tokens')} cached={cached} cache_write={d.get('cache_write_tokens')}" + + +if __name__ == '__main__': + print(f"url={URL.split('/v1')[0]} repeats={REPEATS}", flush=True) + for model in MODELS: + for variant in ('key-only', 'explicit', 'retention'): + k = str(uuid.uuid4()) + hits = [] + for i in range(REPEATS): + cached, text = call(model, variant, k, k, i) + print(f"{model} {variant} #{i}: {text}", flush=True) + if cached is None and text.startswith('HTTP'): + break + hits.append(cached or 0) + time.sleep(2) + later = hits[1:] + print(f"=> {model} {variant}: hits on {sum(1 for h in later if h)}/{len(later)} repeats\n", flush=True) diff --git a/claude-launch-tests/test_router_sticky_cache.py b/claude-launch-tests/test_router_sticky_cache.py new file mode 100644 index 0000000..2128c5e --- /dev/null +++ b/claude-launch-tests/test_router_sticky_cache.py @@ -0,0 +1,110 @@ +""" +Hypothesis: the OpenAI-compatible router used by the plugin's "OpenAI Compatible" +profiles spreads identical requests over several backends with separate prompt +caches, so cached_tokens is randomly 0 even though the prompt prefix is +byte-identical. Some routers keep a conversation on one backend when given a +sticky-routing hint (header or body field). The proxy currently sends only +`prompt_cache_key`. + +Method: for each variant send the same ~8k-token chat request N times (unique +prompt per variant so variants don't share a cache) and print cached_tokens. +Variants: nothing, prompt_cache_key, + x-session-id header, + session_id header, ++ `user` body field, + all of them. Reads the API key from the plugin's NetBeans +profile (never printed). Consumes a few cheap requests. + +Run: python3 claude-launch-tests/test_router_sticky_cache.py +Env: NB_PREFS, NB_PROFILE (default WorkLlmRouter), ROUTER_MODEL (default z-ai/glm-5.3), + REPEATS (default 6) + +Result (2026-10-06, z-ai/glm-5.3 via llm-router.openintegration.inc, 6 x 8.5k-token +identical requests per variant): HYPOTHESIS NOT CONFIRMED for any hint — no variant +gives stable hits; routing is random on the router side. +- nothing: hits on 2/5 repeats +- prompt_cache_key: 1/5 +- + x-session-id: 2/5 +- + session_id: 2/5 +- + user field: 1/5 +- all hints together: 1/5 +A hit (cached ~ the whole prompt) appears after 3-5 identical requests and is often +lost on the next one, so the router spreads requests over several backends with +separate caches and ignores the hints. Nothing to fix in the proxy for this. +""" + +import json +import os +import time +import urllib.error +import urllib.request +import uuid + +NB_PREFS = os.environ.get('NB_PREFS', os.path.expanduser( + '~/.netbeans/29/config/Preferences/io/github/nbclaudecodegui.properties')) +NB_PROFILE = os.environ.get('NB_PROFILE', 'WorkLlmRouter') +MODEL = os.environ.get('ROUTER_MODEL', 'z-ai/glm-5.3') +REPEATS = int(os.environ.get('REPEATS', '6')) + + +def load_profile(): + raw = None + with open(NB_PREFS, encoding='latin1') as f: + for line in f: + if line.startswith('profiles='): + raw = line[len('profiles='):].rstrip('\n') + raw = raw.encode('latin1').decode('unicode_escape').replace('\\:', ':').replace('\\=', '=') + for p in json.loads(raw): + if p.get('name') == NB_PROFILE: + return p + raise SystemExit(f'profile {NB_PROFILE!r} not found') + + +PROFILE = load_profile() +URL = PROFILE['baseUrl'].rstrip('/') + '/v1/chat/completions' +OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) # router host is in noProxy + + +def system_prompt(tag): + return f"[{tag}] You are a helpful coding assistant. " + " ".join( + f"Rule {i}: always keep answers short, precise and correct, rule number {i}." for i in range(450)) + + +def call(tag, key, headers_extra, body_extra, n): + body = {"model": MODEL, "max_tokens": 8, "stream": False, + "messages": [{"role": "system", "content": system_prompt(tag)}, + {"role": "user", "content": f"Reply with just OK {n}"}]} + if key: + body["prompt_cache_key"] = key + body.update(body_extra) + headers = {"Content-Type": "application/json", "Authorization": "Bearer " + PROFILE['apiKey']} + headers.update(headers_extra) + req = urllib.request.Request(URL, data=json.dumps(body).encode(), method='POST', headers=headers) + try: + resp = json.load(OPENER.open(req, timeout=180)) + except urllib.error.HTTPError as e: + return None, f"HTTP {e.code}: {e.read()[:150].decode(errors='replace')}" + u = resp.get('usage', {}) + cached = (u.get('prompt_tokens_details') or {}).get('cached_tokens', u.get('cached_tokens')) + return cached, f"prompt={u.get('prompt_tokens')} cached={cached}" + + +if __name__ == '__main__': + print(f"model={MODEL} url={URL.split('/v1')[0]} repeats={REPEATS}") + variants = [ + ('nothing', False, {}, {}), + ('prompt_cache_key', True, {}, {}), + ('+x-session-id', True, {'x-session-id': '{k}'}, {}), + ('+session_id', True, {'session_id': '{k}'}, {}), + ('+user field', True, {}, {'user': '{k}'}), + ('all hints', True, {'x-session-id': '{k}', 'session_id': '{k}'}, {'user': '{k}'}), + ] + for name, use_key, hdr, extra in variants: + k = str(uuid.uuid4()) + hdr = {a: b.replace('{k}', k) for a, b in hdr.items()} + extra = {a: b.replace('{k}', k) for a, b in extra.items()} + hits = [] + for i in range(REPEATS): + cached, text = call(k, k if use_key else None, hdr, extra, i) + hits.append(cached or 0) + print(f"{name} #{i}: {text}", flush=True) + time.sleep(2) + later = hits[1:] + print(f"=> {name}: hits on {sum(1 for h in later if h)}/{len(later)} repeat requests\n", flush=True) diff --git a/docs/user-manual.md b/docs/user-manual.md index 330ba37..15246f8 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -78,7 +78,7 @@ Contains the input area, **▶ Send** / **✖ Cancel** buttons, and **☰ Histor Press **Shift+Tab** anywhere in the prompt panel to cycle through modes. -**Model selector** — lists the models available in the current session. Standard entries (`sonnet`, `opus`, `haiku`) are discovered by opening the `/model` menu. For **Claude-compatible API** profiles, any models assigned the `custom` alias in **Model Aliases** also appear here, shown by their full provider ID. Selecting a model switches to it immediately. +**Model selector** — lists the models available in the current session. Standard entries (`sonnet`, `opus`, `haiku`) are discovered by opening the `/model` menu. For **Claude-compatible API**, **OpenAI Compatible** and **ChatGPT Subscription** profiles, every model assigned an alias in **Model Aliases** (`sonnet`, `opus`, `haiku` or `custom`) also appears here, shown by its full provider ID. Selecting a model switches to it immediately. On the right side of the status bar: @@ -513,7 +513,7 @@ The dialog shows a table with four columns: | **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. +**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. It applies to the **OpenAI Compatible** connection type only: for **ChatGPT Subscription** the column has no effect, because the ChatGPT Codex backend rejects explicit cache-control fields — caching there is automatic, keyed to your Claude Code session. **Buttons:** @@ -530,7 +530,7 @@ The dialog shows a table with four columns: **How to configure aliases:** 1. Click **Fetch** to populate the table with models available at your endpoint. -2. For each model you want to use as a standard model (`sonnet`, `opus`, or `haiku`), set the corresponding alias. You can skip this step if the model ID already starts with `sonnet`, `opus`, or `haiku` — those are matched automatically. +2. For each model you want to use as a standard model (`sonnet`, `opus`, or `haiku`), set the corresponding alias. You can skip this step if the model ID already starts with `sonnet`, `opus`, or `haiku` — those are matched automatically. Claude Code also uses these aliases on its own — e.g. many subagents run on `haiku` and background requests on `sonnet` — so with **ChatGPT Subscription** and **OpenAI Compatible** profiles map all three, otherwise those requests fail with an unsupported-model error. A model mapped to an alias still appears in the model selector under its own ID. 3. For each additional model you want to appear in the model selector, set its alias to `custom`. You can assign `custom` to as many models as you like — each will appear as a separate entry in the selector. 4. Leave models you do not plan to use with a blank alias, or remove them with **Delete** / **Prune**. diff --git a/pom.xml b/pom.xml index 887e825..4c0fbcb 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.42-SNAPSHOT + 1.3.51-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/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index ce072f9..68e379b 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -238,7 +238,7 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr workingDir = dir; claudeProcess = new ClaudeProcess(); ClaudeProfile profile = ClaudeProfileStore.findByName(profileName); - customModelIds = profile != null ? new ArrayList<>(profile.getCustomModels()) : List.of(); + customModelIds = profile != null ? new ArrayList<>(profile.getComboModelIds()) : List.of(); PtyProcess process = claudeProcess.start(dir.getAbsolutePath(), profile, extraCliArgs != null ? extraCliArgs : "", mode != null ? mode : SessionMode.NEW, 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 52e6372..79d495d 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslator.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToCodexTranslator.java @@ -21,7 +21,8 @@ *