diff --git a/crates/xgeny-cli/src/composition.rs b/crates/xgeny-cli/src/composition.rs index 0630ff7..fd3166c 100644 --- a/crates/xgeny-cli/src/composition.rs +++ b/crates/xgeny-cli/src/composition.rs @@ -69,6 +69,7 @@ use crate::material_catalog::{ WORKSPACE_READ_MATERIAL_PROVIDER_ID, WORKSPACE_READ_RECIPE_DOMAIN, WORKSPACE_READ_RECIPE_FORMAT_VERSION, WorkspaceReadMaterialProvider, WorkspaceReadMaterializer, }; +use crate::model_profile::InferenceLimits; use crate::run_layout::{RunLayout, discover_state_root, generate_run_id}; const WORKSPACE_ID: &str = "primary"; @@ -76,8 +77,6 @@ const WORKSPACE_IDENTITY_PROFILE: &str = "xgeny.fs.workspace-root-identity.v1"; const DEFAULT_PLANNER_ID: &str = "xgeny.cli.openai"; const MAX_GOAL_BYTES: usize = 16 * 1024; const MAX_TICKS: u32 = 1_024; -const MAX_OUTPUT_TOKENS: u32 = 1_024; -const MODEL_TIMEOUT: Duration = Duration::from_secs(60); const MODEL_CHECK_TIMEOUT: Duration = Duration::from_secs(10); const LOCAL_EXECUTION_PROFILE_DOMAIN: &str = "xgeny.cli.local-execution-profile/v1"; const WORKSPACE_DISCOVERY_PROFILE_DOMAIN: &str = @@ -111,6 +110,7 @@ pub struct LocalRunRequest { pub model: String, pub tokenizer: String, pub credential: Option, + pub inference_limits: InferenceLimits, pub allow_files: Vec, pub allow_dirs: Vec, pub allow_executables: Vec, @@ -139,6 +139,7 @@ impl LocalRunRequest { model, tokenizer, credential: None, + inference_limits: InferenceLimits::default(), allow_files, allow_dirs: Vec::new(), allow_executables: Vec::new(), @@ -158,6 +159,7 @@ pub struct LocalResumeRequest { pub workspace: Option, pub base_url: Option, pub credential: Option, + pub inference_limits: InferenceLimits, pub allow_files: Vec, pub allow_dirs: Vec, pub allow_executables: Vec, @@ -224,6 +226,7 @@ pub struct ModelCheckRequest { pub model: String, pub tokenizer: String, pub credential: Option, + pub inference_limits: InferenceLimits, } /// Stable, redacted failure classes returned by `xgeny model check`. @@ -331,6 +334,7 @@ pub fn check_openai_model(request: ModelCheckRequest) -> Result<(), ModelCheckEr model, tokenizer, credential, + inference_limits: _, } = request; let config = OpenAiPlannerConfig::new(&base_url, DEFAULT_PLANNER_ID, &model, &tokenizer) .and_then(|config| config.with_timeout(MODEL_CHECK_TIMEOUT)) @@ -352,6 +356,7 @@ pub fn list_openai_models(request: ModelCheckRequest) -> Result, Mod model, tokenizer, credential, + inference_limits: _, } = request; let config = OpenAiPlannerConfig::new(&base_url, DEFAULT_PLANNER_ID, &model, &tokenizer) .and_then(|config| config.with_timeout(MODEL_CHECK_TIMEOUT)) @@ -373,8 +378,9 @@ pub fn check_openai_compatibility(request: ModelCheckRequest) -> Result<(), Mode model, tokenizer, credential, + inference_limits, } = request; - let config = compatibility_probe_config(&base_url, &model, &tokenizer)?; + let config = compatibility_probe_config(&base_url, &model, &tokenizer, inference_limits)?; OpenAiCompatibilityChecker::new(config, credential) .map_err(map_model_check_config)? .check() @@ -391,10 +397,11 @@ fn compatibility_probe_config( base_url: &str, model: &str, tokenizer: &str, + limits: InferenceLimits, ) -> Result { OpenAiPlannerConfig::new(base_url, DEFAULT_PLANNER_ID, model, tokenizer) - .and_then(|config| config.with_max_output_tokens(MAX_OUTPUT_TOKENS)) - .and_then(|config| config.with_timeout(MODEL_TIMEOUT)) + .and_then(|config| config.with_max_output_tokens(limits.max_output_tokens())) + .and_then(|config| config.with_timeout(limits.timeout())) .map_err(map_model_check_config) } @@ -709,6 +716,7 @@ where &request.planner_id, &request.model, &request.tokenizer, + request.inference_limits, planning_constraints_required, )?; let local_execution_profile_digest = @@ -963,6 +971,7 @@ where manifest.planner_id(), manifest.model(), manifest.tokenizer(), + request.inference_limits, catalog.workspace_discovery() || process.is_some(), )?; if manifest.request_profile_digest() != config.request_profile_digest() { @@ -1508,11 +1517,12 @@ fn planner_config( planner_id: &str, model: &str, tokenizer: &str, + limits: InferenceLimits, planning_constraints_required: bool, ) -> Result { let config = OpenAiPlannerConfig::new(base_url, planner_id, model, tokenizer) - .and_then(|config| config.with_max_output_tokens(MAX_OUTPUT_TOKENS)) - .and_then(|config| config.with_timeout(MODEL_TIMEOUT)) + .and_then(|config| config.with_max_output_tokens(limits.max_output_tokens())) + .and_then(|config| config.with_timeout(limits.timeout())) .map_err(map_provider_config)?; if planning_constraints_required { config @@ -2471,15 +2481,75 @@ mod tests { assert_ne!(error.code(), ModelCheckError::InvalidResponse.code()); } + #[test] + fn inference_limits_default_to_the_measured_local_budget() { + let limits = InferenceLimits::default(); + assert_eq!(limits.timeout(), Duration::from_secs(300)); + assert_eq!(limits.max_output_tokens(), 1_024); + } + + #[test] + fn inference_limits_reject_values_outside_the_provider_bounds() { + assert!(InferenceLimits::new(Duration::from_secs(0), 1_024).is_err()); + assert!(InferenceLimits::new(Duration::from_secs(3_601), 1_024).is_err()); + assert!(InferenceLimits::new(Duration::from_secs(300), 0).is_err()); + assert!(InferenceLimits::new(Duration::from_secs(300), 65_537).is_err()); + assert!(InferenceLimits::new(Duration::from_secs(1), 1).is_ok()); + assert!(InferenceLimits::new(Duration::from_secs(3_600), 65_536).is_ok()); + } + + #[test] + fn probe_and_planner_follow_the_profile_limits_and_stay_digest_identical() { + let limits = InferenceLimits::new(Duration::from_secs(300), 2_048).unwrap(); + let probe = + compatibility_probe_config("http://127.0.0.1:1/v1", "model", "tokenizer", limits) + .unwrap(); + let production = planner_config( + "http://127.0.0.1:1/v1", + DEFAULT_PLANNER_ID, + "model", + "tokenizer", + limits, + false, + ) + .unwrap(); + assert_eq!( + probe.request_profile_digest(), + production.request_profile_digest() + ); + + let other = InferenceLimits::new(Duration::from_secs(60), 2_048).unwrap(); + let shorter = planner_config( + "http://127.0.0.1:1/v1", + DEFAULT_PLANNER_ID, + "model", + "tokenizer", + other, + false, + ) + .unwrap(); + assert_ne!( + production.request_profile_digest(), + shorter.request_profile_digest(), + "timeout is a committed request-profile input, so a different limit is a different profile" + ); + } + #[test] fn compatibility_probe_config_matches_the_production_planner_profile() { - let probe = compatibility_probe_config("http://127.0.0.1:1/v1", "model", "tokenizer") - .expect("probe config should validate"); + let probe = compatibility_probe_config( + "http://127.0.0.1:1/v1", + "model", + "tokenizer", + InferenceLimits::default(), + ) + .expect("probe config should validate"); let production = planner_config( "http://127.0.0.1:1/v1", DEFAULT_PLANNER_ID, "model", "tokenizer", + InferenceLimits::default(), false, ) .expect("production config should validate"); @@ -2586,6 +2656,7 @@ mod tests { model: "model".to_owned(), tokenizer: "tokenizer".to_owned(), credential: None, + inference_limits: InferenceLimits::default(), allow_files: vec!["README.md".to_owned()], allow_dirs: Vec::new(), allow_executables: Vec::new(), @@ -2639,6 +2710,7 @@ mod tests { model: "model".to_owned(), tokenizer: "tokenizer".to_owned(), credential: None, + inference_limits: InferenceLimits::default(), allow_files: Vec::new(), allow_dirs: vec![".".to_owned()], allow_executables: vec![specification], diff --git a/crates/xgeny-cli/src/main.rs b/crates/xgeny-cli/src/main.rs index 26a8961..20549b0 100644 --- a/crates/xgeny-cli/src/main.rs +++ b/crates/xgeny-cli/src/main.rs @@ -2,16 +2,17 @@ use std::env; use std::io::{BufRead as _, ErrorKind, IsTerminal as _, Read as _, Write as _}; use std::path::PathBuf; use std::process::ExitCode; +use std::time::Duration; use clap::{ArgGroup, Args, Parser, Subcommand}; use url::Url; use xgeny_cli::{ - DriverProgress, DriverProgressControl, LocalCommandResult, LocalProcessSession, - LocalResumeRequest, LocalRunRequest, ModelCheckError, ModelCheckRequest, ModelCredentialStore, - ModelProfile, ModelProfileError, ModelProfileStore, OsModelCredentialStore, PublicRunError, - check_openai_compatibility, check_openai_model, list_openai_models, new_credential_reference, - prepare_local_process_session, resume_local, resume_local_with_model_resolver, - resume_local_with_model_resolver_and_progress, + DriverProgress, DriverProgressControl, InferenceLimits, LocalCommandResult, + LocalProcessSession, LocalResumeRequest, LocalRunRequest, ModelCheckError, ModelCheckRequest, + ModelCredentialStore, ModelProfile, ModelProfileError, ModelProfileStore, + OsModelCredentialStore, PublicRunError, check_openai_compatibility, check_openai_model, + list_openai_models, new_credential_reference, prepare_local_process_session, resume_local, + resume_local_with_model_resolver, resume_local_with_model_resolver_and_progress, resume_local_with_process_session_and_model_resolver_progress, run_local_with_process_session_progress, run_local_with_started, }; @@ -82,7 +83,7 @@ enum ModelCommand { #[derive(Debug, Args)] #[command( - after_long_help = "Resolution order: explicit options, XGENY_OPENAI_BASE_URL / XGENY_OPENAI_MODEL / XGENY_OPENAI_TOKENIZER environment, then the selected/active profile. HTTPS authentication uses --token-stdin, XGENY_OPENAI_API_KEY, then the profile secure store; no token value is accepted as a command argument." + after_long_help = "Resolution order: explicit options, XGENY_OPENAI_BASE_URL / XGENY_OPENAI_MODEL / XGENY_OPENAI_TOKENIZER environment, then the selected/active profile. Planner inference limits follow XGENY_OPENAI_INFERENCE_TIMEOUT / XGENY_OPENAI_MAX_OUTPUT_TOKENS, then the profile (default 300s / 1024 tokens). HTTPS authentication uses --token-stdin, XGENY_OPENAI_API_KEY, then the profile secure store; no token value is accepted as a command argument." )] struct ModelCheckArgs { /// OpenAI-compatible API base URL ending in /v1. @@ -128,6 +129,12 @@ struct ModelSetupArgs { /// Persist the supplied stdin/environment token in the platform secure store. #[arg(long)] store_token: bool, + /// Planner inference wall-clock budget in seconds (1..=3600); defaults to the profile value or 300. + #[arg(long, value_name = "SECONDS")] + inference_timeout: Option, + /// Planner output token budget (1..=65536); defaults to the profile value or 1024. + #[arg(long, value_name = "TOKENS")] + max_output_tokens: Option, } #[derive(Debug, Args)] @@ -151,7 +158,7 @@ struct ModelOptionalNameArgs { .multiple(true) .args(["allow_files", "allow_dirs"]) ), - after_long_help = "Resolution order: explicit options, XGENY_OPENAI_BASE_URL / XGENY_OPENAI_MODEL / XGENY_OPENAI_TOKENIZER environment, then the selected/active profile. HTTPS authentication uses --token-stdin, XGENY_OPENAI_API_KEY, then the profile secure store. Credentials are ignored for loopback HTTP and cannot be passed as a command-line value." + after_long_help = "Resolution order: explicit options, XGENY_OPENAI_BASE_URL / XGENY_OPENAI_MODEL / XGENY_OPENAI_TOKENIZER environment, then the selected/active profile. Planner inference limits follow XGENY_OPENAI_INFERENCE_TIMEOUT / XGENY_OPENAI_MAX_OUTPUT_TOKENS, then the profile (default 300s / 1024 tokens). HTTPS authentication uses --token-stdin, XGENY_OPENAI_API_KEY, then the profile secure store. Credentials are ignored for loopback HTTP and cannot be passed as a command-line value." )] struct RunArgs { /// Goal sent to the bounded planner. @@ -366,6 +373,7 @@ impl repl::ReplHost for InteractiveHost { model: model.model, tokenizer: model.tokenizer, credential: model.credential, + inference_limits: model.inference_limits, allow_files: Vec::new(), allow_dirs: vec![".".to_owned()], allow_executables: Vec::new(), @@ -394,6 +402,8 @@ impl repl::ReplHost for InteractiveHost { workspace: Some(self.workspace.clone()), base_url: None, credential: None, + inference_limits: resolve_inference_limits(None, None, None) + .map_err(|error| repl::ReplFailure::new(error.code()))?, allow_files: Vec::new(), allow_dirs: vec![".".to_owned()], allow_executables: if process_session.is_some() { @@ -481,6 +491,8 @@ fn ensure_interactive_model() -> Result<(), ModelCliError> { tokenizer: None, token_stdin: false, store_token: false, + inference_timeout: None, + max_output_tokens: None, })?; println!("XGENy model setup: PASS"); println!(" profile: {}", profile.name()); @@ -539,6 +551,7 @@ fn run_command(args: RunArgs) -> ExitCode { model: resolved.model, tokenizer: resolved.tokenizer, credential: resolved.credential, + inference_limits: resolved.inference_limits, allow_files: args.allow_files, allow_dirs: args.allow_dirs, allow_executables: args.allow_executables, @@ -568,11 +581,16 @@ fn resume_command(args: ResumeArgs) -> ExitCode { allow_execute, max_ticks, } = args; + let inference_limits = match resolve_inference_limits(None, None, None) { + Ok(limits) => limits, + Err(error) => return present_model_configuration_error(error), + }; let request = LocalResumeRequest { run_id, workspace, base_url: None, credential: None, + inference_limits, allow_files, allow_dirs, allow_executables, @@ -609,6 +627,7 @@ struct ResolvedModel { model: String, tokenizer: String, credential: Option, + inference_limits: InferenceLimits, } struct ResolvedEndpoint { @@ -639,6 +658,7 @@ enum ModelCliError { InputUnavailable, InvalidCredential, CredentialRequiresHttps, + InvalidInferenceLimits, } impl ModelCliError { @@ -651,6 +671,7 @@ impl ModelCliError { Self::InputUnavailable => "input_unavailable", Self::InvalidCredential => "api_key_invalid", Self::CredentialRequiresHttps => "api_key_requires_https", + Self::InvalidInferenceLimits => "inference_limits_invalid", } } @@ -669,7 +690,8 @@ impl ModelCliError { | Self::InvalidEnvironment | Self::InputUnavailable | Self::InvalidCredential - | Self::CredentialRequiresHttps => 64, + | Self::CredentialRequiresHttps + | Self::InvalidInferenceLimits => 64, Self::Check(error) => error.exit_code(), } } @@ -695,6 +717,11 @@ fn model_setup(args: ModelSetupArgs) -> ExitCode { println!(" model: {}", profile.model()); println!(" catalog: exact model advertised"); println!(" chat completions: strict JSON compatible"); + println!( + " inference limits: timeout={}s max_output_tokens={}", + profile.inference_limits().timeout().as_secs(), + profile.inference_limits().max_output_tokens() + ); println!( " authentication: {}", if stored { @@ -751,6 +778,7 @@ fn try_model_setup(args: ModelSetupArgs) -> Result<(ModelProfile, bool), ModelCl model: catalog_identity.clone(), tokenizer: catalog_identity, credential: credential.clone(), + inference_limits: InferenceLimits::default(), })?; let model = match requested_model { Some(model) if models.iter().any(|candidate| candidate == &model) => model, @@ -769,11 +797,17 @@ fn try_model_setup(args: ModelSetupArgs) -> Result<(ModelProfile, bool), ModelCl }) .unwrap_or_else(|| model.clone()); + let inference_limits = resolve_inference_limits( + args.inference_timeout, + args.max_output_tokens, + existing.as_ref(), + )?; check_openai_compatibility(ModelCheckRequest { base_url: base_url.clone(), model: model.clone(), tokenizer: tokenizer.clone(), credential, + inference_limits, })?; let _lock = store.try_lock()?; @@ -787,6 +821,7 @@ fn try_model_setup(args: ModelSetupArgs) -> Result<(ModelProfile, bool), ModelCl .map(str::to_owned); let credentials = OsModelCredentialStore; let mut profile = ModelProfile::new(&args.name, base_url, model, tokenizer)?; + profile.set_inference_limits(inference_limits)?; let retain_existing = secret.source == SetupSecretSource::SecureStore; let should_store = args.store_token || secret.source == SetupSecretSource::Interactive; let mut new_reference = None; @@ -836,10 +871,12 @@ fn model_list() -> ExitCode { " " }; println!( - "{marker} {} model={} tokenizer={} authentication={}", + "{marker} {} model={} tokenizer={} timeout={}s max_output_tokens={} authentication={}", profile.name(), profile.model(), profile.tokenizer(), + profile.inference_limits().timeout().as_secs(), + profile.inference_limits().max_output_tokens(), if profile.has_stored_credential() { "secure_store" } else { @@ -948,6 +985,7 @@ fn model_check(args: ModelCheckArgs) -> ExitCode { model: resolved.model.clone(), tokenizer: resolved.tokenizer.clone(), credential: resolved.credential.clone(), + inference_limits: resolved.inference_limits, }; if let Err(error) = check_openai_model(request) { return present_model_check_error(error); @@ -958,6 +996,7 @@ fn model_check(args: ModelCheckArgs) -> ExitCode { model: resolved.model, tokenizer: resolved.tokenizer, credential: resolved.credential, + inference_limits: resolved.inference_limits, }) { return present_model_check_error(error); @@ -1005,14 +1044,50 @@ fn resolve_model( }) .unwrap_or_else(|| model.clone()); let credential = resolve_credential(&base_url, token_stdin, profile.as_ref())?; + let inference_limits = resolve_inference_limits(None, None, profile.as_ref())?; Ok(ResolvedModel { base_url, model, tokenizer, credential, + inference_limits, }) } +/// Resolve planner limits: explicit option, `XGENY_OPENAI_INFERENCE_TIMEOUT` / +/// `XGENY_OPENAI_MAX_OUTPUT_TOKENS`, the profile, then the ADR-0035 defaults. +fn resolve_inference_limits( + timeout_seconds: Option, + max_output_tokens: Option, + profile: Option<&ModelProfile>, +) -> Result { + let base = profile + .map(ModelProfile::inference_limits) + .unwrap_or_default(); + let timeout_seconds = match timeout_seconds { + Some(value) => value, + None => match read_environment("XGENY_OPENAI_INFERENCE_TIMEOUT")? { + Some(value) => value + .trim() + .parse() + .map_err(|_| ModelCliError::InvalidInferenceLimits)?, + None => base.timeout().as_secs(), + }, + }; + let max_output_tokens = match max_output_tokens { + Some(value) => value, + None => match read_environment("XGENY_OPENAI_MAX_OUTPUT_TOKENS")? { + Some(value) => value + .trim() + .parse() + .map_err(|_| ModelCliError::InvalidInferenceLimits)?, + None => base.max_output_tokens(), + }, + }; + InferenceLimits::new(Duration::from_secs(timeout_seconds), max_output_tokens) + .map_err(|_| ModelCliError::InvalidInferenceLimits) +} + fn resolve_endpoint( base_url: Option, profile_name: Option, diff --git a/crates/xgeny-cli/src/model_profile.rs b/crates/xgeny-cli/src/model_profile.rs index b6e4dd1..db82711 100644 --- a/crates/xgeny-cli/src/model_profile.rs +++ b/crates/xgeny-cli/src/model_profile.rs @@ -4,6 +4,7 @@ use std::fmt::Write as _; use std::fs::{self, File, OpenOptions}; use std::io::{ErrorKind, Read as _, Write as _}; use std::path::{Component, Path, PathBuf}; +use std::time::Duration; use cap_std::fs::Dir; use getrandom::fill; @@ -24,6 +25,64 @@ const CREDENTIAL_SERVICE: &str = "com.plateer.xgeny.model"; const PROFILE_VALIDATION_PLANNER_ID: &str = "xgeny.cli.openai"; const TEMP_CREATE_ATTEMPTS: usize = 8; +/// Default planner inference wall-clock budget (ADR-0035). +pub const DEFAULT_INFERENCE_TIMEOUT: Duration = Duration::from_secs(300); +/// Default planner output token budget (ADR-0035). +pub const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 1_024; + +/// Non-secret planner request limits carried by a model profile. +/// +/// Both values are inputs to the committed request profile digest, so they are bound to a Run at +/// start and must be unchanged for that Run to resume. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InferenceLimits { + timeout: Duration, + max_output_tokens: u32, +} + +impl InferenceLimits { + /// Validate one pair of limits against the provider adapter bounds. + /// + /// # Errors + /// + /// Returns `InvalidProfile` for a zero or over-one-hour timeout, or a zero or over-65,536 + /// token budget. + pub fn new(timeout: Duration, max_output_tokens: u32) -> Result { + OpenAiPlannerConfig::new( + "https://limits.invalid/v1", + PROFILE_VALIDATION_PLANNER_ID, + "limits-validation", + "limits-validation", + ) + .and_then(|config| config.with_timeout(timeout)) + .and_then(|config| config.with_max_output_tokens(max_output_tokens)) + .map_err(|_| ModelProfileError::InvalidProfile)?; + Ok(Self { + timeout, + max_output_tokens, + }) + } + + #[must_use] + pub const fn timeout(self) -> Duration { + self.timeout + } + + #[must_use] + pub const fn max_output_tokens(self) -> u32 { + self.max_output_tokens + } +} + +impl Default for InferenceLimits { + fn default() -> Self { + Self { + timeout: DEFAULT_INFERENCE_TIMEOUT, + max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS, + } + } +} + /// One non-secret OpenAI-compatible model profile. #[derive(Clone, PartialEq, Eq)] pub struct ModelProfile { @@ -32,6 +91,7 @@ pub struct ModelProfile { model: String, tokenizer: String, credential_ref: Option, + inference_limits: InferenceLimits, } impl ModelProfile { @@ -52,11 +112,31 @@ impl ModelProfile { model: model.into(), tokenizer: tokenizer.into(), credential_ref: None, + inference_limits: InferenceLimits::default(), }; profile.validate()?; Ok(profile) } + #[must_use] + pub const fn inference_limits(&self) -> InferenceLimits { + self.inference_limits + } + + /// Replace the planner inference limits. + /// + /// # Errors + /// + /// Never fails today; the limits were validated on construction. Kept fallible so callers + /// treat it like the other profile mutators. + pub fn set_inference_limits( + &mut self, + limits: InferenceLimits, + ) -> Result<(), ModelProfileError> { + self.inference_limits = limits; + self.validate() + } + #[must_use] pub fn name(&self) -> &str { &self.name @@ -121,6 +201,8 @@ impl ModelProfile { &self.model, &self.tokenizer, ) + .and_then(|config| config.with_timeout(self.inference_limits.timeout)) + .and_then(|config| config.with_max_output_tokens(self.inference_limits.max_output_tokens)) .map(|_| ()) .map_err(|_| ModelProfileError::InvalidProfile) } @@ -138,6 +220,7 @@ impl std::fmt::Debug for ModelProfile { "credential_ref", &self.credential_ref.as_ref().map(|_| ""), ) + .field("inference_limits", &self.inference_limits) .finish() } } @@ -629,6 +712,18 @@ struct StoredProfile { model: String, tokenizer: String, credential_ref: Option, + #[serde(default = "default_inference_timeout_seconds")] + inference_timeout_seconds: u64, + #[serde(default = "default_max_output_tokens")] + max_output_tokens: u32, +} + +fn default_inference_timeout_seconds() -> u64 { + DEFAULT_INFERENCE_TIMEOUT.as_secs() +} + +const fn default_max_output_tokens() -> u32 { + DEFAULT_MAX_OUTPUT_TOKENS } impl StoredProfile { @@ -639,16 +734,24 @@ impl StoredProfile { model: profile.model.clone(), tokenizer: profile.tokenizer.clone(), credential_ref: profile.credential_ref.clone(), + inference_timeout_seconds: profile.inference_limits.timeout.as_secs(), + max_output_tokens: profile.inference_limits.max_output_tokens, } } fn into_profile(self) -> Result { + let inference_limits = InferenceLimits::new( + Duration::from_secs(self.inference_timeout_seconds), + self.max_output_tokens, + ) + .map_err(|_| ModelProfileError::InvalidProfileFile)?; let profile = ModelProfile { name: self.name, base_url: self.base_url, model: self.model, tokenizer: self.tokenizer, credential_ref: self.credential_ref, + inference_limits, }; profile .validate() @@ -1012,6 +1115,72 @@ mod tests { ); } + #[test] + fn inference_limits_round_trip_and_legacy_files_load_with_defaults() { + let directory = tempdir().unwrap(); + let root = directory.path().join("config"); + let store = ModelProfileStore::at(root.clone()).unwrap(); + let mut profiles = store.load().unwrap(); + let mut tuned = profile("tuned"); + tuned + .set_inference_limits( + InferenceLimits::new(std::time::Duration::from_secs(600), 2_048).unwrap(), + ) + .unwrap(); + profiles.upsert(tuned).unwrap(); + profiles.upsert(profile("plain")).unwrap(); + store.save(&mut profiles).unwrap(); + + let text = fs::read_to_string(root.join(PROFILE_FILE)).unwrap(); + assert!(text.contains("\"inferenceTimeoutSeconds\": 600")); + assert!(text.contains("\"maxOutputTokens\": 2048")); + + let loaded = store.load().unwrap(); + let tuned = loaded.get("tuned").unwrap(); + assert_eq!( + tuned.inference_limits().timeout(), + std::time::Duration::from_secs(600) + ); + assert_eq!(tuned.inference_limits().max_output_tokens(), 2_048); + // A profile created without explicit limits carries the defaults. + assert_eq!( + loaded.get("plain").unwrap().inference_limits(), + InferenceLimits::default() + ); + + // A file written before this field existed still loads, with defaults. + let legacy = br#"{ + "formatVersion": 1, + "activeProfile": "old", + "profiles": [ + {"name":"old","baseUrl":"https://provider.example/v1","model":"m","tokenizer":"t","credentialRef":null} + ] + }"#; + let path = root.join(PROFILE_FILE); + fs::write(&path, legacy).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + } + let legacy_loaded = store.load().unwrap(); + assert_eq!( + legacy_loaded.active().unwrap().inference_limits(), + InferenceLimits::default() + ); + + // Out-of-range stored values fail closed like any other invalid profile field. + let out_of_range = br#"{ + "formatVersion": 1, + "activeProfile": "bad", + "profiles": [ + {"name":"bad","baseUrl":"https://provider.example/v1","model":"m","tokenizer":"t","credentialRef":null,"inferenceTimeoutSeconds":0,"maxOutputTokens":1024} + ] + }"#; + fs::write(&path, out_of_range).unwrap(); + assert_eq!(store.load(), Err(ModelProfileError::InvalidProfileFile)); + } + #[test] fn mutation_lock_is_cross_handle_exclusive_and_released_on_drop() { let directory = tempdir().unwrap(); diff --git a/docs/adr/0035-model-profile-inference-limits.md b/docs/adr/0035-model-profile-inference-limits.md new file mode 100644 index 0000000..471cd7d --- /dev/null +++ b/docs/adr/0035-model-profile-inference-limits.md @@ -0,0 +1,89 @@ +# ADR-0035: planner inference timeout과 출력 예산을 모델 프로필 설정으로 옮긴다 + +- 상태: 제안 +- 날짜: 2026-09-05 +- 관련: ADR-0016 durable model call lifecycle, ADR-0017 OpenAI-compatible provider adapter, ADR-0032 모델 프로필 + +## 배경 + +Public CLI는 planner 호출의 wall-clock 예산과 출력 token 예산을 `xgeny-cli` 상수 +`MODEL_TIMEOUT = 60s`, `MAX_OUTPUT_TOKENS = 1024`로 고정한다. Compatibility probe도 같은 값을 쓴다. + +Production 기준과 같은 계열인 Qwen3.8 27B(Q4_K_M)를 Ollama로 로컬 실행해 측정한 결과, planner 호출 +하나는 prefill 약 34초(planning context 3.1k token)와 생성 약 29초(reasoning 포함 262 token)로 약 +60초가 걸린다. Planning context는 매 호출 고유한 run/call 식별자와 digest를 포함하므로 provider의 +prefix cache가 적용되지 않아 이 비용은 매 호출 반복된다. 읽기 Run은 호출당 45~55초로 경계에 있고, +쓰기 Run은 첫 호출을 59초에 통과한 뒤 tool output이 더해진 두 번째 호출이 60초에서 +`model_call_unknown`으로 닫힌다. 같은 조건에서 model의 제안 자체는 올바르다(파일을 먼저 읽는 step을 +계획하고 digest를 지어내지 않는다). + +Probe는 planning context가 없는 작은 prompt를 보내므로 12초에 통과하며 이 지연을 예측하지 못한다. +Timeout은 model 크기, quantization, hardware, provider의 prefill 처리량에 따라 달라져 하나의 +상수로 모든 환경을 만족시킬 수 없다. + +## 결정 + +### 1. 두 값은 비밀이 아닌 프로필 설정이다 + +`ModelProfile`에 `inferenceTimeoutSeconds`와 `maxOutputTokens`를 추가한다. 둘 다 credential이 +아니며 `model-profiles.json`에 일반 설정으로 저장된다. `xgeny model setup`은 +`--inference-timeout <초>`와 `--max-output-tokens <토큰>`으로 받아 프로필에 기록하고, +`xgeny model list`와 setup 결과가 두 값을 표시한다. 해석 순서는 ADR-0032와 같다: setup의 명시적 +option, `XGENY_OPENAI_INFERENCE_TIMEOUT`/`XGENY_OPENAI_MAX_OUTPUT_TOKENS` 환경변수, 프로필, 기본값. +`run`/`resume`/`check`는 별도 option 없이 환경변수와 프로필로 해석한다. 범위 밖 값은 +`inference_limits_invalid`(exit 64)로 닫는다. + +### 2. 기본값은 실측 근거로 정한다 + +미설정 시 timeout은 300초, 출력 예산은 1024 token이다. 300초는 위 측정에서 두 번째 호출이 넘긴 +60초의 5배로, 로컬 27B가 tool output을 포함한 context를 처리할 여유를 두되 죽은 endpoint의 +catalog GET은 여전히 `MODEL_CHECK_TIMEOUT`(10초)에서 닫히므로 온보딩 실패는 느려지지 않는다. +1024는 27B의 실제 출력(262 token)에 충분하며 기존 값과 같다. 상한은 provider adapter의 기존 +경계(timeout 1시간, 출력 65,536 token)를 그대로 따른다. + +### 3. Probe와 production planner는 계속 같은 request profile을 쓴다 + +`compatibility_probe_config`와 `planner_config`는 둘 다 프로필의 값을 받는다. PR #54가 고정한 +"probe의 `request_profile_digest`는 production planner와 같다"는 불변식은 유지된다. + +### 4. Digest 결과를 그대로 받아들인다 + +Timeout과 출력 예산은 ADR-0017의 `request_profile_digest` 입력이므로 값이 바뀌면 digest가 바뀐다. +Run manifest는 digest만 기록하고 resume은 프로필에서 config를 다시 조립하므로: + +- 이 ADR 이전에 시작해 아직 완료되지 않은 Run은 기본값 변경(60→300초) 때문에 resume 시 + `configuration_mismatch`로 닫힌다. Developer Preview 단계의 의도된 결과이며 새 Run으로 시작한다. +- Run 시작과 resume 사이에 프로필의 두 값을 편집하면 같은 이유로 `configuration_mismatch`가 된다. + 자동 대체는 하지 않는다. +- Manifest schema는 바꾸지 않는다. 값을 manifest에 기록해 resume이 프로필 대신 manifest를 따르게 + 하는 안은 프로필이 단일 진실이라는 ADR-0032의 경계를 흐리므로 채택하지 않는다. + +### 5. 저장 형식은 format version 1을 유지한다 + +새 필드는 `#[serde(default)]`로 읽어 기존 `model-profiles.json`을 그대로 로드한다. 새 필드가 +기록된 파일을 이 ADR 이전 binary가 읽으면 `deny_unknown_fields`로 거부된다. RC 채널 간 downgrade는 +ADR-0031/getting-started의 rollback 절차대로 별도 install directory를 쓰므로 프로필 파일을 공유하지 +않는다. + +## 결과 + +- 로컬 27B에서 쓰기 Run이 두 번째 planner 호출을 통과한다. +- 사용자는 hardware에 맞춰 timeout을 낮추거나 올릴 수 있고 값이 `model list`에 보인다. +- Probe는 여전히 planner 지연을 예측하지 못한다. 이 ADR은 지연을 예측하는 것이 아니라 예산을 환경에 + 맞게 두는 것이다. + +## 대안 + +- 상수만 300초로 올린다: 즉시 효과는 같지만 hardware별 조정이 불가능하고, 원격 provider 사용자에게 + 불필요하게 긴 timeout을 강제한다. +- Planning context의 가변 식별자를 prompt 끝으로 옮겨 prefix cache를 살린다: 호출당 30초 이상 줄일 + 수 있는 유효한 개선이지만 request envelope profile 변경이라 별도 ADR로 다룬다. +- Probe가 production 크기의 planning context를 보낸다: 지연 예측은 가능해지지만 probe가 Run state + 없이 catalog 없이 보내는 원칙(ADR-0032 §4)과 충돌한다. + +## 검증 + +- 프로필 round-trip: 새 필드 저장·로드, 필드 없는 기존 파일 로드 시 기본값, 범위 밖 값 거부. +- `planner_config`/`compatibility_probe_config`가 프로필 값을 쓰고 두 digest가 같다. +- 실측: Qwen3.8 27B(Ollama)에서 쓰기 시나리오가 `XGENY_COMPLETED`, 읽기 시나리오와 8B 회귀 없음, + probe PASS. diff --git a/docs/development/model-onboarding.md b/docs/development/model-onboarding.md index 56ae850..dde8c53 100644 --- a/docs/development/model-onboarding.md +++ b/docs/development/model-onboarding.md @@ -68,7 +68,7 @@ xgeny model remove qwen-xgen `--token-stdin`, `XGENY_OPENAI_API_KEY`, profile secure store 순서다. Profile credential은 profile URL과 최종 URL이 정확히 같을 때만 사용한다. -Compatibility probe는 production planner와 같은 proposal JSON Schema, 출력 token 예산, inference timeout을 사용하고 응답을 production과 같은 document 규칙으로 검증한다. Probe는 model에게 schema 밖의 top-level field를 하나 더 넣으라고 요구하므로, strict schema를 실제로 강제하는 provider만 통과한다. Schema를 받아들이지만 강제하지 못하는 provider(예: 문법 컴파일에 실패하고도 200을 반환하는 서버)는 첫 planner call 대신 `model setup`에서 실패한다. Catalog GET만 더 짧은 timeout을 유지한다. Reasoning을 많이 쓰는 model이 최종 JSON 전에 예산을 소진하면 `provider_output_truncated`로 닫으며, rate limit과 구분한다. +Compatibility probe는 production planner와 같은 proposal JSON Schema와 프로필의 출력 token 예산·inference timeout(ADR-0035, 기본 1024 token·300초)을 사용하고 응답을 production과 같은 document 규칙으로 검증한다. Probe는 model에게 schema 밖의 top-level field를 하나 더 넣으라고 요구하므로, strict schema를 실제로 강제하는 provider만 통과한다. Schema를 받아들이지만 강제하지 못하는 provider(예: 문법 컴파일에 실패하고도 200을 반환하는 서버)는 첫 planner call 대신 `model setup`에서 실패한다. Catalog GET만 더 짧은 timeout을 유지한다. Reasoning을 많이 쓰는 model이 최종 JSON 전에 예산을 소진하면 `provider_output_truncated`로 닫으며, rate limit과 구분한다. `model check`는 기본적으로 기존 계약인 catalog GET만 보낸다. `--compatibility`는 strict JSON Schema Chat Completions POST를 한 번 추가한다. `model setup`은 profile commit 전에 두 요청을 항상 수행한다. diff --git a/docs/development/public-local-run-resume.md b/docs/development/public-local-run-resume.md index d8a2a76..549b9bc 100644 --- a/docs/development/public-local-run-resume.md +++ b/docs/development/public-local-run-resume.md @@ -13,7 +13,7 @@ SQLite 실행 파일이나 server는 필요 없다. 기본 state 위치 대신 `XGENY_STATE_HOME`을 설정한다. API token이 필요한 HTTPS endpoint만 `XGENY_OPENAI_API_KEY`를 사용한다. token을 CLI argument로 전달하지 않는다. 반복 입력을 줄이려면 base URL, model과 tokenizer identity를 각각 `XGENY_OPENAI_BASE_URL`, `XGENY_OPENAI_MODEL`, -`XGENY_OPENAI_TOKENIZER`에 둘 수 있다. Tokenizer를 생략하면 model ID를 같은 identity로 사용한다. +`XGENY_OPENAI_TOKENIZER`에 둘 수 있다. Tokenizer를 생략하면 model ID를 같은 identity로 사용한다. Planner 호출의 wall-clock 예산과 출력 token 예산은 활성 프로필의 값(ADR-0035, 기본 300초·1024 token)을 따르며 `XGENY_OPENAI_INFERENCE_TIMEOUT`, `XGENY_OPENAI_MAX_OUTPUT_TOKENS`로 덮어쓸 수 있다. 두 값은 request profile digest에 들어가므로 Run 시작과 resume 사이에 바꾸면 `configuration_mismatch`가 된다. 처음 연결하는 endpoint는 Run state를 만들기 전에 catalog 조회로 확인할 수 있다. diff --git a/docs/getting-started.md b/docs/getting-started.md index 48efec5..33eeb90 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -173,6 +173,18 @@ xgeny model setup \ Loopback HTTP에는 ambient `XGENY_OPENAI_API_KEY`가 있더라도 전송하지 않는다. +Planner 호출의 wall-clock 예산과 출력 token 예산은 프로필 설정이다. 기본값은 300초와 1024 token이며 +`--inference-timeout <초>`, `--max-output-tokens <토큰>` 또는 `XGENY_OPENAI_INFERENCE_TIMEOUT`, +`XGENY_OPENAI_MAX_OUTPUT_TOKENS`로 바꾼다. 로컬 27B 급 model은 planning context prefill만 30초 +이상 걸릴 수 있으므로 timeout을 줄일 때는 `model_call_unknown`이 늘어난다. 두 값은 Run의 request +profile에 묶이므로 Run 시작 뒤 프로필에서 바꾸면 그 Run의 resume은 `configuration_mismatch`로 +닫힌다. + +```bash +xgeny model setup --name qwen38 --base-url http://127.0.0.1:11434/v1 \ + --model qwen3.8:27b-q4_K_M --inference-timeout 300 --max-output-tokens 1024 +``` + 원격 provider는 HTTPS를 사용한다. Interactive setup은 macOS Keychain, Windows Credential Manager 또는 Linux Secret Service에 key를 저장한다. Headless/CI는 secret manager 출력을 stdin으로 전달한다. `--store-token`을 생략하면 현재 setup 검증에만 사용하고 저장하지 않는다. @@ -420,7 +432,8 @@ State 삭제는 Run 기록과 durable recovery 정보를 잃으므로 uninstall | `provider_output_truncated` | Probe나 planner 응답이 출력 token 예산에서 잘렸다. Reasoning을 많이 쓰는 model은 최종 JSON 전에 예산을 소진할 수 있으므로 model의 thinking 설정이나 profile의 출력 예산을 조정한다. Rate limit이 아니므로 재시도로 해결되지 않는다. | | `proposal_rejected.*` | 뒤의 class가 Core가 제안을 거부한 이유다. `capability_unavailable`/`capability_unsupported`는 허용하지 않은 capability 선택, `invocation_invalid`는 scope 밖 인자나 스키마 위반, `tool_call_budget_exhausted`는 예산 소진이다. Class는 Core 판정이며 model 출력 원문이 아니다. | | `model_rejected.*` | 뒤의 class는 journal의 model call settlement와 같은 값이다. `planner_invalid_response`는 provider가 strict JSON Schema를 지키지 않은 응답(문법 미지원·미적용), `provider_limit`은 출력 예산·요청 크기 초과, `provider_rejected`는 4xx 거부다. Class는 Core 판정이며 model 출력 원문이 아니다. | -| `configuration_mismatch` | 원래 workspace, file/directory scope, executable와 model profile binding으로 resume한다. 자동 대체하지 말고 필요하면 새 Run을 시작한다. | +| `configuration_mismatch` | 원래 workspace, file/directory scope, executable와 model profile binding(inference timeout·출력 예산 포함)으로 resume한다. 자동 대체하지 말고 필요하면 새 Run을 시작한다. | +| `model_call_unknown`이 planner 호출마다 반복 | 프로필의 inference timeout이 model·hardware에 비해 짧다. 로컬 27B는 호출당 60초 안팎이 걸리므로 `--inference-timeout`을 올린다. | | `model_call_unknown` 또는 `effect_outcome_unknown` | 불확정 작업을 자동 반복하지 않는다. `/status`와 `/resume`의 고정 진단을 확인하고 외부 상태를 별도로 검증한다. | 지원 요청에는 `xgeny --version`, OS/architecture, 설치 채널, 종료 코드와 고정된 오류 코드만 우선 제공한다.