diff --git a/TODO.md b/TODO.md index 028ae843..4e3b07f6 100644 --- a/TODO.md +++ b/TODO.md @@ -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) diff --git a/crates/llm-worker/src/llm_client/scheme/openai_responses/events.rs b/crates/llm-worker/src/llm_client/scheme/openai_responses/events.rs index 0b011442..c1984ba9 100644 --- a/crates/llm-worker/src/llm_client/scheme/openai_responses/events.rs +++ b/crates/llm-worker/src/llm_client/scheme/openai_responses/events.rs @@ -210,6 +210,15 @@ struct ResponsesUsage { output_tokens: Option, #[serde(default)] total_tokens: Option, + /// `input_tokens` の内訳。`cached_tokens` がプロンプトキャッシュヒット分。 + #[serde(default)] + input_tokens_details: Option, +} + +#[derive(Debug, Deserialize)] +struct InputTokensDetails { + #[serde(default)] + cached_tokens: Option, } #[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(); diff --git a/crates/llm-worker/src/llm_client/scheme/openai_responses/request.rs b/crates/llm-worker/src/llm_client/scheme/openai_responses/request.rs index 55d2df4e..e66a3f0c 100644 --- a/crates/llm-worker/src/llm_client/scheme/openai_responses/request.rs +++ b/crates/llm-worker/src/llm_client/scheme/openai_responses/request.rs @@ -50,6 +50,11 @@ pub(crate) struct ResponsesRequest { pub temperature: Option, #[serde(skip_serializing_if = "Option::is_none")] pub top_p: Option, + /// 会話単位の安定キー。ChatGPT backend (codex-oauth) は明示キーが + /// 無いとプロンプトキャッシュがほぼ効かない。pod 側は `SessionId` + /// を渡す。`Request::cache_key` が `None` のときはキー自体を送らない。 + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option, } /// 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"` だけのスキーマを diff --git a/crates/llm-worker/src/llm_client/types.rs b/crates/llm-worker/src/llm_client/types.rs index de33f598..f4d9a094 100644 --- a/crates/llm-worker/src/llm_client/types.rs +++ b/crates/llm-worker/src/llm_client/types.rs @@ -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, + /// 会話単位の安定キー。`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, } 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) -> Self { + self.cache_key = Some(key.into()); + self + } } // ============================================================================ diff --git a/crates/llm-worker/src/worker.rs b/crates/llm-worker/src/worker.rs index 23cc7dfe..b638d772 100644 --- a/crates/llm-worker/src/worker.rs +++ b/crates/llm-worker/src/worker.rs @@ -187,6 +187,10 @@ pub struct Worker { /// Index of the last stable cache prefix item, set by higher layers. /// Plumbed into [`Request::cache_anchor`] at request build time. cache_anchor: Option, + /// Conversation-scoped cache key, set by higher layers. Plumbed into + /// [`Request::cache_key`] at request build time. Pod 側では + /// `SessionId` を渡す。 + cache_key: Option, /// State marker _state: PhantomData, } @@ -392,6 +396,14 @@ impl Worker { 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) { + 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 Worker { // 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 Worker { prune_config: None, savings_estimator: None, cache_anchor: None, + cache_key: None, _state: PhantomData, } } @@ -1321,6 +1335,7 @@ impl Worker { 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 Worker { prune_config: self.prune_config, savings_estimator: self.savings_estimator, cache_anchor: self.cache_anchor, + cache_key: self.cache_key, _state: PhantomData, } } diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index be36f537..737881ce 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -1195,6 +1195,7 @@ impl Pod { .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 Pod { // 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 Pod { 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 Pod { }; 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 Pod, 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 Pod, 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 Pod, 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); } diff --git a/docs/research/openai_responses_prompt_cache_key.md b/docs/research/openai_responses_prompt_cache_key.md new file mode 100644 index 00000000..dd3f38f2 --- /dev/null +++ b/docs/research/openai_responses_prompt_cache_key.md @@ -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` を 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 diff --git a/tickets/responses-prompt-cache-key.md b/tickets/responses-prompt-cache-key.md new file mode 100644 index 00000000..62195412 --- /dev/null +++ b/tickets/responses-prompt-cache-key.md @@ -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` を +足し、`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` を追加 (`types.rs:442` + の `cache_anchor` の隣)。doc コメントで「会話単位の安定キー。 + prompt_cache_key として送られる (OpenAI Responses)。 + prefix anchor を持たない provider は無視」を明記 +- ビルダ `Request::cache_key(impl Into)` を追加 +- `OpenAIResponsesScheme::build_request` で `request.cache_key.clone()` + を `ResponsesRequest::prompt_cache_key` にセット +- `ResponsesRequest` に `prompt_cache_key: Option` を追加 + (`#[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 diff --git a/tickets/responses-prompt-cache-key.review.md b/tickets/responses-prompt-cache-key.review.md new file mode 100644 index 00000000..bd2b0f44 --- /dev/null +++ b/tickets/responses-prompt-cache-key.review.md @@ -0,0 +1,63 @@ +# Review: OpenAI Responses prompt_cache_key 送出によるキャッシュ有効化 + +## 前提・要件の確認 + +### llm-worker 側 +- `Request` に `cache_key: Option` を追加し doc を整備: + `crates/llm-worker/src/llm_client/types.rs:458-465`。 + `cache_anchor` の直下、要件通りの位置。doc にも「会話単位の安定キー」「`prompt_cache_key` として送られる」「持たない provider は無視」が明記されている。 +- `Request::cache_key(impl Into)` 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` を `#[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` 観測のみで、これは実走確認に委ねるのが妥当。