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로 사용한다.