update: codexのキャッシュ利用が出来てなかった問題

This commit is contained in:
Keisuke Hirata 2026-05-02 03:23:44 +09:00
parent d8d802d120
commit 6ebd10a006
No known key found for this signature in database
9 changed files with 379 additions and 1 deletions

View File

@ -5,6 +5,7 @@
- [ ] Resume 時の Scope claim の改善 → [tickets/resume-scope-claim.md](tickets/resume-scope-claim.md)
- [ ] Pod CLI: マニフェスト関連フラグの整理 → [tickets/pod-cli-manifest-flags.md](tickets/pod-cli-manifest-flags.md)
- [ ] OpenAI Responses: sampling パラメータの取り扱い → [tickets/responses-sampling-params.md](tickets/responses-sampling-params.md)
- [ ] OpenAI Responses: prompt_cache_key 送出 → [tickets/responses-prompt-cache-key.md](tickets/responses-prompt-cache-key.md)
- [ ] llm-worker のエラー耐性
- [ ] HTTP transient リトライ → [tickets/llm-worker-transient-retry.md](tickets/llm-worker-transient-retry.md)
- [ ] ストリーム途中失敗時の継続 → [tickets/llm-worker-stream-continuation.md](tickets/llm-worker-stream-continuation.md)

View File

@ -210,6 +210,15 @@ struct ResponsesUsage {
output_tokens: Option<u64>,
#[serde(default)]
total_tokens: Option<u64>,
/// `input_tokens` の内訳。`cached_tokens` がプロンプトキャッシュヒット分。
#[serde(default)]
input_tokens_details: Option<InputTokensDetails>,
}
#[derive(Debug, Deserialize)]
struct InputTokensDetails {
#[serde(default)]
cached_tokens: Option<u64>,
}
#[derive(Debug, Deserialize)]
@ -270,7 +279,10 @@ pub(crate) fn parse_sse(
total_tokens: usage.total_tokens.or_else(|| {
Some(usage.input_tokens.unwrap_or(0) + usage.output_tokens.unwrap_or(0))
}),
cache_read_input_tokens: None,
cache_read_input_tokens: usage
.input_tokens_details
.and_then(|d| d.cached_tokens),
// Responses API は cache 書き込みを別計上しないinput_tokens に含まれる)
cache_creation_input_tokens: None,
}));
}
@ -554,9 +566,31 @@ mod tests {
assert_eq!(u.input_tokens, Some(10));
assert_eq!(u.output_tokens, Some(20));
assert_eq!(u.total_tokens, Some(30));
assert_eq!(u.cache_read_input_tokens, None);
assert_eq!(u.cache_creation_input_tokens, None);
}
}
#[test]
fn completed_extracts_cached_tokens_from_input_tokens_details() {
let data = r#"{"response":{"usage":{
"input_tokens":12345,
"input_tokens_details":{"cached_tokens":11000},
"output_tokens":50,
"total_tokens":12395
}}}"#;
let (events, _) = run("response.completed", data);
let Event::Usage(u) = &events[0] else {
panic!("expected usage")
};
assert_eq!(u.input_tokens, Some(12345));
assert_eq!(u.output_tokens, Some(50));
assert_eq!(u.total_tokens, Some(12395));
assert_eq!(u.cache_read_input_tokens, Some(11000));
// OpenAI Responses は cache 書き込みを別計上しない
assert_eq!(u.cache_creation_input_tokens, None);
}
#[test]
fn text_stream_start_delta_stop() {
let mut state = OpenAIResponsesState::default();

View File

@ -50,6 +50,11 @@ pub(crate) struct ResponsesRequest {
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
/// 会話単位の安定キー。ChatGPT backend (codex-oauth) は明示キーが
/// 無いとプロンプトキャッシュがほぼ効かない。pod 側は `SessionId`
/// を渡す。`Request::cache_key` が `None` のときはキー自体を送らない。
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
}
/// reasoning 制御。
@ -220,6 +225,7 @@ impl OpenAIResponsesScheme {
} else {
None
},
prompt_cache_key: request.cache_key.clone(),
}
}
}
@ -531,6 +537,29 @@ mod tests {
);
}
#[test]
fn prompt_cache_key_passed_through_when_set() {
let scheme = OpenAIResponsesScheme::new();
let req = Request::new().user("hi").cache_key("session-abc");
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
assert_eq!(body.prompt_cache_key.as_deref(), Some("session-abc"));
let json = serde_json::to_value(&body).unwrap();
assert_eq!(json["prompt_cache_key"], "session-abc");
}
#[test]
fn prompt_cache_key_omitted_when_none() {
let scheme = OpenAIResponsesScheme::new();
let req = Request::new().user("hi");
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
assert!(body.prompt_cache_key.is_none());
let json = serde_json::to_value(&body).unwrap();
assert!(
json.get("prompt_cache_key").is_none(),
"prompt_cache_key key must not appear in serialised body, got: {json}"
);
}
#[test]
fn tool_schema_without_properties_is_normalized() {
// schemars は引数なし struct から `type:"object"` だけのスキーマを

View File

@ -455,6 +455,14 @@ pub struct Request {
/// (Anthropic today) can place a long-lived cache breakpoint there.
/// Providers without prompt caching ignore the field.
pub cache_anchor: Option<usize>,
/// 会話単位の安定キー。`prompt_cache_key` として送られる
/// (OpenAI Responses)。ChatGPT backend (codex-oauth) は明示キーが
/// 無いと org/project ハッシュ衝突でプロンプトキャッシュが
/// ほぼヒットしないため、pod 側で `SessionId` を渡す運用を想定。
/// `cache_anchor` と違い名前空間キーであり、`prefix anchor` とは
/// 別の概念。`cache_anchor` を読まない provider と同じく、
/// `prompt_cache_key` を持たない provider は無視する。
pub cache_key: Option<String>,
}
impl Request {
@ -534,6 +542,14 @@ impl Request {
self.config.stop_sequences.push(sequence.into());
self
}
/// Set the conversation cache key.
///
/// 詳細は [`Request::cache_key`] のフィールドコメント参照。
pub fn cache_key(mut self, key: impl Into<String>) -> Self {
self.cache_key = Some(key.into());
self
}
}
// ============================================================================

View File

@ -187,6 +187,10 @@ pub struct Worker<C: LlmClient, S: WorkerState = Mutable> {
/// Index of the last stable cache prefix item, set by higher layers.
/// Plumbed into [`Request::cache_anchor`] at request build time.
cache_anchor: Option<usize>,
/// Conversation-scoped cache key, set by higher layers. Plumbed into
/// [`Request::cache_key`] at request build time. Pod 側では
/// `SessionId` を渡す。
cache_key: Option<String>,
/// State marker
_state: PhantomData<S>,
}
@ -392,6 +396,14 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
self.cache_anchor = anchor;
}
/// Set the conversation-scoped cache key. Plumbed into each outgoing
/// [`Request`] via [`Request::cache_key`] — caching-aware providers
/// that scope cache by an explicit key (OpenAI Responses) read it as
/// `prompt_cache_key`. Pass `None` to clear.
pub fn set_cache_key(&mut self, key: Option<String>) {
self.cache_key = key;
}
/// Get a mutable reference to the timeline (for additional handler registration)
pub fn timeline_mut(&mut self) -> &mut Timeline {
&mut self.timeline
@ -585,6 +597,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
// if the prune projection trimmed items from the head — keep it
// in range).
request.cache_anchor = self.cache_anchor.filter(|&anchor| anchor < context.len());
request.cache_key = self.cache_key.clone();
request
}
@ -1065,6 +1078,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
prune_config: None,
savings_estimator: None,
cache_anchor: None,
cache_key: None,
_state: PhantomData,
}
}
@ -1321,6 +1335,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
prune_config: self.prune_config,
savings_estimator: self.savings_estimator,
cache_anchor: self.cache_anchor,
cache_key: self.cache_key,
_state: PhantomData,
}
}
@ -1400,6 +1415,7 @@ impl<C: LlmClient> Worker<C, Locked> {
prune_config: self.prune_config,
savings_estimator: self.savings_estimator,
cache_anchor: self.cache_anchor,
cache_key: self.cache_key,
_state: PhantomData,
}
}

