From 4feaf668a324e4c356ed55befbaa2b48e52e6adf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=84=B1=EC=A4=80?= Date: Tue, 3 Nov 2026 19:13:01 +0900 Subject: [PATCH] =?UTF-8?q?OpenAI=20preset=EC=9D=B4=20=EC=8B=A4=EC=A0=9C?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EA=B3=BC=ED=95=98=EB=8F=84=EB=A1=9D=20par?= =?UTF-8?q?ameter=20style=EC=9D=84=20=EB=8D=94=ED=96=88=EB=8B=A4=20(ADR-00?= =?UTF-8?q?86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI preset으로는 어떤 model도 setup을 통과하지 못했다. 실제 api.openai.com 요청으로 원인을 나눠 확인했다. - 추론 model(gpt-5.5, gpt-6-luna)은 max_tokens와 temperature 0을 거절한다 - preset의 json_schema는 strict 모드가 모든 schema node에 type을 요구해서 Step arguments에서 모든 model이 400으로 거절된다 - 별칭 model은 응답 model이 날짜 붙은 snapshot이라 identity 검사에서 닫힌다 --parameter-style compatible|openai(XGEN_OPENAI_PARAMETER_STYLE, profile parameterStyle)를 더했다. openai는 출력 한도를 max_completion_tokens로 보내고 temperature를 생략한다. model ID로 추측하거나 거절 뒤 자동 전환하지 않는다. OpenAI preset은 json_object와 openai style을 쓴다. compatible은 digest descriptor와 profile 파일에 필드를 쓰지 않으므로 기존 profile과 Run의 digest, 파일 바이트가 그대로다. 별칭 identity 검사는 완화하지 않고 snapshot ID 선택을 문서에 안내했다. 실제 OpenAI 검증(격리된 config/state, 키 미저장): - setup PASS: gpt-6-luna, gpt-5.5-2026-04-23, gpt-4.1-2025-04-14 - run 파일 수정 3/3, 테스트 생성+README 수정 2/2(pytest 4 passed) - 출력 한도 1024에서 잘린 call 0 Co-Authored-By: Claude Opus 5.5 --- MEMORY.md | 6 +- .../xgen-cli/examples/planning_model_pilot.rs | 1 + crates/xgen-cli/src/composition.rs | 14 +- crates/xgen-cli/src/main.rs | 50 ++++++- crates/xgen-cli/src/model_profile.rs | 70 +++++++-- crates/xgen-cli/tests/model_profiles.rs | 51 ++++++- crates/xgen-provider-openai/src/lib.rs | 133 +++++++++++++++++- docs/adr/0086-openai-parameter-style.md | 71 ++++++++++ docs/getting-started.md | 12 +- 9 files changed, 376 insertions(+), 32 deletions(-) create mode 100644 docs/adr/0086-openai-parameter-style.md diff --git a/MEMORY.md b/MEMORY.md index 047e41c..6303b40 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1,6 +1,6 @@ # xgen CLI와 연계 도구 공동 개발 메모 -갱신: 2026-10-31 (KST) +갱신: 2026-11-03 (KST) ## 사용자가 정한 방향 @@ -178,3 +178,7 @@ - 다음 범용 작업(사용자 승인, 2026-10-31): 명세별 HTTP 지원 범위를 한번에 보는 `tools check-http` 진단을 진행한다. 개별 describe/execution의 classifier를 재사용하고 저장된 조회 지정 정책을 적용한다. 판정 범위는 import된 도구이며 실제 API·인증·모델 검증과 구분한다. - HTTP 지원 보고서(ADR-0085, 구현·로컬 검증 완료): 전체 지원/거절·제외 이유 집계, 이름순 bounded page, artifact/policy digest를 제공한다. assets/calendar 설계와 shipment/sensor 실제 CLI 대조에서 개별 describe와 일치했고, bearer 환경변수가 없어도 API·모델 호출0회였다. 외부 통합 과업 수는 계속 미확정이다. - HTTP 지원 보고서 검증: Rust784개(4 ignored), pinned backend+컴파일 CLI Python159개(7 skipped), Clippy·fmt·protocol·문서·workflow 검사 통과. 독립 diff 검토의 policy 전달·pagination 가설은 실제 native 정책 주입·행 크기 상한·마지막 페이지 검사와 대조했다. Run 계약/profile·HTTP 지원 규칙은 바꾸지 않았다. + +- OpenAI 연결(사용자 요청, 2026-11-03): rc.6 설치 후 OpenAI preset으로 어떤 model도 setup을 통과하지 못했다. 원인 3가지를 실제 api.openai.com 요청으로 확정했다. (1) 추론 model은 `max_tokens`·`temperature: 0` 거절 (2) preset의 `json_schema`는 strict 모드가 모든 schema node에 `type`을 요구해 Step `arguments`에서 모든 model이 400 (3) 별칭 model은 응답 `model`이 날짜 붙은 snapshot이라 identity 검사에서 닫힘. +- OpenAI parameter style(ADR-0086, 구현·실제 OpenAI 검증 완료): `--parameter-style compatible|openai`, `openai`는 `max_completion_tokens`·temperature 생략. OpenAI preset은 `json_object`+`openai`. compatible은 digest·profile 파일 바이트가 그대로다. 별칭 identity 검사는 완화하지 않고 snapshot ID 선택을 안내한다. setup PASS gpt-6-luna·gpt-5.5-2026-04-23·gpt-4.1-2025-04-14, run 파일 수정 3/3, 테스트 생성+README 수정 2/2(pytest 4 passed), 출력 한도 1024에서 잘림 0. +- 다음(제안, 미구현): setup/run의 `request_rejected`·`chat_completions_incompatible`에 provider 거절 이유와 응답 model ID를 보여 준다. 지금은 code 한 줄뿐이라 사용자가 원인을 알 수 없다. diff --git a/crates/xgen-cli/examples/planning_model_pilot.rs b/crates/xgen-cli/examples/planning_model_pilot.rs index 63f8f15..f837bdd 100644 --- a/crates/xgen-cli/examples/planning_model_pilot.rs +++ b/crates/xgen-cli/examples/planning_model_pilot.rs @@ -65,6 +65,7 @@ fn main() -> Result<(), Box> { request.request_options = RequestOptions { response_format: config.response_format, thinking: config.thinking, + ..RequestOptions::default() }; request.allow_dirs = vec![".".into()]; request.allow_executables = config.allow_executables; diff --git a/crates/xgen-cli/src/composition.rs b/crates/xgen-cli/src/composition.rs index 324eaa7..7ad794e 100644 --- a/crates/xgen-cli/src/composition.rs +++ b/crates/xgen-cli/src/composition.rs @@ -472,6 +472,7 @@ fn compatibility_probe_config( .and_then(|config| config.with_timeout(limits.timeout())) .and_then(|config| config.with_response_format(options.response_format)) .and_then(|config| config.with_thinking(options.thinking)) + .and_then(|config| config.with_parameter_style(options.parameter_style)) .and_then(artifact_schema_from_environment) .map_err(map_model_check_config) } @@ -2233,6 +2234,7 @@ fn planner_config( .and_then(|config| config.with_timeout(limits.timeout())) .and_then(|config| config.with_response_format(options.response_format)) .and_then(|config| config.with_thinking(options.thinking)) + .and_then(|config| config.with_parameter_style(options.parameter_style)) .and_then(artifact_schema_from_environment) .map_err(map_provider_config)?; if planning_constraints_required { @@ -3919,21 +3921,27 @@ mod tests { #[test] fn explicit_wire_options_have_identical_probe_and_planner_digests() { - use xgen_provider_openai::{ResponseFormat, ThinkingMode}; + use xgen_provider_openai::{ParameterStyle, ResponseFormat, ThinkingMode}; for response_format in [ ResponseFormat::JsonSchema, ResponseFormat::JsonObject, ResponseFormat::JsonSchemaAtomicJson, ] { - for thinking in [ + for (thinking, parameter_style) in [ ThinkingMode::Default, ThinkingMode::Disabled, ThinkingMode::Enabled, ThinkingMode::ChatTemplateDisabled, - ] { + ] + .into_iter() + .flat_map(|thinking| { + [ParameterStyle::Compatible, ParameterStyle::OpenAi] + .map(|parameter_style| (thinking, parameter_style)) + }) { let options = RequestOptions { response_format, thinking, + parameter_style, }; let probe = compatibility_probe_config( "https://provider.example/v1", diff --git a/crates/xgen-cli/src/main.rs b/crates/xgen-cli/src/main.rs index 1f50340..9568ef7 100644 --- a/crates/xgen-cli/src/main.rs +++ b/crates/xgen-cli/src/main.rs @@ -19,7 +19,7 @@ use xgen_cli::{ resume_local_with_process_session_and_model_resolver_progress, run_interactive_with_process_session_progress, run_local_with_progress, }; -use xgen_provider_openai::{BearerCredential, ResponseFormat, ThinkingMode}; +use xgen_provider_openai::{BearerCredential, ParameterStyle, ResponseFormat, ThinkingMode}; use zeroize::Zeroizing; mod repl; @@ -381,6 +381,10 @@ struct RequestOptionArgs { /// Explicit provider thinking setting; default omits the provider-specific setting. #[arg(long, value_parser = ["default", "disabled", "enabled", "chat_template_disabled"])] thinking: Option, + /// Output-limit and sampling parameter names; `openai` sends `max_completion_tokens` and omits + /// `temperature`, as `OpenAI` reasoning models require. + #[arg(long, value_parser = ["compatible", "openai"])] + parameter_style: Option, } fn main() -> ExitCode { @@ -918,6 +922,7 @@ struct ProviderPreset { base_url: &'static str, response_format: &'static str, thinking: &'static str, + parameter_style: &'static str, } /// The endpoints setup can fill in for you, instead of asking you to type one. @@ -941,13 +946,20 @@ const PROVIDER_PRESETS: &[ProviderPreset] = &[ base_url: "https://api.deepseek.com/v1", response_format: "json_object", thinking: "disabled", + parameter_style: "compatible", }, ProviderPreset { id: "openai", label: "OpenAI", base_url: "https://api.openai.com/v1", - response_format: "json_schema", + // Not `json_schema`: OpenAI's strict structured outputs require a `type` on every schema + // node, and the proposal schema leaves Step `arguments` open, so every OpenAI model + // rejects the request before it is generated (HTTP 400, measured 2026-11-03). + response_format: "json_object", thinking: "default", + // Its reasoning models reject `max_tokens` and `temperature: 0`; its other chat models + // accept this spelling too, so one setting covers the whole catalog the picker lists. + parameter_style: "openai", }, ProviderPreset { id: "openrouter", @@ -958,6 +970,7 @@ const PROVIDER_PRESETS: &[ProviderPreset] = &[ // `json_object` is the contract they all accept. response_format: "json_object", thinking: "default", + parameter_style: "compatible", }, ProviderPreset { id: "glm", @@ -971,6 +984,7 @@ const PROVIDER_PRESETS: &[ProviderPreset] = &[ base_url: "https://api.z.ai/api/paas/v4", response_format: "json_object", thinking: "disabled", + parameter_style: "compatible", }, ProviderPreset { id: "local", @@ -985,6 +999,7 @@ const PROVIDER_PRESETS: &[ProviderPreset] = &[ base_url: "http://127.0.0.1:11434/v1", response_format: "json_object", thinking: "default", + parameter_style: "compatible", }, ]; @@ -1486,6 +1501,10 @@ fn model_setup(args: ModelSetupArgs) -> ExitCode { " thinking: {}", thinking_label(profile.request_options().thinking) ); + println!( + " parameters: {}", + parameter_style_label(profile.request_options().parameter_style) + ); println!( " inference limits: timeout={}s max_output_tokens={}", profile.inference_limits().timeout().as_secs(), @@ -1552,6 +1571,11 @@ fn try_model_setup(mut args: ModelSetupArgs) -> Result<(ModelProfile, bool), Mod { args.request_options.thinking = Some(preset.thinking.to_owned()); } + if args.request_options.parameter_style.is_none() + && read_environment("XGEN_OPENAI_PARAMETER_STYLE")?.is_none() + { + args.request_options.parameter_style = Some(preset.parameter_style.to_owned()); + } } let secret = resolve_setup_secret(&base_url, args.token_stdin, existing.as_ref(), interactive)?; if args.store_token && secret.value.is_none() { @@ -1673,7 +1697,7 @@ fn model_list() -> ExitCode { " " }; println!( - "{marker} {} model={} tokenizer={} timeout={}s max_output_tokens={} response_format={} thinking={} authentication={}", + "{marker} {} model={} tokenizer={} timeout={}s max_output_tokens={} response_format={} thinking={} parameter_style={} authentication={}", profile.name(), profile.model(), profile.tokenizer(), @@ -1681,6 +1705,7 @@ fn model_list() -> ExitCode { profile.inference_limits().max_output_tokens(), response_format_label(profile.request_options().response_format), thinking_label(profile.request_options().thinking), + parameter_style_label(profile.request_options().parameter_style), if profile.has_stored_credential() { "secure_store" } else { @@ -1952,9 +1977,20 @@ fn resolve_request_options( Some("chat_template_disabled") => ThinkingMode::ChatTemplateDisabled, Some(_) => return Err(ModelCliError::InvalidRequestOptions), }; + let parameter_style = match explicit + .parameter_style + .or(read_environment("XGEN_OPENAI_PARAMETER_STYLE")?) + .as_deref() + { + None => base.parameter_style, + Some("compatible") => ParameterStyle::Compatible, + Some("openai") => ParameterStyle::OpenAi, + Some(_) => return Err(ModelCliError::InvalidRequestOptions), + }; Ok(RequestOptions { response_format, thinking, + parameter_style, }) } @@ -1966,6 +2002,13 @@ const fn response_format_label(format: ResponseFormat) -> &'static str { } } +const fn parameter_style_label(style: ParameterStyle) -> &'static str { + match style { + ParameterStyle::Compatible => "compatible", + ParameterStyle::OpenAi => "openai", + } +} + const fn thinking_label(thinking: ThinkingMode) -> &'static str { match thinking { ThinkingMode::Default => "default", @@ -2381,6 +2424,7 @@ mod provider_preset_tests { RequestOptionArgs { response_format: Some(preset.response_format.to_owned()), thinking: Some(preset.thinking.to_owned()), + parameter_style: Some(preset.parameter_style.to_owned()), }, None, ) diff --git a/crates/xgen-cli/src/model_profile.rs b/crates/xgen-cli/src/model_profile.rs index 4e63c3f..f730f05 100644 --- a/crates/xgen-cli/src/model_profile.rs +++ b/crates/xgen-cli/src/model_profile.rs @@ -12,7 +12,7 @@ use keyring::{Entry, Error as KeyringError}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; -use xgen_provider_openai::{OpenAiPlannerConfig, ResponseFormat, ThinkingMode}; +use xgen_provider_openai::{OpenAiPlannerConfig, ParameterStyle, ResponseFormat, ThinkingMode}; use zeroize::Zeroizing; const PROFILE_FILE: &str = "model-profiles.json"; @@ -88,6 +88,7 @@ impl Default for InferenceLimits { pub struct RequestOptions { pub response_format: ResponseFormat, pub thinking: ThinkingMode, + pub parameter_style: ParameterStyle, } /// One non-secret OpenAI-compatible model profile. @@ -234,6 +235,7 @@ impl ModelProfile { .and_then(|config| config.with_max_output_tokens(self.inference_limits.max_output_tokens)) .and_then(|config| config.with_response_format(self.request_options.response_format)) .and_then(|config| config.with_thinking(self.request_options.thinking)) + .and_then(|config| config.with_parameter_style(self.request_options.parameter_style)) .map(|_| ()) .map_err(|_| ModelProfileError::InvalidProfile) } @@ -752,6 +754,15 @@ struct StoredProfile { response_format: ResponseFormat, #[serde(default)] thinking: ThinkingMode, + // Written only when not the default, so files that never chose a style stay byte-identical + // and remain readable by earlier binaries, which reject unknown fields. + #[serde(default, skip_serializing_if = "is_compatible_parameter_style")] + parameter_style: ParameterStyle, +} + +#[allow(clippy::trivially_copy_pass_by_ref)] // Serde skip predicates require a reference. +fn is_compatible_parameter_style(style: &ParameterStyle) -> bool { + *style == ParameterStyle::Compatible } fn default_inference_timeout_seconds() -> u64 { @@ -774,6 +785,7 @@ impl StoredProfile { max_output_tokens: profile.inference_limits.max_output_tokens, response_format: profile.request_options.response_format, thinking: profile.request_options.thinking, + parameter_style: profile.request_options.parameter_style, } } @@ -793,6 +805,7 @@ impl StoredProfile { request_options: RequestOptions { response_format: self.response_format, thinking: self.thinking, + parameter_style: self.parameter_style, }, }; profile @@ -1176,24 +1189,53 @@ mod tests { ThinkingMode::Enabled, ThinkingMode::ChatTemplateDisabled, ] { - let mut original = profile("wire"); - let options = RequestOptions { - response_format, - thinking, - }; - original.set_request_options(options).unwrap(); - let encoded = serde_json::to_value(StoredProfile::from_profile(&original)).unwrap(); - let decoded: StoredProfile = serde_json::from_value(encoded.clone()).unwrap(); - assert_eq!(decoded.into_profile().unwrap().request_options(), options); - for field in ["responseFormat", "thinking"] { - let mut malformed = encoded.clone(); - malformed[field] = serde_json::json!("auto-detect"); - assert!(serde_json::from_value::(malformed).is_err()); + for parameter_style in [ParameterStyle::Compatible, ParameterStyle::OpenAi] { + let mut original = profile("wire"); + let options = RequestOptions { + response_format, + thinking, + parameter_style, + }; + original.set_request_options(options).unwrap(); + let encoded = + serde_json::to_value(StoredProfile::from_profile(&original)).unwrap(); + let decoded: StoredProfile = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(decoded.into_profile().unwrap().request_options(), options); + for field in ["responseFormat", "thinking", "parameterStyle"] { + let mut malformed = encoded.clone(); + malformed[field] = serde_json::json!("auto-detect"); + assert!(serde_json::from_value::(malformed).is_err()); + } } } } } + #[test] + fn parameter_style_is_written_only_when_chosen() { + let plain = serde_json::to_value(StoredProfile::from_profile(&profile("plain"))).unwrap(); + assert!(plain.get("parameterStyle").is_none()); + let decoded: StoredProfile = serde_json::from_value(plain).unwrap(); + assert_eq!( + decoded + .into_profile() + .unwrap() + .request_options() + .parameter_style, + ParameterStyle::Compatible + ); + + let mut chosen = profile("openai"); + chosen + .set_request_options(RequestOptions { + parameter_style: ParameterStyle::OpenAi, + ..RequestOptions::default() + }) + .unwrap(); + let encoded = serde_json::to_value(StoredProfile::from_profile(&chosen)).unwrap(); + assert_eq!(encoded["parameterStyle"], "openai"); + } + #[test] fn inference_limits_round_trip_and_legacy_files_load_with_defaults() { let directory = tempdir().unwrap(); diff --git a/crates/xgen-cli/tests/model_profiles.rs b/crates/xgen-cli/tests/model_profiles.rs index 3d77290..053474f 100644 --- a/crates/xgen-cli/tests/model_profiles.rs +++ b/crates/xgen-cli/tests/model_profiles.rs @@ -109,11 +109,25 @@ impl ModelServer { #[test] fn provider_presets_are_saved_and_explicit_options_still_win() { // Two presets plus a held-out environment override; no live provider credentials needed. - for (provider, explicit, env_override, format, thinking) in [ - ("deepseek", false, false, "json_object", "disabled"), - ("openai", false, false, "json_schema", "default"), - ("deepseek", true, false, "json_schema", "enabled"), - ("openai", false, true, "json_object", "disabled"), + for (provider, explicit, env_override, format, thinking, style) in [ + ( + "deepseek", + false, + false, + "json_object", + "disabled", + "compatible", + ), + ("openai", false, false, "json_object", "default", "openai"), + ("deepseek", true, false, "json_schema", "enabled", "openai"), + ( + "openai", + false, + true, + "json_schema", + "disabled", + "compatible", + ), ] { let fixture = tempdir().unwrap(); let config = fixture.path().join("config"); @@ -131,12 +145,20 @@ fn provider_presets_are_saved_and_explicit_options_still_win() { MODEL, ]); if explicit { - command.args(["--response-format", format, "--thinking", thinking]); + command.args([ + "--response-format", + format, + "--thinking", + thinking, + "--parameter-style", + style, + ]); } if env_override { command .env("XGEN_OPENAI_RESPONSE_FORMAT", format) - .env("XGEN_OPENAI_THINKING", thinking); + .env("XGEN_OPENAI_THINKING", thinking) + .env("XGEN_OPENAI_PARAMETER_STYLE", style); } let output = command.output().unwrap(); assert_success(&output); @@ -144,6 +166,12 @@ fn provider_presets_are_saved_and_explicit_options_still_win() { serde_json::from_slice(&fs::read(config.join("model-profiles.json")).unwrap()).unwrap(); assert_eq!(profiles["profiles"][0]["responseFormat"], format); assert_eq!(profiles["profiles"][0]["thinking"], thinking); + if style == "compatible" { + // The default is never written, so files stay readable by earlier binaries. + assert!(profiles["profiles"][0].get("parameterStyle").is_none()); + } else { + assert_eq!(profiles["profiles"][0]["parameterStyle"], style); + } assert!(!state.exists()); let requests = server.handle.join().unwrap(); let body = request_body(&requests[1]); @@ -153,6 +181,15 @@ fn provider_presets_are_saved_and_explicit_options_still_win() { } else { assert_eq!(body["thinking"]["type"], thinking); } + if style == "openai" { + assert!(body.get("max_completion_tokens").is_some()); + assert!(body.get("max_tokens").is_none()); + assert!(body.get("temperature").is_none()); + } else { + assert!(body.get("max_tokens").is_some()); + assert!(body.get("max_completion_tokens").is_none()); + assert_eq!(body.get("temperature").is_some(), thinking != "enabled"); + } } } diff --git a/crates/xgen-provider-openai/src/lib.rs b/crates/xgen-provider-openai/src/lib.rs index 6a83fb3..421c6dc 100644 --- a/crates/xgen-provider-openai/src/lib.rs +++ b/crates/xgen-provider-openai/src/lib.rs @@ -136,6 +136,21 @@ pub enum ThinkingMode { ChatTemplateDisabled, } +/// Explicit sampling and output-limit parameter names, never inferred from a model ID. +/// +/// `OpenAI` reasoning models reject `max_tokens` and any non-default `temperature`, while its +/// other chat models accept the replacement spelling too; compatible servers keep the original. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ParameterStyle { + /// Send `max_tokens` and the deterministic `temperature` (existing request semantics). + #[default] + #[serde(rename = "compatible")] + Compatible, + /// Send `max_completion_tokens` and omit `temperature` (current `OpenAI` Chat Completions). + #[serde(rename = "openai")] + OpenAi, +} + /// A bearer credential retained only as a sensitive HTTP header value. #[derive(Clone)] pub struct BearerCredential(HeaderValue); @@ -184,6 +199,7 @@ pub struct OpenAiPlannerConfig { completion_validator: Option, response_format: ResponseFormat, thinking: ThinkingMode, + parameter_style: ParameterStyle, planning_constraints_required: bool, conversation_responses: bool, evaluation_step_limit: Option, @@ -234,6 +250,7 @@ impl OpenAiPlannerConfig { completion_validator: None, response_format: ResponseFormat::default(), thinking: ThinkingMode::default(), + parameter_style: ParameterStyle::default(), planning_constraints_required: false, conversation_responses: false, evaluation_step_limit: None, @@ -410,6 +427,25 @@ impl OpenAiPlannerConfig { self.thinking } + /// Return a copy with explicit sampling/output-limit parameter names; no model inference. + /// + /// # Errors + /// Returns an error if the updated request-profile digest cannot be constructed. + pub fn with_parameter_style( + mut self, + parameter_style: ParameterStyle, + ) -> Result { + self.parameter_style = parameter_style; + self.refresh_profile_digest()?; + Ok(self) + } + + /// The committed sampling/output-limit parameter names. + #[must_use] + pub const fn parameter_style(&self) -> ParameterStyle { + self.parameter_style + } + /// Return a copy with a different bounded completion-token limit. /// /// # Errors @@ -509,6 +545,8 @@ impl OpenAiPlannerConfig { thinking: self.thinking_request(), chat_template_kwargs: self.chat_template_request(), reasoning_effort: self.reasoning_effort(), + // Absent for the compatible style so existing profiles keep their digest. + parameter_style: (self.parameter_style == ParameterStyle::OpenAi).then_some("openai"), max_output_tokens: self.max_output_tokens, timeout_seconds: self.timeout.as_secs(), timeout_subsec_nanos: self.timeout.subsec_nanos(), @@ -592,7 +630,16 @@ impl OpenAiPlannerConfig { } fn temperature(&self) -> Option { - (self.thinking != ThinkingMode::Enabled).then_some(0) + (self.thinking != ThinkingMode::Enabled + && self.parameter_style == ParameterStyle::Compatible) + .then_some(0) + } + + const fn output_token_limits(&self) -> (Option, Option) { + match self.parameter_style { + ParameterStyle::Compatible => (Some(self.max_output_tokens), None), + ParameterStyle::OpenAi => (None, Some(self.max_output_tokens)), + } } fn thinking_request(&self) -> Option { @@ -618,6 +665,7 @@ impl OpenAiPlannerConfig { } fn chat_request<'a>(&'a self, system: &'a str, user: &'a str) -> ChatCompletionRequest<'a> { + let (max_tokens, max_completion_tokens) = self.output_token_limits(); ChatCompletionRequest { model: &self.model, messages: [ @@ -632,7 +680,8 @@ impl OpenAiPlannerConfig { ], temperature: self.temperature(), seed: self.seed(), - max_tokens: self.max_output_tokens, + max_tokens, + max_completion_tokens, stream: false, n: 1, response_format: match self.response_format { @@ -706,6 +755,7 @@ impl fmt::Debug for OpenAiPlannerConfig { .field("completion_validator", &self.completion_validator.is_some()) .field("response_format", &self.response_format) .field("thinking", &self.thinking) + .field("parameter_style", &self.parameter_style) .field( "planning_constraints_required", &self.planning_constraints_required, @@ -1167,6 +1217,8 @@ struct RequestProfileDescriptor<'a> { chat_template_kwargs: Option, #[serde(skip_serializing_if = "Option::is_none")] reasoning_effort: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + parameter_style: Option<&'static str>, max_output_tokens: u32, timeout_seconds: u64, timeout_subsec_nanos: u32, @@ -1198,7 +1250,10 @@ struct ChatCompletionRequest<'a> { temperature: Option, #[serde(skip_serializing_if = "Option::is_none")] seed: Option, - max_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_completion_tokens: Option, stream: bool, n: u8, response_format: ResponseFormatRequest<'a>, @@ -2499,6 +2554,78 @@ mod tests { ); } + #[test] + fn openai_parameter_style_renames_the_limit_and_omits_temperature() { + let compatible = config("https://provider.example/v1"); + let openai = config("https://provider.example/v1") + .with_parameter_style(ParameterStyle::OpenAi) + .unwrap(); + let body = request_body(&openai); + assert_eq!(body["max_completion_tokens"], DEFAULT_MAX_OUTPUT_TOKENS); + assert!(body.get("max_tokens").is_none()); + assert!(body.get("temperature").is_none()); + // Seed and the strict schema are accepted by OpenAI reasoning models; keep them. + assert_eq!(body["seed"], 0); + assert_eq!(body["response_format"]["type"], "json_schema"); + assert!(body.get("reasoning_effort").is_none()); + assert!(body.get("thinking").is_none()); + + let legacy = request_body(&compatible); + assert_eq!(legacy["max_tokens"], DEFAULT_MAX_OUTPUT_TOKENS); + assert!(legacy.get("max_completion_tokens").is_none()); + assert_eq!(legacy["temperature"], 0); + + assert_ne!( + openai.request_profile_digest(), + compatible.request_profile_digest() + ); + let reset = openai + .with_parameter_style(ParameterStyle::Compatible) + .unwrap(); + assert_eq!( + reset.request_profile_digest(), + compatible.request_profile_digest() + ); + + let limited = config("https://provider.example/v1") + .with_max_output_tokens(2_048) + .unwrap() + .with_parameter_style(ParameterStyle::OpenAi) + .unwrap(); + assert_eq!(request_body(&limited)["max_completion_tokens"], 2_048); + + // The style composes with the other explicit dialects instead of replacing them. + let object = config("https://provider.example/v1") + .with_response_format(ResponseFormat::JsonObject) + .unwrap() + .with_parameter_style(ParameterStyle::OpenAi) + .unwrap(); + let object_body = request_body(&object); + assert_eq!( + object_body["response_format"], + json!({"type":"json_object"}) + ); + assert!(object_body.get("seed").is_none()); + assert!(object_body.get("temperature").is_none()); + assert_eq!( + object_body["max_completion_tokens"], + DEFAULT_MAX_OUTPUT_TOKENS + ); + } + + #[test] + fn parameter_style_has_stable_wire_names() { + assert_eq!( + serde_json::to_value(ParameterStyle::Compatible).unwrap(), + "compatible" + ); + assert_eq!( + serde_json::to_value(ParameterStyle::OpenAi).unwrap(), + "openai" + ); + assert!(serde_json::from_value::(json!("open_ai")).is_err()); + } + #[test] fn chat_template_switch_is_explicit_and_profile_bound() { let base = config("https://provider.example/v1"); diff --git a/docs/adr/0086-openai-parameter-style.md b/docs/adr/0086-openai-parameter-style.md new file mode 100644 index 0000000..301c291 --- /dev/null +++ b/docs/adr/0086-openai-parameter-style.md @@ -0,0 +1,71 @@ +# ADR-0086: OpenAI 출력 한도·sampling 파라미터 이름을 명시적 wire 옵션으로 고른다 + +- 날짜: 2026-11-03 +- 상태: 채택, 구현·실제 OpenAI 검증 완료 +- 범위: OpenAI-compatible adapter, CLI profile·preset. ADR-0042/0043의 명시적 wire 옵션 원칙을 따른다. + +## 문제 + +OpenAI preset으로 `xgen model setup`을 하면 OpenAI의 어떤 model도 통과하지 못했다. 2026-11-03에 +api.openai.com으로 직접 요청을 나눠 보내 세 가지 원인을 확인했다. + +1. 추론 model(gpt-5.5, gpt-6-luna)은 `max_tokens`를 `Unsupported parameter`로 거절하고 + `max_completion_tokens`를 요구한다. `temperature: 0`도 거절한다(기본값 1만 허용). `seed`는 받는다. + gpt-4.1은 `max_completion_tokens`와 temperature 생략을 모두 받는다. +2. preset의 `json_schema`는 OpenAI strict structured outputs에서 모든 model이 400으로 거절한다. + strict 모드는 모든 schema node에 `type`을 요구하는데 proposal schema의 Step `arguments`는 + 열려 있다. `json_schema_atomic_json`도 `arguments.jsonContent`에서 `additionalProperties: false` + 요구로 거절된다. +3. 별칭 model(`gpt-4.1`, `gpt-5.5`)은 응답 `model`에 날짜 붙은 snapshot ID + (`gpt-4.1-2025-04-14`, `gpt-5.5-2026-04-23`)를 돌려주므로 정확한 model identity 검사에서 닫힌다. + +기존 `--thinking enabled`는 temperature/seed를 빼지만 `thinking` 객체를 보내고 OpenAI는 이를 +`Unknown parameter`로 거절한다. 따라서 기존 옵션 조합으로는 OpenAI 추론 model을 쓸 수 없다. + +## 결정 + +`model setup`, `model check`, `run`, `resume`에 `--parameter-style compatible|openai`를 더한다. +환경변수는 `XGEN_OPENAI_PARAMETER_STYLE`, profile 필드는 `parameterStyle`이다. 해석 순서는 다른 wire +옵션과 같다(명시 option, 환경변수, profile, 기본값). 잘못된 값은 fail-closed다. + +- `compatible`(기본): 기존 그대로 `max_tokens`와 `temperature: 0`을 보낸다(thinking 규칙 유지). +- `openai`: 같은 출력 한도를 `max_completion_tokens`로 보내고 `temperature`를 생략한다. `seed`, + `response_format`, thinking 확장 규칙은 바꾸지 않는다. + +model ID나 hostname으로 style을 추측하지 않는다. 거절 뒤 다른 style로 자동 재시도하지 않는다. +OpenAI preset만 `openai` style과 `json_object`를 기본값으로 가진다. `json_object`는 host-validated이며 +server가 schema를 강제한다는 주장은 하지 않는다. 다른 preset은 바뀌지 않는다. + +gpt-4.1 같은 비추론 model도 이 preset에서는 temperature 기본값(1)으로 동작한다. temperature 0이 +필요하면 `--parameter-style compatible`을 명시한다. + +별칭 model의 identity 검사는 완화하지 않는다. 별칭이 가리키는 snapshot이 Run 도중 바뀌는 것을 막는 +검사이기 때문이다. 사용자는 catalog에 함께 나오는 날짜 붙은 snapshot ID를 선택한다. + +## 재개와 저장 형식 + +style은 request profile digest에 들어간다. `compatible`일 때는 descriptor에 필드를 넣지 않으므로 +기존 profile과 Run의 digest가 그대로다(기본 digest 상수 테스트 유지). `openai`로 바꾼 profile로 +기존 Run을 재개하면 `configuration_mismatch`로 닫힌다. + +`StoredProfile`은 `deny_unknown_fields`라서 새 필드는 `openai`일 때만 파일에 쓴다. style을 고르지 +않은 profile 파일은 바이트 그대로이며 이전 binary도 읽는다. `openai` profile이 든 파일은 이전 binary가 +읽지 못한다. + +## 검증 + +provider 단위 테스트: openai style의 `max_completion_tokens`·temperature 생략·seed 유지, json_object와의 +조합, digest 차이와 compatible 복귀 시 원래 digest, wire 이름(`compatible`/`openai`). CLI 테스트: +profile round trip, 잘못된 값 거절, compatible profile의 필드 미기록, preset 해석, probe/planner digest +일치. + +실제 OpenAI(2026-11-03, 격리된 config/state, 키 미저장): + +- `--provider openai` setup PASS: gpt-6-luna, gpt-5.5-2026-04-23, gpt-4.1-2025-04-14. + 별칭 gpt-5.5는 위 3번 원인으로 `chat_completions_incompatible`. +- `xgen run` 파일 수정(add 버그) 3/3 완료(4~14초). 테스트 파일 생성+README 수정 2/2 완료 + (gpt-6-luna 5 call 15초, gpt-5.5 snapshot 7 call 33초), 생성된 pytest 4 passed. 출력 한도 1024에서 + 잘린 call은 없었다. + +거절 이유가 `request_rejected` 한 줄로만 보여 원인을 알 수 없었던 문제와 별칭 model 안내는 이 ADR의 +범위가 아니며 후속 작업으로 남긴다. diff --git a/docs/getting-started.md b/docs/getting-started.md index ad43295..93a4bf6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -175,7 +175,7 @@ xgen model check --compatibility | id | provider | base URL | 검증 | | --- | --- | --- | --- | | `deepseek` | DeepSeek | `https://api.deepseek.com/v1` | 실제 호출로 확인 | -| `openai` | OpenAI | `https://api.openai.com/v1` | 미확인, provider 문서 기준 | +| `openai` | OpenAI | `https://api.openai.com/v1` | 실제 호출로 확인(gpt-6-luna, gpt-5.5-2026-04-23, gpt-4.1-2025-04-14). 별칭 model은 날짜 붙은 snapshot ID를 고른다 | | `openrouter` | OpenRouter | `https://openrouter.ai/api/v1` | 미확인, provider 문서 기준 | | `glm` | GLM / Zhipu (z.ai) | `https://api.z.ai/api/paas/v4` | 미확인, provider 문서 기준. Coding Plan 계정은 `https://api.z.ai/api/coding/paas/v4`이므로 `--base-url`로 지정한다 | | `local` | 로컬 model (Ollama 기본 port) | `http://127.0.0.1:11434/v1` | loopback fixture로 확인 | @@ -187,6 +187,16 @@ preset에 없다. Claude의 OpenAI 호환 layer는 `response_format`을 무시 preset으로 넣으면 설정은 깨끗하게 끝나고 매 턴이 깨진다. 이 설정을 지키는 gateway(예: OpenRouter) 를 통하면 사용할 수 있다. +OpenAI preset은 `json_object`와 `--parameter-style openai`를 쓴다. OpenAI 추론 model은 `max_tokens`와 +`temperature: 0`을 거절하므로 이 style은 출력 한도를 `max_completion_tokens`로 보내고 `temperature`를 +생략한다. 다른 provider는 기본값 `compatible`(기존 `max_tokens`, `temperature: 0`)을 그대로 쓴다. +OpenAI의 별칭 model(`gpt-5.5` 등)은 응답에 날짜 붙은 snapshot ID를 돌려주므로 model 일치 검사에서 +닫힌다. catalog에 함께 나오는 `gpt-5.5-2026-04-23` 같은 snapshot ID를 고른다. + +```bash +xgen model setup --provider openai --model gpt-5.5-2026-04-23 +``` + 인증 없는 loopback vLLM을 non-interactive하게 지정할 수도 있다. Tokenizer를 생략하면 model ID를 tokenizer/profile identity로 사용한다.