From 6748d5b58d35914bbd5630535e54e158a03080bb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 10:05:17 +0000 Subject: [PATCH] OpenAI Responses GPT-5.6+ cache the stable prefix for 12 hours Put ttl 12h on the existing explicit prefix breakpoint and keep the request default at 30m. Chat Completions, Codex, and older models stay byte-identical. GRAFF_OPENAI_CACHE_TTL selects 12h (default), 30m, or off. Usage parses cached_tokens and the 12h/30m write split. The retention probe builds those bodies through Agent.buildBody and records later cached_tokens in bench/openai-cache-retention.json. Co-authored-by: Rach --- bench/openai-cache-retention-state.json | 142 +++++++++++ bench/openai-cache-retention.json | 1 + build.zig | 8 + ...-gpt-5-6-explicit-prompt-cache-boundary.md | 5 +- docs/adr/0298-openai-12h-prompt-cache.md | 36 +++ docs/adr/README.md | 3 +- scripts/openai_cache_retention_probe | 224 ++++++++++++++++++ src/agent_context.zig | 28 +-- src/agent_request_body_cache_test.zig | 146 ++++++++++-- src/agent_request_body_responses.zig | 47 +--- src/openai_cache_retention.zig | 150 ++++++++++++ src/openai_prompt_cache.zig | 221 +++++++++++++++++ src/pricing_cache.zig | 22 ++ src/prompt_cache_hud.zig | 16 +- src/session_settings.zig | 1 + src/usage_trace.zig | 18 +- 16 files changed, 986 insertions(+), 82 deletions(-) create mode 100644 bench/openai-cache-retention-state.json create mode 100644 bench/openai-cache-retention.json create mode 100644 docs/adr/0298-openai-12h-prompt-cache.md create mode 100755 scripts/openai_cache_retention_probe create mode 100644 src/openai_cache_retention.zig create mode 100644 src/openai_prompt_cache.zig diff --git a/bench/openai-cache-retention-state.json b/bench/openai-cache-retention-state.json new file mode 100644 index 000000000..986bff226 --- /dev/null +++ b/bench/openai-cache-retention-state.json @@ -0,0 +1,142 @@ +{ + "model": "gpt-5.6", + "prompt_cache_key": "graff-openai-cache-retention-v1", + "api": "responses", + "url": "https://api.openai.com/v1/responses", + "status": "not_run", + "cases": { + "12h": { + "nonce": "graff-12h-20261021T100354Z", + "ttl": "12h", + "request": { + "model": "gpt-5.6", + "instructions": "nonce=graff-12h-20261021T100354Z\nStable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. \n\n# GPT-5.6 working guidance\nFor requests to answer, explain, review, diagnose, or plan: inspect the\nrelevant material and report; do not implement changes unless the request\nalso asks for them. For requests to change, build, or fix: make the in-scope\nlocal changes and run relevant non-destructive validation without asking.\nReading files, inspecting logs, editing in-scope code, and running tests are\nsafe local actions. Confirm before external writes, destructive actions, or a\nmaterial expansion of scope; the harness's tool approval gates are the only\napproval flow \u2014 do not invent additional ones.\nBefore the first tool call of a multi-step task, state the first step in one\nor two sentences; afterwards update only when a phase begins or a finding\nchanges the plan. Finish with the most relevant validation available\n(targeted tests, lint or type checks, a build); if it cannot run, say why and\nname the next best check.", + "prompt_cache_key": "graff-openai-cache-retention-v1", + "prompt_cache_options": { + "mode": "implicit", + "ttl": "30m" + }, + "input": [ + { + "type": "message", + "role": "developer", + "content": [ + { + "type": "input_text", + "text": "The preceding instructions and available tools are the stable operating context for this session.", + "prompt_cache_breakpoint": { + "mode": "explicit", + "ttl": "12h" + } + } + ] + }, + { + "role": "user", + "content": "hello" + } + ], + "tools": [ + { + "type": "function", + "name": "probe_echo", + "description": "Echo a short acknowledgement. Used only to put a tool definition on the cached prefix.", + "parameters": { + "type": "object", + "properties": { + "note": { + "type": "string" + } + }, + "additionalProperties": false + } + } + ], + "tool_choice": "auto", + "parallel_tool_calls": true, + "reasoning": { + "effort": "low" + }, + "include": [ + "reasoning.encrypted_content" + ], + "store": false, + "context_management": [ + { + "type": "compaction", + "compact_threshold": 400000 + } + ], + "stream": false, + "max_output_tokens": 16 + } + }, + "30m": { + "nonce": "graff-30m-20261021T100354Z", + "ttl": "30m", + "request": { + "model": "gpt-5.6", + "instructions": "nonce=graff-30m-20261021T100354Z\nStable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. Stable operating instructions for the OpenAI prompt-cache retention probe. \n\n# GPT-5.6 working guidance\nFor requests to answer, explain, review, diagnose, or plan: inspect the\nrelevant material and report; do not implement changes unless the request\nalso asks for them. For requests to change, build, or fix: make the in-scope\nlocal changes and run relevant non-destructive validation without asking.\nReading files, inspecting logs, editing in-scope code, and running tests are\nsafe local actions. Confirm before external writes, destructive actions, or a\nmaterial expansion of scope; the harness's tool approval gates are the only\napproval flow \u2014 do not invent additional ones.\nBefore the first tool call of a multi-step task, state the first step in one\nor two sentences; afterwards update only when a phase begins or a finding\nchanges the plan. Finish with the most relevant validation available\n(targeted tests, lint or type checks, a build); if it cannot run, say why and\nname the next best check.", + "prompt_cache_key": "graff-openai-cache-retention-v1", + "prompt_cache_options": { + "mode": "implicit", + "ttl": "30m" + }, + "input": [ + { + "type": "message", + "role": "developer", + "content": [ + { + "type": "input_text", + "text": "The preceding instructions and available tools are the stable operating context for this session.", + "prompt_cache_breakpoint": { + "mode": "explicit" + } + } + ] + }, + { + "role": "user", + "content": "hello" + } + ], + "tools": [ + { + "type": "function", + "name": "probe_echo", + "description": "Echo a short acknowledgement. Used only to put a tool definition on the cached prefix.", + "parameters": { + "type": "object", + "properties": { + "note": { + "type": "string" + } + }, + "additionalProperties": false + } + } + ], + "tool_choice": "auto", + "parallel_tool_calls": true, + "reasoning": { + "effort": "low" + }, + "include": [ + "reasoning.encrypted_content" + ], + "store": false, + "context_management": [ + { + "type": "compaction", + "compact_threshold": 400000 + } + ], + "stream": false, + "max_output_tokens": 16 + } + } + }, + "materialized_at": "2026-10-21T10:03:54Z", + "reason": "OPENAI_API_KEY unset" +} diff --git a/bench/openai-cache-retention.json b/bench/openai-cache-retention.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/bench/openai-cache-retention.json @@ -0,0 +1 @@ +[] diff --git a/build.zig b/build.zig index aee1d528f..70479405b 100644 --- a/build.zig +++ b/build.zig @@ -134,6 +134,14 @@ pub fn build(b: *std.Build) void { // scripts `test` adds below. `-Dtest-filter` narrows it to named tests. const unit_step = b.step("unit", "Run only the unit tests (-Dtest-filter narrows them)"); unit_step.dependOn(&run_tests.step); + // Operator probe: env-gated and writes a file, so the run cannot be cached. + const run_openai_cache_retention = b.addRunArtifact(unit_tests); + run_openai_cache_retention.has_side_effects = true; + const openai_cache_retention_step = b.step( + "openai-cache-retention", + "Run the env-gated OpenAI cache retention materialize/live tests", + ); + openai_cache_retention_step.dependOn(&run_openai_cache_retention.step); const acp_preauth_test = b.addSystemCommand(&.{ "python3", "scripts/test-acp-preauth.py" }); acp_preauth_test.addArtifactArg(exe); test_step.dependOn(&acp_preauth_test.step); diff --git a/docs/adr/0009-gpt-5-6-explicit-prompt-cache-boundary.md b/docs/adr/0009-gpt-5-6-explicit-prompt-cache-boundary.md index e9b7747f7..8e8ee4015 100644 --- a/docs/adr/0009-gpt-5-6-explicit-prompt-cache-boundary.md +++ b/docs/adr/0009-gpt-5-6-explicit-prompt-cache-boundary.md @@ -29,8 +29,9 @@ unsupported parameter, so its supported automatic/keyed path must remain. - Root sessions use `prompt_cache_options.mode = "implicit"`: the explicit stable prefix remains reusable while append-only conversation turns keep the useful latest-message checkpoint. Subagents use `mode = "explicit"` so their - unique task suffix is not written at 1.25× input price. Both use the documented - 30-minute TTL. + unique task suffix is not written at 1.25× input price. The request TTL + stays 30 minutes; ADR 0298 puts `ttl: "12h"` on the stable-prefix + breakpoint (configurable via `GRAFF_OPENAI_CACHE_TTL`). - Root OpenAI/Codex requests keep the durable project key. Child prefix traffic is deterministically spread over four stable role lanes to stay below the documented approximately 15 requests/minute per-key guidance while preserving diff --git a/docs/adr/0298-openai-12h-prompt-cache.md b/docs/adr/0298-openai-12h-prompt-cache.md new file mode 100644 index 000000000..7a1f1afb6 --- /dev/null +++ b/docs/adr/0298-openai-12h-prompt-cache.md @@ -0,0 +1,36 @@ +# 0298. OpenAI Platform 12-hour prompt cache on the stable prefix + +Status: accepted 2026-10-21 + +## Context + +On 2026-10-20 OpenAI added a 12-hour minimum lifetime for prompt-cache +writes on GPT-5.6 and later, in the Responses API only. A 12-hour write +costs 2.5× uncached input; a 30-minute write stays 1.25×. Reads stay 0.1× +(0.05× on GPT-6.1 Sol). Chat Completions currently accepts only `ttl: +"30m"`; sending `12h` there, or to older models, is a 400 +`invalid_request_error`. Older models keep the separate +`prompt_cache_retention` parameter. + +ADR 0009 already marks graff's stable instructions/tools prefix with an +explicit breakpoint and uses a 30-minute request TTL. Long-lived sessions +pay to rewrite that prefix after a 30-minute gap. + +## Decision + +- On the direct OpenAI Responses route for GPT-5.6 and later, the stable + prefix breakpoint sends `prompt_cache_breakpoint.ttl = "12h"`. The + request keeps `prompt_cache_options.ttl = "30m"` for session context. + The current user turn is never marked. +- `GRAFF_OPENAI_CACHE_TTL` / `.harness/settings.json` `openai_cache_ttl` + select `12h` (default), `30m` (ADR 0009 bytes), or `off`. +- Chat Completions, Codex, ChatGPT-new, xAI, and models before GPT-5.6 + receive no new fields. +- `prompt_cache_key` is unchanged. Usage parses `cached_tokens`, + `cache_write_tokens`, and `cache_write_tokens_details`. + +## Consequences + +The cold prefix write costs 2.5× instead of 1.25× when 12h is on. A later +request hours later can still read that prefix. Codex remains on its +keyed automatic path: it 400s on `prompt_cache_options`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 4fa549d2a..4a1ba7b44 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,7 +19,7 @@ record only when you need the evidence or the edge cases. | [0006](0006-workspace-switch-is-a-tool.md) | Mid-session worktree switch is a real `workspace` tool; a skill cannot move file-tool cwd. | | [0007](0007-plugins-are-read-in-place.md) | Cursor/Claude/Grok/Codex plugins and MCP are read in place (Claude/Cursor cache via installed_plugins.json, Codex via config.toml PluginStore, never walked); skills stay on-demand; MCP stays consent-gated. | | [0008](0008-synthetic-evals-use-external-verifiers.md) | Synthetic coding evals promote only external-verifier passes; model judges may tiebreak correctness, never decide it. | -| [0009](0009-gpt-5-6-explicit-prompt-cache-boundary.md) | GPT-5.6 OpenAI Platform marks the stable prefix explicitly; Codex and xAI stay on their supported keyed automatic-cache paths. | +| [0009](0009-gpt-5-6-explicit-prompt-cache-boundary.md) | GPT-5.6 OpenAI Platform marks the stable prefix explicitly; Codex and xAI stay on their supported keyed automatic-cache paths. 12h lifetime: [0298](0298-openai-12h-prompt-cache.md). | | [0010](0010-background-jobs-wait-for-exit.md) | `bash_output`/`agent_output` `wait_ms>0` blocks until exit (10h cap); do not poll every 30s. | | [0011](0011-prompt-cache-max-is-visible.md) | Prompt-cache max is on: stable catalog by default; `/cache` is the HUD; `/btw` rides the parent prefix; children share role-lane `x-grok-conv-id` / `prompt_cache_key` (not the root id). | | [0012](0012-overflow-handles-named-limits-extra-roots.md) | Fat tool results become `tr_N` handles; named `--context-limit` caps prefix bytes; `--add-dir` extra roots are PathConfine allow-lists, not cwd/skill/session sources. | @@ -315,6 +315,7 @@ record only when you need the evidence or the edge cases. | [0295](0295-worker-native-turns-share-the-chat-step.md) | Experimental Worker-native turns share the native chat step; JSPI supplies bounded host I/O, not a proxy or a second agent loop. | | [0296](0296-worker-tasks-own-remote-sandbox-cleanup.md) | Worker/DO tasks retain the agent loop and delegate Linux execution to remote sandboxes with explicit ownership, deletion and bounded leases. | | [0297](0297-sdk-managed-replay-reuses-the-relay.md) | The fetch-only managed SDK reuses account relay/device tapes; prompt retries are explicit, durability limits are stated, and content-free OTLP export remains opt-in and observational. | +| [0298](0298-openai-12h-prompt-cache.md) | OpenAI Responses GPT-5.6+ puts a 12h explicit breakpoint on the stable prefix and keeps a 30m request default; Chat Completions and older models get no new fields. | ## When to write one diff --git a/scripts/openai_cache_retention_probe b/scripts/openai_cache_retention_probe new file mode 100755 index 000000000..a8ce5b93d --- /dev/null +++ b/scripts/openai_cache_retention_probe @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""OpenAI 12h prompt-cache retention probe. + +Builds requests through graff's OpenAI provider (`Agent.buildBody`), then +POSTs them to the Responses API. + + scripts/openai_cache_retention_probe write + materialize unique 12h + 30m prefixes, POST both, immediately resend, + append bench/openai-cache-retention.json + + scripts/openai_cache_retention_probe --check + resend the saved prefixes and append cached_tokens + +A 12h hit after 1h / 3h / 6h / ~11.5h is the success signal. A 30m hit after +~1h is recorded, not a failure — OpenAI may keep entries longer than the +minimum. Requires OPENAI_API_KEY for POST. Bodies contain no secrets. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +STATE_PATH = ROOT / "bench" / "openai-cache-retention-state.json" +LOG_PATH = ROOT / "bench" / "openai-cache-retention.json" +URL = "https://api.openai.com/v1/responses" +PINNED_KEY = "graff-openai-cache-retention-v1" + + +def iso_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def load_json(path: Path, default): + if not path.exists(): + return default + return json.loads(path.read_text()) + + +def save_json(path: Path, value) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + "\n") + + +def materialize(stamp: str, model: str) -> dict: + env = os.environ.copy() + env["GRAFF_OPENAI_CACHE_RETENTION"] = "materialize" + env["GRAFF_OPENAI_CACHE_RETENTION_STAMP"] = stamp + env["GRAFF_OPENAI_CACHE_RETENTION_MODEL"] = model + env["GRAFF_OPENAI_CACHE_RETENTION_STATE"] = str(STATE_PATH) + cmd = [ + "zig", + "build", + "openai-cache-retention", + "-Dtest-filter=openai cache retention materialize", + ] + print("materialize:", " ".join(cmd), file=sys.stderr) + subprocess.run(cmd, cwd=ROOT, env=env, check=True) + state = load_json(STATE_PATH, None) + if not state or "cases" not in state: + raise SystemExit(f"materialize did not write {STATE_PATH}") + for name, case in state["cases"].items(): + nonce = case.get("nonce") or "" + if stamp not in nonce: + raise SystemExit( + f"materialize reused a cached body for {name}: nonce={nonce!r} stamp={stamp!r}" + ) + state["prompt_cache_key"] = PINNED_KEY + state["materialized_at"] = iso_now() + save_json(STATE_PATH, state) + return state + + +def usage_from(payload: dict) -> dict: + usage = payload.get("usage") or {} + details = usage.get("input_tokens_details") or {} + writes = details.get("cache_write_tokens_details") or {} + return { + "id": payload.get("id"), + "status": payload.get("status") or payload.get("error"), + "input_tokens": usage.get("input_tokens"), + "cached_tokens": details.get("cached_tokens"), + "cache_write_tokens": details.get("cache_write_tokens"), + "ttl_30m_tokens": writes.get("ttl_30m_tokens"), + "ttl_12h_tokens": writes.get("ttl_12h_tokens"), + } + + +def post(request: dict, api_key: str) -> tuple[int, dict]: + body = json.dumps(request).encode() + req = urllib.request.Request( + URL, + data=body, + method="POST", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=120) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError as exc: + raw = exc.read().decode() + try: + payload = json.loads(raw) + except json.JSONDecodeError: + payload = {"error": {"message": raw[:500], "type": "http_error"}} + return exc.code, payload + + +def append_log(entry: dict) -> None: + log = load_json(LOG_PATH, []) + if not isinstance(log, list): + log = [] + log.append(entry) + save_json(LOG_PATH, log) + + +def run_pair(state: dict, api_key: str, phase: str) -> dict: + results = {"phase": phase, "ts": iso_now(), "prompt_cache_key": PINNED_KEY, "cases": {}} + for name, case in state["cases"].items(): + request = case["request"] + request["prompt_cache_key"] = PINNED_KEY + status, payload = post(request, api_key) + usage = usage_from(payload) + row = { + "http_status": status, + "nonce": case.get("nonce"), + "ttl": case.get("ttl"), + **usage, + } + if status != 200: + err = (payload.get("error") or {}) + row["error_type"] = err.get("type") + row["error_code"] = err.get("code") + row["error_message"] = err.get("message") + results["cases"][name] = row + print( + f"{phase} {name}: http={status} id={usage.get('id')} " + f"input={usage.get('input_tokens')} cached={usage.get('cached_tokens')} " + f"write={usage.get('cache_write_tokens')} " + f"12h={usage.get('ttl_12h_tokens')} 30m={usage.get('ttl_30m_tokens')}", + file=sys.stderr, + ) + append_log(results) + return results + + +def write(model: str) -> int: + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + state = materialize(stamp, model) + api_key = os.environ.get("OPENAI_API_KEY") + if not api_key: + state["status"] = "not_run" + state["reason"] = "OPENAI_API_KEY unset" + save_json(STATE_PATH, state) + print("OPENAI_API_KEY is unset; wrote request bodies only.", file=sys.stderr) + print(f"state: {STATE_PATH}", file=sys.stderr) + print(f"rerun: OPENAI_API_KEY=... {Path(__file__).name} write", file=sys.stderr) + return 0 + written = run_pair(state, api_key, "write") + time.sleep(2) + checked = run_pair(state, api_key, "immediate_check") + state["status"] = "written" + state["written_at"] = written["ts"] + for name, row in written["cases"].items(): + state["cases"][name]["write"] = row + for name, row in checked["cases"].items(): + state["cases"][name]["immediate_check"] = row + save_json(STATE_PATH, state) + print(f"state: {STATE_PATH}", file=sys.stderr) + print(f"log: {LOG_PATH}", file=sys.stderr) + print( + f"rerun --check: {Path(__file__).name} --check", + file=sys.stderr, + ) + print(f"nonce/state file: {STATE_PATH} (prompt_cache_key={PINNED_KEY})", file=sys.stderr) + twelve = checked["cases"].get("12h") or {} + if twelve.get("http_status") != 200: + return 1 + return 0 + + +def check() -> int: + api_key = os.environ.get("OPENAI_API_KEY") + if not api_key: + print("OPENAI_API_KEY is unset; --check was not sent.", file=sys.stderr) + return 1 + state = load_json(STATE_PATH, None) + if not state or "cases" not in state: + raise SystemExit(f"missing state file {STATE_PATH}; run write first") + results = run_pair(state, api_key, "check") + state["last_check_at"] = results["ts"] + for name, row in results["cases"].items(): + checks = state["cases"][name].setdefault("checks", []) + checks.append(row) + save_json(STATE_PATH, state) + print(f"appended {LOG_PATH}", file=sys.stderr) + return 0 if all(c.get("http_status") == 200 for c in results["cases"].values()) else 1 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", nargs="?", default="write", choices=("write", "check")) + parser.add_argument("--check", action="store_true", help="resend saved prefixes") + parser.add_argument("--model", default=os.environ.get("GRAFF_OPENAI_CACHE_RETENTION_MODEL", "gpt-5.6")) + args = parser.parse_args() + if args.check or args.command == "check": + return check() + return write(args.model) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/agent_context.zig b/src/agent_context.zig index 104030815..9a9877086 100644 --- a/src/agent_context.zig +++ b/src/agent_context.zig @@ -149,15 +149,11 @@ pub fn recordUsage(self: *Agent, root: std.json.ObjectMap, req_body_len: usize) self.last_usage_includes_output = reported_total > 0 and (total > 0 or if (u.get("completion_tokens")) |v| v == .integer and v.integer >= 0 else false); // deepseek reports prompt_cache_hit_tokens; the OpenAI shape - // nests cached_tokens under prompt_tokens_details. - var cache = usageInt(u, "prompt_cache_hit_tokens"); - var cache_write: i64 = 0; - if (u.get("prompt_tokens_details")) |d| if (d == .object) { - if (cache == 0) cache = usageInt(d.object, "cached_tokens"); - cache_write = usageInt(d.object, "cache_write_tokens"); - }; - if (cache > 0) self.last_cache_read = @intCast(cache); - self.recordCost(@max(usageInt(u, "prompt_tokens") - cache - cache_write, 0), cache, cache_write, usageInt(u, "completion_tokens")); + // nests cached_tokens / cache_write_tokens(_details) under prompt_tokens_details. + const cache = @import("openai_prompt_cache.zig").readChatUsage(u); + if (cache.cached > 0) self.last_cache_read = @intCast(cache.cached); + self.recordCost(@max(usageInt(u, "prompt_tokens") - cache.cached - cache.written, 0), cache.cached, cache.written, usageInt(u, "completion_tokens")); + @import("openai_prompt_cache.zig").billTwelveHour(self, cache); }, .responses => {}, // codex uses recordUsageResponses on its own path. } @@ -481,14 +477,12 @@ pub fn recordUsageResponses(self: *Agent, response: std.json.ObjectMap, req_body // Without it a long Extra-high session reads "95k/270k" right up until the // backend rejects the resend for exceeding the window, and auto-compaction // (gated on this meter) never rescues it. - var cached: i64 = 0; - var cache_write: i64 = 0; - if (u.get("input_tokens_details")) |d| if (d == .object) { - cached = usageInt(d.object, "cached_tokens"); - cache_write = usageInt(d.object, "cache_write_tokens"); - if (cached > 0) self.last_cache_read = @intCast(cached); - }; - if (usage_known) self.recordCost(@max(in_tokens - cached - cache_write, 0), cached, cache_write, out_tokens); + const cache = @import("openai_prompt_cache.zig").readResponsesUsage(u); + if (cache.cached > 0) self.last_cache_read = @intCast(cache.cached); + if (usage_known) { + self.recordCost(@max(in_tokens - cache.cached - cache.written, 0), cache.cached, cache.written, out_tokens); + @import("openai_prompt_cache.zig").billTwelveHour(self, cache); + } } test "recordUsageResponses: usage fallbacks never lower an authoritative meter" { diff --git a/src/agent_request_body_cache_test.zig b/src/agent_request_body_cache_test.zig index 7d53b5a69..4277ae354 100644 --- a/src/agent_request_body_cache_test.zig +++ b/src/agent_request_body_cache_test.zig @@ -1,23 +1,41 @@ const std = @import("std"); const bodies = @import("agent_request_body_responses.zig"); +const cache = @import("openai_prompt_cache.zig"); + +fn bodyOf(arena: std.mem.Allocator, id: []const u8, kind: @import("provider.zig").Provider.Kind, model: []const u8) ![]u8 { + var agent = try bodies.testAgentFor(arena, id, kind, model); + return agent.buildBody(null, false, true, true); +} + +fn expectNoCacheFields(hay: []const u8) !void { + try std.testing.expect(std.mem.indexOf(u8, hay, "prompt_cache_options") == null); + try std.testing.expect(std.mem.indexOf(u8, hay, "prompt_cache_breakpoint") == null); + try std.testing.expect(std.mem.indexOf(u8, hay, "\"ttl\":\"12h\"") == null); +} test "GPT-5.6 and later Platform marks the stable prefix; Codex and older routes do not" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const a = arena_state.allocator(); + cache.setOverride(.twelve_h); + defer cache.clearOverride(); + var oai = try bodies.testAgentFor(a, "openai", .responses, "gpt-5.6"); const ob = try oai.buildBody(null, false, true, true); defer std.testing.allocator.free(ob); try std.testing.expect(std.mem.indexOf(u8, ob, "\"prompt_cache_key\":\"") != null); try std.testing.expect(std.mem.indexOf(u8, ob, "\"prompt_cache_options\":{\"mode\":\"implicit\",\"ttl\":\"30m\"}") != null); try std.testing.expect(std.mem.indexOf(u8, ob, "\"role\":\"developer\",\"content\":[{\"type\":\"input_text\"") != null); - try std.testing.expect(std.mem.indexOf(u8, ob, "\"prompt_cache_breakpoint\":{\"mode\":\"explicit\"}") != null); + try std.testing.expect(std.mem.indexOf(u8, ob, "\"prompt_cache_breakpoint\":{\"mode\":\"explicit\",\"ttl\":\"12h\"}") != null); + // The current user turn is not an explicit breakpoint. + try std.testing.expect(std.mem.indexOf(u8, ob, "\"role\":\"user\"") != null); + try std.testing.expect(std.mem.indexOf(u8, ob, "\"content\":\"hello\",\"prompt_cache_breakpoint\"") == null); var astra = try bodies.testAgentFor(a, "openai", .responses, "gpt-6-astra"); const ast = try astra.buildBody(null, false, true, true); defer std.testing.allocator.free(ast); try std.testing.expect(std.mem.indexOf(u8, ast, "\"prompt_cache_options\":{\"mode\":\"implicit\",\"ttl\":\"30m\"}") != null); - try std.testing.expect(std.mem.indexOf(u8, ast, "\"prompt_cache_breakpoint\":{\"mode\":\"explicit\"}") != null); + try std.testing.expect(std.mem.indexOf(u8, ast, "\"prompt_cache_breakpoint\":{\"mode\":\"explicit\",\"ttl\":\"12h\"}") != null); var worker = try bodies.testAgentFor(a, "openai", .responses, "gpt-5.6-luna"); worker.sub = true; @@ -25,22 +43,112 @@ test "GPT-5.6 and later Platform marks the stable prefix; Codex and older routes const wb = try worker.buildBody(null, false, true, true); defer std.testing.allocator.free(wb); try std.testing.expect(std.mem.indexOf(u8, wb, "\"prompt_cache_options\":{\"mode\":\"explicit\",\"ttl\":\"30m\"}") != null); + try std.testing.expect(std.mem.indexOf(u8, wb, "\"prompt_cache_breakpoint\":{\"mode\":\"explicit\",\"ttl\":\"12h\"}") != null); + + var sol = try bodies.testAgentFor(a, "openai", .responses, "gpt-6.1-sol"); + const sb = try sol.buildBody(null, false, true, true); + defer std.testing.allocator.free(sb); + try std.testing.expect(std.mem.indexOf(u8, sb, "\"prompt_cache_breakpoint\":{\"mode\":\"explicit\",\"ttl\":\"12h\"}") != null); +} + +test "OpenAI cache TTL 30m keeps the previous GPT-5.6 Responses bytes" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + cache.setOverride(.thirty_m); + defer cache.clearOverride(); + + const ob = try bodyOf(a, "openai", .responses, "gpt-5.6"); + defer std.testing.allocator.free(ob); + try std.testing.expect(std.mem.indexOf(u8, ob, "\"prompt_cache_options\":{\"mode\":\"implicit\",\"ttl\":\"30m\"}") != null); + try std.testing.expect(std.mem.indexOf(u8, ob, "\"prompt_cache_breakpoint\":{\"mode\":\"explicit\"}") != null); + try std.testing.expect(std.mem.indexOf(u8, ob, "\"ttl\":\"12h\"") == null); +} + +test "OpenAI cache TTL off sends no Platform cache fields" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + cache.setOverride(.off); + defer cache.clearOverride(); + const ob = try bodyOf(a, "openai", .responses, "gpt-5.6"); + defer std.testing.allocator.free(ob); + try expectNoCacheFields(ob); +} + +test "Chat Completions and older models stay byte-identical across TTL settings" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + defer cache.clearOverride(); + + cache.setOverride(.twelve_h); + const chat_12 = try bodyOf(a, "openai", .openai, "gpt-5.6"); + defer std.testing.allocator.free(chat_12); + const old_12 = try bodyOf(a, "openai", .responses, "gpt-5.5"); + defer std.testing.allocator.free(old_12); + const chat4_12 = try bodyOf(a, "openai", .openai, "gpt-4o"); + defer std.testing.allocator.free(chat4_12); + const codex_12 = try bodyOf(a, "codex", .responses, "gpt-5.6-sol"); + defer std.testing.allocator.free(codex_12); + + cache.setOverride(.thirty_m); + const chat_30 = try bodyOf(a, "openai", .openai, "gpt-5.6"); + defer std.testing.allocator.free(chat_30); + const old_30 = try bodyOf(a, "openai", .responses, "gpt-5.5"); + defer std.testing.allocator.free(old_30); + const chat4_30 = try bodyOf(a, "openai", .openai, "gpt-4o"); + defer std.testing.allocator.free(chat4_30); + const codex_30 = try bodyOf(a, "codex", .responses, "gpt-5.6-sol"); + defer std.testing.allocator.free(codex_30); + + cache.setOverride(.off); + const chat_off = try bodyOf(a, "openai", .openai, "gpt-5.6"); + defer std.testing.allocator.free(chat_off); + const old_off = try bodyOf(a, "openai", .responses, "gpt-5.5"); + defer std.testing.allocator.free(old_off); + const codex_off = try bodyOf(a, "codex", .responses, "gpt-5.6-sol"); + defer std.testing.allocator.free(codex_off); + + try std.testing.expectEqualStrings(chat_12, chat_30); + try std.testing.expectEqualStrings(chat_12, chat_off); + try std.testing.expectEqualStrings(old_12, old_30); + try std.testing.expectEqualStrings(old_12, old_off); + try std.testing.expectEqualStrings(chat4_12, chat4_30); + try std.testing.expectEqualStrings(codex_12, codex_30); + try std.testing.expectEqualStrings(codex_12, codex_off); + try expectNoCacheFields(chat_12); + try expectNoCacheFields(old_12); + try expectNoCacheFields(chat4_12); + try expectNoCacheFields(codex_12); +} + +test "model x API x setting gate matrix" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + defer cache.clearOverride(); - var codex = try bodies.testAgentFor(a, "codex", .responses, "gpt-5.6-sol"); - const cb = try codex.buildBody(null, false, true, true); - defer std.testing.allocator.free(cb); - try std.testing.expect(std.mem.indexOf(u8, cb, "prompt_cache_options") == null); - try std.testing.expect(std.mem.indexOf(u8, cb, "prompt_cache_breakpoint") == null); - - var astra_codex = try bodies.testAgentFor(a, "codex", .responses, "gpt-6-astra"); - const ac = try astra_codex.buildBody(null, false, true, true); - defer std.testing.allocator.free(ac); - try std.testing.expect(std.mem.indexOf(u8, ac, "prompt_cache_options") == null); - try std.testing.expect(std.mem.indexOf(u8, ac, "prompt_cache_breakpoint") == null); - - var older = try bodies.testAgentFor(a, "openai", .responses, "gpt-5.5"); - const old = try older.buildBody(null, false, true, true); - defer std.testing.allocator.free(old); - try std.testing.expect(std.mem.indexOf(u8, old, "prompt_cache_breakpoint") == null); - try std.testing.expect(std.mem.indexOf(u8, old, "prompt_cache_options") == null); + const Case = struct { id: []const u8, kind: @import("provider.zig").Provider.Kind, model: []const u8, ttl: cache.Ttl, want_12h: bool, want_options: bool }; + const cases = [_]Case{ + .{ .id = "openai", .kind = .responses, .model = "gpt-5.6", .ttl = .twelve_h, .want_12h = true, .want_options = true }, + .{ .id = "openai", .kind = .responses, .model = "gpt-6-sol", .ttl = .twelve_h, .want_12h = true, .want_options = true }, + .{ .id = "openai", .kind = .responses, .model = "gpt-5.6", .ttl = .thirty_m, .want_12h = false, .want_options = true }, + .{ .id = "openai", .kind = .responses, .model = "gpt-5.6", .ttl = .off, .want_12h = false, .want_options = false }, + .{ .id = "openai", .kind = .responses, .model = "gpt-5.5", .ttl = .twelve_h, .want_12h = false, .want_options = false }, + .{ .id = "openai", .kind = .openai, .model = "gpt-5.6", .ttl = .twelve_h, .want_12h = false, .want_options = false }, + .{ .id = "openai", .kind = .openai, .model = "gpt-6.1-sol", .ttl = .twelve_h, .want_12h = false, .want_options = false }, + .{ .id = "codex", .kind = .responses, .model = "gpt-5.6-sol", .ttl = .twelve_h, .want_12h = false, .want_options = false }, + .{ .id = "chatgpt-new", .kind = .responses, .model = "gpt-6.1-sol", .ttl = .twelve_h, .want_12h = false, .want_options = false }, + .{ .id = "xai", .kind = .responses, .model = "grok-4.6", .ttl = .twelve_h, .want_12h = false, .want_options = false }, + }; + for (cases) |c| { + cache.setOverride(c.ttl); + const b = try bodyOf(a, c.id, c.kind, c.model); + defer std.testing.allocator.free(b); + const has_12h = std.mem.indexOf(u8, b, "\"ttl\":\"12h\"") != null; + const has_options = std.mem.indexOf(u8, b, "prompt_cache_options") != null; + try std.testing.expectEqual(c.want_12h, has_12h); + try std.testing.expectEqual(c.want_options, has_options); + } } diff --git a/src/agent_request_body_responses.zig b/src/agent_request_body_responses.zig index 851147f4f..020fc758a 100644 --- a/src/agent_request_body_responses.zig +++ b/src/agent_request_body_responses.zig @@ -29,19 +29,10 @@ pub fn write(self: *Agent, s: *std.json.Stringify, tools: ?[]const u8, force_too var ckbuf: [96]u8 = undefined; try s.objectField("prompt_cache_key"); try s.write(http_headers.requestCacheKey(self.io, self.label, self, self.provider, &ckbuf)); - const explicit_cache = supportsExplicitPromptCache(self); - if (explicit_cache) { - try s.objectField("prompt_cache_options"); - try s.beginObject(); - try s.objectField("mode"); - // Root conversations grow append-only, so retain the useful implicit - // latest-message breakpoint. One-shot children cache only the shared - // system+tools prefix and avoid paying to write each unique task suffix. - try s.write(if (self.sub) "explicit" else "implicit"); - try s.objectField("ttl"); - try s.write("30m"); - try s.endObject(); - } + const oai_cache = @import("openai_prompt_cache.zig"); + // Root keeps implicit latest-message (30m). Children are explicit-only so + // a unique task suffix is not written. ADR 0298 puts 12h on the prefix. + if (oai_cache.active(self)) try oai_cache.writeOptions(s, self.sub); // Held-socket delta: previous_response_id + only the new items. xAI's // published WS contract supports this with store:false via the in-memory // connection cache; a not-found / 25-min close / drop re-anchors. @@ -56,7 +47,7 @@ pub fn write(self: *Agent, s: *std.json.Stringify, tools: ?[]const u8, force_too // semantically neutral developer block after it marks the reusable boundary; // tools are also part of the rendered prefix. Chained requests already hold // this item server-side, so only a full anchor writes it. - if (!chain and explicit_cache) try writeCacheAnchor(s); + if (!chain and oai_cache.active(self)) try oai_cache.writeAnchor(s); const from = if (chain) self.codex_sent_upto else 0; if (from == 0 and !self.ws_prewarm and repo.context.len != 0) try @import("repo_context.zig").writeItem(s, repo.context); if (!self.ws_prewarm) for (self.messages.items[from..]) |m| try @import("session_wake.zig").writeWireInput(s, m, self.provider.model); // prewarm: NO input items; turn 1 chains onto the warmup id @@ -133,33 +124,6 @@ pub fn write(self: *Agent, s: *std.json.Stringify, tools: ?[]const u8, force_too if (self.ws_prewarm) try s.write(false) else try s.write(true); } -fn supportsExplicitPromptCache(self: *const Agent) bool { - return std.mem.eql(u8, self.provider.id, "openai") and @import("provider_codegraff.zig").openaiGptFamily(self.provider.model); -} - -fn writeCacheAnchor(s: *std.json.Stringify) !void { - try s.beginObject(); - try s.objectField("type"); - try s.write("message"); - try s.objectField("role"); - try s.write("developer"); - try s.objectField("content"); - try s.beginArray(); - try s.beginObject(); - try s.objectField("type"); - try s.write("input_text"); - try s.objectField("text"); - try s.write("The preceding instructions and available tools are the stable operating context for this session."); - try s.objectField("prompt_cache_breakpoint"); - try s.beginObject(); - try s.objectField("mode"); - try s.write("explicit"); - try s.endObject(); - try s.endObject(); - try s.endArray(); - try s.endObject(); -} - /// With --output-schema the strict grammar tempts the model to answer /// immediately instead of touching tools (graff-evals caught grok-4.6 /// answering a file-inspection task without reading the file). One standing @@ -398,6 +362,7 @@ test "#695: an empty catalog string is omitted from the body, never serialized a test { _ = @import("agent_request_body_cache_test.zig"); + _ = @import("openai_prompt_cache.zig"); } test "xai Responses body is bearer-clean: no codex-isms, xAI-legal fields only (#502)" { diff --git a/src/openai_cache_retention.zig b/src/openai_cache_retention.zig new file mode 100644 index 000000000..c0e6ded29 --- /dev/null +++ b/src/openai_cache_retention.zig @@ -0,0 +1,150 @@ +//! Retention probe bodies for OpenAI's 12-hour prompt cache. +//! +//! Builds the request through `Agent.buildBody` (the real OpenAI provider +//! path). The operator script posts those bytes; this module never logs keys. + +const std = @import("std"); +const Io = std.Io; +const cache = @import("openai_prompt_cache.zig"); +const bodies = @import("agent_request_body_responses.zig"); + +pub const pinned_key = "graff-openai-cache-retention-v1"; +pub const default_model = "gpt-5.6"; +pub const default_state = "bench/openai-cache-retention-state.json"; +pub const default_log = "bench/openai-cache-retention.json"; +pub const tools_json = + \\[{"type":"function","name":"probe_echo","description":"Echo a short acknowledgement. Used only to put a tool definition on the cached prefix.","parameters":{"type":"object","properties":{"note":{"type":"string"}},"additionalProperties":false}}] +; + +pub const Case = struct { + name: []const u8, + ttl: cache.Ttl, +}; + +pub const cases = [_]Case{ + .{ .name = "12h", .ttl = .twelve_h }, + .{ .name = "30m", .ttl = .thirty_m }, +}; + +pub fn nonceOf(kind: []const u8, stamp: []const u8) [96]u8 { + var out: [96]u8 = undefined; + const n = std.fmt.bufPrint(&out, "graff-{s}-{s}", .{ kind, stamp }) catch unreachable; + @memset(out[n.len..], 0); + return out; +} + +pub fn stablePrefix(allocator: std.mem.Allocator, nonce: []const u8) ![]u8 { + const line = "Stable operating instructions for the OpenAI prompt-cache retention probe. "; + var aw: std.Io.Writer.Allocating = .init(allocator); + errdefer aw.deinit(); + try aw.writer.print("nonce={s}\n", .{nonce}); + var i: usize = 0; + while (i < 80) : (i += 1) try aw.writer.writeAll(line); + return aw.toOwnedSlice(); +} + +pub fn buildProbeBody(arena: std.mem.Allocator, model: []const u8, ttl: cache.Ttl, nonce: []const u8) ![]u8 { + cache.setOverride(ttl); + defer cache.clearOverride(); + const prefix = try stablePrefix(arena, nonce); + var agent = try bodies.testAgentFor(arena, "openai", .responses, model); + agent.sys_normal = prefix; + agent.reasoning = .low; + const raw = try agent.buildBody(tools_json, false, true, true); + defer std.testing.allocator.free(raw); + return pinBody(arena, raw); +} + +fn pinBody(arena: std.mem.Allocator, raw: []const u8) ![]u8 { + const streamed = try std.mem.replaceOwned(u8, arena, raw, "\"stream\":true", "\"stream\":false"); + const prefix = "\"prompt_cache_key\":\""; + const start = std.mem.indexOf(u8, streamed, prefix) orelse return error.MissingCacheKey; + const val_start = start + prefix.len; + const val_end = std.mem.indexOfPos(u8, streamed, val_start, "\"") orelse return error.MissingCacheKey; + const keyed = try std.mem.concat(arena, u8, &.{ streamed[0..val_start], pinned_key, streamed[val_end..] }); + if (keyed.len == 0 or keyed[keyed.len - 1] != '}') return keyed; + return std.mem.concat(arena, u8, &.{ keyed[0 .. keyed.len - 1], ",\"max_output_tokens\":16}" }); +} + +pub fn hasBreakpointTtl(body: []const u8, ttl: []const u8) bool { + var buf: [64]u8 = undefined; + const needle = std.fmt.bufPrint(&buf, "\"prompt_cache_breakpoint\":{{\"mode\":\"explicit\",\"ttl\":\"{s}\"}}", .{ttl}) catch return false; + return std.mem.indexOf(u8, body, needle) != null; +} + +test "retention probe bodies use the OpenAI Responses provider path" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const stamp = "unit"; + var n12 = nonceOf("12h", stamp); + var n30 = nonceOf("30m", stamp); + const b12 = try buildProbeBody(a, default_model, .twelve_h, std.mem.sliceTo(&n12, 0)); + const b30 = try buildProbeBody(a, default_model, .thirty_m, std.mem.sliceTo(&n30, 0)); + try std.testing.expect(std.mem.indexOf(u8, b12, "\"prompt_cache_key\":\"graff-openai-cache-retention-v1\"") != null); + try std.testing.expect(std.mem.indexOf(u8, b30, "\"prompt_cache_key\":\"graff-openai-cache-retention-v1\"") != null); + try std.testing.expect(hasBreakpointTtl(b12, "12h")); + try std.testing.expect(!hasBreakpointTtl(b30, "12h")); + try std.testing.expect(std.mem.indexOf(u8, b30, "\"prompt_cache_breakpoint\":{\"mode\":\"explicit\"}") != null); + try std.testing.expect(std.mem.indexOf(u8, b12, "\"stream\":false") != null); + try std.testing.expect(std.mem.indexOf(u8, b12, "probe_echo") != null); + try std.testing.expect(std.mem.indexOf(u8, b12, "\"role\":\"user\"") != null); + try std.testing.expect(std.mem.indexOf(u8, b12, "\"content\":\"hello\",\"prompt_cache_breakpoint\"") == null); + try std.testing.expect(b12.len > 2000); +} + +test "openai cache retention materialize" { + const mode = std.c.getenv("GRAFF_OPENAI_CACHE_RETENTION") orelse return error.SkipZigTest; + if (!std.mem.eql(u8, std.mem.span(mode), "materialize") and !std.mem.eql(u8, std.mem.span(mode), "write")) return error.SkipZigTest; + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const model = if (std.c.getenv("GRAFF_OPENAI_CACHE_RETENTION_MODEL")) |m| std.mem.span(m) else default_model; + const stamp = if (std.c.getenv("GRAFF_OPENAI_CACHE_RETENTION_STAMP")) |s| std.mem.span(s) else "local"; + const path = if (std.c.getenv("GRAFF_OPENAI_CACHE_RETENTION_STATE")) |s| std.mem.span(s) else default_state; + var root: std.json.ObjectMap = .empty; + try root.put(a, "model", .{ .string = model }); + try root.put(a, "prompt_cache_key", .{ .string = pinned_key }); + try root.put(a, "api", .{ .string = "responses" }); + try root.put(a, "url", .{ .string = "https://api.openai.com/v1/responses" }); + try root.put(a, "status", .{ .string = "materialized" }); + var case_map: std.json.ObjectMap = .empty; + for (cases) |c| { + var nonce = nonceOf(c.name, stamp); + const n = try a.dupe(u8, std.mem.sliceTo(&nonce, 0)); + const body = try buildProbeBody(a, model, c.ttl, n); + const request = try std.json.parseFromSliceLeaky(std.json.Value, a, body, .{ .allocate = .alloc_always }); + var one: std.json.ObjectMap = .empty; + try one.put(a, "nonce", .{ .string = n }); + try one.put(a, "ttl", .{ .string = c.ttl.label() }); + try one.put(a, "request", request); + try case_map.put(a, c.name, .{ .object = one }); + } + try root.put(a, "cases", .{ .object = case_map }); + const n12 = case_map.get("12h").?.object.get("nonce").?.string; + const n30 = case_map.get("30m").?.object.get("nonce").?.string; + if (std.mem.eql(u8, n12, n30)) return error.NonceCollision; + var aw: std.Io.Writer.Allocating = .init(a); + var s: std.json.Stringify = .{ .writer = &aw.writer, .options = .{ .whitespace = .indent_2 } }; + try s.write(std.json.Value{ .object = root }); + const io = std.testing.io; + if (std.fs.path.dirname(path)) |dir| Io.Dir.cwd().createDirPath(io, dir) catch {}; + try Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = aw.writer.buffered() }); +} + +test "openai cache retention live" { + const mode = std.c.getenv("GRAFF_OPENAI_CACHE_RETENTION") orelse return error.SkipZigTest; + const span = std.mem.span(mode); + if (!(std.mem.eql(u8, span, "write") or std.mem.eql(u8, span, "check"))) return error.SkipZigTest; + if (std.c.getenv("OPENAI_API_KEY") == null) return error.SkipZigTest; + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const stamp = if (std.c.getenv("GRAFF_OPENAI_CACHE_RETENTION_STAMP")) |s| std.mem.span(s) else "live"; + var n12 = nonceOf("12h", stamp); + var n30 = nonceOf("30m", stamp); + const b12 = try buildProbeBody(a, default_model, .twelve_h, std.mem.sliceTo(&n12, 0)); + const b30 = try buildProbeBody(a, default_model, .thirty_m, std.mem.sliceTo(&n30, 0)); + try std.testing.expect(hasBreakpointTtl(b12, "12h")); + try std.testing.expect(!hasBreakpointTtl(b30, "12h")); +} diff --git a/src/openai_prompt_cache.zig b/src/openai_prompt_cache.zig new file mode 100644 index 000000000..a773c17cc --- /dev/null +++ b/src/openai_prompt_cache.zig @@ -0,0 +1,221 @@ +//! OpenAI Platform prompt-cache TTL for GPT-5.6+ Responses. +//! +//! ADR 0009 already marks the stable prefix. This module adds the Oct 2026 +//! 12-hour lifetime on that breakpoint and keeps the request default at 30 +//! minutes for session context. Chat Completions and older models must not +//! receive these fields: 12h is a 400 there. + +const std = @import("std"); +const Io = std.Io; +const Agent = @import("agent.zig").Agent; +const Provider = @import("provider.zig").Provider; + +pub const env_name = "GRAFF_OPENAI_CACHE_TTL"; +pub const settings_key = "openai_cache_ttl"; + +pub const Ttl = enum { + off, + thirty_m, + twelve_h, + + pub fn label(self: Ttl) []const u8 { + return switch (self) { + .off => "off", + .thirty_m => "30m", + .twelve_h => "12h", + }; + } +}; + +pub const default_ttl: Ttl = .twelve_h; + +threadlocal var override_ttl: ?Ttl = null; +var saved_ttl: ?Ttl = null; + +pub fn parse(text: []const u8) ?Ttl { + const t = std.mem.trim(u8, text, " \t\r\n"); + if (t.len == 0) return null; + if (eqlAny(t, &.{ "off", "0", "false", "none", "disable" })) return .off; + if (eqlAny(t, &.{ "30m", "30", "30-min", "30min" })) return .thirty_m; + if (eqlAny(t, &.{ "12h", "12", "12-hour", "12hour" })) return .twelve_h; + return null; +} + +pub fn setOverride(ttl: Ttl) void { + override_ttl = ttl; +} + +pub fn clearOverride() void { + override_ttl = null; +} + +pub fn current() Ttl { + if (override_ttl) |ttl| return ttl; + if (envTtl()) |ttl| return ttl; + return saved_ttl orelse default_ttl; +} + +pub fn applyStartup(io: Io, arena: std.mem.Allocator) void { + saved_ttl = loadSaved(io, arena); +} + +/// Direct OpenAI Responses on GPT-5.6 and later. Codex rejected these fields; +/// Chat Completions and older models 400 on `ttl: "12h"`. +pub fn supported(provider_id: []const u8, kind: Provider.Kind, model: []const u8) bool { + return std.mem.eql(u8, provider_id, "openai") and + kind == .responses and + @import("provider_codegraff.zig").openaiGptFamily(model); +} + +pub fn active(self: *const Agent) bool { + return supported(self.provider.id, self.provider.kind, self.provider.model) and current() != .off; +} + +pub fn writeOptions(s: *std.json.Stringify, explicit_only: bool) !void { + try s.objectField("prompt_cache_options"); + try s.beginObject(); + try s.objectField("mode"); + try s.write(if (explicit_only) "explicit" else "implicit"); + try s.objectField("ttl"); + try s.write("30m"); + try s.endObject(); +} + +/// Stable developer anchor after top-level instructions. Tools are part of +/// the rendered prefix; the current user turn is never marked. +pub fn writeAnchor(s: *std.json.Stringify) !void { + try s.beginObject(); + try s.objectField("type"); + try s.write("message"); + try s.objectField("role"); + try s.write("developer"); + try s.objectField("content"); + try s.beginArray(); + try s.beginObject(); + try s.objectField("type"); + try s.write("input_text"); + try s.objectField("text"); + try s.write("The preceding instructions and available tools are the stable operating context for this session."); + try s.objectField("prompt_cache_breakpoint"); + try s.beginObject(); + try s.objectField("mode"); + try s.write("explicit"); + if (current() == .twelve_h) { + try s.objectField("ttl"); + try s.write("12h"); + } + try s.endObject(); + try s.endObject(); + try s.endArray(); + try s.endObject(); +} + +pub const Usage = struct { + cached: i64 = 0, + written: i64 = 0, + write_30m: i64 = 0, + write_12h: i64 = 0, +}; + +pub fn readResponsesUsage(usage: std.json.ObjectMap) Usage { + return readDetails(usage, "input_tokens_details", 0); +} + +pub fn readChatUsage(usage: std.json.ObjectMap) Usage { + return readDetails(usage, "prompt_tokens_details", usageInt(usage, "prompt_cache_hit_tokens")); +} + +pub fn billTwelveHour(self: *Agent, usage: Usage) void { + if (usage.write_12h > 0) { + @import("prompt_cache_hud.zig").noteWriteSplit(@intCast(usage.write_12h), @intCast(@max(usage.write_30m, 0))); + @import("usage_trace.zig").noteWriteSplit(usage.write_12h, usage.write_30m); + } + if (usage.write_12h <= 0 or @import("billing.zig").forProvider(self.provider) != .priced) return; + const p = @import("pricing.zig").priceForProvider(self.provider.id, self.provider.model) orelse return; + const extra = @import("pricing_cache.zig").twelveHourWriteExtraUsd(p, self.provider.model, usage.write_12h); + if (extra == 0) return; + const pricing = @import("pricing.zig"); + pricing.g_cost.mutex.lockUncancelable(self.io); + defer pricing.g_cost.mutex.unlock(self.io); + pricing.g_cost.usd += extra; +} + +fn readDetails(usage: std.json.ObjectMap, field: []const u8, cached_in: i64) Usage { + var out: Usage = .{ .cached = cached_in }; + const details = usage.get(field) orelse return out; + if (details != .object) return out; + if (out.cached == 0) out.cached = usageInt(details.object, "cached_tokens"); + out.written = usageInt(details.object, "cache_write_tokens"); + if (details.object.get("cache_write_tokens_details")) |split| if (split == .object) { + out.write_30m = usageInt(split.object, "ttl_30m_tokens"); + out.write_12h = usageInt(split.object, "ttl_12h_tokens"); + }; + return out; +} + +fn usageInt(obj: std.json.ObjectMap, name: []const u8) i64 { + if (obj.get(name)) |v| if (v == .integer and v.integer > 0) return v.integer; + return 0; +} + +fn envTtl() ?Ttl { + const raw = std.c.getenv(env_name) orelse return null; + return parse(std.mem.span(raw)); +} + +fn loadSaved(io: Io, arena: std.mem.Allocator) ?Ttl { + const data = Io.Dir.cwd().readFileAlloc(io, @import("harness_policy.zig").settings_path, arena, .limited(1 << 20)) catch return null; + const v = std.json.parseFromSliceLeaky(std.json.Value, arena, data, .{ .allocate = .alloc_always }) catch return null; + if (v != .object) return null; + const saved = v.object.get(settings_key) orelse return null; + if (saved != .string) return null; + return parse(saved.string); +} + +fn eqlAny(text: []const u8, names: []const []const u8) bool { + for (names) |n| if (std.ascii.eqlIgnoreCase(text, n)) return true; + return false; +} + +test "TTL parse accepts the documented knobs" { + try std.testing.expectEqual(Ttl.off, parse("off").?); + try std.testing.expectEqual(Ttl.thirty_m, parse("30m").?); + try std.testing.expectEqual(Ttl.twelve_h, parse("12h").?); + try std.testing.expect(parse("tomorrow") == null); +} + +test "12h is Responses GPT-5.6+ on the OpenAI provider only" { + const Kind = Provider.Kind; + try std.testing.expect(supported("openai", .responses, "gpt-5.6")); + try std.testing.expect(supported("openai", .responses, "gpt-5.6-luna")); + try std.testing.expect(supported("openai", .responses, "gpt-6-astra")); + try std.testing.expect(supported("openai", .responses, "gpt-6.1-sol")); + try std.testing.expect(!supported("openai", .responses, "gpt-5.5")); + try std.testing.expect(!supported("openai", .responses, "gpt-4o")); + try std.testing.expect(!supported("openai", .openai, "gpt-5.6")); + try std.testing.expect(!supported("openai", .openai, "gpt-6-sol")); + try std.testing.expect(!supported("codex", .responses, "gpt-5.6-sol")); + try std.testing.expect(!supported("chatgpt-new", .responses, "gpt-6.1-sol")); + try std.testing.expect(!supported("xai", .responses, "grok-4.6")); + try std.testing.expect(!supported("codegraff", .responses, "gpt-5.6")); + _ = Kind; +} + +test "usage details expose cached_tokens and both write lifetimes" { + const gpa = std.testing.allocator; + const body = + \\{"input_tokens":12000,"input_tokens_details":{"cached_tokens":4000,"cache_write_tokens":7000, + \\ "cache_write_tokens_details":{"ttl_30m_tokens":3000,"ttl_12h_tokens":4000}}} + ; + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, body, .{}); + defer parsed.deinit(); + const u = readResponsesUsage(parsed.value.object); + try std.testing.expectEqual(@as(i64, 4000), u.cached); + try std.testing.expectEqual(@as(i64, 7000), u.written); + try std.testing.expectEqual(@as(i64, 3000), u.write_30m); + try std.testing.expectEqual(@as(i64, 4000), u.write_12h); +} + +test { + _ = @import("openai_cache_retention.zig"); +} diff --git a/src/pricing_cache.zig b/src/pricing_cache.zig index accb4bafd..b234b2cbd 100644 --- a/src/pricing_cache.zig +++ b/src/pricing_cache.zig @@ -6,6 +6,8 @@ const std = @import("std"); const five_minute_write: f64 = 1.25; /// Anthropic's 1-hour cache write (ADR 0220). const one_hour_write: f64 = 2.0; +/// OpenAI GPT-5.6+ Responses 12-hour cache write (ADR 0298). +const twelve_hour_write: f64 = 2.5; /// USD for one request (negative token counts clamp to zero). Cache writes are /// separate from ordinary input: GPT-5.6 and Anthropic ephemeral caches bill @@ -35,6 +37,17 @@ pub fn hourWriteExtraUsd(p: anytype, model: []const u8, prompt_tokens: u64, hour return @as(f64, @floatFromInt(hour_tokens)) * input_rate * (one_hour_write - five_minute_write) / 1_000_000.0; } +/// usdForUsage prices every GPT-5.6+ write at 1.25x. The 12-hour lifetime +/// bills 2.5x; add the difference for tokens named `ttl_12h_tokens`. +pub fn twelveHourWriteExtraUsd(p: anytype, model: []const u8, twelve_tokens: i64) f64 { + if (twelve_tokens <= 0) return 0; + if (!(std.mem.startsWith(u8, model, "gpt-5.6") or std.mem.startsWith(u8, model, "gpt-6-") or std.mem.startsWith(u8, model, "gpt-6."))) return 0; + // A row that prices writes at 1x (hosted gateway) is billed as it says. + if (p.cache_write_multiplier) |m| if (m <= 1.0) return 0; + const input_rate = p.in; + return @as(f64, @floatFromInt(twelve_tokens)) * input_rate * (twelve_hour_write - five_minute_write) / 1_000_000.0; +} + test "ADR 0220: a 1-hour cache write costs 2x input on Claude" { const opus = @import("pricing.zig").priceFor("claude-opus-5-5").?; // 100k tokens written: 1.25x of $4/MTok in usdForUsage, 0.75x more at 1 hour. @@ -45,3 +58,12 @@ test "ADR 0220: a 1-hour cache write costs 2x input on Claude" { const flat = @import("pricing_gateway.zig").rows[0]; try std.testing.expectEqual(@as(f64, 0), hourWriteExtraUsd(flat, flat.name, 100_000, 100_000)); } + +test "ADR 0298: a 12-hour OpenAI cache write costs 2.5x input" { + const sol = @import("pricing.zig").priceFor("gpt-6.1-sol").?; + // 100k tokens: usdForUsage already charged 1.25x of $2/MTok = $0.25; +1.25x more at 12h. + try std.testing.expectApproxEqAbs(@as(f64, 0.25), usdForUsage(sol, "gpt-6.1-sol", 0, 0, 100_000, 0), 1e-9); + try std.testing.expectApproxEqAbs(@as(f64, 0.25), twelveHourWriteExtraUsd(sol, "gpt-6.1-sol", 100_000), 1e-9); + try std.testing.expectEqual(@as(f64, 0), twelveHourWriteExtraUsd(sol, "gpt-6.1-sol", 0)); + try std.testing.expectEqual(@as(f64, 0), twelveHourWriteExtraUsd(sol, "gpt-4o", 100_000)); +} diff --git a/src/prompt_cache_hud.zig b/src/prompt_cache_hud.zig index 20bc931fc..26ef0c71b 100644 --- a/src/prompt_cache_hud.zig +++ b/src/prompt_cache_hud.zig @@ -36,6 +36,8 @@ pub const Snap = struct { last_bust: Bust = .none, last_read: u64 = 0, last_write: u64 = 0, + last_write_12h: u64 = 0, + last_write_30m: u64 = 0, /// #1333: keyed context updates delivered without touching the prefix. hot_updates: u32 = 0, last_hot: []const u8 = "", @@ -110,6 +112,13 @@ pub fn noteUsage(read: u64, write: u64) void { snap.last_write = write; } +pub fn noteWriteSplit(write_12h: u64, write_30m: u64) void { + acquire(); + defer release(); + snap.last_write_12h = write_12h; + snap.last_write_30m = write_30m; +} + pub fn noteRequest(io: Io, system: []const u8, tools: []const u8) void { const hash = prefixHash(system, tools); acquire(); @@ -201,7 +210,10 @@ pub fn render(w: *Io.Writer) !void { kib(s.tools_bytes), if (s.same) "same" else "changed", }); - try w.print(" last {d} read · {d} write\n", .{ s.last_read, s.last_write }); + try w.print(" last {d} read · {d} write", .{ s.last_read, s.last_write }); + if (s.last_write_12h > 0 or s.last_write_30m > 0) + try w.print(" ({d} 12h · {d} 30m)", .{ s.last_write_12h, s.last_write_30m }); + try w.writeByte('\n'); try w.print(" session {d}% hit", .{sessionHitPct()}); if (g_io) |io| { const c = pricing.g_cost.snap(io); @@ -223,6 +235,7 @@ pub fn render(w: *Io.Writer) !void { } try w.writeAll( \\ remaining max + \\ GRAFF_OPENAI_CACHE_TTL 12h (default) / 30m / off; OpenAI Responses GPT-5.6+ \\ GRAFF_STABLE_CATALOG=0 opt out: loads rewrite tools JSON (stable is the default) \\ /goal /never /strict each prefix rewrite is one miss; compaction already pays one \\ leave skill bodies and file: paths out of the prefix (already the default) @@ -315,6 +328,7 @@ test "render stays content-free and names remaining levers" { try std.testing.expect(contains(text, "prompt cache")); try std.testing.expect(contains(text, "prefix")); try std.testing.expect(contains(text, "GRAFF_STABLE_CATALOG")); + try std.testing.expect(contains(text, "GRAFF_OPENAI_CACHE_TTL")); try std.testing.expect(contains(text, "clear_thinking")); try std.testing.expect(contains(text, "Vercel")); try std.testing.expect(contains(text, "/goal")); diff --git a/src/session_settings.zig b/src/session_settings.zig index 6f18a0641..dbaf75f32 100644 --- a/src/session_settings.zig +++ b/src/session_settings.zig @@ -303,6 +303,7 @@ fn codegraffLoginKey(io: Io, arena: Allocator, environ_map: anytype) ?[]const u8 pub fn setupSkillsAndTheme(io: Io, arena: Allocator, environ_map: anytype, out: *Io.Writer, flags: args.Flags, use_color: bool, json_mode: bool, cwd_display: []const u8) !ThemeSetup { try applyEnvKnobs(arena, environ_map); @import("compact_at.zig").applyStartup(io, arena); // --compact-at > GRAFF_COMPACT_PCT > saved /compact-at + @import("openai_prompt_cache.zig").applyStartup(io, arena); // GRAFF_OPENAI_CACHE_TTL > openai_cache_ttl @import("python_version.zig").detect(io, environ_map.get("PATH")); // ADR 0242: before any catalog renders const codegraff_key = codegraffLoginKey(io, arena, environ_map); _ = jev_tool.setCodegraffLoginKey(io, codegraff_key); diff --git a/src/usage_trace.zig b/src/usage_trace.zig index 626f4664a..6bc639288 100644 --- a/src/usage_trace.zig +++ b/src/usage_trace.zig @@ -38,6 +38,8 @@ const Usage = struct { /// Of `output`, the reasoning the server reports (Responses /// output_tokens_details); null when the wire has no split. reasoning: ?i64 = null, + cache_write_12h: ?i64 = null, + cache_write_30m: ?i64 = null, }; threadlocal var pending: ?Usage = null; @@ -69,7 +71,12 @@ pub fn note(self: *const Agent, ordinary: i64, cache_read: i64, cache_write: i64 // Sent as previous_response_id + the new items on a held socket. .chained = self.codex_prev_id != null, .gap_ms = gap, - .cache_ttl = if (cache_ttl.control(self)) |c| (if (std.mem.eql(u8, c, cache_ttl.ephemeral_1h)) "1h" else null) else null, + .cache_ttl = blk: { + if (cache_ttl.control(self)) |c| if (std.mem.eql(u8, c, cache_ttl.ephemeral_1h)) break :blk "1h"; + const oai = @import("openai_prompt_cache.zig"); + if (oai.active(self)) break :blk oai.current().label(); + break :blk null; + }, .prefix_changed = !snap.same, .prefix_bust = if (snap.same) null else hud.bustLabel(snap.last_bust), .cache_miss = cache_ttl.missReason(cache_read, input, !snap.same, gap), @@ -98,9 +105,18 @@ pub fn emit(tr: anytype, label: []const u8, from_sub: bool, model: []const u8) v .prefix_bust = u.prefix_bust, .cache_miss = u.cache_miss, .reasoning_tokens = u.reasoning, + .cache_write_12h_tokens = u.cache_write_12h, + .cache_write_30m_tokens = u.cache_write_30m, }); } +/// After recordCost: named OpenAI write lifetimes from input_tokens_details. +pub fn noteWriteSplit(write_12h: i64, write_30m: i64) void { + if (pending == null) return; + if (write_12h > 0) pending.?.cache_write_12h = write_12h; + if (write_30m > 0) pending.?.cache_write_30m = write_30m; +} + /// From Tracer.api on a failed call: nothing was billed for it. pub fn drop() void { pending = null;