Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
124 changes: 124 additions & 0 deletions claude-launch-tests/test_codex_prompt_cache.py
Original file line number Diff line number Diff line change
@@ -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)
260 changes: 260 additions & 0 deletions claude-launch-tests/test_prompt_prefix_stability.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading