Compare commits

...

2 Commits

13 changed files with 453 additions and 22 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
.direnv .direnv
*.local* *.local*
.env .env
.worktree

View File

@ -2,8 +2,10 @@
- [ ] 内部 Worker / 内部 Pod の Workflow 化 → [tickets/internal-worker-workflow.md](tickets/internal-worker-workflow.md) - [ ] 内部 Worker / 内部 Pod の Workflow 化 → [tickets/internal-worker-workflow.md](tickets/internal-worker-workflow.md)
- [ ] Agent Skills を Workflow として ingest → [tickets/agent-skills.md](tickets/agent-skills.md) - [ ] Agent Skills を Workflow として ingest → [tickets/agent-skills.md](tickets/agent-skills.md)
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md) - [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
- [ ] 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) - [ ] 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: 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 のエラー耐性 - [ ] llm-worker のエラー耐性
- [ ] HTTP transient リトライ → [tickets/llm-worker-transient-retry.md](tickets/llm-worker-transient-retry.md) - [ ] 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) - [ ] ストリーム途中失敗時の継続 → [tickets/llm-worker-stream-continuation.md](tickets/llm-worker-stream-continuation.md)

View File

@ -210,6 +210,15 @@ struct ResponsesUsage {
output_tokens: Option<u64>, output_tokens: Option<u64>,
#[serde(default)] #[serde(default)]
total_tokens: Option<u64>, 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)] #[derive(Debug, Deserialize)]
@ -270,7 +279,10 @@ pub(crate) fn parse_sse(
total_tokens: usage.total_tokens.or_else(|| { total_tokens: usage.total_tokens.or_else(|| {
Some(usage.input_tokens.unwrap_or(0) + usage.output_tokens.unwrap_or(0)) 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, cache_creation_input_tokens: None,
})); }));
} }
@ -554,9 +566,31 @@ mod tests {
assert_eq!(u.input_tokens, Some(10)); assert_eq!(u.input_tokens, Some(10));
assert_eq!(u.output_tokens, Some(20)); assert_eq!(u.output_tokens, Some(20));
assert_eq!(u.total_tokens, Some(30)); 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] #[test]
fn text_stream_start_delta_stop() { fn text_stream_start_delta_stop() {
let mut state = OpenAIResponsesState::default(); let mut state = OpenAIResponsesState::default();

View File

@ -50,6 +50,11 @@ pub(crate) struct ResponsesRequest {
pub temperature: Option<f32>, pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>, 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 制御。 /// reasoning 制御。
@ -220,6 +225,7 @@ impl OpenAIResponsesScheme {
} else { } else {
None 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] #[test]
fn tool_schema_without_properties_is_normalized() { fn tool_schema_without_properties_is_normalized() {
// schemars は引数なし struct から `type:"object"` だけのスキーマを // schemars は引数なし struct から `type:"object"` だけのスキーマを

View File

@ -455,6 +455,14 @@ pub struct Request {
/// (Anthropic today) can place a long-lived cache breakpoint there. /// (Anthropic today) can place a long-lived cache breakpoint there.
/// Providers without prompt caching ignore the field. /// Providers without prompt caching ignore the field.
pub cache_anchor: Option<usize>, 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 { impl Request {
@ -534,6 +542,14 @@ impl Request {
self.config.stop_sequences.push(sequence.into()); self.config.stop_sequences.push(sequence.into());
self 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. /// Index of the last stable cache prefix item, set by higher layers.
/// Plumbed into [`Request::cache_anchor`] at request build time. /// Plumbed into [`Request::cache_anchor`] at request build time.
cache_anchor: Option<usize>, 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 marker
_state: PhantomData<S>, _state: PhantomData<S>,
} }
@ -392,6 +396,14 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
self.cache_anchor = anchor; 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) /// Get a mutable reference to the timeline (for additional handler registration)
pub fn timeline_mut(&mut self) -> &mut Timeline { pub fn timeline_mut(&mut self) -> &mut Timeline {
&mut self.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 // if the prune projection trimmed items from the head — keep it
// in range). // in range).
request.cache_anchor = self.cache_anchor.filter(|&anchor| anchor < context.len()); request.cache_anchor = self.cache_anchor.filter(|&anchor| anchor < context.len());
request.cache_key = self.cache_key.clone();
request request
} }
@ -1065,6 +1078,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
prune_config: None, prune_config: None,
savings_estimator: None, savings_estimator: None,
cache_anchor: None, cache_anchor: None,
cache_key: None,
_state: PhantomData, _state: PhantomData,
} }
} }
@ -1321,6 +1335,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
prune_config: self.prune_config, prune_config: self.prune_config,
savings_estimator: self.savings_estimator, savings_estimator: self.savings_estimator,
cache_anchor: self.cache_anchor, cache_anchor: self.cache_anchor,
cache_key: self.cache_key,
_state: PhantomData, _state: PhantomData,
} }
} }
@ -1400,6 +1415,7 @@ impl<C: LlmClient> Worker<C, Locked> {
prune_config: self.prune_config, prune_config: self.prune_config,
savings_estimator: self.savings_estimator, savings_estimator: self.savings_estimator,
cache_anchor: self.cache_anchor, cache_anchor: self.cache_anchor,
cache_key: self.cache_key,
_state: PhantomData, _state: PhantomData,
} }
} }

