diff --git a/docker-compose.control-panel.local.yml b/docker-compose.control-panel.local.yml index a90147f..ddacac6 100644 --- a/docker-compose.control-panel.local.yml +++ b/docker-compose.control-panel.local.yml @@ -32,7 +32,7 @@ services: # gateway; a gateway key must never be assumed valid for api.openai.com. OPENAI_API_KEY: ${OPENAI_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} - CASAN_GOAL_OPENAI_MODEL: ${CASAN_GOAL_OPENAI_MODEL:-gpt-4.1} + CASAN_GOAL_OPENAI_MODEL: ${CASAN_GOAL_OPENAI_MODEL:-gpt-5.3-codex} CASAN_GOAL_ANTHROPIC_MODEL: ${CASAN_GOAL_ANTHROPIC_MODEL:-claude-3-5-sonnet-latest} CASAN_PREFLIGHT: ${CASAN_PREFLIGHT:-0} CASAN_AUTH_BRIDGE_URL: http://host.docker.internal:20130 diff --git a/infra/local-prod/casan.local.env.example b/infra/local-prod/casan.local.env.example index 2878590..c2ccf92 100644 --- a/infra/local-prod/casan.local.env.example +++ b/infra/local-prod/casan.local.env.example @@ -10,5 +10,5 @@ CASAN_PREFLIGHT=1 # in governed goal review and H2 patch-format recovery when configured. OPENAI_API_KEY= ANTHROPIC_API_KEY= -CASAN_GOAL_OPENAI_MODEL=gpt-4.1 +CASAN_GOAL_OPENAI_MODEL=gpt-5.3-codex CASAN_GOAL_ANTHROPIC_MODEL=claude-3-5-sonnet-latest diff --git a/packages/casan-control-panel/backend/src/goals/goals.service.ts b/packages/casan-control-panel/backend/src/goals/goals.service.ts index ea01791..aae9f59 100644 --- a/packages/casan-control-panel/backend/src/goals/goals.service.ts +++ b/packages/casan-control-panel/backend/src/goals/goals.service.ts @@ -158,7 +158,7 @@ export class GoalsService { return { model: String(runtime.CASAN_CHAT_SELECTED_MODEL || model), runtime }; }); const envCloudCandidates = [ - process.env.OPENAI_API_KEY ? { model: `openai:${process.env.CASAN_GOAL_OPENAI_MODEL || 'gpt-4.1'}`, runtime: {} } : null, + process.env.OPENAI_API_KEY ? { model: `openai:${process.env.CASAN_GOAL_OPENAI_MODEL || 'gpt-5.3-codex'}`, runtime: {} } : null, process.env.ANTHROPIC_API_KEY ? { model: `anthropic:${process.env.CASAN_GOAL_ANTHROPIC_MODEL || 'claude-3-5-sonnet-latest'}`, runtime: {} } : null, ].filter((candidate): candidate is { model: string; runtime: Record } => candidate !== null); const cloudCandidates = [...envCloudCandidates, ...savedCloudCandidates].filter((candidate, index, rows) => rows.findIndex((row) => row.model === candidate.model) === index); diff --git a/packages/casan-harness/scripts/bash/model-call.py b/packages/casan-harness/scripts/bash/model-call.py index 9212f8f..693ed78 100755 --- a/packages/casan-harness/scripts/bash/model-call.py +++ b/packages/casan-harness/scripts/bash/model-call.py @@ -239,6 +239,58 @@ def call_openai(model_name, prompt, role): } +def parse_responses_payload(payload): + try: + text = str(payload.get("output_text") or "").strip() + if not text: + text = "".join( + str(part.get("text") or "") + for item in payload.get("output", []) if isinstance(item, dict) + for part in item.get("content", []) if isinstance(part, dict) and part.get("type") == "output_text" + ).strip() + usage = payload["usage"] + input_tokens = int(usage["input_tokens"]) + output_tokens = int(usage["output_tokens"]) + if not text: + raise ValueError("output text missing") + except (KeyError, TypeError, ValueError) as exc: + fail(f"provider_usage_invalid openai-responses {type(exc).__name__}: {str(exc)[:80]}") + return text, input_tokens, output_tokens + + +def call_openai_responses(model_name, prompt, role): + """Use Responses API for Codex models that are not Chat Completions routes.""" + host = "api.openai.com" + if host not in ALLOWED_CLOUD: + fail(f"endpoint_not_allowed openai host={host}") + key = os.environ["OPENAI_API_KEY"] + body = { + "model": model_name, + "input": prompt, + "max_output_tokens": generation_max_tokens(role), + "store": False, + } + data = json.dumps(body).encode() + req = urllib.request.Request( + f"https://{host}/v1/responses", data=data, + headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"}, + ) + t0 = time.time() + try: + with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp: + payload = json.loads(resp.read().decode()) + except Exception as exc: + fail(f"backend_unreachable {type(exc).__name__}: {str(exc)[:120]}") + latency_ms = int((time.time() - t0) * 1000) + text, input_tokens, output_tokens = parse_responses_payload(payload) + return { + "text": text, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "latency_ms": latency_ms, + } + + def openai_compatible_url(): """Return a vetted OpenAI-compatible chat-completions endpoint. @@ -412,7 +464,7 @@ def main(): if backend == "ollama": result = call_ollama(model_name, prompt, args.role) elif backend == "openai": - result = call_openai(model_name, prompt, args.role) + result = call_openai_responses(model_name, prompt, args.role) if model_name.endswith("-codex") else call_openai(model_name, prompt, args.role) elif backend == "openai-compatible": result = call_openai_compatible(model_name, prompt, args.role) else: # anthropic diff --git a/packages/casan-harness/tests/phase3-model-router-tests.sh b/packages/casan-harness/tests/phase3-model-router-tests.sh index 07e5813..b53007a 100755 --- a/packages/casan-harness/tests/phase3-model-router-tests.sh +++ b/packages/casan-harness/tests/phase3-model-router-tests.sh @@ -152,6 +152,11 @@ class FakeResp: def fake_urlopen(req, timeout): seen.append((req.full_url, dict(req.header_items()), json.loads(req.data.decode()))) + if req.full_url.endswith("/v1/responses"): + return FakeResp({ + "output_text": "CASAN_OK", + "usage": {"input_tokens": 13, "output_tokens": 4}, + }) if "openai.com" in req.full_url or "127.0.0.1:20128" in req.full_url: return FakeResp({ "choices": [{"message": {"content": "SAFE"}}], @@ -166,17 +171,21 @@ def fake_urlopen(req, timeout): mc.urllib.request.urlopen = fake_urlopen op = mc.call_openai("gpt-test", "hello", "classify") +codex = mc.call_openai_responses("gpt-5.3-codex", "hello", "generate") gw = mc.call_openai_compatible("local-test", "hello", "classify") an = mc.call_anthropic("claude-test", "hello", "judge") assert op["text"] == "SAFE" and op["input_tokens"] == 11 and op["output_tokens"] == 3, op +assert codex["text"] == "CASAN_OK" and codex["input_tokens"] == 13 and codex["output_tokens"] == 4, codex assert gw["text"] == "SAFE" and gw["input_tokens"] == 11 and gw["output_tokens"] == 3, gw assert an["text"] == "APPROVED" and an["input_tokens"] == 17 and an["output_tokens"] == 5, an assert seen[0][0] == "https://api.openai.com/v1/chat/completions", seen[0] -assert seen[1][0] == "http://127.0.0.1:20128/v1/chat/completions", seen[1] -assert seen[2][0] == "https://api.anthropic.com/v1/messages", seen[2] +assert seen[1][0] == "https://api.openai.com/v1/responses", seen[1] +assert seen[2][0] == "http://127.0.0.1:20128/v1/chat/completions", seen[2] +assert seen[3][0] == "https://api.anthropic.com/v1/messages", seen[3] assert seen[0][2]["temperature"] == 0 and seen[0][2]["max_tokens"] == 16, seen[0][2] -assert seen[1][2]["temperature"] == 0 and seen[1][2]["max_tokens"] == 16, seen[1][2] -assert "temperature" not in seen[2][2] and seen[2][2]["max_tokens"] == 16, seen[2][2] +assert seen[1][2]["max_output_tokens"] == 1400 and seen[1][2]["store"] is False, seen[1][2] +assert seen[2][2]["temperature"] == 0 and seen[2][2]["max_tokens"] == 16, seen[2][2] +assert "temperature" not in seen[3][2] and seen[3][2]["max_tokens"] == 16, seen[3][2] os.environ["CASAN_OPENAI_COMPATIBLE_BASE_URL"] = "http://169.254.169.254/v1" try: @@ -188,6 +197,7 @@ else: for fn, bad in ( (mc.parse_openai_payload, {"choices": [{"message": {"content": "SAFE"}}]}), + (mc.parse_responses_payload, {"output_text": "CASAN_OK"}), (mc.parse_anthropic_payload, {"content": [{"type": "text", "text": "APPROVED"}]}), ): try: