mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
* feat(llm): add OpenAI Codex backend config and OAuth session manager Add OpenAiCodex as a new LLM backend variant with config for auth endpoint, API base URL, client ID, and session persistence path. The session manager implements OpenAI's device code auth flow (headless-friendly, no browser required on the server) with automatic token refresh, following the same persistence pattern as the existing NEAR AI session manager. Closes #742 Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(llm): add Responses API client and token-refreshing decorator Native Responses API client for chatgpt.com/backend-api/codex/responses, the endpoint that works with ChatGPT subscription tokens. Handles SSE streaming, text completions, and tool call round-trips. Token-refreshing decorator wraps the provider to pre-emptively refresh OAuth tokens before API calls and retry once on auth failures. Reports zero cost since billing is through subscription. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(llm): wire OpenAI Codex into provider factory, CLI, and setup wizard Connect the new provider to the LLM factory, add openai_codex to the CLI --backend flag, and add it as an option in the onboarding wizard. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(llm): address PR #744 review feedback (20 items) Review fixes for the OpenAI Codex provider PR: - Remove dead `generate_pkce()` code (device flow gets PKCE from server) - Fix `refresh_tokens()` to use `.form()` instead of `.json()` per OAuth spec - Inline codex dispatch into `build_provider_chain()` (single async function, no separate `assemble_provider_chain()` helper — matches main's pattern) - Remove Clone from `OpenAiCodexSession`, restrict fields to `pub(crate)` - Propagate HTTP client builder error instead of silent fallback - Redact device code response body from debug log - Change `set_model()` in TokenRefreshingProvider to delegate to inner - Replace hardcoded `/tmp/` test path with `tempfile::tempdir()` - Accept `request_timeout_secs` from config instead of hardcoded 300s - Parse `Retry-After` header on 429 responses (matches nearai_chat.rs pattern) - Reuse `normalize_schema_strict()` for Codex tool definitions - Add warning log for dropped image attachments - Add doc comments on `list_models()` and `include` field - Add `OPENAI_CODEX_API_URL` to `.env.example` - Fix codex error message in `create_llm_provider()` for clarity - Revert unrelated `.worktrees` addition to `.gitignore` - Update `src/llm/CLAUDE.md` with Codex provider docs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review feedback and harden OpenAI Codex provider (takeover #744) Security: - Add SSRF validation (validate_base_url) on OPENAI_CODEX_AUTH_URL and OPENAI_CODEX_API_URL, matching the pattern used by all other base URL configs (regression test for #1103 included) Correctness: - Add missing cache_write_multiplier() and cache_read_discount() trait delegation in TokenRefreshingProvider - Cap device-code polling backoff at 60s to prevent unbounded interval growth on repeated 429 responses - Default expires_in to 3600s when server returns 0, preventing immediately-expired sessions - Fix pre-existing SseEvent::JobResult missing fallback_deliverable field in job_monitor.rs tests Cleanup: - Extract duplicated make_test_jwt() and test_codex_config() into shared codex_test_helpers module Co-Authored-By: Sanjeev-S <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review feedback on OpenAI Codex provider (#1461) - Login command now resolves OPENAI_CODEX_* env overrides even when LLM_BACKEND isn't set to openai_codex (Copilot review) - Setup wizard "Keep current provider?" for codex no longer re-triggers device code login — mirrors Bedrock's keep-and-return pattern (Copilot) - Revert provider init log from info back to debug (Copilot) - Add warning log when token expires_in=0, before defaulting to 3600s (Gemini review) Co-Authored-By: Sanjeev-S <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Sanjeev Suresh <[email protected]> Co-authored-by: Claude Opus 4.6 <[email protected]>
1268 lines
44 KiB
Rust
1268 lines
44 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use secrecy::SecretString;
|
|
|
|
use crate::bootstrap::ironclaw_base_dir;
|
|
use crate::config::helpers::{optional_env, parse_optional_env, validate_base_url};
|
|
use crate::error::ConfigError;
|
|
use crate::llm::config::*;
|
|
use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
|
|
use crate::llm::session::SessionConfig;
|
|
use crate::settings::Settings;
|
|
impl LlmConfig {
|
|
/// Create a test-friendly config without reading env vars.
|
|
#[cfg(feature = "libsql")]
|
|
pub fn for_testing() -> Self {
|
|
Self {
|
|
backend: "nearai".to_string(),
|
|
session: SessionConfig {
|
|
auth_base_url: "http://localhost:0".to_string(),
|
|
session_path: std::env::temp_dir().join("ironclaw-test-session.json"),
|
|
},
|
|
nearai: NearAiConfig {
|
|
model: "test-model".to_string(),
|
|
cheap_model: None,
|
|
base_url: "http://localhost:0".to_string(),
|
|
api_key: None,
|
|
fallback_model: None,
|
|
max_retries: 0,
|
|
circuit_breaker_threshold: None,
|
|
circuit_breaker_recovery_secs: 30,
|
|
response_cache_enabled: false,
|
|
response_cache_ttl_secs: 3600,
|
|
response_cache_max_entries: 100,
|
|
failover_cooldown_secs: 300,
|
|
failover_cooldown_threshold: 3,
|
|
smart_routing_cascade: false,
|
|
},
|
|
provider: None,
|
|
bedrock: None,
|
|
openai_codex: None,
|
|
request_timeout_secs: 120,
|
|
cheap_model: None,
|
|
smart_routing_cascade: false,
|
|
}
|
|
}
|
|
|
|
/// Resolve a model name from env var -> settings.selected_model -> hardcoded default.
|
|
fn resolve_model(
|
|
env_var: &str,
|
|
settings: &Settings,
|
|
default: &str,
|
|
) -> Result<String, ConfigError> {
|
|
Ok(optional_env(env_var)?
|
|
.or_else(|| settings.selected_model.clone())
|
|
.unwrap_or_else(|| default.to_string()))
|
|
}
|
|
|
|
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
|
let registry = ProviderRegistry::load();
|
|
|
|
// Determine backend: env var > settings > default ("nearai")
|
|
let backend = if let Some(b) = optional_env("LLM_BACKEND")? {
|
|
b
|
|
} else if let Some(ref b) = settings.llm_backend {
|
|
b.clone()
|
|
} else {
|
|
"nearai".to_string()
|
|
};
|
|
|
|
// Validate the backend is known
|
|
let backend_lower = backend.to_lowercase();
|
|
let is_nearai =
|
|
backend_lower == "nearai" || backend_lower == "near_ai" || backend_lower == "near";
|
|
let is_bedrock =
|
|
backend_lower == "bedrock" || backend_lower == "aws_bedrock" || backend_lower == "aws";
|
|
let is_openai_codex = backend_lower == "openai_codex"
|
|
|| backend_lower == "openai-codex"
|
|
|| backend_lower == "codex";
|
|
|
|
if !is_nearai && !is_bedrock && !is_openai_codex && registry.find(&backend_lower).is_none()
|
|
{
|
|
tracing::warn!(
|
|
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
|
|
backend
|
|
);
|
|
}
|
|
|
|
// Session config (used by NearAI provider for OAuth/session-token auth)
|
|
let nearai_auth_url = optional_env("NEARAI_AUTH_URL")?
|
|
.unwrap_or_else(|| "https://private.near.ai".to_string());
|
|
validate_base_url(&nearai_auth_url, "NEARAI_AUTH_URL")?;
|
|
let session = SessionConfig {
|
|
auth_base_url: nearai_auth_url,
|
|
session_path: optional_env("NEARAI_SESSION_PATH")?
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(default_session_path),
|
|
};
|
|
|
|
// Always resolve NEAR AI config (used for embeddings even when not the primary backend)
|
|
let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
|
|
let nearai = NearAiConfig {
|
|
model: Self::resolve_model("NEARAI_MODEL", settings, crate::llm::DEFAULT_MODEL)?,
|
|
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
|
|
base_url: {
|
|
let url = optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
|
|
if nearai_api_key.is_some() {
|
|
"https://cloud-api.near.ai".to_string()
|
|
} else {
|
|
"https://private.near.ai".to_string()
|
|
}
|
|
});
|
|
validate_base_url(&url, "NEARAI_BASE_URL")?;
|
|
url
|
|
},
|
|
api_key: nearai_api_key,
|
|
fallback_model: optional_env("NEARAI_FALLBACK_MODEL")?,
|
|
max_retries: parse_optional_env("NEARAI_MAX_RETRIES", 3)?,
|
|
circuit_breaker_threshold: optional_env("CIRCUIT_BREAKER_THRESHOLD")?
|
|
.map(|s| s.parse())
|
|
.transpose()
|
|
.map_err(|e| ConfigError::InvalidValue {
|
|
key: "CIRCUIT_BREAKER_THRESHOLD".to_string(),
|
|
message: format!("must be a positive integer: {e}"),
|
|
})?,
|
|
circuit_breaker_recovery_secs: parse_optional_env("CIRCUIT_BREAKER_RECOVERY_SECS", 30)?,
|
|
response_cache_enabled: parse_optional_env("RESPONSE_CACHE_ENABLED", false)?,
|
|
response_cache_ttl_secs: parse_optional_env("RESPONSE_CACHE_TTL_SECS", 3600)?,
|
|
response_cache_max_entries: parse_optional_env("RESPONSE_CACHE_MAX_ENTRIES", 1000)?,
|
|
failover_cooldown_secs: parse_optional_env("LLM_FAILOVER_COOLDOWN_SECS", 300)?,
|
|
failover_cooldown_threshold: parse_optional_env("LLM_FAILOVER_THRESHOLD", 3)?,
|
|
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
|
|
};
|
|
|
|
// Resolve registry provider config (for non-NearAI, non-Bedrock, non-Codex backends)
|
|
let provider = if is_nearai || is_bedrock || is_openai_codex {
|
|
None
|
|
} else {
|
|
Some(Self::resolve_registry_provider(
|
|
&backend_lower,
|
|
®istry,
|
|
settings,
|
|
)?)
|
|
};
|
|
|
|
let bedrock = if is_bedrock {
|
|
let explicit_region =
|
|
optional_env("BEDROCK_REGION")?.or_else(|| settings.bedrock_region.clone());
|
|
if explicit_region.is_none() {
|
|
tracing::info!("BEDROCK_REGION not set, defaulting to us-east-1");
|
|
}
|
|
let region = explicit_region.unwrap_or_else(|| "us-east-1".to_string());
|
|
let model = optional_env("BEDROCK_MODEL")?
|
|
.or_else(|| settings.selected_model.clone())
|
|
.ok_or_else(|| ConfigError::MissingRequired {
|
|
key: "BEDROCK_MODEL".to_string(),
|
|
hint: "Set BEDROCK_MODEL when LLM_BACKEND=bedrock".to_string(),
|
|
})?;
|
|
let cross_region = optional_env("BEDROCK_CROSS_REGION")?
|
|
.or_else(|| settings.bedrock_cross_region.clone());
|
|
if let Some(ref cr) = cross_region
|
|
&& !matches!(cr.as_str(), "us" | "eu" | "apac" | "global")
|
|
{
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "BEDROCK_CROSS_REGION".to_string(),
|
|
message: format!(
|
|
"'{}' is not valid, expected one of: us, eu, apac, global",
|
|
cr
|
|
),
|
|
});
|
|
}
|
|
let profile = optional_env("AWS_PROFILE")?.or_else(|| settings.bedrock_profile.clone());
|
|
Some(BedrockConfig {
|
|
region,
|
|
model,
|
|
cross_region,
|
|
profile,
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Resolve OpenAI Codex config
|
|
let openai_codex = if is_openai_codex {
|
|
// Model: OPENAI_CODEX_MODEL > OPENAI_MODEL > settings.selected_model > default
|
|
let model = optional_env("OPENAI_CODEX_MODEL")?
|
|
.or(optional_env("OPENAI_MODEL")?)
|
|
.or_else(|| settings.selected_model.clone())
|
|
.unwrap_or_else(|| "gpt-5.3-codex".to_string());
|
|
let auth_endpoint = optional_env("OPENAI_CODEX_AUTH_URL")?
|
|
.unwrap_or_else(|| "https://auth.openai.com".to_string());
|
|
validate_base_url(&auth_endpoint, "OPENAI_CODEX_AUTH_URL")?;
|
|
let api_base_url = optional_env("OPENAI_CODEX_API_URL")?
|
|
.unwrap_or_else(|| "https://chatgpt.com/backend-api/codex".to_string());
|
|
validate_base_url(&api_base_url, "OPENAI_CODEX_API_URL")?;
|
|
let client_id = optional_env("OPENAI_CODEX_CLIENT_ID")?
|
|
.unwrap_or_else(|| "app_EMoamEEZ73f0CkXaXp7hrann".to_string());
|
|
let session_path = optional_env("OPENAI_CODEX_SESSION_PATH")?
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| ironclaw_base_dir().join("openai_codex_session.json"));
|
|
let token_refresh_margin_secs =
|
|
parse_optional_env("OPENAI_CODEX_REFRESH_MARGIN_SECS", 300)?;
|
|
Some(OpenAiCodexConfig {
|
|
model,
|
|
auth_endpoint,
|
|
api_base_url,
|
|
client_id,
|
|
session_path,
|
|
token_refresh_margin_secs,
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?;
|
|
|
|
// Generic cheap model (works with any backend).
|
|
// Falls back to NearAI-specific cheap_model in provider chain logic.
|
|
let cheap_model = optional_env("LLM_CHEAP_MODEL")?;
|
|
|
|
// Generic smart routing cascade flag.
|
|
// Defaults to true. Overrides NearAI-specific smart_routing_cascade.
|
|
let smart_routing_cascade = parse_optional_env("SMART_ROUTING_CASCADE", true)?;
|
|
|
|
Ok(Self {
|
|
backend: if is_nearai {
|
|
"nearai".to_string()
|
|
} else if is_bedrock {
|
|
"bedrock".to_string()
|
|
} else if is_openai_codex {
|
|
"openai_codex".to_string()
|
|
} else if let Some(ref p) = provider {
|
|
p.provider_id.clone()
|
|
} else {
|
|
backend_lower
|
|
},
|
|
session,
|
|
nearai,
|
|
provider,
|
|
bedrock,
|
|
openai_codex,
|
|
request_timeout_secs,
|
|
cheap_model,
|
|
smart_routing_cascade,
|
|
})
|
|
}
|
|
|
|
/// Resolve a `RegistryProviderConfig` from the registry and env vars.
|
|
fn resolve_registry_provider(
|
|
backend: &str,
|
|
registry: &ProviderRegistry,
|
|
settings: &Settings,
|
|
) -> Result<RegistryProviderConfig, ConfigError> {
|
|
// Look up provider definition. Fall back to openai_compatible if unknown.
|
|
let def = registry
|
|
.find(backend)
|
|
.or_else(|| registry.find("openai_compatible"));
|
|
|
|
let (
|
|
canonical_id,
|
|
protocol,
|
|
api_key_env,
|
|
base_url_env,
|
|
model_env,
|
|
default_model,
|
|
default_base_url,
|
|
extra_headers_env,
|
|
api_key_required,
|
|
base_url_required,
|
|
unsupported_params,
|
|
) = if let Some(def) = def {
|
|
(
|
|
def.id.as_str(),
|
|
def.protocol,
|
|
def.api_key_env.as_deref(),
|
|
def.base_url_env.as_deref(),
|
|
def.model_env.as_str(),
|
|
def.default_model.as_str(),
|
|
def.default_base_url.as_deref(),
|
|
def.extra_headers_env.as_deref(),
|
|
def.api_key_required,
|
|
def.base_url_required,
|
|
def.unsupported_params.clone(),
|
|
)
|
|
} else {
|
|
// Absolute fallback: treat as generic openai_completions
|
|
(
|
|
backend,
|
|
ProviderProtocol::OpenAiCompletions,
|
|
Some("LLM_API_KEY"),
|
|
Some("LLM_BASE_URL"),
|
|
"LLM_MODEL",
|
|
"default",
|
|
None,
|
|
Some("LLM_EXTRA_HEADERS"),
|
|
false,
|
|
true,
|
|
Vec::new(),
|
|
)
|
|
};
|
|
|
|
// Codex auth.json override: when LLM_USE_CODEX_AUTH=true,
|
|
// credentials from the Codex CLI's auth.json take highest priority
|
|
// (over env vars AND secrets store). In ChatGPT mode, the base URL
|
|
// is also overridden to the private ChatGPT backend endpoint.
|
|
let mut codex_base_url_override: Option<String> = None;
|
|
let codex_creds = if parse_optional_env("LLM_USE_CODEX_AUTH", false)? {
|
|
let path = optional_env("CODEX_AUTH_PATH")?
|
|
.map(std::path::PathBuf::from)
|
|
.unwrap_or_else(crate::llm::codex_auth::default_codex_auth_path);
|
|
crate::llm::codex_auth::load_codex_credentials(&path)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let codex_refresh_token = codex_creds.as_ref().and_then(|c| c.refresh_token.clone());
|
|
let codex_auth_path = codex_creds.as_ref().and_then(|c| c.auth_path.clone());
|
|
|
|
let api_key = if let Some(creds) = codex_creds {
|
|
if creds.is_chatgpt_mode {
|
|
codex_base_url_override = Some(creds.base_url().to_string());
|
|
}
|
|
Some(creds.token)
|
|
} else if let Some(env_var) = api_key_env {
|
|
// Resolve API key from env (including secrets store overlay)
|
|
optional_env(env_var)?.map(SecretString::from)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if api_key_required && api_key.is_none() {
|
|
// Don't hard-fail here. The key might be injected later from the secrets store
|
|
// via inject_llm_keys_from_secrets(). Log a warning instead.
|
|
if let Some(env_var) = api_key_env {
|
|
tracing::debug!(
|
|
"API key not found in {env_var} for backend '{backend}'. \
|
|
Will be injected from secrets store if available."
|
|
);
|
|
}
|
|
}
|
|
|
|
// Resolve base URL: codex override > env var > settings (backward compat) > registry default
|
|
let is_codex_chatgpt = codex_base_url_override.is_some();
|
|
let base_url = codex_base_url_override
|
|
.or_else(|| {
|
|
if let Some(env_var) = base_url_env {
|
|
optional_env(env_var).ok().flatten()
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.or_else(|| {
|
|
// Backward compat: check legacy settings fields
|
|
match backend {
|
|
"ollama" => settings.ollama_base_url.clone(),
|
|
"openai_compatible" | "openrouter" => {
|
|
settings.openai_compatible_base_url.clone()
|
|
}
|
|
_ => None,
|
|
}
|
|
})
|
|
.or_else(|| default_base_url.map(String::from))
|
|
.unwrap_or_default();
|
|
|
|
if base_url_required
|
|
&& base_url.is_empty()
|
|
&& let Some(env_var) = base_url_env
|
|
{
|
|
return Err(ConfigError::MissingRequired {
|
|
key: env_var.to_string(),
|
|
hint: format!("Set {env_var} when LLM_BACKEND={backend}"),
|
|
});
|
|
}
|
|
|
|
// Validate base URL to prevent SSRF (#1103).
|
|
if !base_url.is_empty() {
|
|
let field = base_url_env.unwrap_or("LLM_BASE_URL");
|
|
validate_base_url(&base_url, field)?;
|
|
}
|
|
|
|
// Resolve model
|
|
let model = Self::resolve_model(model_env, settings, default_model)?;
|
|
|
|
// Resolve extra headers
|
|
let extra_headers = if let Some(env_var) = extra_headers_env {
|
|
optional_env(env_var)?
|
|
.map(|val| parse_extra_headers(&val))
|
|
.transpose()?
|
|
.unwrap_or_default()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
// Resolve OAuth token (Anthropic-specific: `claude login` flow).
|
|
// Only check for OAuth token when the provider is actually Anthropic.
|
|
let oauth_token = if canonical_id == "anthropic" {
|
|
optional_env("ANTHROPIC_OAUTH_TOKEN")?.map(SecretString::from)
|
|
} else {
|
|
None
|
|
};
|
|
let api_key = if api_key.is_none() && oauth_token.is_some() {
|
|
// OAuth token present but no API key: use a placeholder so the
|
|
// config block is populated. The provider factory will route to
|
|
// the OAuth provider instead of rig-core's x-api-key client.
|
|
Some(SecretString::from(OAUTH_PLACEHOLDER.to_string()))
|
|
} else {
|
|
api_key
|
|
};
|
|
|
|
// Resolve Anthropic prompt cache retention from env (default: Short).
|
|
let cache_retention: CacheRetention = if canonical_id == "anthropic" {
|
|
optional_env("ANTHROPIC_CACHE_RETENTION")?
|
|
.and_then(|val| match val.parse::<CacheRetention>() {
|
|
Ok(r) => Some(r),
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Invalid ANTHROPIC_CACHE_RETENTION: {e}; defaulting to short"
|
|
);
|
|
None
|
|
}
|
|
})
|
|
.unwrap_or_default()
|
|
} else {
|
|
CacheRetention::default()
|
|
};
|
|
|
|
Ok(RegistryProviderConfig {
|
|
protocol,
|
|
provider_id: canonical_id.to_string(),
|
|
api_key,
|
|
base_url,
|
|
model,
|
|
extra_headers,
|
|
oauth_token,
|
|
is_codex_chatgpt,
|
|
refresh_token: codex_refresh_token,
|
|
auth_path: codex_auth_path,
|
|
cache_retention,
|
|
unsupported_params,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Parse `LLM_EXTRA_HEADERS` value into a list of (key, value) pairs.
|
|
///
|
|
/// Format: `Key1:Value1,Key2:Value2` (colon-separated, not `=`, because
|
|
/// header values often contain `=`).
|
|
fn parse_extra_headers(val: &str) -> Result<Vec<(String, String)>, ConfigError> {
|
|
if val.trim().is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut headers = Vec::new();
|
|
for pair in val.split(',') {
|
|
let pair = pair.trim();
|
|
if pair.is_empty() {
|
|
continue;
|
|
}
|
|
let Some((key, value)) = pair.split_once(':') else {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "LLM_EXTRA_HEADERS".to_string(),
|
|
message: format!("malformed header entry '{}', expected Key:Value", pair),
|
|
});
|
|
};
|
|
let key = key.trim();
|
|
if key.is_empty() {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "LLM_EXTRA_HEADERS".to_string(),
|
|
message: format!("empty header name in entry '{}'", pair),
|
|
});
|
|
}
|
|
headers.push((key.to_string(), value.trim().to_string()));
|
|
}
|
|
Ok(headers)
|
|
}
|
|
|
|
/// Get the default session file path (~/.ironclaw/session.json).
|
|
pub fn default_session_path() -> PathBuf {
|
|
ironclaw_base_dir().join("session.json")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::helpers::ENV_MUTEX;
|
|
use crate::settings::Settings;
|
|
use crate::testing::credentials::*;
|
|
|
|
/// Clear all openai-compatible-related env vars.
|
|
fn clear_openai_compatible_env() {
|
|
// SAFETY: Only called under ENV_MUTEX in tests.
|
|
unsafe {
|
|
std::env::remove_var("LLM_BACKEND");
|
|
std::env::remove_var("LLM_BASE_URL");
|
|
std::env::remove_var("LLM_MODEL");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn openai_compatible_uses_selected_model_when_llm_model_unset() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_openai_compatible_env();
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("openai_compatible".to_string()),
|
|
openai_compatible_base_url: Some("https://openrouter.ai/api/v1".to_string()),
|
|
selected_model: Some("openai/gpt-5.1-codex".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
let provider = cfg.provider.expect("provider config should be present");
|
|
|
|
assert_eq!(provider.model, "openai/gpt-5.1-codex");
|
|
}
|
|
|
|
#[test]
|
|
fn openai_compatible_llm_model_env_overrides_selected_model() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_openai_compatible_env();
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::set_var("LLM_MODEL", "openai/gpt-5-codex");
|
|
}
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("openai_compatible".to_string()),
|
|
openai_compatible_base_url: Some("https://openrouter.ai/api/v1".to_string()),
|
|
selected_model: Some("openai/gpt-5.1-codex".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
let provider = cfg.provider.expect("provider config should be present");
|
|
|
|
assert_eq!(provider.model, "openai/gpt-5-codex");
|
|
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("LLM_MODEL");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_extra_headers_parsed() {
|
|
let result = parse_extra_headers("HTTP-Referer:https://myapp.com,X-Title:MyApp").unwrap();
|
|
assert_eq!(
|
|
result,
|
|
vec![
|
|
("HTTP-Referer".to_string(), "https://myapp.com".to_string()),
|
|
("X-Title".to_string(), "MyApp".to_string()),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extra_headers_empty_string() {
|
|
let result = parse_extra_headers("").unwrap();
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extra_headers_whitespace_only() {
|
|
let result = parse_extra_headers(" ").unwrap();
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extra_headers_malformed() {
|
|
let result = parse_extra_headers("NoColonHere");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extra_headers_empty_key() {
|
|
let result = parse_extra_headers(":value");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extra_headers_value_with_colons() {
|
|
let result = parse_extra_headers("Authorization:Bearer abc:def").unwrap();
|
|
assert_eq!(
|
|
result,
|
|
vec![("Authorization".to_string(), "Bearer abc:def".to_string())]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extra_headers_trailing_comma() {
|
|
let result = parse_extra_headers("X-Title:MyApp,").unwrap();
|
|
assert_eq!(result, vec![("X-Title".to_string(), "MyApp".to_string())]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extra_headers_with_spaces() {
|
|
let result =
|
|
parse_extra_headers(" HTTP-Referer : https://myapp.com , X-Title : MyApp ").unwrap();
|
|
assert_eq!(
|
|
result,
|
|
vec![
|
|
("HTTP-Referer".to_string(), "https://myapp.com".to_string()),
|
|
("X-Title".to_string(), "MyApp".to_string()),
|
|
]
|
|
);
|
|
}
|
|
|
|
/// Clear all ollama-related env vars.
|
|
fn clear_ollama_env() {
|
|
// SAFETY: Only called under ENV_MUTEX in tests.
|
|
unsafe {
|
|
std::env::remove_var("LLM_BACKEND");
|
|
std::env::remove_var("OLLAMA_BASE_URL");
|
|
std::env::remove_var("OLLAMA_MODEL");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ollama_uses_selected_model_when_ollama_model_unset() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_ollama_env();
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("ollama".to_string()),
|
|
selected_model: Some("llama3.2".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
let provider = cfg.provider.expect("provider config should be present");
|
|
|
|
assert_eq!(provider.model, "llama3.2");
|
|
}
|
|
|
|
#[test]
|
|
fn ollama_model_env_overrides_selected_model() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_ollama_env();
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::set_var("OLLAMA_MODEL", "mistral:latest");
|
|
}
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("ollama".to_string()),
|
|
selected_model: Some("llama3.2".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
let provider = cfg.provider.expect("provider config should be present");
|
|
|
|
assert_eq!(provider.model, "mistral:latest");
|
|
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("OLLAMA_MODEL");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn openai_compatible_preserves_dotted_model_name() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_openai_compatible_env();
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("openai_compatible".to_string()),
|
|
openai_compatible_base_url: Some("http://localhost:11434/v1".to_string()),
|
|
selected_model: Some("llama3.2".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
let provider = cfg.provider.expect("provider config should be present");
|
|
|
|
assert_eq!(
|
|
provider.model, "llama3.2",
|
|
"model name with dot must not be truncated"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn registry_provider_resolves_groq() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("LLM_BACKEND");
|
|
std::env::remove_var("GROQ_API_KEY");
|
|
std::env::remove_var("GROQ_MODEL");
|
|
}
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("groq".to_string()),
|
|
selected_model: Some("llama-3.3-70b-versatile".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
assert_eq!(cfg.backend, "groq");
|
|
let provider = cfg.provider.expect("provider config should be present");
|
|
assert_eq!(provider.provider_id, "groq");
|
|
assert_eq!(provider.model, "llama-3.3-70b-versatile");
|
|
assert_eq!(provider.base_url, "https://api.groq.com/openai/v1");
|
|
assert_eq!(provider.protocol, ProviderProtocol::OpenAiCompletions);
|
|
}
|
|
|
|
#[test]
|
|
fn registry_provider_resolves_tinfoil() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("LLM_BACKEND");
|
|
std::env::remove_var("TINFOIL_API_KEY");
|
|
std::env::remove_var("TINFOIL_MODEL");
|
|
}
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("tinfoil".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
assert_eq!(cfg.backend, "tinfoil");
|
|
let provider = cfg.provider.expect("provider config should be present");
|
|
assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1");
|
|
assert_eq!(provider.model, "kimi-k2-5");
|
|
assert!(
|
|
provider
|
|
.unsupported_params
|
|
.contains(&"temperature".to_string()),
|
|
"tinfoil should propagate unsupported_params from registry"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn registry_provider_alias_resolves_zai() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("LLM_BACKEND");
|
|
std::env::remove_var("ZAI_API_KEY");
|
|
std::env::remove_var("ZAI_MODEL");
|
|
}
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("bigmodel".to_string()),
|
|
selected_model: Some("glm-5".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
assert_eq!(cfg.backend, "zai");
|
|
let provider = cfg.provider.expect("provider config should be present");
|
|
assert_eq!(provider.provider_id, "zai");
|
|
assert_eq!(provider.model, "glm-5");
|
|
assert_eq!(provider.base_url, "https://api.z.ai/api/paas/v4");
|
|
assert_eq!(provider.protocol, ProviderProtocol::OpenAiCompletions);
|
|
}
|
|
|
|
#[test]
|
|
fn nearai_backend_has_no_registry_provider() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("LLM_BACKEND");
|
|
}
|
|
|
|
let settings = Settings::default();
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
assert_eq!(cfg.backend, "nearai");
|
|
assert!(cfg.provider.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn backend_alias_normalized_to_canonical_id() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_openai_compatible_env();
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::set_var("LLM_BACKEND", "open_ai");
|
|
std::env::set_var("OPENAI_API_KEY", TEST_API_KEY);
|
|
}
|
|
|
|
let settings = Settings::default();
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
assert_eq!(
|
|
cfg.backend, "openai",
|
|
"alias 'open_ai' should be normalized to canonical 'openai'"
|
|
);
|
|
let provider = cfg.provider.expect("should have provider config");
|
|
assert_eq!(provider.provider_id, "openai");
|
|
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("LLM_BACKEND");
|
|
std::env::remove_var("OPENAI_API_KEY");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_backend_falls_back_to_openai_compatible() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_openai_compatible_env();
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::set_var("LLM_BACKEND", "some_custom_provider");
|
|
std::env::set_var("LLM_BASE_URL", "http://localhost:8080/v1");
|
|
}
|
|
|
|
let settings = Settings::default();
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
assert_eq!(cfg.backend, "openai_compatible");
|
|
let provider = cfg.provider.expect("should have provider config");
|
|
assert_eq!(provider.provider_id, "openai_compatible");
|
|
assert_eq!(provider.base_url, "http://localhost:8080/v1");
|
|
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("LLM_BACKEND");
|
|
std::env::remove_var("LLM_BASE_URL");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nearai_aliases_all_resolve_to_nearai() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
|
|
for alias in &["nearai", "near_ai", "near"] {
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::set_var("LLM_BACKEND", alias);
|
|
}
|
|
let settings = Settings::default();
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
assert_eq!(
|
|
cfg.backend, "nearai",
|
|
"alias '{alias}' should resolve to 'nearai'"
|
|
);
|
|
assert!(
|
|
cfg.provider.is_none(),
|
|
"nearai should not have a registry provider"
|
|
);
|
|
}
|
|
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("LLM_BACKEND");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn base_url_resolution_priority() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_openai_compatible_env();
|
|
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::set_var("LLM_BACKEND", "openai_compatible");
|
|
std::env::set_var("LLM_BASE_URL", "http://env-url/v1");
|
|
}
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("openai_compatible".to_string()),
|
|
openai_compatible_base_url: Some("http://settings-url/v1".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
let provider = cfg.provider.expect("should have provider config");
|
|
assert_eq!(
|
|
provider.base_url, "http://env-url/v1",
|
|
"env var should take priority over settings"
|
|
);
|
|
|
|
// Now without env var, settings should win over registry default
|
|
unsafe {
|
|
std::env::remove_var("LLM_BASE_URL");
|
|
}
|
|
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
let provider = cfg.provider.expect("should have provider config");
|
|
assert_eq!(
|
|
provider.base_url, "http://settings-url/v1",
|
|
"settings should take priority over registry default"
|
|
);
|
|
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("LLM_BACKEND");
|
|
}
|
|
}
|
|
|
|
// ── OAuth resolution tests ──────────────────────────────────────
|
|
|
|
/// Clear all Anthropic-related env vars.
|
|
fn clear_anthropic_env() {
|
|
// SAFETY: Only called under ENV_MUTEX in tests.
|
|
unsafe {
|
|
std::env::remove_var("LLM_BACKEND");
|
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
|
std::env::remove_var("ANTHROPIC_OAUTH_TOKEN");
|
|
std::env::remove_var("ANTHROPIC_MODEL");
|
|
std::env::remove_var("ANTHROPIC_BASE_URL");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn anthropic_oauth_token_sets_placeholder_api_key() {
|
|
use secrecy::ExposeSecret;
|
|
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_anthropic_env();
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", TEST_ANTHROPIC_OAUTH_TOKEN);
|
|
}
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("anthropic".to_string()),
|
|
..Default::default()
|
|
};
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
let provider = cfg.provider.expect("provider config should be present");
|
|
|
|
assert_eq!(
|
|
provider
|
|
.api_key
|
|
.as_ref()
|
|
.map(|k| k.expose_secret().to_string()),
|
|
Some(OAUTH_PLACEHOLDER.to_string()),
|
|
"api_key should be the OAuth placeholder when only OAuth token is set"
|
|
);
|
|
assert!(
|
|
provider.oauth_token.is_some(),
|
|
"oauth_token should be populated"
|
|
);
|
|
assert_eq!(
|
|
provider.oauth_token.as_ref().unwrap().expose_secret(),
|
|
TEST_ANTHROPIC_OAUTH_TOKEN
|
|
);
|
|
|
|
clear_anthropic_env();
|
|
}
|
|
|
|
#[test]
|
|
fn anthropic_api_key_takes_priority_over_oauth() {
|
|
use secrecy::ExposeSecret;
|
|
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_anthropic_env();
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::set_var("ANTHROPIC_API_KEY", TEST_ANTHROPIC_API_KEY);
|
|
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", TEST_ANTHROPIC_OAUTH_TOKEN);
|
|
}
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("anthropic".to_string()),
|
|
..Default::default()
|
|
};
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
let provider = cfg.provider.expect("provider config should be present");
|
|
|
|
assert_eq!(
|
|
provider
|
|
.api_key
|
|
.as_ref()
|
|
.map(|k| k.expose_secret().to_string()),
|
|
Some(TEST_ANTHROPIC_API_KEY.to_string()),
|
|
"real API key should take priority over OAuth placeholder"
|
|
);
|
|
assert!(
|
|
provider.oauth_token.is_some(),
|
|
"oauth_token should still be populated"
|
|
);
|
|
|
|
clear_anthropic_env();
|
|
}
|
|
|
|
#[test]
|
|
fn non_anthropic_provider_has_no_oauth_token() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_anthropic_env();
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", TEST_ANTHROPIC_OAUTH_TOKEN);
|
|
}
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("openai".to_string()),
|
|
..Default::default()
|
|
};
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
let provider = cfg.provider.expect("provider config should be present");
|
|
|
|
assert!(
|
|
provider.oauth_token.is_none(),
|
|
"non-Anthropic providers should not pick up ANTHROPIC_OAUTH_TOKEN"
|
|
);
|
|
|
|
clear_anthropic_env();
|
|
}
|
|
|
|
// ── Cache retention tests ───────────────────────────────────────
|
|
|
|
#[test]
|
|
fn cache_retention_from_str_primary_values() {
|
|
assert_eq!(
|
|
"none".parse::<CacheRetention>().unwrap(),
|
|
CacheRetention::None
|
|
);
|
|
assert_eq!(
|
|
"short".parse::<CacheRetention>().unwrap(),
|
|
CacheRetention::Short
|
|
);
|
|
assert_eq!(
|
|
"long".parse::<CacheRetention>().unwrap(),
|
|
CacheRetention::Long
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cache_retention_from_str_aliases() {
|
|
assert_eq!(
|
|
"off".parse::<CacheRetention>().unwrap(),
|
|
CacheRetention::None
|
|
);
|
|
assert_eq!(
|
|
"disabled".parse::<CacheRetention>().unwrap(),
|
|
CacheRetention::None
|
|
);
|
|
assert_eq!(
|
|
"5m".parse::<CacheRetention>().unwrap(),
|
|
CacheRetention::Short
|
|
);
|
|
assert_eq!(
|
|
"ephemeral".parse::<CacheRetention>().unwrap(),
|
|
CacheRetention::Short
|
|
);
|
|
assert_eq!(
|
|
"1h".parse::<CacheRetention>().unwrap(),
|
|
CacheRetention::Long
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cache_retention_from_str_case_insensitive() {
|
|
assert_eq!(
|
|
"NONE".parse::<CacheRetention>().unwrap(),
|
|
CacheRetention::None
|
|
);
|
|
assert_eq!(
|
|
"Short".parse::<CacheRetention>().unwrap(),
|
|
CacheRetention::Short
|
|
);
|
|
assert_eq!(
|
|
"LONG".parse::<CacheRetention>().unwrap(),
|
|
CacheRetention::Long
|
|
);
|
|
assert_eq!(
|
|
"Ephemeral".parse::<CacheRetention>().unwrap(),
|
|
CacheRetention::Short
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cache_retention_from_str_invalid() {
|
|
let err = "bogus".parse::<CacheRetention>().unwrap_err();
|
|
assert!(
|
|
err.contains("bogus"),
|
|
"error should mention the invalid value"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cache_retention_display_round_trip() {
|
|
for variant in [
|
|
CacheRetention::None,
|
|
CacheRetention::Short,
|
|
CacheRetention::Long,
|
|
] {
|
|
let s = variant.to_string();
|
|
let parsed: CacheRetention = s.parse().unwrap();
|
|
assert_eq!(parsed, variant, "round-trip failed for {s}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_request_timeout_defaults_to_120() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("LLM_REQUEST_TIMEOUT_SECS");
|
|
}
|
|
let config = LlmConfig::resolve(&Settings::default()).expect("resolve");
|
|
assert_eq!(config.request_timeout_secs, 120);
|
|
}
|
|
|
|
#[test]
|
|
fn test_request_timeout_configurable() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::set_var("LLM_REQUEST_TIMEOUT_SECS", "300");
|
|
}
|
|
let config = LlmConfig::resolve(&Settings::default()).expect("resolve");
|
|
assert_eq!(config.request_timeout_secs, 300);
|
|
// SAFETY: Cleanup
|
|
unsafe {
|
|
std::env::remove_var("LLM_REQUEST_TIMEOUT_SECS");
|
|
}
|
|
}
|
|
|
|
// ── OpenAI Codex tests ──────────────────────────────────────────
|
|
|
|
/// Clear all openai-codex-related env vars.
|
|
fn clear_openai_codex_env() {
|
|
// SAFETY: Only called under ENV_MUTEX in tests.
|
|
unsafe {
|
|
std::env::remove_var("LLM_BACKEND");
|
|
std::env::remove_var("OPENAI_CODEX_MODEL");
|
|
std::env::remove_var("OPENAI_MODEL");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn openai_codex_resolves_config() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_openai_codex_env();
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("openai_codex".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
assert_eq!(cfg.backend, "openai_codex");
|
|
let codex = cfg.openai_codex.expect("codex config should be present");
|
|
assert_eq!(codex.model, "gpt-5.3-codex"); // default
|
|
assert!(
|
|
cfg.provider.is_none(),
|
|
"codex should not use registry provider"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn openai_codex_model_env_resolution() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_openai_codex_env();
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::set_var("OPENAI_CODEX_MODEL", "o3-pro");
|
|
}
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("openai_codex".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
let codex = cfg.openai_codex.expect("codex config should be present");
|
|
assert_eq!(codex.model, "o3-pro");
|
|
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("OPENAI_CODEX_MODEL");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn openai_codex_falls_back_to_openai_model() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_openai_codex_env();
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::set_var("OPENAI_MODEL", "gpt-4o");
|
|
}
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("openai_codex".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
let codex = cfg.openai_codex.expect("codex config should be present");
|
|
assert_eq!(codex.model, "gpt-4o");
|
|
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("OPENAI_MODEL");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn openai_codex_falls_back_to_selected_model() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_openai_codex_env();
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("openai_codex".to_string()),
|
|
selected_model: Some("gpt-4o-mini".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
|
let codex = cfg.openai_codex.expect("codex config should be present");
|
|
assert_eq!(codex.model, "gpt-4o-mini");
|
|
}
|
|
|
|
/// Regression: SSRF validation on OPENAI_CODEX_API_URL (#1103).
|
|
#[test]
|
|
fn openai_codex_rejects_ssrf_api_url() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_openai_codex_env();
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::set_var(
|
|
"OPENAI_CODEX_API_URL",
|
|
"http://169.254.169.254/latest/meta-data",
|
|
);
|
|
}
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("openai_codex".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let err = LlmConfig::resolve(&settings).unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("OPENAI_CODEX_API_URL"),
|
|
"error should reference the field name: {msg}"
|
|
);
|
|
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("OPENAI_CODEX_API_URL");
|
|
}
|
|
}
|
|
|
|
/// Regression: SSRF validation on OPENAI_CODEX_AUTH_URL (#1103).
|
|
#[test]
|
|
fn openai_codex_rejects_ssrf_auth_url() {
|
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
clear_openai_codex_env();
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::set_var("OPENAI_CODEX_AUTH_URL", "http://10.0.0.1");
|
|
}
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("openai_codex".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let err = LlmConfig::resolve(&settings).unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("OPENAI_CODEX_AUTH_URL"),
|
|
"error should reference the field name: {msg}"
|
|
);
|
|
|
|
// SAFETY: Under ENV_MUTEX.
|
|
unsafe {
|
|
std::env::remove_var("OPENAI_CODEX_AUTH_URL");
|
|
}
|
|
}
|
|
}
|