Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion MEMORY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# xgen CLI와 연계 도구 공동 개발 메모

갱신: 2026-10-31 (KST)
갱신: 2026-11-03 (KST)

## 사용자가 정한 방향

Expand Down Expand Up @@ -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 한 줄뿐이라 사용자가 원인을 알 수 없다.
1 change: 1 addition & 0 deletions crates/xgen-cli/examples/planning_model_pilot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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;
Expand Down
14 changes: 11 additions & 3 deletions crates/xgen-cli/src/composition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down
50 changes: 47 additions & 3 deletions crates/xgen-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String>,
/// 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<String>,
}

fn main() -> ExitCode {
Expand Down Expand Up @@ -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.
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
},
];

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -1673,14 +1697,15 @@ 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(),
profile.inference_limits().timeout().as_secs(),
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 {
Expand Down Expand Up @@ -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,
})
}

Expand All @@ -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",
Expand Down Expand Up @@ -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,
)
Expand Down
70 changes: 56 additions & 14 deletions crates/xgen-cli/src/model_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
}
}

Expand All @@ -793,6 +805,7 @@ impl StoredProfile {
request_options: RequestOptions {
response_format: self.response_format,
thinking: self.thinking,
parameter_style: self.parameter_style,
},
};
profile
Expand Down Expand Up @@ -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::<StoredProfile>(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::<StoredProfile>(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();
Expand Down
Loading