merge: responses reasoning context safety

This commit is contained in:
Keisuke Hirata 2026-05-29 17:10:22 +09:00
commit b9891e6127
No known key found for this signature in database
10 changed files with 333 additions and 15 deletions

View File

@ -477,6 +477,48 @@ mod tests {
}
}
#[test]
fn persisted_reasoning_items_are_preserved_across_user_turns() {
let scheme = OpenAIResponsesScheme::new();
let old_reasoning = Item::reasoning("old").with_encrypted_content("OLD_ENC");
let current_reasoning = Item::reasoning("current").with_encrypted_content("CURRENT_ENC");
let req = Request::new()
.user("old prompt")
.item(old_reasoning)
.assistant("old answer")
.user("new prompt")
.item(current_reasoning);
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
let encrypted: Vec<_> = body
.input
.iter()
.filter_map(|item| match item {
InputItem::Reasoning {
encrypted_content, ..
} => encrypted_content.as_deref(),
_ => None,
})
.collect();
assert_eq!(encrypted, vec!["OLD_ENC", "CURRENT_ENC"]);
}
#[test]
fn reasoning_is_kept_across_function_call_loop() {
let scheme = OpenAIResponsesScheme::new();
let req = Request::new()
.user("run tool")
.item(Item::reasoning("plan").with_encrypted_content("ENC"))
.item(Item::tool_call("c1", "tool", "{}"))
.item(Item::tool_result("c1", "ok"));
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
assert!(matches!(body.input[1], InputItem::Reasoning { .. }));
assert!(matches!(body.input[2], InputItem::FunctionCall { .. }));
assert!(matches!(
body.input[3],
InputItem::FunctionCallOutput { .. }
));
}
#[test]
fn reasoning_summary_field_is_always_serialized() {
// Responses API は reasoning item に `summary` を必須で要求する。
@ -509,6 +551,11 @@ mod tests {
let reasoning = body.reasoning.expect("reasoning should be set");
assert_eq!(reasoning.effort.as_deref(), Some("high"));
assert_eq!(reasoning.summary, "auto");
let json = serde_json::to_value(reasoning).unwrap();
assert!(
json.get("context").is_none(),
"reasoning.context must not be serialized, got: {json}"
);
}
#[test]

View File

@ -14,7 +14,7 @@ use futures::{Stream, StreamExt, TryStreamExt};
use reqwest::header::{
ACCEPT, CONTENT_ENCODING, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, RETRY_AFTER,
};
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
use super::auth::{AuthProvider, AuthRequirement};
use super::capability::ModelCapability;
@ -260,6 +260,55 @@ fn json_value_kind(value: &Value) -> &'static str {
}
}
fn request_body_shape_payload(body: &Value) -> Value {
let mut map = Map::new();
if let Some(input) = body.get("input").and_then(Value::as_array) {
let items_json_bytes = serde_json::to_vec(input).map(|bytes| bytes.len()).ok();
let mut reasoning_items = 0usize;
let mut reasoning_encrypted_content_count = 0usize;
let mut reasoning_encrypted_content_bytes = 0usize;
for item in input {
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
continue;
}
reasoning_items += 1;
if let Some(encrypted) = item.get("encrypted_content").and_then(Value::as_str) {
reasoning_encrypted_content_count += 1;
reasoning_encrypted_content_bytes += encrypted.len();
}
}
map.insert("items_len".to_string(), json!(input.len()));
map.insert("items_json_bytes".to_string(), json!(items_json_bytes));
map.insert("reasoning_items".to_string(), json!(reasoning_items));
map.insert(
"reasoning_encrypted_content_count".to_string(),
json!(reasoning_encrypted_content_count),
);
map.insert(
"reasoning_encrypted_content_bytes".to_string(),
json!(reasoning_encrypted_content_bytes),
);
}
Value::Object(map)
}
fn api_error_code(error: &ClientError) -> Option<&str> {
match error {
ClientError::Api { code, .. } => code.as_deref(),
_ => None,
}
}
fn is_context_length_exceeded(error: &ClientError) -> bool {
match error {
ClientError::Api { code, message, .. } => {
code.as_deref() == Some("context_length_exceeded")
|| message.contains("context_length_exceeded")
}
_ => false,
}
}
async fn response_with_timeout(
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
timeout: Duration,
@ -296,7 +345,11 @@ async fn classify_error_response(resp: reqwest::Response) -> ClientError {
let text = resp.text().await.unwrap_or_default();
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
let error = json.get("error").unwrap_or(&json);
let code = error.get("type").and_then(|v| v.as_str()).map(String::from);
let code = error
.get("code")
.and_then(|v| v.as_str())
.or_else(|| error.get("type").and_then(|v| v.as_str()))
.map(String::from);
let message = error
.get("message")
.and_then(|v| v.as_str())
@ -406,12 +459,14 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
let body = self
.scheme
.build_request_body(&self.model_id, &request, &self.capability);
let body_shape = request_body_shape_payload(&body);
emit_transport_trace(
&request,
"transport_body_build_done",
json!({
"elapsed_ms": body_started.elapsed().as_millis() as u64,
"body_kind": json_value_kind(&body),
"request_shape": body_shape.clone(),
}),
);
@ -438,6 +493,7 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
"encoding": request_body.encoding(),
"raw_json_bytes": request_body.raw_json_bytes(),
"wire_bytes": request_body.wire_bytes(),
"request_shape": body_shape.clone(),
}),
);
@ -479,15 +535,23 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
};
if !response.status().is_success() {
let status = response.status().as_u16();
let retry_after_present = response.headers().get(RETRY_AFTER).is_some();
let error = classify_error_response(response).await;
let context_length_exceeded = is_context_length_exceeded(&error);
emit_transport_trace(
&request,
"transport_http_status_error",
json!({
"status": response.status().as_u16(),
"retry_after_present": response.headers().get(RETRY_AFTER).is_some(),
"status": status,
"retry_after_present": retry_after_present,
"api_error_code": api_error_code(&error),
"context_length_exceeded": context_length_exceeded,
"provider_usage_absent": context_length_exceeded,
"request_shape": body_shape.clone(),
}),
);
return Err(classify_error_response(response).await);
return Err(error);
}
emit_transport_trace(
@ -611,6 +675,23 @@ mod tests {
)
}
#[test]
fn request_body_shape_counts_reasoning_encrypted_content() {
let payload = request_body_shape_payload(&json!({
"reasoning": { "summary": "auto" },
"input": [
{ "type": "message", "role": "user", "content": [] },
{ "type": "reasoning", "encrypted_content": "abc", "summary": [] },
{ "type": "reasoning", "encrypted_content": "defgh", "summary": [] }
]
}));
assert_eq!(payload["items_len"], 3);
assert_eq!(payload["reasoning_items"], 2);
assert_eq!(payload["reasoning_encrypted_content_count"], 2);
assert_eq!(payload["reasoning_encrypted_content_bytes"], 8);
assert!(payload["items_json_bytes"].as_u64().unwrap() > 0);
}
#[tokio::test]
async fn response_timeout_returns_retryable_lifecycle_timeout() {
let err = response_with_timeout(

View File

@ -2029,12 +2029,31 @@ fn items_trace_payload(
_ => None,
};
let mut reasoning_items = 0usize;
let mut reasoning_encrypted_content_count = 0usize;
let mut reasoning_encrypted_content_bytes = 0usize;
for item in items {
if let Item::Reasoning {
encrypted_content, ..
} = item
{
reasoning_items += 1;
if let Some(encrypted) = encrypted_content {
reasoning_encrypted_content_count += 1;
reasoning_encrypted_content_bytes += encrypted.len();
}
}
}
json!({
"items_len": items.len(),
"items_json_bytes": serde_json::to_vec(items).map(|bytes| bytes.len()).ok(),
"tools_len": tools_len,
"cache_anchor": cache_anchor,
"cache_key_present": cache_key_present,
"reasoning_items": reasoning_items,
"reasoning_encrypted_content_count": reasoning_encrypted_content_count,
"reasoning_encrypted_content_bytes": reasoning_encrypted_content_bytes,
"last_item_kind": last.map(item_kind),
"last_item_json_bytes": last.and_then(|item| serde_json::to_vec(item).ok().map(|bytes| bytes.len())),
"last_tool_result": last_tool_result,

View File

@ -52,10 +52,16 @@ pub struct ModelManifest {
/// `default_capability` → scheme 既定の順で解決される。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability: Option<ModelCapability>,
/// モデルのコンテキストウィンドウ上限tokens。カタログ未掲載 / inline
/// モデルでもここで明示 override できる。
/// モデルの希望コンテキストウィンドウtokens。カタログ未掲載 / inline
/// モデルでもここで明示 override できる。実効値は `max_context_window`
/// またはカタログ上の backend maximum で clamp される。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<u64>,
/// backend が実際に受け付けるコンテキストウィンドウ上限tokens
/// 表示・安全判定に使う実効 context window は `context_window` とこの値の
/// 小さい方になる。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_context_window: Option<u64>,
}
impl ModelManifest {
@ -70,6 +76,7 @@ impl ModelManifest {
auth: upper.auth.or(self.auth),
capability: upper.capability.or(self.capability),
context_window: upper.context_window.or(self.context_window),
max_context_window: upper.max_context_window.or(self.max_context_window),
}
}
}

View File

@ -22,6 +22,7 @@ use tracing::info;
use tracing::warn;
use crate::compact::state::CompactState;
use crate::compact::usage_tracker::UsageTracker;
use session_store::{SystemItem, SystemReminder};
use tools::{TaskEntry, TaskStatus, TaskStore};
@ -91,6 +92,10 @@ pub(crate) struct PodInterceptor {
/// per-request `context` to estimate current occupancy for threshold
/// checks. `None` when compaction is disabled (both thresholds unset).
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
/// In-flight usage records observed during the current run but not yet
/// persisted into `usage_history`. Subsequent tool-loop LLM calls must
/// see these records during pre-request safety accounting.
usage_tracker: Option<Arc<UsageTracker>>,
/// Pending-notification buffer drained into `worker.history`
/// via [`Self::pending_history_appends`] just before the next LLM
/// request. The Worker `extend`s these into its persistent history
@ -138,6 +143,7 @@ impl PodInterceptor {
registry,
compact_state,
usage_history,
usage_tracker: None,
pending_notifies,
pending_attachments,
task_store,
@ -149,6 +155,11 @@ impl PodInterceptor {
}
}
pub(crate) fn with_usage_tracker(mut self, usage_tracker: Arc<UsageTracker>) -> Self {
self.usage_tracker = Some(usage_tracker);
self
}
/// Commit each `SystemItem` as its own `LogEntry::SystemItem`
/// entry through the attached writer (no-op when no writer is
/// wired). Sync — writes complete before the matching
@ -175,7 +186,10 @@ impl PodInterceptor {
/// `usage_history` is not attached (compaction fully disabled).
fn estimated_tokens(&self, context: &[Item]) -> Option<u64> {
let handle = self.usage_history.as_ref()?;
let records = handle.lock().expect("usage_history poisoned").clone();
let mut records = handle.lock().expect("usage_history poisoned").clone();
if let Some(tracker) = self.usage_tracker.as_ref() {
records.extend(tracker.records());
}
Some(total_tokens(context, &records).tokens)
}
@ -305,9 +319,15 @@ impl Interceptor for PodInterceptor {
if !state.is_disabled() && !state.just_compacted() {
let current = current_tokens.unwrap_or(0);
if state.exceeds_request(current) {
let shape = context_shape(context);
info!(
input_tokens = current,
threshold = state.request_threshold().unwrap_or(0),
items_len = shape.items_len,
items_json_bytes = shape.items_json_bytes,
reasoning_items = shape.reasoning_items,
reasoning_encrypted_content_count = shape.reasoning_encrypted_content_count,
reasoning_encrypted_content_bytes = shape.reasoning_encrypted_content_bytes,
"Between-requests compaction threshold exceeded, yielding"
);
return PreRequestAction::Yield;
@ -400,6 +420,37 @@ impl Interceptor for PodInterceptor {
}
}
struct ContextShape {
items_len: usize,
items_json_bytes: Option<usize>,
reasoning_items: usize,
reasoning_encrypted_content_count: usize,
reasoning_encrypted_content_bytes: usize,
}
fn context_shape(context: &[Item]) -> ContextShape {
let mut shape = ContextShape {
items_len: context.len(),
items_json_bytes: serde_json::to_vec(context).ok().map(|bytes| bytes.len()),
reasoning_items: 0,
reasoning_encrypted_content_count: 0,
reasoning_encrypted_content_bytes: 0,
};
for item in context {
if let Item::Reasoning {
encrypted_content, ..
} = item
{
shape.reasoning_items += 1;
if let Some(encrypted) = encrypted_content {
shape.reasoning_encrypted_content_count += 1;
shape.reasoning_encrypted_content_bytes += encrypted.len();
}
}
}
shape
}
fn extract_message_text(item: &Item) -> Option<String> {
match item {
Item::Message { content, .. } => Some(
@ -528,6 +579,40 @@ mod tests {
assert_eq!(count.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn pre_llm_request_counts_in_flight_usage_records() {
let registry = Arc::new(HookRegistryBuilder::new().build());
let state = Arc::new(CompactState::new(None, Some(100), 2));
let ctx_items = vec![Item::user_message("hi")];
let history = usage_handle_with(ctx_items.len(), 50);
let usage_tracker = Arc::new(UsageTracker::new());
usage_tracker.note_request(ctx_items.len());
usage_tracker.record_usage(&llm_worker::event::UsageEvent {
input_tokens: Some(150),
output_tokens: Some(0),
total_tokens: Some(150),
cache_read_input_tokens: Some(0),
cache_creation_input_tokens: Some(0),
});
let interceptor = PodInterceptor::new(
registry,
Some(state),
Some(history),
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
TaskStore::new(),
Arc::new(TaskReminderState::new()),
PromptCatalog::builtins_only().unwrap(),
None,
)
.with_usage_tracker(usage_tracker);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
assert!(matches!(action, PreRequestAction::Yield));
}
#[tokio::test]
async fn pre_llm_request_runs_hooks_when_under_threshold() {
let count = Arc::new(AtomicUsize::new(0));

View File

@ -1270,7 +1270,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.task_reminder_state.clone(),
self.prompts.clone(),
self.log_writer.clone(),
);
)
.with_usage_tracker(self.usage_tracker.clone());
self.worker_mut().set_interceptor(interceptor);
self.interceptor_installed = true;
}

View File

@ -117,9 +117,14 @@ pub struct ModelEntry {
#[serde(default)]
pub capability: Option<ModelCapability>,
/// モデル単位の context window。省略時は provider default → builtin
/// fallback にフォールバックする。
/// fallback にフォールバックする。実効値は `max_context_window` で clamp
/// される。
#[serde(default)]
pub context_window: Option<u64>,
/// backend が実際に受け付ける context window の上限。UI や pre-request
/// safety は希望値ではなく clamp 済みの実効値を使う。
#[serde(default)]
pub max_context_window: Option<u64>,
}
/// 解決済みモデル設定。`build_client` が消費する完成形。
@ -130,7 +135,10 @@ pub struct ModelConfig {
pub model_id: String,
pub auth: AuthRef,
pub capability: Option<ModelCapability>,
/// Effective context window after backend maximum clamping.
pub context_window: u64,
/// Backend maximum that constrained `context_window`, when known.
pub max_context_window: Option<u64>,
}
#[derive(Debug, Deserialize)]
@ -259,7 +267,8 @@ fn split_ref(s: &str) -> Option<(&str, &str)> {
/// manifest 明示 > model catalog > provider.default_capability >
/// `build_client` 側で)`Scheme::default_capability()`。
/// context_window は manifest 明示 > model catalog > provider default >
/// [`DEFAULT_CONTEXT_WINDOW`]。
/// [`DEFAULT_CONTEXT_WINDOW`]。実効 context_window は manifest/model の
/// max_context_window で clamp される。
pub fn resolve_model_manifest(manifest: &ModelManifest) -> Result<ModelConfig, ResolveError> {
let providers = load_providers().map_err(ResolveError::LoadProviders)?;
let models = load_models().map_err(ResolveError::LoadModels)?;
@ -310,11 +319,15 @@ pub fn resolve_with_catalogs(
.and_then(|m| m.capability.clone())
.or_else(|| provider.default_capability.clone())
});
let context_window = manifest
let desired_context_window = manifest
.context_window
.or_else(|| model_entry.and_then(|m| m.context_window))
.or(provider.default_context_window)
.unwrap_or(DEFAULT_CONTEXT_WINDOW);
let max_context_window = manifest
.max_context_window
.or_else(|| model_entry.and_then(|m| m.max_context_window));
let context_window = clamp_context_window(desired_context_window, max_context_window);
Ok(ModelConfig {
scheme,
base_url,
@ -322,6 +335,7 @@ pub fn resolve_with_catalogs(
auth,
capability,
context_window,
max_context_window,
})
} else {
let scheme = manifest
@ -335,17 +349,24 @@ pub fn resolve_with_catalogs(
.auth
.clone()
.ok_or(ResolveError::InlineMissing("auth"))?;
let desired_context_window = manifest.context_window.unwrap_or(DEFAULT_CONTEXT_WINDOW);
let max_context_window = manifest.max_context_window;
Ok(ModelConfig {
scheme,
base_url: manifest.base_url.clone(),
model_id,
auth,
capability: manifest.capability.clone(),
context_window: manifest.context_window.unwrap_or(DEFAULT_CONTEXT_WINDOW),
context_window: clamp_context_window(desired_context_window, max_context_window),
max_context_window,
})
}
}
fn clamp_context_window(desired: u64, max: Option<u64>) -> u64 {
max.map(|limit| desired.min(limit)).unwrap_or(desired)
}
#[cfg(test)]
mod tests {
use super::*;
@ -420,6 +441,52 @@ mod tests {
assert_eq!(cfg.context_window, 123_456);
}
#[test]
fn context_window_is_clamped_by_catalog_backend_max() {
let providers = load_builtin_providers().unwrap();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest {
ref_: Some("codex-oauth/gpt-5.5".into()),
context_window: Some(1_000_000),
..Default::default()
};
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
assert_eq!(cfg.context_window, 272_000);
assert_eq!(cfg.max_context_window, Some(272_000));
}
#[test]
fn inline_context_window_is_clamped_by_manifest_backend_max() {
let providers = load_builtin_providers().unwrap();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest {
scheme: Some(SchemeKind::Anthropic),
model_id: Some("custom".into()),
auth: Some(AuthRef::None),
context_window: Some(1_000_000),
max_context_window: Some(272_000),
..Default::default()
};
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
assert_eq!(cfg.context_window, 272_000);
assert_eq!(cfg.max_context_window, Some(272_000));
}
#[test]
fn manifest_backend_max_overrides_catalog_backend_max() {
let providers = load_builtin_providers().unwrap();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest {
ref_: Some("codex-oauth/gpt-5.5".into()),
context_window: Some(1_000_000),
max_context_window: Some(500_000),
..Default::default()
};
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
assert_eq!(cfg.context_window, 500_000);
assert_eq!(cfg.max_context_window, Some(500_000));
}
#[test]
fn resolve_ref_with_inline_overrides() {
let providers = load_builtin_providers().unwrap();

View File

@ -187,6 +187,7 @@ mod tests {
},
capability: None,
context_window: 200_000,
max_context_window: None,
}
}
@ -315,6 +316,7 @@ mod tests {
auth: AuthRef::None,
capability: None,
context_window: 200_000,
max_context_window: None,
};
assert!(build_client_from_config(&config).is_ok());
}

View File

@ -85,7 +85,9 @@ reasoning トークンは各ターンの後に破棄される。次ターンに
1. `previous_response_id` パラメータで過去のレスポンスを参照
2. `response.output` の全アイテムを次の `input` に手動で渡す
ステートレス利用(`store=false`、ZDR組織の場合は `include=["reasoning.encrypted_content"]` を指定すれば暗号化された推論コンテンツを受け取り、次リクエストに渡すことで推論を引き継げる。
ステートレス利用(`store=false`、ZDR組織の場合は `include=["reasoning.encrypted_content"]` を指定すれば暗号化された推論コンテンツを受け取り、次リクエストに渡すことで推論を引き継げる。Insomnia は履歴から復元した reasoning item を通常の API message として扱い、独自の turn-boundary filtering はしない。
同一ターン内の function-call loop でも、`reasoning item → function_call → function_call_output → 次の Responses request` の連続性を保つため、履歴上の reasoning item は通常の API message として保持する。ToolResult は wire 上で user 側 item に見えるが、reasoning item の削除境界としては扱わない。
#### モデル世代差
@ -185,7 +187,7 @@ Ollamaはローカル実行プラットフォームで、モデルごとに思
**ChatGPT を使うとき**
- 新規実装は **Responses API** を選ぶChat Completions は推論引き継ぎが弱い)
- ZDR組織でも `reasoning.encrypted_content` で推論を引き継げる
- ZDR組織でも `reasoning.encrypted_content` で推論を引き継げる。履歴上の reasoning item は通常の API message として扱い、独自の turn-boundary filtering はしない
- raw reasoning の抽出を試みない(規約違反の可能性)
**Ollama を使うとき**

View File

@ -38,6 +38,13 @@ provider = "codex-oauth"
context_window = 400000
capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "effort", vision = true, prompt_caching = { kind = "auto" } }
[[model]]
id = "gpt-5.5"
provider = "codex-oauth"
context_window = 1000000
max_context_window = 272000
capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "effort", vision = true, prompt_caching = { kind = "auto" } }
# OpenRouter
[[model]]
id = "anthropic/claude-sonnet-4"