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
27 changes: 27 additions & 0 deletions src/harness/providers/openai/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,33 @@ fn compatible_presets_set_base_url_and_default_model() {
assert_eq!(named.model(), "custom-model");
}

#[test]
fn local_runtime_presets_normalize_endpoint_and_model() {
let ollama = OpenAiModel::ollama_at("127.0.0.1:11434/", "qwen3:8b");
assert_eq!(ollama.provider(), "ollama");
assert_eq!(ollama.base_url(), "http://127.0.0.1:11434/v1");
assert_eq!(ollama.model(), "qwen3:8b");

let lm_studio = OpenAiModel::lm_studio("http://127.0.0.1:1234/v1/models", "", "local-model");
assert_eq!(lm_studio.provider(), "lm_studio");
assert_eq!(lm_studio.base_url(), "http://127.0.0.1:1234/v1");
assert_eq!(lm_studio.model(), "local-model");

assert_eq!(
OpenAiModel::ollama_at("http://models", "qwen3").base_url(),
"http://models/v1"
);
assert_eq!(
OpenAiModel::ollama_at("http://v1", "qwen3").base_url(),
"http://v1/v1"
);

let overridden = OpenAiModel::ollama().with_model("qwen3:8b");
let profile = <OpenAiModel as ChatModel<()>>::profile(&overridden).unwrap();
assert!(!profile.tool_calling);
assert!(!profile.modalities.image_in);
}

#[test]
fn provider_spec_builds_compatible_model() {
let spec = ProviderSpec::for_kind(ProviderKind::Ollama).with_model("qwen2.5");
Expand Down
104 changes: 102 additions & 2 deletions src/harness/providers/openai/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ pub struct OpenAiModel {
/// Capability profile derived from the default model id, optionally adjusted
/// by [`Self::with_native_tool_calling`] / [`Self::with_vision`].
profile: ModelProfile,
/// Preserve conservative local-runtime tool/vision capability overrides
/// when callers subsequently replace the default model id.
local_capabilities_locked: bool,
/// Provider-specific options baked onto every request (e.g. a local model's
/// `{"options": {"num_ctx": 8192}}`). Merged under each request's own
/// `provider_options`, which win on key conflicts. `Value::Null` by default
Expand Down Expand Up @@ -319,6 +322,7 @@ impl OpenAiModel {
provider: "openai".to_string(),
base_url: DEFAULT_BASE_URL.to_string(),
profile: derive_profile("openai", DEFAULT_MODEL),
local_capabilities_locked: false,
default_provider_options: Value::Null,
responses_api_primary: false,
responses_omit_max_output_tokens: false,
Expand Down Expand Up @@ -520,14 +524,28 @@ impl OpenAiModel {
/// Overrides the default model id.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
let local_capabilities = self
.local_capabilities_locked
.then_some((self.profile.tool_calling, self.profile.modalities.image_in));
self.profile = derive_profile(&self.provider, &self.model);
if let Some((tool_calling, image_in)) = local_capabilities {
self.profile.tool_calling = tool_calling;
self.profile.modalities.image_in = image_in;
Comment thread
senamakel marked this conversation as resolved.
}
self
}

/// Overrides the provider family id used in profiles and normalized errors.
pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
self.provider = provider.into();
let local_capabilities = self
.local_capabilities_locked
.then_some((self.profile.tool_calling, self.profile.modalities.image_in));
self.profile = derive_profile(&self.provider, &self.model);
if let Some((tool_calling, image_in)) = local_capabilities {
self.profile.tool_calling = tool_calling;
self.profile.modalities.image_in = image_in;
}
self
}

Expand Down Expand Up @@ -755,9 +773,61 @@ impl OpenAiModel {
}

/// A local Ollama server (`http://localhost:11434/v1`), default model
/// `llama3.2`. Ollama ignores the API key, so a placeholder is used.
/// `llama3.2`.
pub fn ollama() -> Self {
Self::compatible_provider("ollama", "ollama", "http://localhost:11434/v1", "llama3.2")
Self::ollama_at("http://localhost:11434", "llama3.2")
}

/// An Ollama server exposed through its OpenAI-compatible HTTP API.
pub fn ollama_at(base_url: impl Into<String>, model: impl Into<String>) -> Self {
Comment thread
senamakel marked this conversation as resolved.
Self::local_runtime(
"ollama",
normalize_local_v1_base_url(base_url.into(), "http://localhost:11434"),
"",
model,
)
}

/// An LM Studio server exposed through its OpenAI-compatible HTTP API.
///
/// Authentication is disabled when `api_key` is empty and uses a bearer
/// token otherwise, matching LM Studio's optional API-token mode.
pub fn lm_studio(
base_url: impl Into<String>,
api_key: impl Into<String>,
model: impl Into<String>,
) -> Self {
let api_key = api_key.into();
let auth = if api_key.trim().is_empty() {
AuthStyle::None
} else {
AuthStyle::Bearer
};
Self::local_runtime(
"lm_studio",
normalize_local_v1_base_url(base_url.into(), "http://localhost:1234"),
api_key,
model,
)
.with_auth_style(auth)
}

fn local_runtime(
provider: &str,
base_url: String,
api_key: impl Into<String>,
model: impl Into<String>,
) -> Self {
Self::compatible_provider(provider, api_key, base_url, model)
.with_auth_style(AuthStyle::None)
.with_native_tool_calling(false)
.with_vision(false)
Comment thread
senamakel marked this conversation as resolved.
.lock_local_capabilities()
Comment thread
senamakel marked this conversation as resolved.
}

fn lock_local_capabilities(mut self) -> Self {
self.local_capabilities_locked = true;
self
}

/// Returns the default model id this instance will request.
Expand Down Expand Up @@ -1187,6 +1257,36 @@ impl OpenAiModel {
}
}

fn normalize_local_v1_base_url(raw: String, default_root: &str) -> String {
let trimmed = raw.trim().trim_end_matches('/');
let root = if trimmed.is_empty() {
default_root.to_owned()
} else if trimmed.contains("://") {
trimmed.to_owned()
} else {
format!("http://{trimmed}")
};
let mut url =
reqwest::Url::parse(&root).expect("local runtime URL is normalized with a scheme");
Comment thread
senamakel marked this conversation as resolved.
let mut segments: Vec<&str> = url
.path()
.split('/')
.filter(|part| !part.is_empty())
.collect();
if segments.ends_with(&["chat", "completions"]) {
segments.truncate(segments.len() - 2);
} else if segments.last() == Some(&"models") {
segments.pop();
}
if segments.last() != Some(&"v1") {
segments.push("v1");
}
url.set_path(&format!("/{}", segments.join("/")));
url.set_query(None);
url.set_fragment(None);
url.to_string().trim_end_matches('/').to_owned()
}

/// Request-shape degradations to apply when building an OpenAI wire body.
///
/// Each field, when `true`, replaces a request shape that some local
Expand Down