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
142 changes: 142 additions & 0 deletions bench/openai-cache-retention-state.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions bench/openai-cache-retention.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
8 changes: 8 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,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);
Expand Down
5 changes: 3 additions & 2 deletions docs/adr/0009-gpt-5-6-explicit-prompt-cache-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 0312 can put `ttl: "12h"` (opt-in) 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
Expand Down
42 changes: 42 additions & 0 deletions docs/adr/0312-openai-12h-prompt-cache-is-opt-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 0312. OpenAI Platform 12-hour prompt cache on the stable prefix is opt-in

Status: accepted 2026-11-02 (proposed 2026-10-21 as 0298)

## 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, with 12h
selected, 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 `30m` (default, ADR 0009 bytes), `12h`, or `off`.
- 12h is opt-in until a live probe records it. The API reference still
documents only `"30m"`, an unsupported value is a 400 on every request,
and a 12-hour write costs 2.5× uncached input against 1.25×, so the
default changes only after `scripts/openai_cache_retention_probe write`
succeeds and a later `--check` shows the prefix still cached.
- 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

With the default nothing changes on the wire. 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`.
3 changes: 2 additions & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (opt-in): [0312](0312-openai-12h-prompt-cache-is-opt-in.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. |
Expand Down Expand Up @@ -335,6 +335,7 @@ record only when you need the evidence or the edge cases.
| [0309](0309-claude-agent-runs-the-users-claude.md) | Claude Agent runs the user's own `claude` over the SDK control protocol: graff's tools as an in-process MCP server with held calls, graff's history as the source of truth, never reading Claude Code's login. |
| [0310](0310-auth-failures-name-the-rejected-credential.md) | A terminal auth rejection names the seat that failed: sign-in text keeps "sign in again"; key and promoted-key texts say "API key was rejected" and never the signed-out phrases. Key seats carry `{"kind":"api_key_rejected","provider":…}` (v1 `-32603` data, v2 `_meta["graff/recovery"]`); `reauth_required` is unchanged. |
| [0311](0311-tool-refresh-and-addressed-wakes.md) | Claude Agent refreshes a changed tool list with MCP `tools/list_changed` (served before anything else; fresh session if not re-read in 15 s) instead of removing and re-adding its server, which failed held calls. `attempt_completion` settles a task like a final answer: room mail waits for the next human prompt, addressed mail can still start a turn; `yield_turn` stays strict. A peer wake preloads `peer_message`. |
| [0312](0312-openai-12h-prompt-cache-is-opt-in.md) | OpenAI Responses GPT-5.6+ can put a 12h explicit breakpoint on the stable prefix (`GRAFF_OPENAI_CACHE_TTL=12h` or `openai_cache_ttl`); the default stays 30m (ADR 0009 bytes) until a live probe confirms OpenAI accepts the field and keeps the entry. Usage parses cache writes by TTL. |

| [0302](0302-sse-idle-watchdogs-follow-body-byte-progress.md) | SSE idle watchdogs observe partial body-byte progress, while separate unfinished-event time and size bounds preserve recovery. |

Expand Down
224 changes: 224 additions & 0 deletions scripts/openai_cache_retention_probe
Original file line number Diff line number Diff line change
@@ -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())
28 changes: 11 additions & 17 deletions src/agent_context.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
Expand Down Expand Up @@ -480,14 +476,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" {
Expand Down
Loading
Loading