mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
LM Studio OSS Support (#2312)
## Overview Adds LM Studio OSS support. Closes #1883 ### Changes This PR enhances the behavior of `--oss` flag to support LM Studio as a provider. Additionally, it introduces a new flag`--local-provider` which can take in `lmstudio` or `ollama` as values if the user wants to explicitly choose which one to use. If no provider is specified `codex --oss` will auto-select the provider based on whichever is running. #### Additional enhancements The default can be set using `oss-provider` in config like: ``` oss_provider = "lmstudio" ``` For non-interactive users, they will need to either provide the provider as an arg or have it in their `config.toml` ### Notes For best performance, [set the default context length](https://lmstudio.ai/docs/app/advanced/per-model) for gpt-oss to the maximum your machine can support --------- Co-authored-by: Matt Clayton <matt@lmstudio.ai> Co-authored-by: Eric Traut <etraut@openai.com>
This commit is contained in:
co-authored by
Matt Clayton
Eric Traut
parent
842a1b7fe7
commit
837bc98a1d
@@ -25,7 +25,9 @@ use crate::git_info::resolve_root_git_project_for_trust;
|
||||
use crate::model_family::ModelFamily;
|
||||
use crate::model_family::derive_default_model_family;
|
||||
use crate::model_family::find_family_for_model;
|
||||
use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID;
|
||||
use crate::model_provider_info::built_in_model_providers;
|
||||
use crate::openai_model_info::get_model_info;
|
||||
use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
|
||||
@@ -466,6 +468,48 @@ pub fn set_project_trust_level(
|
||||
.apply_blocking()
|
||||
}
|
||||
|
||||
/// Save the default OSS provider preference to config.toml
|
||||
pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::Result<()> {
|
||||
// Validate that the provider is one of the known OSS providers
|
||||
match provider {
|
||||
LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => {
|
||||
// Valid provider, continue
|
||||
}
|
||||
_ => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
|
||||
// Read existing config or create empty string if file doesn't exist
|
||||
let content = match std::fs::read_to_string(&config_path) {
|
||||
Ok(content) => content,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
// Parse as DocumentMut for editing while preserving structure
|
||||
let mut doc = content.parse::<DocumentMut>().map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("failed to parse config.toml: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Set the default_oss_provider at root level
|
||||
use toml_edit::value;
|
||||
doc["oss_provider"] = value(provider);
|
||||
|
||||
// Write the modified document back
|
||||
std::fs::write(&config_path, doc.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a single dotted-path override onto a TOML value.
|
||||
fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) {
|
||||
use toml::value::Table;
|
||||
@@ -663,6 +707,8 @@ pub struct ConfigToml {
|
||||
pub experimental_use_rmcp_client: Option<bool>,
|
||||
pub experimental_use_freeform_apply_patch: Option<bool>,
|
||||
pub experimental_sandbox_command_assessment: Option<bool>,
|
||||
/// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama".
|
||||
pub oss_provider: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ConfigToml> for UserSavedConfig {
|
||||
@@ -851,6 +897,34 @@ pub struct ConfigOverrides {
|
||||
pub additional_writable_roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Resolves the OSS provider from CLI override, profile config, or global config.
|
||||
/// Returns `None` if no provider is configured at any level.
|
||||
pub fn resolve_oss_provider(
|
||||
explicit_provider: Option<&str>,
|
||||
config_toml: &ConfigToml,
|
||||
config_profile: Option<String>,
|
||||
) -> Option<String> {
|
||||
if let Some(provider) = explicit_provider {
|
||||
// Explicit provider specified (e.g., via --local-provider)
|
||||
Some(provider.to_string())
|
||||
} else {
|
||||
// Check profile config first, then global config
|
||||
let profile = config_toml.get_config_profile(config_profile).ok();
|
||||
if let Some(profile) = &profile {
|
||||
// Check if profile has an oss provider
|
||||
if let Some(profile_oss_provider) = &profile.oss_provider {
|
||||
Some(profile_oss_provider.clone())
|
||||
}
|
||||
// If not then check if the toml has an oss provider
|
||||
else {
|
||||
config_toml.oss_provider.clone()
|
||||
}
|
||||
} else {
|
||||
config_toml.oss_provider.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Meant to be used exclusively for tests: `load_with_overrides()` should
|
||||
/// be used in all other cases.
|
||||
@@ -3265,6 +3339,41 @@ trust_level = "trusted"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_default_oss_provider() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let codex_home = temp_dir.path();
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
|
||||
// Test setting valid provider on empty config
|
||||
set_default_oss_provider(codex_home, OLLAMA_OSS_PROVIDER_ID)?;
|
||||
let content = std::fs::read_to_string(&config_path)?;
|
||||
assert!(content.contains("oss_provider = \"ollama\""));
|
||||
|
||||
// Test updating existing config
|
||||
std::fs::write(&config_path, "model = \"gpt-4\"\n")?;
|
||||
set_default_oss_provider(codex_home, LMSTUDIO_OSS_PROVIDER_ID)?;
|
||||
let content = std::fs::read_to_string(&config_path)?;
|
||||
assert!(content.contains("oss_provider = \"lmstudio\""));
|
||||
assert!(content.contains("model = \"gpt-4\""));
|
||||
|
||||
// Test overwriting existing oss_provider
|
||||
set_default_oss_provider(codex_home, OLLAMA_OSS_PROVIDER_ID)?;
|
||||
let content = std::fs::read_to_string(&config_path)?;
|
||||
assert!(content.contains("oss_provider = \"ollama\""));
|
||||
assert!(!content.contains("oss_provider = \"lmstudio\""));
|
||||
|
||||
// Test invalid provider
|
||||
let result = set_default_oss_provider(codex_home, "invalid_provider");
|
||||
assert!(result.is_err());
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert!(error.to_string().contains("Invalid OSS provider"));
|
||||
assert!(error.to_string().contains("invalid_provider"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_untrusted_project_gets_workspace_write_sandbox() -> anyhow::Result<()> {
|
||||
let config_with_untrusted = r#"
|
||||
@@ -3295,6 +3404,85 @@ trust_level = "untrusted"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oss_provider_explicit_override() {
|
||||
let config_toml = ConfigToml::default();
|
||||
let result = resolve_oss_provider(Some("custom-provider"), &config_toml, None);
|
||||
assert_eq!(result, Some("custom-provider".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oss_provider_from_profile() {
|
||||
let mut profiles = std::collections::HashMap::new();
|
||||
let profile = ConfigProfile {
|
||||
oss_provider: Some("profile-provider".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
profiles.insert("test-profile".to_string(), profile);
|
||||
let config_toml = ConfigToml {
|
||||
profiles,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = resolve_oss_provider(None, &config_toml, Some("test-profile".to_string()));
|
||||
assert_eq!(result, Some("profile-provider".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oss_provider_from_global_config() {
|
||||
let config_toml = ConfigToml {
|
||||
oss_provider: Some("global-provider".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = resolve_oss_provider(None, &config_toml, None);
|
||||
assert_eq!(result, Some("global-provider".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oss_provider_profile_fallback_to_global() {
|
||||
let mut profiles = std::collections::HashMap::new();
|
||||
let profile = ConfigProfile::default(); // No oss_provider set
|
||||
profiles.insert("test-profile".to_string(), profile);
|
||||
let config_toml = ConfigToml {
|
||||
oss_provider: Some("global-provider".to_string()),
|
||||
profiles,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = resolve_oss_provider(None, &config_toml, Some("test-profile".to_string()));
|
||||
assert_eq!(result, Some("global-provider".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oss_provider_none_when_not_configured() {
|
||||
let config_toml = ConfigToml::default();
|
||||
let result = resolve_oss_provider(None, &config_toml, None);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oss_provider_explicit_overrides_all() {
|
||||
let mut profiles = std::collections::HashMap::new();
|
||||
let profile = ConfigProfile {
|
||||
oss_provider: Some("profile-provider".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
profiles.insert("test-profile".to_string(), profile);
|
||||
let config_toml = ConfigToml {
|
||||
oss_provider: Some("global-provider".to_string()),
|
||||
profiles,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = resolve_oss_provider(
|
||||
Some("explicit-provider"),
|
||||
&config_toml,
|
||||
Some("test-profile".to_string()),
|
||||
);
|
||||
assert_eq!(result, Some("explicit-provider".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_untrusted_project_gets_unless_trusted_approval_policy() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -33,6 +33,7 @@ pub struct ConfigProfile {
|
||||
/// Optional feature toggles scoped to this profile.
|
||||
#[serde(default)]
|
||||
pub features: Option<crate::features::FeaturesToml>,
|
||||
pub oss_provider: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ConfigProfile> for codex_app_server_protocol::Profile {
|
||||
|
||||
@@ -40,8 +40,11 @@ pub mod token_data;
|
||||
mod truncate;
|
||||
mod unified_exec;
|
||||
mod user_instructions;
|
||||
pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID;
|
||||
pub use model_provider_info::DEFAULT_LMSTUDIO_PORT;
|
||||
pub use model_provider_info::DEFAULT_OLLAMA_PORT;
|
||||
pub use model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
pub use model_provider_info::ModelProviderInfo;
|
||||
pub use model_provider_info::OLLAMA_OSS_PROVIDER_ID;
|
||||
pub use model_provider_info::WireApi;
|
||||
pub use model_provider_info::built_in_model_providers;
|
||||
pub use model_provider_info::create_oss_provider_with_base_url;
|
||||
|
||||
@@ -258,9 +258,11 @@ impl ModelProviderInfo {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_OLLAMA_PORT: u32 = 11434;
|
||||
pub const DEFAULT_LMSTUDIO_PORT: u16 = 1234;
|
||||
pub const DEFAULT_OLLAMA_PORT: u16 = 11434;
|
||||
|
||||
pub const BUILT_IN_OSS_MODEL_PROVIDER_ID: &str = "oss";
|
||||
pub const LMSTUDIO_OSS_PROVIDER_ID: &str = "lmstudio";
|
||||
pub const OLLAMA_OSS_PROVIDER_ID: &str = "ollama";
|
||||
|
||||
/// Built-in default provider list.
|
||||
pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
|
||||
@@ -311,14 +313,21 @@ pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
|
||||
requires_openai_auth: true,
|
||||
},
|
||||
),
|
||||
(BUILT_IN_OSS_MODEL_PROVIDER_ID, create_oss_provider()),
|
||||
(
|
||||
OLLAMA_OSS_PROVIDER_ID,
|
||||
create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Chat),
|
||||
),
|
||||
(
|
||||
LMSTUDIO_OSS_PROVIDER_ID,
|
||||
create_oss_provider(DEFAULT_LMSTUDIO_PORT, WireApi::Responses),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn create_oss_provider() -> ModelProviderInfo {
|
||||
pub fn create_oss_provider(default_provider_port: u16, wire_api: WireApi) -> ModelProviderInfo {
|
||||
// These CODEX_OSS_ environment variables are experimental: we may
|
||||
// switch to reading values from config.toml instead.
|
||||
let codex_oss_base_url = match std::env::var("CODEX_OSS_BASE_URL")
|
||||
@@ -331,22 +340,21 @@ pub fn create_oss_provider() -> ModelProviderInfo {
|
||||
port = std::env::var("CODEX_OSS_PORT")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(DEFAULT_OLLAMA_PORT)
|
||||
.and_then(|v| v.parse::<u16>().ok())
|
||||
.unwrap_or(default_provider_port)
|
||||
),
|
||||
};
|
||||
|
||||
create_oss_provider_with_base_url(&codex_oss_base_url)
|
||||
create_oss_provider_with_base_url(&codex_oss_base_url, wire_api)
|
||||
}
|
||||
|
||||
pub fn create_oss_provider_with_base_url(base_url: &str) -> ModelProviderInfo {
|
||||
pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> ModelProviderInfo {
|
||||
ModelProviderInfo {
|
||||
name: "gpt-oss".into(),
|
||||
base_url: Some(base_url.into()),
|
||||
env_key: None,
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
wire_api: WireApi::Chat,
|
||||
wire_api,
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
|
||||
Reference in New Issue
Block a user