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
20 changes: 15 additions & 5 deletions crates/agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2690,13 +2690,23 @@ mod tests {
}

#[test]
fn ollama_default_uses_small_local_model_id() {
fn ollama_default_is_unavailable_until_the_local_catalog_answers() {
// Y-2: `DEFAULT_OLLAMA_MODEL` is deliberately "unknown". The real
// default comes from the live local catalog, so the header never names
// a model the session cannot reach; without that catalog the registry
// must say so instead of resolving a costume.
let registry = ModelRegistry::default();
let resolved = registry.resolve_ok(None, Some(ProviderKind::Ollama));
let error = registry
.resolve(None, Some(ProviderKind::Ollama))
.expect_err("the placeholder default must not resolve");

assert_eq!(resolved.resolved.provider, ProviderKind::Ollama);
assert_eq!(resolved.resolved.id, "deepseek-v4-flash");
assert!(resolved.resolved.supports_reasoning);
assert!(matches!(
error,
ModelResolutionError::ProviderDefaultUnavailable {
provider: ProviderKind::Ollama,
ref default_model,
} if default_model == "unknown"
));
}

#[test]
Expand Down
5 changes: 4 additions & 1 deletion crates/config/src/provider_defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,10 @@ pub(crate) const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
pub(crate) const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub(crate) const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub(crate) const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
pub(crate) const DEFAULT_OLLAMA_MODEL: &str = "deepseek-v4-flash";
/// Unresolved local-Ollama default. A live `GET /v1/models` (Ollama's
/// OpenAI-compat catalog, same tags as `/api/tags`) must supply the real id;
/// this marker must never be sent as a model name.
pub(crate) const DEFAULT_OLLAMA_MODEL: &str = "unknown";
pub(crate) const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Ollama request path can still submit the unknown placeholder before the live refresh arrives

Changing DEFAULT_OLLAMA_MODEL to unknown means any code path that calls config.default_model() directly will send an invalid model id before the background refresh has populated live tags. The new is_unresolved_local_ollama_model helper is only used in provider_default_model; no request/dispatch guard appears in this diff. A user who submits immediately, or whose local Ollama daemon is unavailable, will get model 'unknown' not found rather than the previous hosted-model failure.

pub(crate) const DEFAULT_OLLAMA_CLOUD_MODEL: &str = "gpt-oss:120b";
pub(crate) const DEFAULT_OLLAMA_CLOUD_BASE_URL: &str = "https://ollama.com/v1";
Expand Down
2 changes: 1 addition & 1 deletion crates/config/src/route/providers-export.golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,7 @@
"label": "Ollama",
"endpoint": "http://localhost:11434/v1",
"wire": "chat-completions",
"defaultModel": "deepseek-v4-flash",
"defaultModel": "unknown",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Public providers export exposes the internal unknown sentinel as defaultModel

DEFAULT_OLLAMA_MODEL is now "unknown", and providers-export.golden.json is updated to advertise defaultModel: "unknown" for Ollama. The constant's own doc says this marker must never be sent as a model name, but consumers of the export route may treat defaultModel as a literal request model and send "unknown" to Ollama. The export should represent the unresolved default as null/omitted, or otherwise prevent clients from using the sentinel as a model id.

"envVars": [
"OLLAMA_API_KEY"
],
Expand Down
5 changes: 4 additions & 1 deletion crates/config/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6291,7 +6291,7 @@ fn vllm_provider_defaults_to_local_endpoint_and_model() {
}

#[test]
fn ollama_provider_defaults_to_local_endpoint_and_small_model() {
fn ollama_provider_defaults_to_local_endpoint_and_unresolved_model() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let config = ConfigToml {
Expand All @@ -6303,6 +6303,9 @@ fn ollama_provider_defaults_to_local_endpoint_and_small_model() {

assert_eq!(resolved.provider, ProviderKind::Ollama);
assert_eq!(resolved.base_url, DEFAULT_OLLAMA_BASE_URL);
// Pre-refresh window: do not invent a hosted DeepSeek id. A live
// `GET /v1/models` (same tags as `/api/tags`) must replace this marker.
assert_eq!(resolved.model, "unknown");
assert_eq!(resolved.model, DEFAULT_OLLAMA_MODEL);
assert_eq!(resolved.api_key, None);
}
Expand Down
14 changes: 10 additions & 4 deletions crates/tui/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2625,16 +2625,22 @@ impl DeepSeekClient {
/// snapshot via `provider_lake::merge_live_offerings`, preserving rows
/// from other sources.
///
/// Currently activated for providers whose model list is not covered by the
/// Models.dev catalog (e.g. TelecomJS TokenHub). The refresh is non-fatal:
/// on failure, existing/bundled rows remain available.
/// Activated for providers whose model list is not covered by the
/// Models.dev catalog (TelecomJS TokenHub, Eden AI, Concentrate, local
/// Ollama).
/// Ollama's OpenAI-compat `GET /v1/models` returns the same tags as
/// native `GET /api/tags`. The refresh is non-fatal: on failure,
/// existing/bundled rows remain available.
pub fn spawn_active_provider_catalog_refresh(config: &Config) {
let provider = config.api_provider();
// Only refresh for providers that serve their own model list and are
// not already covered by the Models.dev catalog.
if !matches!(
provider,
ApiProvider::Telecomjs | ApiProvider::Edenai | ApiProvider::Concentrate
ApiProvider::Telecomjs
| ApiProvider::Edenai
| ApiProvider::Concentrate
| ApiProvider::Ollama
) {
return;
}
Expand Down
11 changes: 10 additions & 1 deletion crates/tui/src/config/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,16 @@ pub const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
pub const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
pub const DEFAULT_OLLAMA_MODEL: &str = "deepseek-v4-flash";
/// Unresolved local-Ollama default. A live provider-catalog refresh must
/// replace this with a tag `GET /v1/models` actually returned. Do not send
/// this string as a model id.
pub const DEFAULT_OLLAMA_MODEL: &str = "unknown";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] unknown sentinel collides with legitimate Ollama model names

Because is_unresolved_local_ollama_model compares case-insensitively to "unknown", a real local Ollama tag named unknown would be treated as the unresolved marker and filtered out of default selection. A less collision-prone sentinel (empty string, __ollama_unresolved__, or a dedicated enum) would avoid hiding a valid user tag.

/// True when `model` is the pre-refresh local-Ollama placeholder, not a tag.
#[must_use]
pub fn is_unresolved_local_ollama_model(model: &str) -> bool {
model.trim().eq_ignore_ascii_case(DEFAULT_OLLAMA_MODEL)
Comment on lines +140 to +148

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] is_unresolved_local_ollama_model reserves the literal model id unknown

A real local Ollama tag named unknown (case-insensitive, with or without surrounding whitespace) would be treated as unresolved and replaced by a live tag. That is probably acceptable as a reserved marker, but the limitation is not documented or tested.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a unit test for is_unresolved_local_ollama_model covering case-insensitive/whitespace handling and a real Ollama tag rather than the placeholder.

}
pub const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
pub const DEFAULT_OLLAMA_CLOUD_MODEL: &str = "gpt-oss:120b";
pub const DEFAULT_OLLAMA_CLOUD_BASE_URL: &str = codewhale_config::provider::OLLAMA_CLOUD_BASE_URL;
Expand Down
53 changes: 53 additions & 0 deletions crates/tui/src/model_inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,30 @@ fn configured_model_for_provider(config: &Config, provider: ApiProvider) -> Opti
}

fn provider_default_model(config: &Config, provider: ApiProvider) -> String {
if provider == ApiProvider::Ollama {
let configured = if provider == config.api_provider() {
Some(config.default_model())
} else {
configured_model_for_provider(config, provider)
};
let unresolved = configured.as_deref().is_none_or(|model| {
model.trim().eq_ignore_ascii_case("auto")
|| crate::config::is_unresolved_local_ollama_model(model)
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] First live Ollama tag is used without considering default_for_provider

provider_default_model takes the first string from live_per_provider_models and ignores CatalogOffering::default_for_provider. If the live catalog order is arbitrary or a non-default row happens to appear first, auto-selection may choose a non-default tag. Prefer a row marked default_for_provider when present, then fall back to a stable ordering.

if unresolved
&& let Some(live) = crate::provider_lake::live_per_provider_models(provider)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] provider_default_model chooses an arbitrary first live tag instead of a chat-endpoint or flagged default offering

live_per_provider_models flattens offerings into plain model strings, and provider_default_model takes .next(). Live /v1/models may return multiple tags, and offerings can include different endpoints. The current test only covers a single offering, so it does not prove the header default will be a usable chat model.

.into_iter()
.next()
{
return live;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filter live offerings to the chat endpoint and prefer the offering flagged default_for_provider before taking the first model, so the header default matches what the completion path will use.

if let Some(model) = configured.filter(|model| {
!model.trim().eq_ignore_ascii_case("auto")
&& !crate::config::is_unresolved_local_ollama_model(model)
}) {
return model;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Unresolved Ollama default can still fall through to older resolution paths

The new Ollama branch in provider_default_model returns the first live per-provider tag when available, but when there are no live tags and the configured model is none, auto, or unknown, it does not return and falls through to the existing generic logic below. If any of that generic logic consults Models.dev or the bundled catalog before the default constant, a local Ollama default can still resolve to a hosted model. Add an explicit terminal return of DEFAULT_OLLAMA_MODEL for the unresolved/no-live Ollama case.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the configured-model filter block inside the Ollama branch, return the unresolved default immediately so unresolved Ollama never falls through to generic resolution that could consult Models.dev or the bundled catalog.

}
}
if provider == config.api_provider() {
let model = config.default_model();
if !model.trim().eq_ignore_ascii_case("auto") {
Expand Down Expand Up @@ -1107,4 +1131,33 @@ mod tests {
};
assert!(!ModelInventory::from_config(&deepseek).router_available);
}

#[test]
fn ollama_default_prefers_live_local_tags_over_the_unresolved_marker() {
let _live = crate::provider_lake::lock_live_snapshot();
crate::provider_lake::clear_live_snapshot();
let config = Config {
provider: Some("ollama".to_string()),
..Default::default()
};
assert_eq!(
provider_default_model(&config, ApiProvider::Ollama),
crate::config::DEFAULT_OLLAMA_MODEL
);

crate::provider_lake::merge_live_offerings(vec![
codewhale_config::catalog::CatalogOffering {
provider: "ollama".to_string(),
wire_model_id: "qwen2.5:0.5b".to_string(),
endpoint_key: "chat".to_string(),
default_for_provider: true,
..Default::default()
},
]);
assert_eq!(
provider_default_model(&config, ApiProvider::Ollama),
"qwen2.5:0.5b"
);
crate::provider_lake::clear_live_snapshot();
}
}
54 changes: 54 additions & 0 deletions crates/tui/src/provider_lake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,23 @@ fn catalog_models_from_offerings<'a>(
models
}

/// Tags from the provider's own live `/v1/models` partition.
///
/// Models.dev rows must not satisfy a LOCAL default (Ollama). This reads only
/// the PerProvider snapshot so a cross-provider catalog cannot costume a
/// machine that has not answered with its own tags.
#[must_use]
pub fn live_per_provider_models(provider: ApiProvider) -> Vec<String> {
let catalog_id = catalog_provider_id(provider).to_ascii_lowercase();
let Ok(guard) = LIVE_SNAPSHOT.read() else {
return Vec::new();
};
let Some(snapshot) = guard.per_provider.get(&catalog_id) else {
return Vec::new();
};
catalog_models_from_offerings(&snapshot.offerings)
}

/// Catalog-backed model ids for one provider (#4188).
///
/// Precedence: live Models.dev rows (when published) override bundled offline
Expand Down Expand Up @@ -606,6 +623,43 @@ mod tests {
// the lake must still return empty rather than inventing rows.
assert!(all_catalog_models_for_provider(ApiProvider::Ollama).is_empty());
assert!(model_completion_names_for_provider(ApiProvider::Ollama).is_empty());
assert!(live_per_provider_models(ApiProvider::Ollama).is_empty());
}

#[test]
fn ollama_live_default_uses_per_provider_tags_not_models_dev() {
let _live = lock_live_snapshot();
clear_live_snapshot();

set_live_snapshot(
CatalogSnapshot {
offerings: vec![CatalogOffering {
provider: "ollama".to_string(),
wire_model_id: "deepseek-v4-flash".to_string(),
endpoint_key: "chat".to_string(),
default_for_provider: true,
..Default::default()
}],
},
LiveSource::ModelsDev,
);
assert!(
live_per_provider_models(ApiProvider::Ollama).is_empty(),
"Models.dev must not satisfy a local Ollama default"
);

merge_live_offerings(vec![CatalogOffering {
provider: "ollama".to_string(),
wire_model_id: "qwen2.5:0.5b".to_string(),
endpoint_key: "chat".to_string(),
default_for_provider: true,
..Default::default()
}]);
assert_eq!(
live_per_provider_models(ApiProvider::Ollama),
vec!["qwen2.5:0.5b".to_string()]
);
clear_live_snapshot();
}

/// #4116 / #4188 (AC): a provider with no bundled/live catalog coverage must
Expand Down
16 changes: 8 additions & 8 deletions crates/tui/src/tui/goldens/startup_100x30.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
██▄ ▄██
███████▄ ▄███████
████████ ████████
▀███████████████▀
▀▀▀█████▀▀▀
███
█████
███████
▄████████>
████████████████▄
███████████████████
▀████▀ ▀████
▀▀▀▀▀▀
▀▀▀

What are we working on?
welcome back · 4 saved sessions in this workspace
Expand Down
16 changes: 8 additions & 8 deletions crates/tui/src/tui/goldens/startup_120x32.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
██▄ ▄██
███████▄ ▄███████
████████ ████████
▀███████████████▀
▀▀▀█████▀▀▀
███
█████
███████
▄████████>
████████████████▄
███████████████████
▀████▀ ▀████
▀▀▀▀▀▀
▀▀▀

What are we working on?
welcome back · 4 saved sessions in this workspace
Expand Down
16 changes: 8 additions & 8 deletions crates/tui/src/tui/goldens/startup_160x40.txt
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@


██▄ ▄██
███████▄ ▄███████
████████ ████████
▀███████████████▀
▀▀▀█████▀▀▀
███
█████
███████
▄████████>
████████████████▄
███████████████████
▀████▀ ▀████
▀▀▀▀▀▀
▀▀▀

What are we working on?
welcome back · 4 saved sessions in this workspace
Expand Down
Loading
Loading