View File

@ -1195,6 +1195,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.compact_system() .compact_system()
.map_err(PodError::PromptCatalog)?; .map_err(PodError::PromptCatalog)?;
let mut summary_worker = Worker::new(summary_client).system_prompt(summary_system_prompt); 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 // Cumulative input-token meter + interceptor. The meter is bumped
// from the on_usage callback and read on every pre_llm_request. // 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 // can place a durable `cache_control` breakpoint there — our
// compact layout guarantees history[0] is the summary. // compact layout guarantees history[0] is the summary.
worker.set_cache_anchor(Some(0)); 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 self.usage_history
.lock() .lock()
.expect("usage_history poisoned") .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 client = self.build_extractor_client(memory_cfg)?;
let mut extract_worker = Worker::new(client).system_prompt(extract::EXTRACT_SYSTEM_PROMPT); 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 // Cumulative input-token meter + interceptor (mirror of
// CompactWorkerInterceptor). Aborts the extract worker if its // CompactWorkerInterceptor). Aborts the extract worker if its
@ -1741,6 +1747,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}; };
let mut worker = let mut worker =
Worker::new(client).system_prompt(consolidate::CONSOLIDATION_SYSTEM_PROMPT); 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)); 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); let mut worker = Worker::new(common.client);
apply_worker_manifest(&mut worker, &manifest.worker); apply_worker_manifest(&mut worker, &manifest.worker);
worker.set_cache_key(Some(session_id.to_string()));
let mut pod = Self { let mut pod = Self {
manifest, manifest,
@ -1965,6 +1973,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
let mut worker = Worker::new(common.client); let mut worker = Worker::new(common.client);
apply_worker_manifest(&mut worker, &manifest.worker); apply_worker_manifest(&mut worker, &manifest.worker);
worker.set_cache_key(Some(session_id.to_string()));
let mut pod = Self { let mut pod = Self {
manifest, manifest,
@ -2052,6 +2061,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
// overwrite the pieces the session log is authoritative for. // overwrite the pieces the session log is authoritative for.
let mut worker = Worker::new(common.client); let mut worker = Worker::new(common.client);
apply_worker_manifest(&mut worker, &manifest.worker); apply_worker_manifest(&mut worker, &manifest.worker);
worker.set_cache_key(Some(session_id.to_string()));
if let Some(ref prompt) = state.system_prompt { if let Some(ref prompt) = state.system_prompt {
worker.set_system_prompt(prompt); worker.set_system_prompt(prompt);
} }

View File

@ -268,12 +268,11 @@ async fn wait_for_ready(
form: &mut Form, form: &mut Form,
overlay_toml: &str, overlay_toml: &str,
) -> Result<SpawnReady, SpawnError> { ) -> Result<SpawnReady, SpawnError> {
let (pod_bin, pod_args) = resolve_pod_command(); let pod_bin = resolve_pod_command();
let cwd = std::env::current_dir().map_err(SpawnError::Io)?; let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
let mut command = Command::new(&pod_bin); let mut command = Command::new(&pod_bin);
command command
.args(&pod_args)
.arg("--overlay") .arg("--overlay")
.arg(overlay_toml) .arg(overlay_toml)
.current_dir(&cwd) .current_dir(&cwd)
@ -375,28 +374,21 @@ fn build_overlay_toml(form: &Form) -> String {
toml::to_string(&toml::Value::Table(root)).expect("overlay serialisation cannot fail") toml::to_string(&toml::Value::Table(root)).expect("overlay serialisation cannot fail")
} }
/// Resolves the program (and any leading args) used to launch a child Pod. /// Resolves the binary used to launch a child Pod. Must point at a
/// `pod`-compatible executable — the parent reads the child's stderr
/// directly looking for `INSOMNIA-READY`, so any wrapper that emits
/// extra lines on stderr will pollute that handshake.
/// ///
/// `INSOMNIA_POD_COMMAND` is split on whitespace so devshells can point it /// `INSOMNIA_POD_COMMAND` overrides the lookup (used by tests to inject
/// at e.g. `cargo run -p pod --quiet --`; the first token is the program /// a mock binary). Otherwise we defer to `PATH` — missing binary
/// and the rest are prepended before `--overlay` and friends. /// surfaces as the spawn `io::Error`.
fn resolve_pod_command() -> (PathBuf, Vec<String>) { fn resolve_pod_command() -> PathBuf {
if let Ok(cmd) = std::env::var("INSOMNIA_POD_COMMAND") { if let Ok(cmd) = std::env::var("INSOMNIA_POD_COMMAND") {
let mut tokens = cmd.split_whitespace(); if !cmd.is_empty() {
if let Some(program) = tokens.next() { return PathBuf::from(cmd);
let args = tokens.map(str::to_owned).collect();
return (PathBuf::from(program), args);
} }
} }
if let Ok(exe) = std::env::current_exe() { PathBuf::from("pod")
if let Some(dir) = exe.parent() {
let candidate = dir.join("pod");
if candidate.is_file() {
return (candidate, Vec::new());
}
}
}
(PathBuf::from("pod"), Vec::new())
} }
struct StderrTail { struct StderrTail {

View File

@ -1,4 +1,23 @@
{ pkgs }: { pkgs }:
let
# Dev-only wrapper. tui の spawn 経路は `pod` バイナリを直に exec し、
# stderr の `INSOMNIA-READY` 行で握手するので、cargo の進捗や rustc の
# warning が混ざると tail に余計な行が積もり本当のエラーが押し出される。
# ここで一度ビルドを切り離し、成功時はビルド出力を一切捨てて素のバイナリ
# を exec、失敗時のみ build log を stderr に流して exit する。
pod-dev = pkgs.writeShellScriptBin "pod" ''
set -u
buildlog=$(mktemp)
trap 'rm -f "$buildlog"' EXIT
if ! cargo build --quiet -p pod 2>"$buildlog"; then
cat "$buildlog" >&2
exit 1
fi
manifest=$(cargo locate-project --workspace --message-format plain 2>/dev/null)
target_dir=''${CARGO_TARGET_DIR:-$(dirname "$manifest")/target}
exec "$target_dir/debug/pod" "$@"
'';
in
pkgs.mkShell { pkgs.mkShell {
packages = with pkgs; [ packages = with pkgs; [
nixfmt nixfmt
@ -6,12 +25,12 @@ pkgs.mkShell {
git git
rustc rustc
cargo cargo
pod-dev
]; ];
buildInputs = with pkgs; [ buildInputs = with pkgs; [
pkg-config pkg-config
openssl openssl
]; ];
INSOMNIA_POD_COMMAND = "cargo run -p pod --quiet --";
shellHook = '' shellHook = ''
echo "dev-shell-loaded" echo "dev-shell-loaded"
''; '';

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` 観測のみで、これは実走確認に委ねるのが妥当。

View File

@ -0,0 +1,40 @@
# Resume 時の Scope Claim の改善
## 背景
`tickets/dynamic-scope.md` で in-process Scope の縮小SpawnPod による委譲時の Write revokeと pod-registry 上の delegation 記録が揃った。これにより「セッション中に scope が縮む」状態を Pod / registry の双方が一貫して表現できる。
一方で `tui -r` 経由の resume は、`crates/tui/src/spawn.rs` の `build_overlay_toml` を通じて fresh spawn と同じロジックで overlay を合成する。manifest cascade に scope 宣言が無い場合、cwd 直下に `write` 再帰の rule を毎回付ける挙動。
このため次のような衝突が起きる:
- セッション S が稼働中に SpawnPod で子 C を作り、cwd 配下のサブパスを委譲した
- 親が exit、子 C は registry 上にエントリが残存(あるいはまだ稼働中)
- ユーザーが S を resume しようとすると、新しい Pod が cwd 全体に `write` を claim → 委譲された部分と overlap して registry が拒否
resume の意図は「過去のセッションの続きを取る」であって「過去の effective scope より広い範囲を新たに掴み直す」ではない。現状は後者になっており、過去に手放した scope を resume が勝手に取り戻そうとする形になっている。
## ゴール
セッション resume 時に claim する scope が、当該セッションが最後に持っていた effective scope に揃う。委譲済み・他 Pod が保持中の部分は claim 対象から外れ、resume された Pod は当時と同じ範囲だけで動作する。
## 要件
- resume 時の overlay 合成は cwd 盲信ではなく、当該セッションが過去に持っていた scope を反映する。情報源は session log / registry / その他のいずれでも良いが、何らかの永続情報から復元できること
- 過去の scope 情報が取得できないセッション(旧形式 / 破損)は、明示的なエラーで止めるか、ユーザーに確認させてから fresh claim にフォールバックする(黙って広げない)
- claim 試行が registry の既存 allocation と衝突した場合、エラーメッセージで衝突相手の Pod 名 と target rule の双方が伝わる(現状は Pod 名のみ)
- 委譲済みエントリ(`delegated_from` を持つ allocationが同じセッションの委譲チェーンに属する場合、resume はその範囲を claim せずに進行する
## 完了条件
- 「親 Pod がセッション中に SpawnPod を実行 → 子に委譲 → 親 exit → 親セッションを resume」のフローが、既存子 allocation を残したまま衝突なしで成功する
- 既存の無関係な Pod と衝突するケースは、衝突 rule と相手 Pod 名を含む明確なエラーで失敗する
- 単体テスト or 統合テストで上記 2 ケースが検証される
- 既存の fresh spawn (resume なし) の挙動には変化なし
## 範囲外
- 過去スコープの永続化スキーマを新規導入するかの判断は実装時に決めるsession log の既存フィールドで足りるなら追加しない)
- 自動的に既存 Pod を kill / reclaim して claim を通す挙動
- protocol 経由の外部からの GrantScope / RevokeScope`tickets/dynamic-scope.md` の範囲外宣言を継承)
- registry 側のエラー型の全面再設計rule 情報を含めるための最小限の拡張のみで足りる想定)