View File

@ -1195,6 +1195,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.compact_system()
.map_err(PodError::PromptCatalog)?;
let mut summary_worker = Worker::new(summary_client).system_prompt(summary_system_prompt);
summary_worker.set_cache_key(Some(self.session_id.to_string()));
// Cumulative input-token meter + interceptor. The meter is bumped
// from the on_usage callback and read on every pre_llm_request.
@ -1356,6 +1357,10 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
// can place a durable `cache_control` breakpoint there — our
// compact layout guarantees history[0] is the summary.
worker.set_cache_anchor(Some(0));
// Re-key the OpenAI Responses prompt cache namespace to the new
// session_id so post-compact turns share a key with extract /
// consolidate workers running in the same session.
worker.set_cache_key(Some(new_session_id.to_string()));
self.usage_history
.lock()
.expect("usage_history poisoned")
@ -1545,6 +1550,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
let client = self.build_extractor_client(memory_cfg)?;
let mut extract_worker = Worker::new(client).system_prompt(extract::EXTRACT_SYSTEM_PROMPT);
extract_worker.set_cache_key(Some(self.session_id.to_string()));
// Cumulative input-token meter + interceptor (mirror of
// CompactWorkerInterceptor). Aborts the extract worker if its
@ -1741,6 +1747,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
};
let mut worker =
Worker::new(client).system_prompt(consolidate::CONSOLIDATION_SYSTEM_PROMPT);
worker.set_cache_key(Some(self.session_id.to_string()));
let input_so_far = Arc::new(std::sync::atomic::AtomicU64::new(0));
{
@ -1905,6 +1912,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
let mut worker = Worker::new(common.client);
apply_worker_manifest(&mut worker, &manifest.worker);
worker.set_cache_key(Some(session_id.to_string()));
let mut pod = Self {
manifest,
@ -1965,6 +1973,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
let mut worker = Worker::new(common.client);
apply_worker_manifest(&mut worker, &manifest.worker);
worker.set_cache_key(Some(session_id.to_string()));
let mut pod = Self {
manifest,
@ -2052,6 +2061,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
// overwrite the pieces the session log is authoritative for.
let mut worker = Worker::new(common.client);
apply_worker_manifest(&mut worker, &manifest.worker);
worker.set_cache_key(Some(session_id.to_string()));
if let Some(ref prompt) = state.system_prompt {
worker.set_system_prompt(prompt);
}

View File

@ -0,0 +1,91 @@
# OpenAI Responses API — `prompt_cache_key` Parameter
- **Source**: https://platform.openai.com/docs/api-reference/responses/create
- **Retrieved**: 2026-05-02
---
## 1. パラメータ名
`prompt_cache_key` — Responses API のリクエスト body に載せる任意の文字列キー。
プロンプトキャッシュのスコープを明示する。
## 2. 必須 / 任意
**任意 (optional)**。
公式 OpenAI Responses API では省略しても automatic prefix matching が
走るためキャッシュは効く。一方 ChatGPT backend (codex-oauth /
`https://chatgpt.com/backend-api/codex/responses`) では **明示キーが
無いと事実上ヒットしない**(後述)。
## 3. ChatGPT backend (`codex-oauth`) でのキャッシュ挙動
実観測(`019de419-...` セッション、171 turn / 累計入力 22.2M token
`cache_read_tokens` が全 turn 0 だった。最新セッション (`019de48f-...`,
5 turn) でも 0。
原因:
1. ChatGPT backend のプロンプトキャッシュは org / project 単位で
ハッシュ衝突する設計
2. `prompt_cache_key` を送らないと、複数 conversation のリクエストが
同じハッシュ空間に積み上がり、prefix が他 conversation で
上書きされてヒット率が落ちる
3. Codex CLI の実装はこれを認識しており、conversation_id を毎リクエスト
送って自分専用の名前空間にキャッシュさせている:
```rust
// codex-rs/core/src/client.rs:853
let prompt_cache_key = Some(self.client.state.conversation_id.to_string());
```
## 4. 公式 OpenAI API での挙動
公式エンドポイント (`https://api.openai.com/v1/responses`) では、
明示キーが無くても automatic prefix matching が走る。明示キーを
送ることで、複数 client / 複数 organization が同じ prefix を共有する
シナリオ(マルチテナント等)で意図しないヒット混線を避ける用途
で使う。少なくとも害は無いので両 backend で同じ値を送って良い。
## 5. insomnia での運用
- `Request::cache_key: Option<String>` を provider-agnostic な
キャッシュヒントとして持つ。`cache_anchor` (Anthropic 用 prefix
index) と並立する別概念。
- `OpenAIResponsesScheme::build_request`
`request.cache_key.clone()``prompt_cache_key` に投影。
`None` のときは body にキー自体が載らない
(`#[serde(skip_serializing_if = "Option::is_none")]`)。
- pod 側は LLM 呼び出し時に `SessionId.to_string()` を渡す。
主 Run / compactor / extract / consolidate worker のすべてが
同じ `session_id` を使うので、pod 内の派生 worker が prefix を
共有しているところでヒットが期待できる。
- 他 scheme (`anthropic`, `gemini`, `openai_chat`) は
`Request::cache_key` を未参照のまま無視する。
## 6. Fork との関係
`session-store::fork` / `fork_at` はいずれも新 `SessionId` を発行する。
新 fork = 新 cache_key とする(素直に `SessionId.to_string()` を渡す)。
fork 直後の cache 明示ヒットは失われるが、OpenAI Responses は
automatic prefix matching も走るため完全に冷えるわけではない。
fork 越しに親の cache_key を継承して明示ヒットも残す最適化は
別チケット扱い。
## 7. Compaction との関係
compaction は session_id を入れ替える (`create_compacted_session`)。
compact 直後に worker の `cache_key` も新 session_id で更新するため、
post-compact turn は extract / consolidate worker と同じ namespace で
動く。compact 自体は prefix を大幅に書き換えるので、明示キー継続の
有無に関わらずヒット率は元から低い。
## 8. ドキュメント URL
- 公式 API リファレンス: https://platform.openai.com/docs/api-reference/responses/create
- Codex CLI 実装 (conversation_id を prompt_cache_key に渡す):
https://github.com/openai/codex/blob/main/codex-rs/core/src/client.rs
- ChatGPT backend のサポートパラメータ (LiteLLM issue):
https://github.com/BerriAI/litellm/issues/21193

View File

@ -0,0 +1,118 @@
# OpenAI Responses: prompt_cache_key 送出によるキャッシュ有効化
## 背景
codex-oauth 経由 (ChatGPT backend) の OpenAI Responses API において、
プロンプトキャッシュが事実上効いていない。実セッションログ
(`019de419-...`, 171 turn / 累計入力 22.2M token) で `cache_read_tokens`
が全 turn 0、最新セッション (`019de48f-...`, 5 turn) でも 0。
直近のパース修正 (`events.rs` の `ResponsesUsage`
`input_tokens_details.cached_tokens` を追加) で計測経路は復旧したが、
それでも 0 が観測される → **server 側でそもそもキャッシュが効いていない**
原因は codex-rs の実装で確定:
```rust
// codex-rs/core/src/client.rs:853
let prompt_cache_key = Some(self.client.state.conversation_id.to_string());
```
ChatGPT backend では `prompt_cache_key` をリクエストに含めないと
プロンプトキャッシュが期待通りに動かない (org/project ハッシュが
別 conversation と衝突しやすく、ヒット率が著しく落ちる)。Codex は
conversation 単位の安定キーを毎リクエスト付けて、その名前空間内で
prefix をキャッシュさせている。
insomnia 側の `ResponsesRequest` には `prompt_cache_key` フィールドが
存在せず、`Request` 構造体にも会話/セッション単位の安定キー概念が無い。
このため codex-oauth で長尺の Run を走らせると毎 turn 全 prefix を
従量課金している。
## 方針
`Request` に provider-agnostic な `cache_key: Option<String>`
足し、`OpenAIResponsesScheme` がそれを `prompt_cache_key` として
送る。pod 側は LLM 呼び出し時に `SessionId` をキーとして渡す。
他 scheme (Anthropic / Gemini / OpenAI Chat / Ollama) はフィールドを
無視する。既存の `cache_anchor` (Anthropic 用 prefix anchor) と
同じ「キャッシュヒントを Request に載せ、効く provider だけ拾う」
規約に揃える。
### Fork との関係
`session-store::fork` / `fork_at` はいずれも新 `SessionId` を発行する。
本チケットでは **新 fork = 新 cache_key** とする (素直に
`SessionId.to_string()` を渡す)。fork 直後の cache 明示ヒットは失われる
が、OpenAI Responses は automatic prefix matching も走るため完全に
冷えるわけではない。fork 越しに親の cache_key を継承して明示ヒットも
残す最適化は別チケットで検討する (本ticketの範囲外)。
## 要件
### llm-worker 側
- `Request``cache_key: Option<String>` を追加 (`types.rs:442`
`cache_anchor` の隣)。doc コメントで「会話単位の安定キー。
prompt_cache_key として送られる (OpenAI Responses)。
prefix anchor を持たない provider は無視」を明記
- ビルダ `Request::cache_key(impl Into<String>)` を追加
- `OpenAIResponsesScheme::build_request``request.cache_key.clone()`
`ResponsesRequest::prompt_cache_key` にセット
- `ResponsesRequest``prompt_cache_key: Option<String>` を追加
(`#[serde(skip_serializing_if = "Option::is_none")]`)
- 他 scheme (`anthropic`, `gemini`, `openai_chat`) は touch しない
(Request の新フィールドを未参照のまま残す)
### pod 側
- LLM クライアントに渡す `Request` を組み立てる箇所で
`cache_key(session_id.to_string())` を入れる。少なくとも以下:
- 主 Run の LLM 呼び出し (`pod.rs` の Run / Worker 経路)
- compactor worker
- memory extract worker
- `SessionId``SharedState::session_id` から取得できる
(`shared_state.rs:21`)
- compactor / extract のように pod の中で派生する worker でも
同じ `session_id` を使う。これにより pod 内のすべての LLM
呼び出しが同一 cache_key 名前空間で動き、prefix が共有される
ところでヒットが期待できる
### docs
- `docs/research/` 配下に `openai_responses_prompt_cache_key.md`
(仮) を追加し、「ChatGPT backend では prompt_cache_key 必須」
「codex-rs の挙動」「insomnia での Fork 方針」を残す。
既存の `openai_responses_max_output_tokens.md` と並びで置く
## 完了条件
- `Request::cache_key("abc")` で組んだリクエストが、
`OpenAIResponsesScheme::build_request`
`prompt_cache_key: "abc"` を含む body を生成する (unit test)
- `cache_key = None` のときは body に `prompt_cache_key` キーが
載らない (`skip_serializing_if`) (unit test)
- pod の Run で codex-oauth + Responses を使ったとき、2 turn 目
以降の `cache_read_tokens` が 0 でない (実セッションログで確認)
- `cargo check` / `cargo test``llm-worker`, `provider`, `pod`
で通る
## 範囲外
- Fork 越しのキャッシュ継承 (`forked_from` を辿って root の
cache_key を継承する最適化)。別チケット
- 公式 OpenAI Responses API (非 ChatGPT backend) での
`prompt_cache_key` 必要性検証。少なくとも害は無いので両経路で
同じ値を送って良い
- compaction で prefix が大きく書き換わる経路の cache_key 戦略
(compaction 後は prefix がほぼ別物なので、ヒット率を最大化する
なら compaction 直後だけ別 key にする手もあるが、まずは単純に
session_id 一本で動かす)
- `cache_anchor` (Anthropic 用) と `cache_key` (Responses 用) の
統合。両者は別概念 (前者は prefix の境界 index、後者は
名前空間キー) なので並立させる
## Review
- 状態: Approve
- レビュー詳細: [./responses-prompt-cache-key.review.md](./responses-prompt-cache-key.review.md)
- 日付: 2026-05-02

View File

@ -0,0 +1,63 @@
# Review: OpenAI Responses prompt_cache_key 送出によるキャッシュ有効化
## 前提・要件の確認
### llm-worker 側
- `Request``cache_key: Option<String>` を追加し doc を整備:
`crates/llm-worker/src/llm_client/types.rs:458-465`
`cache_anchor` の直下、要件通りの位置。doc にも「会話単位の安定キー」「`prompt_cache_key` として送られる」「持たない provider は無視」が明記されている。
- `Request::cache_key(impl Into<String>)` builder:
`crates/llm-worker/src/llm_client/types.rs:546-552`。要件通り。
- `OpenAIResponsesScheme::build_request``request.cache_key.clone()``ResponsesRequest::prompt_cache_key` に投影:
`crates/llm-worker/src/llm_client/scheme/openai_responses/request.rs:228`
- `ResponsesRequest::prompt_cache_key: Option<String>``#[serde(skip_serializing_if = "Option::is_none")]` 付きで追加:
`request.rs:51-57`
- 他 scheme (`anthropic`, `gemini`, `openai_chat`) は touch されておらず、`Request::cache_key` を未参照のまま無視しているgrep 確認済)。要件通り。
### pod 側
- 主 Worker の構築 3 経路すべてで `set_cache_key(Some(session_id.to_string()))` を実施:
- `from_manifest`: `crates/pod/src/pod.rs:1915`
- `from_manifest_spawned`: 同 `:1976`
- `restore_from_manifest`: 同 `:2064`
- compactor (`summary_worker`) と memory extract (`extract_worker`) も session_id でキー付け済み:
`pod.rs:1198`, `pod.rs:1553`。要件で明示された 3 経路(主 Run / compactor / extractすべてカバーされている。
- `Worker::set_cache_key` の追加と、`build_request` 時の `request.cache_key = self.cache_key.clone()` 投影、`lock()`/`unlock()` 越しの引き継ぎ:
`crates/llm-worker/src/worker.rs:193, 399-405, 600, 1338, 1418`。状態遷移で落ちないことが確認できる。
### docs
- `docs/research/openai_responses_prompt_cache_key.md` を新規作成。
「ChatGPT backend では prompt_cache_key 必須」「codex-rs の挙動」「insomnia での Fork / Compaction 方針」「公式 API での挙動」「URL」がカバーされている。`openai_responses_max_output_tokens.md` と同じ並び。要件通り。
### 完了条件
- ユニットテスト 2 件:
`prompt_cache_key_passed_through_when_set` / `prompt_cache_key_omitted_when_none`
(`crates/llm-worker/src/llm_client/scheme/openai_responses/request.rs:540-560`)。
body にキーが乗る/省略される、両方を JSON 値で確認している。完了条件を満たす。
- `cargo check --workspace` 通過確認済(手元再実行で確認)。
- `cargo test -p llm-worker --lib` 121 件パス、`-p provider --lib` 41 件、`-p pod --lib` 133 件パスを確認。
- 「pod の Run で codex-oauth + Responses を使ったとき、2 turn 目以降の `cache_read_tokens` が 0 でない」については実セッションログ要観測(実装上は events.rs のパース修正で `cache_read_input_tokens` を埋める経路が出来ており、`prompt_cache_key` も乗ることがテストで確認できているので、計測経路としては揃っている)。これはコード単体では検証できない要件で、実走行に委ねるのが妥当。
## アーキテクチャ・スコープ
- llm-worker は provider-agnostic な `cache_key` を持ち、scheme ごとの解釈は scheme 配下で完結。`cache_anchor` (Anthropic 用 prefix index) と並立して別概念として扱う方針が doc とコードの両方で明確。低レベル基盤を歪めず、`cache_anchor` の規約パターンに素直に乗っている。
- 他 scheme (`anthropic`, `gemini`, `openai_chat`) はフィールドを未参照のまま残しており、不要な実装拡散がない。
- 範囲外項目への踏み込み確認:
- **memory consolidate worker への適用** (`pod.rs:1750`): ticket の要件節で明示されているのは Run/compactor/extract の 3 件だが、同節末尾に「compactor / extract のように pod の中で派生する worker でも 同じ session_id を使う。これにより pod 内のすべての LLM 呼び出しが同一 cache_key 名前空間で動き、prefix が共有されるところでヒットが期待できる」というポリシーが明記されている。consolidate も pod 内派生 worker なので、明示列挙されていなくとも policy の対象に当然含まれる解釈で妥当。漏れて consolidate だけ別 namespace になる方が不自然。
- **compact 後の re-key** (`pod.rs:1363`): compact 中に `self.session_id = new_session_id` (1333) で session_id 自体が入れ替わる。worker 側のキャッシュキーを古い session_id のまま放置すると、post-compact turn と extract/consolidate (これらは `self.session_id` = 新) で namespace が分裂する。範囲外の「compaction の cache_key 戦略」は「明示キーを別系統に切り替える等の最適化」を指しており、ここは「session_id 一本で動かす」という ticket 末尾の方針 (110行) を素直に維持しているだけ。むしろ re-key しない方がポリシー違反になる。
- Worker の構築・状態遷移箇所すべてで `cache_key` をハンドリング (`Worker::new` 初期化、`lock`/`unlock` 引き継ぎ) しており、後段で見落としによる空キー問題が起きない。
- `events.rs``input_tokens_details.cached_tokens` 取り込みは ticket 本文では「直近のパース修正で復旧した」前提として記述されているが、実際には未コミットだった分が今回まとめて入っている。これは本 ticket 完了の前提として必要な計測経路であり (= cache 効果が実環境で確認可能になる)、ticket の精神を満たすために必要。範囲外項目ではない。
## 指摘事項
### Blocking
なし。
### Non-blocking / Follow-up
- 完了条件の最後codex-oauth で実走行して `cache_read_tokens` が 0 でないことを実セッションログで確認は実装変更だけでは取り込めない。ticket クローズ前に 1 セッション流して `cache_read_input_tokens` がログに出ることを確認してほしい。
- ticket 本文では `cache_anchor` の隣としていたが、`Request::cache_anchor` フィールドの doc コメントが英語、`cache_key` は日本語になっている。プロジェクト方針として混在は許容されているように見えるが、両者揃える価値はある。優先度は低い。
### Nits
- `docs/research/openai_responses_prompt_cache_key.md:79-83`「Compaction との関係」セクションは ticket 範囲外項目を補足する形になっているが、現時点の実装方針 (post-compact で new session_id に re-key) と完全整合しているので有用。残してよい。
## 判断
**Approve** — ticket の前提・要件・完了条件はコード上満たされており、ticket 範囲外と明記された fork 越し継承や compaction 戦略への踏み込みも無い。consolidate worker への展開と post-compact re-key は ticket の policy 文 (「pod 内のすべての LLM 呼び出しが同一 cache_key 名前空間で動き」「session_id 一本で動かす」) に沿った最小拡張で、コードベースを歪めていない。残るは実セッションでの `cache_read_tokens > 0` 観測のみで、これは実走確認に委ねるのが妥当。