max_tokensのスキーマ不整合に関する修正

This commit is contained in:
2026-04-28 17:58:24 +09:00
parent 3d1b8a4761
commit ce4c0930c3
10 changed files with 266 additions and 13 deletions
@@ -16,11 +16,12 @@ pub use scheme_impl::OpenAIResponsesState;
/// OpenAI Responses scheme 本体。
///
/// `store` / `include_encrypted_content` は scheme 固定の wire 設定で、
/// デフォルトは stateless + ZDR 相当 (`store=false`, `include=[...]`)。
/// 将来 ZDR 非対応環境で `store=true` にしたくなった場合に限り override
/// する。`ModelCapability` には入れない(これはモデルの能力ではなく、
/// クライアントの運用方針)。
/// `store` / `include_encrypted_content` / `send_max_output_tokens` は
/// scheme 固定の wire 設定で、デフォルトは公式 OpenAI Responses API
/// 向け (stateless + ZDR + `max_output_tokens` 送出可)。ChatGPT backend
/// (codex-oauth) のように受理パラメータが subset の経路では provider 層で
/// `send_max_output_tokens=false` 等に上書きする。`ModelCapability` には
/// 入れない(モデル能力ではなく wire policy)。
#[derive(Debug, Clone)]
pub struct OpenAIResponsesScheme {
/// サーバ側に response を保存するか。ZDR/stateless 運用では `false`。
@@ -28,6 +29,10 @@ pub struct OpenAIResponsesScheme {
/// `include: ["reasoning.encrypted_content"]` を付けるか。
/// `store=false` で reasoning を使うなら必須。
pub include_encrypted_content: bool,
/// `max_output_tokens` を body に載せるか。公式 OpenAI Responses API は
/// 受理するが、ChatGPT backend (codex-oauth) は `Unsupported parameter`
/// で 400 を返すため、その経路では `false` にする。
pub send_max_output_tokens: bool,
}
impl Default for OpenAIResponsesScheme {
@@ -35,12 +40,14 @@ impl Default for OpenAIResponsesScheme {
Self {
store: false,
include_encrypted_content: true,
send_max_output_tokens: true,
}
}
}
impl OpenAIResponsesScheme {
/// デフォルト設定 (`store=false`, `include=["reasoning.encrypted_content"]`)。
/// デフォルト設定 (`store=false`, `include=["reasoning.encrypted_content"]`,
/// `send_max_output_tokens=true`)。
pub fn new() -> Self {
Self::default()
}
@@ -56,4 +63,10 @@ impl OpenAIResponsesScheme {
self.include_encrypted_content = include;
self
}
/// `max_output_tokens` を body に載せるかを上書き。
pub fn with_send_max_output_tokens(mut self, send: bool) -> Self {
self.send_max_output_tokens = send;
self
}
}
@@ -38,6 +38,9 @@ pub(crate) struct ResponsesRequest {
/// `["reasoning.encrypted_content"]` 等。
#[serde(skip_serializing_if = "Vec::is_empty")]
pub include: Vec<&'static str>,
/// 公式 OpenAI Responses API では受理されるが、ChatGPT backend
/// (codex-oauth) は 400 で弾く。scheme の `send_max_output_tokens`
/// が `false` のときは `None` のまま送る (skip_serializing_if で除外)。
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -195,7 +198,11 @@ impl OpenAIResponsesScheme {
store: self.store,
stream: true,
include,
max_output_tokens: request.config.max_tokens,
max_output_tokens: if self.send_max_output_tokens {
request.config.max_tokens
} else {
None
},
temperature: request.config.temperature,
top_p: request.config.top_p,
}
@@ -444,13 +451,26 @@ mod tests {
}
#[test]
fn max_output_tokens_passed_through() {
fn max_output_tokens_passed_through_by_default() {
let scheme = OpenAIResponsesScheme::new();
let req = Request::new().user("hi").max_tokens(100);
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
assert_eq!(body.max_output_tokens, Some(100));
}
#[test]
fn max_output_tokens_dropped_when_send_disabled() {
let scheme = OpenAIResponsesScheme::new().with_send_max_output_tokens(false);
let req = Request::new().user("hi").max_tokens(100);
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
assert_eq!(body.max_output_tokens, None);
let json = serde_json::to_value(&body).unwrap();
assert!(
json.get("max_output_tokens").is_none(),
"max_output_tokens key must not appear in serialised body, got: {json}"
);
}
#[test]
fn tool_schema_without_properties_is_normalized() {
// schemars は引数なし struct から `type:"object"` だけのスキーマを
@@ -3,8 +3,9 @@
use serde_json::Value;
use crate::llm_client::{
ClientError, auth::AuthRequirement, capability::ModelCapability, event::Event, scheme::Scheme,
types::Request,
ClientError, auth::AuthRequirement, capability::ModelCapability,
client::ConfigWarning, event::Event, scheme::Scheme,
types::{Request, RequestConfig},
};
use super::OpenAIResponsesScheme;
@@ -51,4 +52,18 @@ impl Scheme for OpenAIResponsesScheme {
fn default_capability(&self) -> ModelCapability {
super::capability::default_capability()
}
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
let mut warnings = Vec::new();
// ChatGPT backend (codex-oauth) は `max_output_tokens` を 400 で弾く。
// scheme 構築時に `send_max_output_tokens=false` で組まれていれば
// body 投影は止まっているので、ユーザの意図が落ちることだけを通知する。
if !self.send_max_output_tokens && config.max_tokens.is_some() {
warnings.push(ConfigWarning::unsupported(
"max_tokens",
"OpenAI Responses (ChatGPT backend)",
));
}
warnings
}
}
-2
View File
@@ -1064,7 +1064,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
let mut summary_worker = Worker::new(summary_client)
.system_prompt(summary_system_prompt)
.temperature(0.0);
summary_worker.set_max_tokens(4096);
// Cumulative input-token meter + interceptor. The meter is bumped
// from the on_usage callback and read on every pre_llm_request.
@@ -1413,7 +1412,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
let mut extract_worker = Worker::new(client)
.system_prompt(extract::EXTRACT_SYSTEM_PROMPT)
.temperature(0.0);
extract_worker.set_max_tokens(4096);
// Cumulative input-token meter + interceptor (mirror of
// CompactWorkerInterceptor). Aborts the extract worker if its
+6 -1
View File
@@ -142,7 +142,12 @@ fn build_from_config(config: &ModelConfig) -> Result<Box<dyn LlmClient>, Provide
SchemeKind::OpenaiChat => build_transport(OpenAIScheme::new(), config, resolved),
SchemeKind::Gemini => build_transport(GeminiScheme::new(), config, resolved),
SchemeKind::OpenaiResponses => {
build_transport(OpenAIResponsesScheme::new(), config, resolved)
// ChatGPT backend (codex-oauth) は `max_output_tokens` を
// 400 で弾くため、その経路では送出を止める。
let scheme = OpenAIResponsesScheme::new().with_send_max_output_tokens(
!matches!(config.auth, AuthRef::CodexOAuth),
);
build_transport(scheme, config, resolved)
}
}
}