diff --git a/AGENTS.md b/AGENTS.md index 990d58140..b8ba20671 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -517,7 +517,8 @@ If pushes to a PR branch suddenly create **no CI runs at all**, check baseline PNG conflict with main is blocking the merge ref and GitHub silently skips `pull_request` runs. Merge `main`, resolving every conflicted PNG by taking **main's bytes** (if main's copy is stale, the next capture re-drifts -and the bot fixes it), then push. +and the bot fixes it), then push. A modify/delete conflict means main *retired* +that baseline — take the deletion. For local interactive review, capture scratch PNGs — optionally filtered to a story-id substring so small subsets are cheap: diff --git a/lp-app/lpa-agent/src/lib.rs b/lp-app/lpa-agent/src/lib.rs index 66e0ce406..06a5e2e82 100644 --- a/lp-app/lpa-agent/src/lib.rs +++ b/lp-app/lpa-agent/src/lib.rs @@ -33,8 +33,8 @@ pub use provider::anthropic::{AnthropicConfig, AnthropicProvider}; pub use provider::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider}; pub use provider::{ BoxStream, ChatMessage, ChatRole, ContentBlock, HttpGetTransport, HttpSseTransport, - ListModelsError, ModelInfo, ModelProvider, StopReason, TokenUsage, ToolDef, TurnEvent, - TurnRequest, list_anthropic_models, list_openai_compat_models, + ListModelsError, ModelInfo, ModelPrice, ModelProvider, StopReason, TokenUsage, ToolDef, + TurnEvent, TurnRequest, list_anthropic_models, list_openai_compat_models, }; pub use session::{AgentError, AgentEvent, AgentSession, AgentTranscript, MAX_TURNS_PER_RUN}; pub use tool::{ diff --git a/lp-app/lpa-agent/src/provider/mod.rs b/lp-app/lpa-agent/src/provider/mod.rs index 3a13ec761..8c5b37384 100644 --- a/lp-app/lpa-agent/src/provider/mod.rs +++ b/lp-app/lpa-agent/src/provider/mod.rs @@ -21,7 +21,7 @@ pub use http_transport::{ HttpGetTransport, HttpRequest, HttpResponse, HttpSseTransport, TransportError, }; pub use model_discovery::{ - ListModelsError, ModelInfo, list_anthropic_models, list_openai_compat_models, + ListModelsError, ModelInfo, ModelPrice, list_anthropic_models, list_openai_compat_models, }; pub use model_provider::{ BoxStream, ChatMessage, ChatRole, ContentBlock, ModelProvider, StopReason, TokenUsage, ToolDef, diff --git a/lp-app/lpa-agent/src/provider/model_discovery.rs b/lp-app/lpa-agent/src/provider/model_discovery.rs index 1ed4f5efb..0ec39805a 100644 --- a/lp-app/lpa-agent/src/provider/model_discovery.rs +++ b/lp-app/lpa-agent/src/provider/model_discovery.rs @@ -10,7 +10,7 @@ //! its per-provider guidance copy. use futures_util::StreamExt; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use crate::provider::anthropic::{ANTHROPIC_VERSION, AnthropicConfig}; use crate::provider::http_transport::{ByteStream, HttpGetTransport, HttpResponse, TransportError}; @@ -22,13 +22,53 @@ use crate::provider::openai_compat::OpenAiCompatConfig; const MAX_MODELS_BODY_BYTES: usize = 8 * 1024 * 1024; /// One discoverable model. -#[derive(Clone, Debug, PartialEq, Eq)] +/// +/// Everything past `id`/`display_name` is metadata some providers publish +/// and others do not — kept here because it is what makes a 340-model +/// listing browsable (see `ModelInfo::rank_key` and the settings store's +/// option building). `None` always means "the provider did not say". +#[derive(Clone, Debug, PartialEq)] pub struct ModelInfo { /// The id the provider expects in requests. pub id: String, /// Human-readable name when the provider supplies one (Anthropic's /// `display_name`, OpenRouter's `name`; absent for OpenAI/Ollama). pub display_name: Option, + /// Published rates, $ per million tokens (OpenRouter's per-token + /// `pricing`, converted). + pub price: Option, + /// The provider's published score for coding work (OpenRouter carries + /// Artificial Analysis' indices). The ordering signal: without it a + /// listing can only be alphabetical, which buries every model worth + /// picking under `ai21/…`. + pub coding_score: Option, + /// Whether the provider says the model can call tools. The agent is a + /// single-tool loop, so an explicit `false` cannot run it at all. + pub supports_tools: Option, + /// Publication time (epoch seconds) when the provider gives one. + pub created: Option, +} + +/// Published rates for a model, $ per million tokens. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ModelPrice { + pub input_per_mtok: f64, + pub output_per_mtok: f64, +} + +impl ModelInfo { + /// A model known only by its id — what a bare OpenAI-style listing + /// gives, and the starting point for fixtures. + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + display_name: None, + price: None, + coding_score: None, + supports_tools: None, + created: None, + } + } } /// Why a model listing failed, typed for guidance mapping (bad key vs @@ -112,26 +152,89 @@ async fn fetch_models( message: truncated(&text, 300), }); } - let page: ModelsPage = serde_json::from_str(&text).map_err(|e| ListModelsError::Parse { + parse_models_page(&text) +} + +/// Read a `/models` body into [`ModelInfo`]s, in the order the server gave +/// them. Public because the local-server connection probe reads the same +/// bodies and must agree with discovery about what they mean. +pub fn parse_models_page(text: &str) -> Result, ListModelsError> { + let page: ModelsPage = serde_json::from_str(text).map_err(|e| ListModelsError::Parse { message: e.to_string(), })?; Ok(page .data .into_iter() .map(|model| ModelInfo { - display_name: model.display_name.or(model.name), id: model.id, + display_name: model.display_name.or(model.name), + price: model.pricing.and_then(parse_price), + coding_score: model.benchmarks.and_then(parse_coding_score), + supports_tools: model + .supported_parameters + .map(|parameters| parameters.iter().any(|parameter| parameter == "tools")), + created: model.created, }) .collect()) } +/// OpenRouter prices are per-token decimal strings; everything downstream +/// speaks $/MTok. A pair is only a price when both halves parse. +fn parse_price(pricing: WirePricing) -> Option { + let per_mtok = |raw: Option| -> Option { + let value = raw?.parse::().ok()?; + (value.is_finite() && value >= 0.0).then_some(value * 1_000_000.0) + }; + Some(ModelPrice { + input_per_mtok: per_mtok(pricing.prompt)?, + output_per_mtok: per_mtok(pricing.completion)?, + }) +} + +/// The published score for the work this agent does, preferring the coding +/// index and falling back to the agentic and general ones. `benchmarks` +/// mixes shapes (`artificial_analysis` is an object of scalars, +/// `design_arena` an array of per-arena entries), so only the object of +/// comparable indices is read. +fn parse_coding_score(benchmarks: WireBenchmarks) -> Option { + let analysis = benchmarks.artificial_analysis?; + [ + analysis.coding_index, + analysis.agentic_index, + analysis.intelligence_index, + ] + .into_iter() + .flatten() + .find(|score| score.is_finite()) +} + /// The listing shape shared by every provider (unknown fields ignored). #[derive(Deserialize)] struct ModelsPage { - #[serde(default)] + /// A nullable-but-REQUIRED field, which is the whole distinction: + /// + /// - `"data": null` — Ollama with nothing pulled answers exactly this. + /// An empty listing, not a failure (and serde's `default` would not + /// have covered it: that applies to a MISSING field, not a null one). + /// - no `data` at all — not a models listing. A body like Ollama's + /// native `{"models":[…]}` means the base URL is missing its `/v1`, + /// and saying so beats reporting zero models. + /// + /// `deserialize_with` rather than a plain `Option` field: serde gives + /// `Option` an implicit default, which would silently accept the + /// second case as an empty listing. + #[serde(deserialize_with = "nullable_model_list")] data: Vec, } +/// A `data` that must be present but may be null (see [`ModelsPage`]). +fn nullable_model_list<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok(Option::>::deserialize(deserializer)?.unwrap_or_default()) +} + #[derive(Deserialize)] struct WireModel { id: String, @@ -141,6 +244,42 @@ struct WireModel { /// OpenRouter's human-readable name. #[serde(default)] name: Option, + /// OpenRouter's per-token rates. + #[serde(default)] + pricing: Option, + /// OpenRouter's published quality indices. + #[serde(default)] + benchmarks: Option, + /// OpenRouter's capability list; `tools` is the one that matters here. + #[serde(default)] + supported_parameters: Option>, + /// Epoch seconds (OpenAI and OpenRouter). + #[serde(default)] + created: Option, +} + +#[derive(Deserialize)] +struct WirePricing { + #[serde(default)] + prompt: Option, + #[serde(default)] + completion: Option, +} + +#[derive(Deserialize)] +struct WireBenchmarks { + #[serde(default)] + artificial_analysis: Option, +} + +#[derive(Deserialize)] +struct WireIndices { + #[serde(default)] + coding_index: Option, + #[serde(default)] + agentic_index: Option, + #[serde(default)] + intelligence_index: Option, } /// Read a body stream to completion, capped at [`MAX_MODELS_BODY_BYTES`]. @@ -210,17 +349,74 @@ mod tests { models, vec![ ModelInfo { - id: "claude-sonnet-5".into(), display_name: Some("Claude Sonnet 5".into()), + ..ModelInfo::new("claude-sonnet-5") }, ModelInfo { - id: "claude-haiku-4-5".into(), display_name: Some("Claude Haiku 4.5".into()), + ..ModelInfo::new("claude-haiku-4-5") }, ] ); } + #[test] + fn a_null_data_list_is_an_empty_listing_not_a_parse_error() { + // Ollama with nothing pulled answers exactly this, and serde's + // `default` covers a missing field, not an explicit null. + let models = parse_models_page(r#"{"object":"list","data":null}"#).expect("empty listing"); + assert!(models.is_empty()); + } + + #[test] + fn openrouter_metadata_rides_along_for_ranking_and_pricing() { + // Field shapes taken from a live openrouter.ai/api/v1/models body: + // per-token decimal strings, indices under `artificial_analysis`, + // and a sibling `design_arena` ARRAY that must not break the read. + let models = parse_models_page( + r#"{"data":[{ + "id":"anthropic/claude-opus-5", + "name":"Anthropic: Claude Opus 5", + "created":1785190561, + "pricing":{"prompt":"0.000005","completion":"0.000025"}, + "supported_parameters":["tools","max_tokens"], + "benchmarks":{ + "design_arena":[{"arena":"models","category":"3d","elo":1387}], + "artificial_analysis":{"intelligence_index":60.7,"coding_index":78,"agentic_index":55.3}} + },{ + "id":"some/embedding-only", + "supported_parameters":["max_tokens"] + }]}"#, + ) + .expect("models"); + let opus = &models[0]; + let price = opus.price.expect("pricing"); + assert!((price.input_per_mtok - 5.0).abs() < 1e-9, "{price:?}"); + assert!((price.output_per_mtok - 25.0).abs() < 1e-9, "{price:?}"); + assert_eq!(opus.coding_score, Some(78.0)); + assert_eq!(opus.supports_tools, Some(true)); + assert_eq!(opus.created, Some(1785190561)); + assert_eq!(models[1].supports_tools, Some(false)); + assert_eq!(models[1].price, None); + } + + #[test] + fn the_score_falls_back_through_the_published_indices() { + let models = parse_models_page( + r#"{"data":[ + {"id":"a","benchmarks":{"artificial_analysis":{"agentic_index":50,"intelligence_index":40}}}, + {"id":"b","benchmarks":{"artificial_analysis":{"intelligence_index":45}}}, + {"id":"c","benchmarks":{"design_arena":[{"arena":"models","elo":1300}]}} + ]}"#, + ) + .expect("models"); + assert_eq!(models[0].coding_score, Some(50.0)); + assert_eq!(models[1].coding_score, Some(45.0)); + // Per-arena elo is not a comparable index; no score rather than a + // number that would sort against a different scale. + assert_eq!(models[2].coding_score, None); + } + #[test] fn anthropic_request_carries_auth_version_and_browser_headers() { let transport = FakeGet::ok(ANTHROPIC_PAGE); @@ -282,8 +478,8 @@ mod tests { assert_eq!( models, vec![ModelInfo { - id: "anthropic/claude-sonnet-5".into(), display_name: Some("Anthropic: Claude Sonnet 5".into()), + ..ModelInfo::new("anthropic/claude-sonnet-5") }] ); let requests = transport.requests.borrow(); diff --git a/lp-app/lpa-studio-core/src/app/settings/agent_models.rs b/lp-app/lpa-studio-core/src/app/settings/agent_models.rs index 0af0d35dd..f0e55aecf 100644 --- a/lp-app/lpa-studio-core/src/app/settings/agent_models.rs +++ b/lp-app/lpa-studio-core/src/app/settings/agent_models.rs @@ -11,6 +11,7 @@ use lpa_agent::{ListModelsError, ModelInfo}; use crate::app::agent::agent_provider_config::AgentProviderConfig; use crate::app::settings::agent_provider::AgentProvider; +use crate::app::settings::ui_settings_view::UiModelOption; /// One provider's model-listing fetch state. #[derive(Clone, Debug, PartialEq)] @@ -79,6 +80,122 @@ pub fn models_error_copy(provider: AgentProvider, error: &ListModelsError) -> St } } +/// Turn one provider's listing into the dropdown's options: drop what the +/// agent cannot drive, then order best-first. +/// +/// Server order is unusable at OpenRouter's scale (~340 models) and +/// alphabetical is worse — it leads with `ai21/…` and buries every model +/// worth picking. There is no popularity signal in any provider's API +/// (`/models` publishes none, and the endpoint openrouter.ai's rankings +/// page uses is CORS-locked to their own origin), so the ranking uses what +/// the payload itself carries: +/// +/// 1. published coding score, best first; +/// 2. then newest first, for the models scored by nobody; +/// 3. then by id, so the tail is stable. +/// +/// Providers that publish neither (Anthropic, OpenAI, local servers) keep +/// their listing order, which is already meaningful. +pub fn model_options(models: &[ModelInfo]) -> Vec { + let mut offerable: Vec<&ModelInfo> = + models.iter().filter(|model| is_offerable(model)).collect(); + if offerable.iter().any(|model| model.coding_score.is_some()) { + offerable.sort_by(|a, b| { + descending(a.coding_score, b.coding_score) + .then_with(|| descending(a.created, b.created)) + .then_with(|| a.id.cmp(&b.id)) + }); + } + offerable + .into_iter() + .map(|model| UiModelOption { + id: model.id.clone(), + label: model.display_name.clone(), + detail: option_detail(model), + }) + .collect() +} + +/// How many of a listing's models the picker declines to offer, for the +/// "N hidden" line — a missing id must read as a filter, not a bug. +pub fn hidden_model_count(models: &[ModelInfo]) -> usize { + models.iter().filter(|model| !is_offerable(model)).count() +} + +/// Whether a model belongs in the dropdown at all. +/// +/// Two exclusions, both about the agent being unable to use the model +/// rather than taste: ids that name a non-chat modality (embeddings, +/// speech, images), and models the provider marks tool-incapable — the +/// agent is a single-tool loop, so those fail on the first turn. Silence +/// about tool support is not a no; only OpenRouter publishes the field. +fn is_offerable(model: &ModelInfo) -> bool { + is_chat_model_id(&model.id) && model.supports_tools != Some(false) +} + +/// Whether an id names something this agent can drive. A provider's list +/// mixes in embedding, speech, image and moderation models; the model +/// field still accepts anything typed, so this only trims the menu. +pub fn is_chat_model_id(id: &str) -> bool { + const UNUSABLE: &[&str] = &[ + "embed", + "whisper", + "tts", + "-audio", + "transcribe", + "speech", + "dall-e", + "moderation", + "-image", + "image-", + "rerank", + "stable-diffusion", + "davinci", + "babbage", + ]; + let lowered = id.to_ascii_lowercase(); + !UNUSABLE.iter().any(|marker| lowered.contains(marker)) +} + +/// The trailing detail on an option: the score that put it where it is, +/// and what it costs. Absent for providers that publish neither. +fn option_detail(model: &ModelInfo) -> Option { + let score = model.coding_score.map(|score| format!("code {score:.0}")); + let price = model.price.map(|price| { + format!( + "${}/${}", + format_rate(price.input_per_mtok), + format_rate(price.output_per_mtok) + ) + }); + match (score, price) { + (Some(score), Some(price)) => Some(format!("{score} · {price}")), + (Some(only), None) | (None, Some(only)) => Some(only), + (None, None) => None, + } +} + +/// Trim a rate for display: whole dollars bare, cents to two decimals. +fn format_rate(rate: f64) -> String { + if rate == 0.0 { + "0".to_string() + } else if (rate - rate.round()).abs() < f64::EPSILON { + format!("{}", rate.round()) + } else { + format!("{rate:.2}") + } +} + +/// Descending order for an optional key, with absent values last. +fn descending(a: Option, b: Option) -> core::cmp::Ordering { + match (a, b) { + (Some(a), Some(b)) => b.partial_cmp(&a).unwrap_or(core::cmp::Ordering::Equal), + (Some(_), None) => core::cmp::Ordering::Less, + (None, Some(_)) => core::cmp::Ordering::Greater, + (None, None) => core::cmp::Ordering::Equal, + } +} + #[cfg(test)] mod tests { use lpa_agent::{AnthropicConfig, OpenAiCompatConfig}; @@ -120,6 +237,106 @@ mod tests { assert_eq!(discovery_fingerprint(&keyless), "http://box/v1\n"); } + #[test] + fn the_best_models_lead_the_options_not_the_alphabet() { + let scored = |id: &str, score: f64, created: i64| ModelInfo { + coding_score: Some(score), + created: Some(created), + supports_tools: Some(true), + ..ModelInfo::new(id) + }; + let options = model_options(&[ + ModelInfo { + created: Some(100), + supports_tools: Some(true), + ..ModelInfo::new("aion-labs/aion-2.0") + }, + scored("anthropic/claude-opus-5", 78.0, 200), + scored("openai/gpt-5.6-sol", 77.4, 150), + ModelInfo { + created: Some(300), + supports_tools: Some(true), + ..ModelInfo::new("zz/newer-unscored") + }, + ]); + assert_eq!( + options.iter().map(|o| o.id.as_str()).collect::>(), + vec![ + "anthropic/claude-opus-5", + "openai/gpt-5.6-sol", + // unscored fall below, newest first + "zz/newer-unscored", + "aion-labs/aion-2.0", + ] + ); + } + + #[test] + fn a_listing_without_scores_keeps_the_providers_own_order() { + // Anthropic and local servers publish no ranking signal, and their + // listings are already meaningful (newest first / pull order). + let options = model_options(&[ + ModelInfo::new("claude-sonnet-5"), + ModelInfo::new("claude-haiku-4-5"), + ]); + assert_eq!( + options.iter().map(|o| o.id.as_str()).collect::>(), + vec!["claude-sonnet-5", "claude-haiku-4-5"] + ); + } + + #[test] + fn models_the_agent_cannot_drive_are_not_offered() { + let models = vec![ + ModelInfo { + supports_tools: Some(false), + ..ModelInfo::new("chat-only") + }, + ModelInfo { + supports_tools: Some(true), + ..ModelInfo::new("tool-capable") + }, + // Silence about tool support is not a no. + ModelInfo::new("unknown-support"), + ModelInfo::new("text-embedding-3-large"), + ModelInfo::new("nomic-embed-text:latest"), + ]; + let options = model_options(&models); + assert_eq!( + options.iter().map(|o| o.id.as_str()).collect::>(), + vec!["tool-capable", "unknown-support"] + ); + assert_eq!(hidden_model_count(&models), 3); + } + + #[test] + fn the_option_detail_carries_the_score_and_the_rates() { + let options = model_options(&[ModelInfo { + coding_score: Some(78.0), + price: Some(lpa_agent::ModelPrice { + input_per_mtok: 5.0, + output_per_mtok: 25.0, + }), + ..ModelInfo::new("anthropic/claude-opus-5") + }]); + assert_eq!(options[0].detail.as_deref(), Some("code 78 · $5/$25")); + + // Sub-dollar rates keep their cents; a scoreless model shows price + // alone, and a listing with neither shows nothing. + let options = model_options(&[ + ModelInfo { + price: Some(lpa_agent::ModelPrice { + input_per_mtok: 0.15, + output_per_mtok: 0.6, + }), + ..ModelInfo::new("cheap/model") + }, + ModelInfo::new("bare/model"), + ]); + assert_eq!(options[0].detail.as_deref(), Some("$0.15/$0.60")); + assert_eq!(options[1].detail, None); + } + #[test] fn error_copy_maps_auth_network_and_parse_distinctly() { let auth = models_error_copy( diff --git a/lp-app/lpa-studio-core/src/app/settings/agent_provider.rs b/lp-app/lpa-studio-core/src/app/settings/agent_provider.rs index 4e49d47f8..24c9f0d3c 100644 --- a/lp-app/lpa-studio-core/src/app/settings/agent_provider.rs +++ b/lp-app/lpa-studio-core/src/app/settings/agent_provider.rs @@ -111,7 +111,8 @@ pub fn provider_guidance(provider: AgentProvider) -> AgentProviderGuidance { setup: "Any OpenAI-compatible server works — e.g. Ollama: run \ `ollama serve`, set the base URL to http://localhost:11434/v1, \ no key needed. LM Studio, llama.cpp, vLLM: use the base URL \ - the server shows, and the model id it serves.", + the server shows, and the model id it serves. Not sure? \ + Press Find my server and Studio will look.", links: &[("ollama.com", "https://ollama.com/")], note: Some( "Browser note: the server must allow cross-origin requests — \ diff --git a/lp-app/lpa-studio-core/src/app/settings/local_model_probe.rs b/lp-app/lpa-studio-core/src/app/settings/local_model_probe.rs new file mode 100644 index 000000000..1fa434916 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/settings/local_model_probe.rs @@ -0,0 +1,898 @@ +//! Local OpenAI-compatible server discovery and connection diagnosis. +//! +//! Pointing the Custom provider at a local model server fails in a handful +//! of specific ways, and a browser reports almost all of them as the same +//! opaque "fetch failed". This module owns the decision layer that turns a +//! probe result into an actionable sentence: which ports are worth trying, +//! how a base URL should be normalized, and — crucially — the difference +//! between *nothing is listening* and *the server answered but the browser +//! dropped the response because CORS headers were missing*. +//! +//! Pure and host-tested. The platform edge (`lpa-studio-web`) performs the +//! actual fetches and reports each attempt back as a [`ProbeOutcome`]; the +//! CORS-vs-unreachable discrimination it feeds us comes from a second, +//! opaque `no-cors` request — a request that succeeds whenever the port +//! accepted the connection, whatever headers came back. + +use serde_json::Value; + +/// A well-known local server: where it listens by default, and how to let a +/// browser page talk to it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LocalServer { + /// Product name, as the user knows it. + pub label: &'static str, + /// Default OpenAI-compatible base URL. + pub base_url: &'static str, + /// How to allow this page's origin, with `{origin}` standing in for it. + pub cors_fix: &'static str, +} + +/// The ports a scan tries, in order. Every entry speaks the OpenAI +/// `/chat/completions` dialect at the listed base URL. +pub const COMMON_LOCAL_SERVERS: &[LocalServer] = &[ + LocalServer { + label: "Ollama", + base_url: "http://localhost:11434/v1", + cors_fix: "Restart it with this page allowed: \ + OLLAMA_ORIGINS={origin} ollama serve", + }, + LocalServer { + label: "LM Studio", + base_url: "http://localhost:1234/v1", + cors_fix: "In LM Studio's Developer → Local Server settings, turn on \ + CORS, then restart the server.", + }, + LocalServer { + label: "llama.cpp (llama-server)", + base_url: "http://localhost:8080/v1", + cors_fix: "llama-server allows every origin by default — if it is \ + behind a proxy, allow {origin} there.", + }, + LocalServer { + label: "vLLM", + base_url: "http://localhost:8000/v1", + cors_fix: "Start it with --allowed-origins '[\"{origin}\"]'.", + }, + LocalServer { + label: "Jan", + base_url: "http://localhost:1337/v1", + cors_fix: "Allow {origin} in Jan's local API server settings.", + }, +]; + +/// What one attempt against a base URL produced. Built by the platform +/// edge; the copy is this module's job. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProbeOutcome { + /// A 2xx model list. + Models(Vec), + /// A 2xx body that is not an OpenAI model list. + BadBody(String), + /// A real HTTP response carrying a non-2xx status. + Status { status: u16, body: String }, + /// The response never became readable, but the port accepted the + /// connection — the opaque probe went through. That is CORS. + CorsBlocked, + /// Nothing answered, or the browser refused to make the request. + Unreachable { detail: String }, +} + +/// How a finding reads: reachable and usable, reachable but blocked or +/// misconfigured, or nothing there. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProbeLevel { + Ok, + Warn, + Error, +} + +/// Which of the local-server failure modes a finding is. The summary and +/// the UI branch on this rather than on the copy, so rewording a sentence +/// can never change behavior. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FindingKind { + /// A model list came back and the configured model (if any) is served. + Usable, + /// A model list came back, but the configured model is not in it. + WrongModel, + /// A readable answer with no models in it (nothing pulled/loaded). + NoModels, + /// Reachable, but the browser would not let this page read the answer. + Cors, + /// A real HTTP response with a non-2xx status. + HttpStatus, + /// A readable answer that is not an OpenAI model list. + BadBody, + /// Nothing accepted the connection. + Unreachable, +} + +/// One diagnosed attempt, ready to render. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProbeFinding { + /// The URL that was probed (already normalized). + pub base_url: String, + /// The product name, when the URL is a known default. + pub server_label: Option, + pub kind: FindingKind, + pub level: ProbeLevel, + /// One short sentence: what happened. + pub headline: String, + /// Optional second line: why, or what it means. + pub detail: Option, + /// Optional copy-pasteable remedy. + pub fix: Option, + /// Model ids the server serves (empty unless it answered with a list). + pub models: Vec, +} + +impl ProbeFinding { + /// Whether this server can run the agent as configured. + pub fn is_usable(&self) -> bool { + self.kind == FindingKind::Usable + } +} + +/// The browser facts a diagnosis needs, sampled by the platform edge. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BrowserFacts { + /// This page's origin, e.g. `https://lightplayer.app` — the exact + /// string a server's allow-list needs. + pub page_origin: String, + /// The page itself is served over https (so `http://…` targets are + /// subject to the browser's mixed-content and local-network rules). + pub page_is_https: bool, + /// WebKit/Safari, which refuses plain-http localhost requests from an + /// https page where Chrome and Firefox allow them. + pub is_safari: bool, +} + +/// The headline over a set of findings. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProbeSummary { + pub level: ProbeLevel, + pub headline: String, + pub detail: Option, +} + +/// The probe slice of the settings view: what the popover renders under the +/// base-URL field. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LocalModelProbeState { + /// A probe is in flight (buttons disabled, spinner copy shown). + pub running: bool, + /// What is being probed right now, e.g. `Testing http://localhost:…`. + pub running_label: Option, + pub summary: Option, + /// Findings worth showing, most useful first. + pub findings: Vec, +} + +/// Bring a hand-typed base URL to the shape the provider needs: a scheme, no +/// trailing slash, and the `/v1` prefix these servers serve their +/// OpenAI-compatible routes under. Returns `None` for blank input. +/// +/// The `/v1` completion matters: `http://localhost:11434` is the address +/// Ollama prints, and it 404s every OpenAI-compatible path. +pub fn normalize_base_url(input: &str) -> Option { + let trimmed = input.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + let with_scheme = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + }; + // Append `/v1` only when no path was given at all — a user who typed + // `/v1beta` or `/openai/v1` means it. + let after_scheme = with_scheme.split_once("://").map_or("", |(_, rest)| rest); + if after_scheme.contains('/') { + Some(with_scheme) + } else { + Some(format!("{with_scheme}/v1")) + } +} + +/// The model-list URL for a base URL. +pub fn models_url(base_url: &str) -> String { + format!("{}/models", base_url.trim_end_matches('/')) +} + +/// The same URL against the IPv4 loopback literal. A server bound to +/// `127.0.0.1` is unreachable at `localhost` whenever the browser resolves +/// that name to `::1` first — one of the more baffling local-server +/// failures, and free to rule out. +pub fn ipv4_loopback_variant(base_url: &str) -> Option { + let (scheme, rest) = base_url.split_once("://")?; + let host_end = rest.find('/').unwrap_or(rest.len()); + let (authority, path) = rest.split_at(host_end); + let port = authority.split_once(':').map(|(_, port)| port); + if !authority.starts_with("localhost") { + return None; + } + Some(match port { + Some(port) => format!("{scheme}://127.0.0.1:{port}{path}"), + None => format!("{scheme}://127.0.0.1{path}"), + }) +} + +/// Whether a URL points at this machine. +pub fn is_loopback_url(base_url: &str) -> bool { + let host = base_url + .split_once("://") + .map_or(base_url, |(_, rest)| rest) + .split('/') + .next() + .unwrap_or_default(); + let host = host.rsplit_once(':').map_or(host, |(host, _)| host); + matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1") + || host.ends_with(".localhost") + || host.starts_with("127.") +} + +/// The known server whose default base URL this is, if any. +pub fn known_server(base_url: &str) -> Option<&'static LocalServer> { + let normalized = normalize_base_url(base_url)?; + COMMON_LOCAL_SERVERS.iter().find(|server| { + server.base_url == normalized + || ipv4_loopback_variant(server.base_url).as_deref() == Some(normalized.as_str()) + }) +} + +/// The CORS remedy for a URL: the known server's own instructions when we +/// recognize it, the protocol-level requirement otherwise. +pub fn cors_fix_for(base_url: &str, page_origin: &str) -> String { + let origin = if page_origin.is_empty() { + "this page's origin" + } else { + page_origin + }; + match known_server(base_url) { + Some(server) => server.cors_fix.replace("{origin}", origin), + None => format!( + "The server must answer with Access-Control-Allow-Origin: {origin} \ + (or *) — check its CORS / allowed-origins setting." + ), + } +} + +/// Turn one attempt into rendered copy. +/// +/// `configured_model` is the model id the settings currently name, so a +/// server that answers but does not serve that id is reported as the +/// misconfiguration it is rather than a success. +pub fn diagnose( + base_url: &str, + outcome: ProbeOutcome, + facts: &BrowserFacts, + configured_model: Option<&str>, +) -> ProbeFinding { + let server_label = known_server(base_url).map(|server| server.label.to_string()); + let finding = |kind: FindingKind, + level: ProbeLevel, + headline: String, + detail: Option, + fix: Option, + models: Vec| ProbeFinding { + base_url: base_url.to_string(), + server_label: server_label.clone(), + kind, + level, + headline, + detail, + fix, + models, + }; + match outcome { + ProbeOutcome::Models(models) if models.is_empty() => finding( + FindingKind::NoModels, + ProbeLevel::Warn, + "Answered, but serves no models".to_string(), + Some("The server is running and reachable — it just has nothing loaded.".to_string()), + Some("Pull or load a model first (Ollama: ollama pull qwen3-coder:30b).".to_string()), + models, + ), + ProbeOutcome::Models(models) => { + let count = models.len(); + let plural = if count == 1 { "model" } else { "models" }; + let missing = configured_model + .filter(|model| !models.iter().any(|served| served == model)) + .map(str::to_string); + match missing { + Some(model) => finding( + FindingKind::WrongModel, + ProbeLevel::Warn, + format!("Connected — but this server does not serve “{model}”"), + Some(format!("It offers {count} other {plural}; pick one below.")), + None, + models, + ), + None => finding( + FindingKind::Usable, + ProbeLevel::Ok, + format!("Connected — {count} {plural} available"), + None, + None, + models, + ), + } + } + ProbeOutcome::CorsBlocked => finding( + FindingKind::Cors, + ProbeLevel::Warn, + "Something is listening, but the browser blocked the response (CORS)".to_string(), + Some( + "The connection succeeded, so the address is reachable — whatever \ + answered did not allow this page to read it." + .to_string(), + ), + Some(cors_fix_for(base_url, &facts.page_origin)), + Vec::new(), + ), + ProbeOutcome::Status { status, body } => diagnose_status(finding, status, &body, base_url), + ProbeOutcome::Unreachable { detail } => { + let mut hints = Vec::new(); + if is_loopback_url(base_url) && facts.page_is_https { + if facts.is_safari { + hints.push( + "Safari refuses http://localhost requests from an https page — \ + try Chrome, or run Studio from a local http:// address." + .to_string(), + ); + } else { + hints.push( + "A browser can also block a public site from reaching your local \ + network — check this site's permissions in the address bar." + .to_string(), + ); + } + } + finding( + FindingKind::Unreachable, + ProbeLevel::Error, + "Nothing answered here".to_string(), + Some(match detail.is_empty() { + true => "No server accepted the connection.".to_string(), + false => format!("No server accepted the connection ({detail})."), + }), + hints.pop(), + Vec::new(), + ) + } + ProbeOutcome::BadBody(detail) => finding( + FindingKind::BadBody, + ProbeLevel::Warn, + "Something answered, but not an OpenAI-compatible model list".to_string(), + Some(detail), + Some( + "Check the base URL — these servers serve the OpenAI routes under \ + a prefix such as /v1." + .to_string(), + ), + Vec::new(), + ), + } +} + +/// Non-2xx statuses, which are the informative failures: the server exists, +/// it just said no. +fn diagnose_status( + finding: impl Fn( + FindingKind, + ProbeLevel, + String, + Option, + Option, + Vec, + ) -> ProbeFinding, + status: u16, + body: &str, + base_url: &str, +) -> ProbeFinding { + let excerpt = body_excerpt(body); + match status { + 401 | 403 => finding( + FindingKind::HttpStatus, + ProbeLevel::Warn, + format!("The server answered {status} — it wants an API key"), + excerpt, + Some("Paste the key the server was started with in the API key field.".to_string()), + Vec::new(), + ), + 404 => finding( + FindingKind::HttpStatus, + ProbeLevel::Warn, + "Reachable, but there is no model list at this path (404)".to_string(), + Some(format!("Nothing is served at {}.", models_url(base_url))), + Some( + "Most servers expect the base URL to end in /v1 \ + (e.g. http://localhost:11434/v1)." + .to_string(), + ), + Vec::new(), + ), + status if status >= 500 => finding( + FindingKind::HttpStatus, + ProbeLevel::Warn, + format!("The server answered {status} — it is up but erroring"), + excerpt, + Some("Check the server's own log for the failure.".to_string()), + Vec::new(), + ), + status => finding( + FindingKind::HttpStatus, + ProbeLevel::Warn, + format!("The server answered {status}"), + excerpt, + None, + Vec::new(), + ), + } +} + +/// The summary over a scan's findings. +pub fn scan_summary(findings: &[ProbeFinding], facts: &BrowserFacts) -> ProbeSummary { + let usable: Vec<&ProbeFinding> = findings.iter().filter(|f| f.is_usable()).collect(); + if let Some(first) = usable.first() { + let name = first + .server_label + .clone() + .unwrap_or_else(|| first.base_url.clone()); + return ProbeSummary { + level: ProbeLevel::Ok, + headline: match usable.len() { + 1 => format!("Found {name}"), + more => format!("Found {name} and {} more", more - 1), + }, + detail: Some("Pick a server and model below to use it.".to_string()), + }; + } + // Nothing runnable. Lead with the closest miss, in the order that gets a + // user to a working setup fastest: a server that only needs a model, then + // one that only needs to allow this page, then anything else that spoke. + if let Some(empty) = findings + .iter() + .find(|f| matches!(f.kind, FindingKind::NoModels | FindingKind::WrongModel)) + { + return ProbeSummary { + level: ProbeLevel::Warn, + headline: empty.headline.clone(), + detail: empty.detail.clone(), + }; + } + if let Some(blocked) = findings.iter().find(|f| f.kind == FindingKind::Cors) { + return ProbeSummary { + level: ProbeLevel::Warn, + headline: format!( + "Something answered at {}, but this page cannot read it yet", + blocked.base_url + ), + detail: Some("Usually one setting on the server — see below.".to_string()), + }; + } + if let Some(warned) = findings.iter().find(|f| f.level == ProbeLevel::Warn) { + return ProbeSummary { + level: ProbeLevel::Warn, + headline: warned.headline.clone(), + detail: warned.detail.clone(), + }; + } + ProbeSummary { + level: ProbeLevel::Error, + headline: format!( + "No local server answered on {} common {}", + findings.len(), + if findings.len() == 1 { "port" } else { "ports" } + ), + detail: Some(match facts.page_is_https && facts.is_safari { + true => "Start your server, or try Chrome — Safari blocks \ + http://localhost from an https page." + .to_string(), + false => "Start the server first, then scan again — or type its \ + address above and press Test." + .to_string(), + }), + } +} + +/// The model ids a `/models` body lists, in the order the server gave them. +/// +/// Reads through the agent crate's discovery parser, so a diagnosis and the +/// settings dropdown can never disagree about what a body means — including +/// the `{"data":null}` empty listing Ollama answers with nothing pulled. +pub fn parse_models(body: &str) -> Result, String> { + lpa_agent::provider::model_discovery::parse_models_page(body) + .map(|models| models.into_iter().map(|model| model.id).collect()) + .map_err(|error| match error { + lpa_agent::ListModelsError::Parse { message } => { + format!("the response was not a model list: {message}") + } + other => format!("{other:?}"), + }) +} + +/// A short, quote-worthy slice of an error body. +fn body_excerpt(body: &str) -> Option { + let trimmed = body.trim(); + if trimmed.is_empty() { + return None; + } + // Servers usually wrap the reason in {"error": …}; surface that when + // it is there, and the raw text otherwise. + let message = serde_json::from_str::(trimmed) + .ok() + .and_then(|json| { + let error = json.get("error")?; + error + .get("message") + .and_then(Value::as_str) + .or_else(|| error.as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| trimmed.to_string()); + Some(short(&message, 160)) +} + +fn short(text: &str, max: usize) -> String { + let text = text.trim(); + match text.char_indices().nth(max) { + Some((index, _)) => format!("{}…", &text[..index]), + None => text.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_urls_normalize_scheme_slash_and_v1() { + assert_eq!( + normalize_base_url("localhost:11434").as_deref(), + Some("http://localhost:11434/v1") + ); + assert_eq!( + normalize_base_url(" http://localhost:11434/ ").as_deref(), + Some("http://localhost:11434/v1") + ); + assert_eq!( + normalize_base_url("http://localhost:11434/v1").as_deref(), + Some("http://localhost:11434/v1") + ); + // An explicit path is the user's business. + assert_eq!( + normalize_base_url("http://box:8080/openai/v1").as_deref(), + Some("http://box:8080/openai/v1") + ); + assert_eq!(normalize_base_url(" "), None); + } + + #[test] + fn ipv4_variant_only_rewrites_localhost() { + assert_eq!( + ipv4_loopback_variant("http://localhost:1234/v1").as_deref(), + Some("http://127.0.0.1:1234/v1") + ); + assert_eq!(ipv4_loopback_variant("http://127.0.0.1:1234/v1"), None); + assert_eq!(ipv4_loopback_variant("http://box:1234/v1"), None); + } + + #[test] + fn loopback_detection_covers_the_usual_spellings() { + assert!(is_loopback_url("http://localhost:11434/v1")); + assert!(is_loopback_url("http://127.0.0.1:11434/v1")); + assert!(is_loopback_url("http://[::1]:11434/v1")); + assert!(!is_loopback_url("http://192.168.1.9:11434/v1")); + assert!(!is_loopback_url("https://api.openai.com/v1")); + } + + #[test] + fn known_servers_are_recognized_through_both_loopback_spellings() { + assert_eq!( + known_server("http://localhost:11434/v1").map(|s| s.label), + Some("Ollama") + ); + // Un-normalized input still resolves. + assert_eq!( + known_server("localhost:1234").map(|s| s.label), + Some("LM Studio") + ); + assert_eq!( + known_server("http://127.0.0.1:11434/v1").map(|s| s.label), + Some("Ollama") + ); + assert_eq!(known_server("http://localhost:9999/v1"), None); + } + + #[test] + fn cors_fix_names_the_real_origin_and_the_real_server() { + let fix = cors_fix_for("http://localhost:11434/v1", "https://lightplayer.app"); + assert!( + fix.contains("OLLAMA_ORIGINS=https://lightplayer.app"), + "{fix}" + ); + // Unknown servers get the protocol-level requirement instead. + let fix = cors_fix_for("http://box:9999/v1", "https://lightplayer.app"); + assert!( + fix.contains("Access-Control-Allow-Origin: https://lightplayer.app"), + "{fix}" + ); + } + + #[test] + fn cors_blocked_is_reported_as_reachable_with_a_fix() { + let finding = diagnose( + "http://localhost:11434/v1", + ProbeOutcome::CorsBlocked, + &facts(), + None, + ); + assert_eq!(finding.level, ProbeLevel::Warn); + assert_eq!(finding.server_label.as_deref(), Some("Ollama")); + assert!(finding.headline.contains("CORS"), "{}", finding.headline); + // The opaque probe proves a listener, not which product it is: the + // headline must not claim the port's usual owner is running. + assert!(!finding.headline.contains("Ollama"), "{}", finding.headline); + assert!(finding.fix.expect("fix").contains("OLLAMA_ORIGINS")); + } + + #[test] + fn unreachable_loopback_over_https_explains_the_browser_policy() { + let mut safari = facts(); + safari.is_safari = true; + let finding = diagnose( + "http://localhost:11434/v1", + ProbeOutcome::Unreachable { + detail: "TypeError: Load failed".to_string(), + }, + &safari, + None, + ); + assert_eq!(finding.level, ProbeLevel::Error); + assert!(finding.fix.expect("fix").contains("Safari")); + + // Chrome/Firefox get the local-network permission hint instead. + let finding = diagnose( + "http://localhost:11434/v1", + ProbeOutcome::Unreachable { + detail: String::new(), + }, + &facts(), + None, + ); + assert!(finding.fix.expect("fix").contains("local network")); + + // A remote server carries no browser-policy hint at all. + let finding = diagnose( + "http://box.lan:11434/v1", + ProbeOutcome::Unreachable { + detail: String::new(), + }, + &facts(), + None, + ); + assert_eq!(finding.fix, None); + } + + #[test] + fn a_served_model_list_is_a_success_unless_the_configured_id_is_absent() { + let models = vec!["qwen3-coder:30b".to_string(), "llama3.2".to_string()]; + let finding = diagnose( + "http://localhost:11434/v1", + ProbeOutcome::Models(models.clone()), + &facts(), + Some("llama3.2"), + ); + assert_eq!(finding.level, ProbeLevel::Ok); + assert!( + finding.headline.contains("2 models"), + "{}", + finding.headline + ); + + let finding = diagnose( + "http://localhost:11434/v1", + ProbeOutcome::Models(models), + &facts(), + Some("gpt-4o"), + ); + assert_eq!(finding.level, ProbeLevel::Warn); + assert!(finding.headline.contains("gpt-4o"), "{}", finding.headline); + assert_eq!(finding.models.len(), 2); + } + + #[test] + fn an_empty_model_list_says_load_a_model() { + let finding = diagnose( + "http://localhost:11434/v1", + ProbeOutcome::Models(Vec::new()), + &facts(), + None, + ); + assert_eq!(finding.level, ProbeLevel::Warn); + assert!(finding.fix.expect("fix").contains("ollama pull")); + } + + #[test] + fn statuses_map_to_their_own_remedies() { + let key = diagnose( + "http://localhost:1234/v1", + ProbeOutcome::Status { + status: 401, + body: r#"{"error":{"message":"missing api key"}}"#.to_string(), + }, + &facts(), + None, + ); + assert!(key.headline.contains("API key"), "{}", key.headline); + assert_eq!(key.detail.as_deref(), Some("missing api key")); + + let missing_path = diagnose( + "http://localhost:11434", + ProbeOutcome::Status { + status: 404, + body: "404 page not found".to_string(), + }, + &facts(), + None, + ); + assert!(missing_path.fix.expect("fix").contains("/v1")); + assert_eq!( + missing_path.detail.as_deref(), + Some("Nothing is served at http://localhost:11434/models.") + ); + + let down = diagnose( + "http://localhost:8000/v1", + ProbeOutcome::Status { + status: 503, + body: String::new(), + }, + &facts(), + None, + ); + assert!(down.headline.contains("503"), "{}", down.headline); + assert_eq!(down.detail, None); + } + + #[test] + fn summary_leads_with_a_usable_server_then_a_blocked_one() { + let found = scan_summary( + &[ + diagnose( + "http://localhost:1234/v1", + ProbeOutcome::Unreachable { + detail: String::new(), + }, + &facts(), + None, + ), + diagnose( + "http://localhost:11434/v1", + ProbeOutcome::Models(vec!["llama3.2".to_string()]), + &facts(), + None, + ), + ], + &facts(), + ); + assert_eq!(found.level, ProbeLevel::Ok); + assert_eq!(found.headline, "Found Ollama"); + + let blocked = scan_summary( + &[diagnose( + "http://localhost:11434/v1", + ProbeOutcome::CorsBlocked, + &facts(), + None, + )], + &facts(), + ); + assert_eq!(blocked.level, ProbeLevel::Warn); + assert!( + blocked + .headline + .contains("Something answered at http://localhost:11434/v1"), + "{blocked:?}" + ); + } + + #[test] + fn a_server_with_no_models_outranks_an_unreadable_one_in_the_summary() { + // The real shape of "Ollama is running but nothing is pulled": a + // readable answer, so it must not be summarized as a CORS problem. + let summary = scan_summary( + &[ + diagnose( + "http://localhost:1234/v1", + ProbeOutcome::CorsBlocked, + &facts(), + None, + ), + diagnose( + "http://localhost:11434/v1", + ProbeOutcome::Models(Vec::new()), + &facts(), + None, + ), + ], + &facts(), + ); + assert_eq!(summary.level, ProbeLevel::Warn); + assert!(summary.headline.contains("serves no models"), "{summary:?}"); + } + + #[test] + fn an_all_quiet_scan_says_so_and_names_the_safari_case() { + let quiet: Vec = COMMON_LOCAL_SERVERS + .iter() + .map(|server| { + diagnose( + server.base_url, + ProbeOutcome::Unreachable { + detail: String::new(), + }, + &facts(), + None, + ) + }) + .collect(); + let summary = scan_summary(&quiet, &facts()); + assert_eq!(summary.level, ProbeLevel::Error); + assert!( + summary + .headline + .contains(&format!("{} common ports", COMMON_LOCAL_SERVERS.len())), + "{summary:?}" + ); + assert!(summary.detail.expect("detail").contains("Start the server")); + + let mut safari = facts(); + safari.is_safari = true; + let summary = scan_summary(&quiet, &safari); + assert!(summary.detail.expect("detail").contains("Safari")); + } + + #[test] + fn model_lists_parse_and_report_their_own_shape_failures() { + let models = parse_models(r#"{"object":"list","data":[{"id":"a"},{"id":"b"}]}"#).unwrap(); + assert_eq!(models, vec!["a".to_string(), "b".to_string()]); + // Ollama's native /api/tags shape is not the OpenAI one. + // Ollama serving nothing: a list object with a null `data`. + assert_eq!( + parse_models(r#"{"object":"list","data":null}"#).unwrap(), + Vec::::new() + ); + // Ollama's native /api/tags shape is not the OpenAI one — a body + // with no `data` at all means the base URL is missing its /v1. + let error = parse_models(r#"{"models":[{"name":"llama3.2"}]}"#).unwrap_err(); + assert!(error.contains("data"), "{error}"); + let error = parse_models("404").unwrap_err(); + assert!(error.contains("not a model list"), "{error}"); + } + + #[test] + fn every_candidate_is_a_known_v1_url_with_an_origin_slot() { + for server in COMMON_LOCAL_SERVERS { + assert_eq!( + normalize_base_url(server.base_url).as_deref(), + Some(server.base_url), + "{} base URL is not already normalized", + server.label + ); + assert!(is_loopback_url(server.base_url), "{}", server.label); + let fix = cors_fix_for(server.base_url, "https://lightplayer.app"); + assert!(!fix.contains("{origin}"), "{}: {fix}", server.label); + } + } + + fn facts() -> BrowserFacts { + BrowserFacts { + page_origin: "https://lightplayer.app".to_string(), + page_is_https: true, + is_safari: false, + } + } +} diff --git a/lp-app/lpa-studio-core/src/app/settings/mod.rs b/lp-app/lpa-studio-core/src/app/settings/mod.rs index 4336c170d..378ee4484 100644 --- a/lp-app/lpa-studio-core/src/app/settings/mod.rs +++ b/lp-app/lpa-studio-core/src/app/settings/mod.rs @@ -21,6 +21,7 @@ pub mod agent_models; pub mod agent_provider; +pub mod local_model_probe; pub mod settings_command; pub mod settings_layer; pub mod settings_store; @@ -29,6 +30,10 @@ pub mod ui_settings_view; pub use agent_models::{AgentModelsFetch, AgentModelsState, discovery_fingerprint}; pub use agent_provider::{AgentProvider, AgentProviderGuidance, provider_guidance}; +pub use local_model_probe::{ + BrowserFacts, COMMON_LOCAL_SERVERS, FindingKind, LocalModelProbeState, LocalServer, + ProbeFinding, ProbeLevel, ProbeOutcome, ProbeSummary, +}; pub use settings_command::SettingsCommand; pub use settings_layer::SettingsLayer; pub use settings_store::SettingsStore; diff --git a/lp-app/lpa-studio-core/src/app/settings/settings_store.rs b/lp-app/lpa-studio-core/src/app/settings/settings_store.rs index bec8df2f2..cdb9a2875 100644 --- a/lp-app/lpa-studio-core/src/app/settings/settings_store.rs +++ b/lp-app/lpa-studio-core/src/app/settings/settings_store.rs @@ -5,14 +5,16 @@ use lpa_agent::{AnthropicConfig, ListModelsError, ModelInfo, OpenAiCompatConfig} use crate::app::agent::agent_pricing::AgentCostRates; use crate::app::agent::agent_provider_config::AgentProviderConfig; -use crate::app::settings::agent_models::{AgentModelsFetch, AgentModelsState, models_error_copy}; +use crate::app::settings::agent_models::{ + AgentModelsFetch, AgentModelsState, model_options, models_error_copy, +}; use crate::app::settings::agent_provider::{AgentProvider, provider_guidance}; use crate::app::settings::settings_layer::SettingsLayer; use crate::app::settings::studio_settings::{ DEFAULT_AGENT_MODEL, DEFAULT_OPENROUTER_MODEL, OPENROUTER_BASE_URL, StudioSettings, }; use crate::app::settings::ui_settings_view::{ - UiAgentSettingsView, UiModelOption, UiSettingsView, masked_key_preview, + UiAgentSettingsView, UiSettingsView, masked_key_preview, }; /// Two overlays over the baked defaults, merged per-field with @@ -85,6 +87,38 @@ impl SettingsStore { /// Set or clear the user's model override (trimmed; empty ⇒ clear). pub fn set_agent_model(&mut self, model: Option) { self.user.agent.model = normalized(model); + self.adopt_discovered_rates(); + } + + /// Carry the chosen model's published rates onto the cost-estimate + /// overrides, from the provider's own listing. + /// + /// Nobody fills those fields in by hand, and the alternative is worse + /// than leaving them empty: rates from the PREVIOUSLY chosen model + /// would quietly mis-price the new one. So a model the listing prices + /// sets them, and a model it does not price clears them — falling back + /// to the built-in table for known ids. Providers that publish no rates + /// at all (Anthropic, OpenAI, local servers) leave any hand-entered + /// values alone, since there is no listing to disagree with. + fn adopt_discovered_rates(&mut self) { + let provider = self.agent_provider(); + let Some(model) = self.agent_model().map(str::to_string) else { + return; + }; + let Some(AgentModelsFetch::Loaded { models, .. }) = + self.agent_models(provider).map(|state| &state.fetch) + else { + return; + }; + if !models.iter().any(|listed| listed.price.is_some()) { + return; + } + let price = models + .iter() + .find(|listed| listed.id == model) + .and_then(|listed| listed.price); + self.user.agent.price_input_per_mtok = price.map(|price| price.input_per_mtok); + self.user.agent.price_output_per_mtok = price.map(|price| price.output_per_mtok); } /// Set or clear the input-rate override from its text-field value @@ -397,25 +431,19 @@ impl SettingsStore { }; // The discovered-model slice (P8): options only from a Loaded // fetch; Loading and Failed render as flags on the free-text path. - let (model_options, models_loading, models_error) = - match self.agent_models(provider).map(|state| &state.fetch) { - Some(AgentModelsFetch::Loading) => (Vec::new(), true, None), - Some(AgentModelsFetch::Loaded { models, .. }) => ( - models - .iter() - .map(|model| UiModelOption { - id: model.id.clone(), - label: model.display_name.clone(), - }) - .collect(), - false, - None, - ), - Some(AgentModelsFetch::Failed { error }) => { - (Vec::new(), false, Some(models_error_copy(provider, error))) - } - None => (Vec::new(), false, None), - }; + let (model_options, models_loading, models_error) = match self + .agent_models(provider) + .map(|state| &state.fetch) + { + Some(AgentModelsFetch::Loading) => (Vec::new(), true, None), + // Ranked and filtered here, not rendered raw: OpenRouter + // alone lists ~340 models (see `model_options`). + Some(AgentModelsFetch::Loaded { models, .. }) => (model_options(models), false, None), + Some(AgentModelsFetch::Failed { error }) => { + (Vec::new(), false, Some(models_error_copy(provider, error))) + } + None => (Vec::new(), false, None), + }; UiSettingsView { agent: UiAgentSettingsView { provider, @@ -499,6 +527,7 @@ fn normalized_settings(mut settings: StudioSettings) -> StudioSettings { mod tests { use super::*; use crate::app::settings::studio_settings::AgentSettings; + use crate::app::settings::ui_settings_view::UiModelOption; #[test] fn defaults_apply_with_no_layers() { @@ -742,11 +771,70 @@ mod tests { fn model(id: &str, display_name: Option<&str>) -> ModelInfo { ModelInfo { - id: id.to_string(), display_name: display_name.map(str::to_string), + ..ModelInfo::new(id) } } + #[test] + fn picking_a_priced_model_carries_its_rates_and_a_later_pick_replaces_them() { + let mut store = SettingsStore::default(); + store.set_agent_provider(Some(AgentProvider::OpenRouter)); + store.set_agent_openrouter_api_key(Some("sk-or-x".to_string())); + let priced = |id: &str, input: f64, output: f64| ModelInfo { + price: Some(lpa_agent::ModelPrice { + input_per_mtok: input, + output_per_mtok: output, + }), + ..ModelInfo::new(id) + }; + store.request_agent_models(AgentProvider::OpenRouter, "fp".into(), false); + store.agent_models_loaded( + AgentProvider::OpenRouter, + "fp", + Ok(vec![ + priced("anthropic/claude-opus-5", 5.0, 25.0), + priced("qwen/qwen3-coder", 0.07, 0.27), + ModelInfo::new("free/unpriced"), + ]), + 1.0, + ); + + store.set_agent_model(Some("anthropic/claude-opus-5".to_string())); + let rates = store.agent_cost_rates().expect("rates"); + assert_eq!((rates.input_per_mtok, rates.output_per_mtok), (5.0, 25.0)); + + store.set_agent_model(Some("qwen/qwen3-coder".to_string())); + let rates = store.agent_cost_rates().expect("rates"); + assert_eq!((rates.input_per_mtok, rates.output_per_mtok), (0.07, 0.27)); + + // A model the listing does not price clears them rather than + // pricing it at the last model's rates. + store.set_agent_model(Some("free/unpriced".to_string())); + assert_eq!(store.user_layer().agent.price_input_per_mtok, None); + assert_eq!(store.user_layer().agent.price_output_per_mtok, None); + } + + #[test] + fn a_listing_without_rates_leaves_hand_entered_ones_alone() { + // Anthropic publishes no pricing, so its listing has nothing to say + // about the rate fields — a user's own values must survive. + let mut store = SettingsStore::default(); + store.set_agent_anthropic_api_key(Some("sk-ant-x".to_string())); + store.set_agent_price_input_per_mtok(Some("2".to_string())); + store.set_agent_price_output_per_mtok(Some("8".to_string())); + store.request_agent_models(AgentProvider::Anthropic, "fp".into(), false); + store.agent_models_loaded( + AgentProvider::Anthropic, + "fp", + Ok(vec![ModelInfo::new("claude-opus-5")]), + 1.0, + ); + store.set_agent_model(Some("claude-opus-5".to_string())); + let rates = store.agent_cost_rates().expect("rates"); + assert_eq!((rates.input_per_mtok, rates.output_per_mtok), (2.0, 8.0)); + } + #[test] fn discovery_config_resolves_without_a_model() { let mut store = SettingsStore::default(); @@ -796,10 +884,12 @@ mod tests { UiModelOption { id: "claude-sonnet-5".into(), label: Some("Claude Sonnet 5".into()), + detail: None, }, UiModelOption { id: "claude-haiku-4-5".into(), label: None, + detail: None, }, ] ); diff --git a/lp-app/lpa-studio-core/src/app/settings/ui_settings_view.rs b/lp-app/lpa-studio-core/src/app/settings/ui_settings_view.rs index 20bb43d7a..9a2c165ac 100644 --- a/lp-app/lpa-studio-core/src/app/settings/ui_settings_view.rs +++ b/lp-app/lpa-studio-core/src/app/settings/ui_settings_view.rs @@ -115,6 +115,10 @@ pub struct UiModelOption { /// Human-readable name when the provider supplies one (the option's /// text; the id renders otherwise). pub label: Option, + /// The published facts that put this option where it is — its coding + /// score and rates, when the provider publishes them (`code 78 · $5/$25`). + /// Rendered after the name so the ordering explains itself. + pub detail: Option, } /// Mask an API key for display: enough of the tail to recognize which key diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs index 87ddd93c5..ea40606fe 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs @@ -4836,8 +4836,8 @@ mod tests { studio.set_agent_models_fetcher(|_config| { Box::pin(async { Ok(vec![ModelInfo { - id: "claude-sonnet-5".to_string(), display_name: Some("Claude Sonnet 5".to_string()), + ..ModelInfo::new("claude-sonnet-5".to_string()) }]) }) }); @@ -4921,8 +4921,8 @@ mod tests { studio.set_agent_models_fetcher(|_config| { Box::pin(async { Ok(vec![ModelInfo { - id: "claude-haiku-4".to_string(), display_name: Some("Claude Haiku 4".to_string()), + ..ModelInfo::new("claude-haiku-4".to_string()) }]) }) }); diff --git a/lp-app/lpa-studio-core/src/lib.rs b/lp-app/lpa-studio-core/src/lib.rs index 72617f836..da9640640 100644 --- a/lp-app/lpa-studio-core/src/lib.rs +++ b/lp-app/lpa-studio-core/src/lib.rs @@ -96,9 +96,10 @@ pub use app::server::{ StudioServerClient, }; pub use app::settings::{ - AgentProvider, AgentProviderGuidance, AgentSettings, DEFAULT_AGENT_MODEL, SettingsCommand, - SettingsLayer, SettingsStore, StudioSettings, UiAgentSettingsView, UiModelOption, - UiSettingsView, provider_guidance, + AgentProvider, AgentProviderGuidance, AgentSettings, BrowserFacts, COMMON_LOCAL_SERVERS, + DEFAULT_AGENT_MODEL, FindingKind, LocalModelProbeState, LocalServer, ProbeFinding, ProbeLevel, + ProbeOutcome, ProbeSummary, SettingsCommand, SettingsLayer, SettingsStore, StudioSettings, + UiAgentSettingsView, UiModelOption, UiSettingsView, provider_guidance, }; pub use app::share::{ NODE_KIND, NodeEnvelope, PACKAGE_KIND, PackageEnvelope, SHARE_FORMAT_VERSION, ShareError, diff --git a/lp-app/lpa-studio-web/Cargo.toml b/lp-app/lpa-studio-web/Cargo.toml index 9baa19533..879625796 100644 --- a/lp-app/lpa-studio-web/Cargo.toml +++ b/lp-app/lpa-studio-web/Cargo.toml @@ -70,6 +70,7 @@ web-sys = { version = "0.3", features = [ "OffscreenCanvas", "Performance", "PointerEvent", + "RequestMode", "ResizeObserver", "ResizeObserverEntry", "Storage", diff --git a/lp-app/lpa-studio-web/src/app/layout/studio_settings_popover.rs b/lp-app/lpa-studio-web/src/app/layout/studio_settings_popover.rs index eda28f25e..be824cd76 100644 --- a/lp-app/lpa-studio-web/src/app/layout/studio_settings_popover.rs +++ b/lp-app/lpa-studio-web/src/app/layout/studio_settings_popover.rs @@ -8,11 +8,12 @@ use dioxus::prelude::*; use lpa_studio_core::{ - AgentProvider, SettingsCommand, SettingsLayer, UiAgentSettingsView, UiModelOption, - UiSettingsView, + AgentProvider, LocalModelProbeState, ProbeFinding, ProbeLevel, SettingsCommand, SettingsLayer, + UiAgentSettingsView, UiModelOption, UiSettingsView, }; use crate::base::{IconPopoverButton, PopoverPlacement, StudioIconName}; +use crate::local_model_probe::ProbeRequest; /// The model dropdown's free-text escape hatch (its option value; never a /// plausible model id). @@ -28,6 +29,15 @@ pub fn StudioSettingsPopover( // The OpenRouter connect wiring (App-provided context; absent under // stories, which render the section directly with fixture props). let openrouter_error = try_consume_context::>>(); + // Local-server discovery state. It lives HERE, not in the section: the + // popover content unmounts when the popover closes, and a result the + // user just read should still be there when they reopen it. + let mut probe = use_signal(LocalModelProbeState::default); + // The effective model, for the "does this server serve it" check — the + // placeholder IS the effective model whenever one resolves. + let configured_model = (!settings.agent.model_missing) + .then(|| settings.agent.model_placeholder.clone()) + .filter(|_| settings.agent.provider == AgentProvider::Custom); rsx! { IconPopoverButton { class: TRIGGER_CLASS.to_string(), @@ -47,11 +57,44 @@ pub fn StudioSettingsPopover( on_settings, on_connect: move |_| crate::openrouter_oauth::begin_connect(openrouter_error), connect_error: openrouter_error.and_then(|error| error()), + probe: probe(), + on_probe: move |request: ProbeRequest| { + probe.set(crate::local_model_probe::running_state(&request)); + let model = configured_model.clone(); + spawn_probe(probe, request, model); + }, } } } } +/// Run a probe request into the state signal. wasm-only: host builds (unit +/// tests, stories) render fixtures and never probe, so the request simply +/// leaves the in-flight state showing there. +#[cfg_attr( + not(target_arch = "wasm32"), + allow(unused_variables, reason = "host builds never probe") +)] +fn spawn_probe( + #[allow(unused_mut, reason = "only the wasm body writes the signal")] mut probe: Signal< + LocalModelProbeState, + >, + request: ProbeRequest, + model: Option, +) { + #[cfg(target_arch = "wasm32")] + { + // The key the agent itself would send, resolved from the two layers + // this edge owns — the view carries only a masked preview, and a + // server that wants a key must be tested with it. + let api_key = crate::settings_io::effective_custom_api_key(); + wasm_bindgen_futures::spawn_local(async move { + let state = crate::local_model_probe::run(request, api_key, model).await; + probe.set(state); + }); + } +} + /// Requests the selected provider's model list once on mount — the /// settings-surface-open trigger for the discovery dropdown (P8). The /// store debounces repeats against its config fingerprint, so re-opens @@ -63,6 +106,74 @@ fn RequestModelsOnOpen(on_settings: EventHandler) -> Element { rsx! {} } +/// One probed address: what happened, how to fix it, and — when it works — +/// one-click adoption of the server and model it serves. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn ProbeFindingCard(finding: ProbeFinding, on_settings: EventHandler) -> Element { + let base_url = finding.base_url.clone(); + let adopt_url = base_url.clone(); + rsx! { + div { class: "tw:grid tw:min-w-0 tw:gap-1 tw:rounded-sm tw:border {level_border_class(finding.level)} tw:bg-card-muted tw:px-2 tw:py-1.5", + div { class: "tw:flex tw:min-w-0 tw:flex-wrap tw:items-baseline tw:gap-x-2", + code { class: "tw:font-mono tw:text-[11px] tw:text-muted-foreground", "{base_url}" } + if let Some(label) = finding.server_label.as_deref() { + span { class: HINT_CLASS, "{label}" } + } + } + span { class: "tw:text-[11px] tw:font-bold tw:leading-snug {level_text_class(finding.level)}", + "{finding.headline}" + } + if let Some(detail) = finding.detail.as_deref() { + p { class: "tw:m-0 tw:text-[11px] tw:leading-snug tw:text-dim-foreground", "{detail}" } + } + if let Some(fix) = finding.fix.as_deref() { + p { class: "tw:m-0 tw:font-mono tw:text-[11px] tw:leading-snug tw:text-soft-foreground tw:select-all tw:[overflow-wrap:anywhere]", + "{fix}" + } + } + // A model chip adopts the whole answer in one click: this + // server's address AND the id it actually serves. + if !finding.models.is_empty() { + div { class: "tw:flex tw:min-w-0 tw:flex-wrap tw:gap-1", + for model in finding.models.iter().take(MAX_LISTED_MODELS) { + button { + key: "{model}", + class: MODEL_CHIP_CLASS, + r#type: "button", + title: "Use this server and model", + onclick: { + let url = base_url.clone(); + let model = model.clone(); + move |_| { + on_settings.call(SettingsCommand::SetAgentCustomBaseUrl(Some(url.clone()))); + on_settings.call(SettingsCommand::SetAgentModel(Some(model.clone()))); + } + }, + "{model}" + } + } + if finding.models.len() > MAX_LISTED_MODELS { + span { class: HINT_CLASS, "+{finding.models.len() - MAX_LISTED_MODELS} more" } + } + } + } else if finding.level != ProbeLevel::Error { + // Nothing to pick, but the address itself is worth keeping + // (the CORS case: right port, one server setting away). + button { + class: "{CLEAR_BUTTON_CLASS} tw:justify-self-start", + r#type: "button", + title: "Save this address as the base URL", + onclick: move |_| { + on_settings.call(SettingsCommand::SetAgentCustomBaseUrl(Some(adopt_url.clone()))); + }, + "Use this address" + } + } + } + } +} + /// Pure presentation of the agent settings (provider, credentials, model, /// cost rates), driven entirely by the [`UiAgentSettingsView`] DTO. #[component] @@ -77,8 +188,19 @@ pub fn AgentSettingsSection( /// Transient connect-flow failure to surface under the button. #[props(default)] connect_error: Option, + /// Local-server discovery state (Custom provider only). + #[props(default)] + probe: LocalModelProbeState, + /// Test / Scan requests (absent under stories ⇒ the buttons are inert). + #[props(default)] + on_probe: Option>, ) -> Element { let key_hint = key_provenance_hint(&agent); + // Uncontrolled-input mirror for the base-URL field (see its `oninput`): + // presentational state only — the committed value stays the prop. + let mut base_url_draft = use_signal(|| None::); + // What Test probes when the field has not been touched this session. + let base_url_effective = agent.base_url_effective.clone(); let model_hint = model_provenance_hint(&agent); let model_value = agent.model_override.clone().unwrap_or_default(); let base_url_value = agent.base_url_override.clone().unwrap_or_default(); @@ -163,6 +285,10 @@ pub fn AgentSettingsSection( placeholder: base_url_placeholder(&agent), autocomplete: "off", spellcheck: "false", + // The draft mirrors keystrokes so Test probes what + // the user is looking at, not the last committed + // value (the field commits on blur / Enter). + oninput: move |event| base_url_draft.set(Some(event.value())), onchange: move |event| { on_settings.call(SettingsCommand::SetAgentCustomBaseUrl(Some(event.value()))); }, @@ -181,6 +307,58 @@ pub fn AgentSettingsSection( } } } + + // Test / Scan: the two questions a local setup raises — + // "does this address work?" and "where is my server?". + div { class: "tw:flex tw:min-w-0 tw:items-center tw:gap-2 tw:pt-0.5", + button { + class: PROBE_BUTTON_CLASS, + r#type: "button", + disabled: probe.running, + title: "Ask this server for its model list", + onclick: move |_| { + if let Some(on_probe) = on_probe { + let url = base_url_draft() + .or_else(|| base_url_effective.clone()) + .unwrap_or_default(); + on_probe.call(ProbeRequest::TestBaseUrl(url)); + } + }, + "Test connection" + } + button { + class: PROBE_BUTTON_CLASS, + r#type: "button", + disabled: probe.running, + title: "Try the ports Ollama, LM Studio, llama.cpp, vLLM and friends use", + onclick: move |_| { + if let Some(on_probe) = on_probe { + on_probe.call(ProbeRequest::ScanCommonPorts); + } + }, + "Find my server" + } + } + if let Some(running) = probe.running_label.as_deref() { + span { class: "tw:text-[11px] tw:text-status-working-foreground", "{running}" } + } + if let Some(summary) = probe.summary.as_ref() { + div { class: "tw:grid tw:min-w-0 tw:gap-0.5", + span { class: "tw:text-[11px] tw:font-bold {level_text_class(summary.level)}", + "{summary.headline}" + } + if let Some(detail) = summary.detail.as_deref() { + p { class: "tw:m-0 tw:text-[11px] tw:leading-snug tw:text-dim-foreground", "{detail}" } + } + } + } + for finding in probe.findings.iter() { + ProbeFindingCard { + key: "{finding.base_url}", + finding: finding.clone(), + on_settings, + } + } } } @@ -404,9 +582,31 @@ const POPUP_CLASS: &str = "tw:grid tw:w-[min(340px,calc(100vw-24px))] tw:overflo const INPUT_CLASS: &str = "tw:h-7 tw:w-full tw:rounded-sm tw:border tw:border-border-strong tw:bg-card-muted tw:px-1.5 tw:font-mono tw:text-xs tw:text-muted-foreground"; const CLEAR_BUTTON_CLASS: &str = "tw:rounded-sm tw:border tw:border-border-strong tw:bg-card-muted tw:px-1.5 tw:py-0.5 tw:text-[11px] tw:text-muted-foreground tw:hover:text-soft-foreground"; const CONNECT_BUTTON_CLASS: &str = "tw:justify-self-start tw:cursor-pointer tw:rounded-xs tw:border tw:border-accent-border tw:bg-transparent tw:px-3 tw:py-1.5 tw:text-xs tw:font-bold tw:text-accent tw:transition tw:duration-300 tw:hover:bg-accent-wash"; +const PROBE_BUTTON_CLASS: &str = "tw:cursor-pointer tw:rounded-xs tw:border tw:border-border-strong tw:bg-card-muted tw:px-2 tw:py-1 tw:text-[11px] tw:font-bold tw:text-muted-foreground tw:transition tw:duration-200 tw:hover:text-strong-foreground tw:disabled:cursor-default tw:disabled:text-dim-foreground"; +const MODEL_CHIP_CLASS: &str = "tw:cursor-pointer tw:rounded-xs tw:border tw:border-accent-border tw:bg-transparent tw:px-1.5 tw:py-0.5 tw:font-mono tw:text-[11px] tw:text-accent tw:hover:bg-accent-wash"; +/// How many served model ids a finding lists before collapsing the rest. +const MAX_LISTED_MODELS: usize = 6; const LABEL_CLASS: &str = "tw:text-[0.68rem] tw:font-bold tw:uppercase tw:text-subtle-foreground"; const HINT_CLASS: &str = "tw:text-[0.68rem] tw:text-subtle-foreground"; +/// Status foreground for a probe level. +fn level_text_class(level: ProbeLevel) -> &'static str { + match level { + ProbeLevel::Ok => "tw:text-status-good-foreground", + ProbeLevel::Warn => "tw:text-status-warning-foreground", + ProbeLevel::Error => "tw:text-status-error-foreground", + } +} + +/// Status border for a finding card. +fn level_border_class(level: ProbeLevel) -> &'static str { + match level { + ProbeLevel::Ok => "tw:border-status-good-border", + ProbeLevel::Warn => "tw:border-status-warning-border", + ProbeLevel::Error => "tw:border-status-error-border", + } +} + /// One segmented provider button. fn provider_button_class(active: bool) -> &'static str { if active { @@ -490,8 +690,14 @@ fn selected_model_value(agent: &UiAgentSettingsView, custom_mode: bool) -> Strin } /// Option row text: the provider's display name over the raw id. -fn model_option_label(option: &UiModelOption) -> &str { - option.label.as_deref().unwrap_or(&option.id) +fn model_option_label(option: &UiModelOption) -> String { + let name = option.label.as_deref().unwrap_or(&option.id); + match option.detail.as_deref() { + // The detail is why this option sits where it does in the list + // (`code 78 · $5/$25`); without it a ranked order looks arbitrary. + Some(detail) => format!("{name} — {detail}"), + None => name.to_string(), + } } #[cfg(test)] @@ -567,6 +773,7 @@ mod tests { .map(|id| UiModelOption { id: id.to_string(), label: None, + detail: None, }) .collect() } @@ -606,11 +813,13 @@ mod tests { let named = UiModelOption { id: "claude-sonnet-5".to_string(), label: Some("Claude Sonnet 5".to_string()), + detail: None, }; assert_eq!(model_option_label(&named), "Claude Sonnet 5"); let bare = UiModelOption { id: "llama3.2:latest".to_string(), label: None, + detail: None, }; assert_eq!(model_option_label(&bare), "llama3.2:latest"); } diff --git a/lp-app/lpa-studio-web/src/app/layout/studio_settings_popover_stories.rs b/lp-app/lpa-studio-web/src/app/layout/studio_settings_popover_stories.rs index b18b4c544..9c2ef65a0 100644 --- a/lp-app/lpa-studio-web/src/app/layout/studio_settings_popover_stories.rs +++ b/lp-app/lpa-studio-web/src/app/layout/studio_settings_popover_stories.rs @@ -6,9 +6,10 @@ //! or network fetch. use dioxus::prelude::*; +use lpa_studio_core::app::settings::local_model_probe::{self as probe, ProbeOutcome}; use lpa_studio_core::{ - AgentProvider, SettingsLayer, UiAgentSettingsView, UiModelOption, UiSettingsView, - provider_guidance, + AgentProvider, BrowserFacts, LocalModelProbeState, SettingsLayer, UiAgentSettingsView, + UiModelOption, UiSettingsView, provider_guidance, }; use lpa_studio_web_story_macros::story; @@ -82,6 +83,81 @@ pub(crate) fn custom_local_server() -> Element { panel(agent) } +#[story( + description = "A scan that found a local Ollama: the summary leads, and each served model id is a one-click adopt (address + model together)." +)] +pub(crate) fn custom_scan_found_a_server() -> Element { + let findings = vec![diagnosed( + "http://localhost:11434/v1", + ProbeOutcome::Models(vec![ + "qwen3-coder:30b".to_string(), + "qwen3.5:9b".to_string(), + "llama3.2".to_string(), + ]), + )]; + probe_panel(LocalModelProbeState { + running: false, + running_label: None, + summary: Some(probe::scan_summary(&findings, &story_facts())), + findings, + }) +} + +#[story( + description = "The CORS case: the server answered but the browser dropped the response. Reported as reachable, with the exact copy-pasteable fix for the recognized server, plus a dead port for contrast." +)] +pub(crate) fn custom_scan_blocked_by_cors() -> Element { + let findings = vec![ + diagnosed("http://localhost:11434/v1", ProbeOutcome::CorsBlocked), + diagnosed( + "http://localhost:1234/v1", + ProbeOutcome::Status { + status: 401, + body: r#"{"error":{"message":"API key required"}}"#.to_string(), + }, + ), + ]; + probe_panel(LocalModelProbeState { + running: false, + running_label: None, + summary: Some(probe::scan_summary(&findings, &story_facts())), + findings, + }) +} + +#[story( + description = "A scan that found nothing: every common port silent, with the browser-policy hint the summary adds when a page is served over https." +)] +pub(crate) fn custom_scan_found_nothing() -> Element { + let findings: Vec<_> = probe::COMMON_LOCAL_SERVERS + .iter() + .map(|server| { + diagnosed( + server.base_url, + ProbeOutcome::Unreachable { + detail: "TypeError: Failed to fetch".to_string(), + }, + ) + }) + .collect(); + probe_panel(LocalModelProbeState { + running: false, + running_label: None, + summary: Some(probe::scan_summary(&findings, &story_facts())), + // Dead ports are dropped from the list; the summary counts them. + findings: Vec::new(), + }) +} + +#[story( + description = "A scan in flight: both probe buttons disabled while the working-status line names what is being tried." +)] +pub(crate) fn custom_scan_running() -> Element { + probe_panel(crate::local_model_probe::running_state( + &crate::local_model_probe::ProbeRequest::ScanCommonPorts, + )) +} + #[story( description = "OpenRouter selected, not yet connected: the one-click Connect button replaces the key field, with a sample exchange-failure warning underneath." )] @@ -118,14 +194,17 @@ pub(crate) fn model_dropdown_populated() -> Element { UiModelOption { id: "claude-sonnet-5".to_string(), label: Some("Claude Sonnet 5".to_string()), + detail: None, }, UiModelOption { id: "claude-opus-5".to_string(), label: Some("Claude Opus 5".to_string()), + detail: None, }, UiModelOption { id: "claude-haiku-4-5".to_string(), label: Some("Claude Haiku 4.5".to_string()), + detail: None, }, ]; agent.model_override = Some("claude-opus-5".to_string()); @@ -174,10 +253,12 @@ pub(crate) fn model_custom_entry() -> Element { UiModelOption { id: "gpt-5.2".to_string(), label: None, + detail: None, }, UiModelOption { id: "gpt-5.2-mini".to_string(), label: None, + detail: None, }, ]; agent.model_default = None; @@ -210,3 +291,42 @@ fn panel(agent: UiAgentSettingsView) -> Element { } } } + +/// The Custom-provider panel carrying a discovery result. +fn probe_panel(probe: LocalModelProbeState) -> Element { + let agent = custom_agent(); + rsx! { + div { class: "tw:w-[340px] tw:rounded-md tw:border tw:border-status-neutral-border tw:bg-card", + AgentSettingsSection { agent, on_settings: move |_| {}, probe } + } + } +} + +/// The Custom-provider fixture base: local server selected, nothing chosen. +fn custom_agent() -> UiAgentSettingsView { + let mut agent = UiSettingsView::default().agent; + agent.provider = AgentProvider::Custom; + agent.provider_overridden = true; + agent.provider_layer = SettingsLayer::User; + agent.guidance = provider_guidance(AgentProvider::Custom); + agent.api_key_optional = true; + agent.model_default = None; + agent.model_placeholder = "model id from your provider — see its docs".to_string(); + agent.model_missing = true; + agent +} + +/// One finding, diagnosed through the same core path the browser glue uses. +fn diagnosed(base_url: &str, outcome: ProbeOutcome) -> probe::ProbeFinding { + probe::diagnose(base_url, outcome, &story_facts(), None) +} + +/// The deployed-site case these findings describe: an https page reaching +/// for plain-http localhost. +fn story_facts() -> BrowserFacts { + BrowserFacts { + page_origin: "https://lightplayer.app".to_string(), + page_is_https: true, + is_safari: false, + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/agent_chat.rs b/lp-app/lpa-studio-web/src/app/node/agent_chat.rs index d86b319d5..20bf1cbdf 100644 --- a/lp-app/lpa-studio-web/src/app/node/agent_chat.rs +++ b/lp-app/lpa-studio-web/src/app/node/agent_chat.rs @@ -577,6 +577,7 @@ fn model_chip_options(model: &UiAgentModelView) -> Vec { UiModelOption { id: effective.clone(), label: None, + detail: None, }, ); } @@ -908,6 +909,7 @@ mod tests { let listed = UiModelOption { id: "claude-sonnet-5".into(), label: Some("Claude Sonnet 5".into()), + detail: None, }; let mut model = UiAgentModelView { effective: Some("claude-sonnet-5".into()), diff --git a/lp-app/lpa-studio-web/src/app/node/agent_chat_stories.rs b/lp-app/lpa-studio-web/src/app/node/agent_chat_stories.rs index 48b60b337..27881590a 100644 --- a/lp-app/lpa-studio-web/src/app/node/agent_chat_stories.rs +++ b/lp-app/lpa-studio-web/src/app/node/agent_chat_stories.rs @@ -430,14 +430,17 @@ fn model_chip_populated() -> Element { UiModelOption { id: "claude-sonnet-5".to_string(), label: Some("Claude Sonnet 5".to_string()), + detail: None, }, UiModelOption { id: "claude-haiku-4".to_string(), label: Some("Claude Haiku 4".to_string()), + detail: None, }, UiModelOption { id: "claude-opus-5".to_string(), label: Some("Claude Opus 5".to_string()), + detail: None, }, ], loading: false, diff --git a/lp-app/lpa-studio-web/src/local_model_probe.rs b/lp-app/lpa-studio-web/src/local_model_probe.rs new file mode 100644 index 000000000..853902d11 --- /dev/null +++ b/lp-app/lpa-studio-web/src/local_model_probe.rs @@ -0,0 +1,310 @@ +//! Browser glue for local-model discovery: the fetches behind the settings +//! popover's Test and Scan buttons. +//! +//! Every decision — which ports, how to normalize a URL, what a result +//! means, what copy to show — lives in +//! [`lpa_studio_core::app::settings::local_model_probe`]. This file only +//! performs IO and reports [`ProbeOutcome`]s back. +//! +//! The one browser-specific trick worth knowing: a cross-origin `fetch` that +//! a server answers *without* CORS headers fails identically to a fetch at a +//! port where nothing listens — same `TypeError`, no status, no headers. So +//! every failure is retried once in `no-cors` mode, which resolves (with an +//! unreadable opaque response) whenever the connection was actually +//! accepted. Opaque success after a normal failure means "the server is +//! there, it just did not allow this page" — the single most common local- +//! model setup problem, and otherwise indistinguishable from a wrong port. + +#![cfg_attr( + not(target_arch = "wasm32"), + allow( + dead_code, + reason = "the call sites are wasm-only glue; the decisions are host-tested in core" + ) +)] + +use lpa_studio_core::app::settings::local_model_probe::{ + self as probe, LocalModelProbeState, ProbeLevel, +}; + +/// How long one attempt may take. A refused connection fails in +/// milliseconds; this bound is for addresses that swallow packets (a +/// firewalled box, a stale VPN route) so a scan cannot hang the UI. +const PROBE_TIMEOUT_MS: u32 = 3_000; + +/// What the UI asked for. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProbeRequest { + /// Test one base URL (the field's current value). + TestBaseUrl(String), + /// Try every well-known local server port. + ScanCommonPorts, +} + +/// The in-flight state to render while a request runs. +pub fn running_state(request: &ProbeRequest) -> LocalModelProbeState { + LocalModelProbeState { + running: true, + running_label: Some(match request { + ProbeRequest::TestBaseUrl(url) => match probe::normalize_base_url(url) { + Some(url) => format!("Testing {url}…"), + None => "Testing…".to_string(), + }, + ProbeRequest::ScanCommonPorts => format!( + "Looking for local servers on {} common ports…", + probe::COMMON_LOCAL_SERVERS.len() + ), + }), + summary: None, + findings: Vec::new(), + } +} + +/// The state for a Test with nothing in the base-URL field. +pub fn empty_url_state() -> LocalModelProbeState { + LocalModelProbeState { + running: false, + running_label: None, + summary: Some(probe::ProbeSummary { + level: ProbeLevel::Error, + headline: "Enter a base URL first".to_string(), + detail: Some("Or press Find my server and let Studio look for one itself.".to_string()), + }), + findings: Vec::new(), + } +} + +#[cfg(target_arch = "wasm32")] +pub use glue::run; + +#[cfg(target_arch = "wasm32")] +mod glue { + use futures_util::future::{Either, join_all, select}; + use gloo_net::http::Request; + use gloo_timers::future::TimeoutFuture; + use lpa_studio_core::app::settings::local_model_probe::{ + BrowserFacts, ProbeFinding, ProbeOutcome, + }; + use web_sys::RequestMode; + + use super::*; + + /// Run one probe request to completion. + pub async fn run( + request: ProbeRequest, + api_key: Option, + configured_model: Option, + ) -> LocalModelProbeState { + let facts = browser_facts(); + let key = api_key.as_deref(); + let model = configured_model.as_deref(); + match request { + ProbeRequest::TestBaseUrl(url) => { + let Some(url) = probe::normalize_base_url(&url) else { + return empty_url_state(); + }; + let findings = probe_with_ipv4_fallback(&url, key, &facts, model).await; + LocalModelProbeState { + running: false, + running_label: None, + // One target speaks for itself; the finding carries the + // headline, so a summary would only repeat it. + summary: None, + findings, + } + } + ProbeRequest::ScanCommonPorts => { + let probes = probe::COMMON_LOCAL_SERVERS + .iter() + .map(|server| probe_with_ipv4_fallback(server.base_url, key, &facts, model)); + let mut findings: Vec = + join_all(probes).await.into_iter().flatten().collect(); + let summary = probe::scan_summary(&findings, &facts); + // Dead ports are the expected case and say nothing once + // something better exists — the summary already counts them. + if findings.iter().any(|f| f.level != ProbeLevel::Error) { + findings.retain(|f| f.level != ProbeLevel::Error); + } + findings.sort_by_key(|f| match f.level { + ProbeLevel::Ok => 0, + ProbeLevel::Warn => 1, + ProbeLevel::Error => 2, + }); + LocalModelProbeState { + running: false, + running_label: None, + summary: Some(summary), + findings, + } + } + } + } + + /// Probe one base URL, and — when nothing answered at a `localhost` + /// address — the same port at `127.0.0.1`. A server bound to the IPv4 + /// loopback is invisible to a browser that resolved `localhost` to `::1`, + /// and the retry turns that dead end into a working address. + async fn probe_with_ipv4_fallback( + base_url: &str, + api_key: Option<&str>, + facts: &BrowserFacts, + configured_model: Option<&str>, + ) -> Vec { + let outcome = probe_once(base_url, api_key).await; + let unreachable = matches!(outcome, ProbeOutcome::Unreachable { .. }); + let first = probe::diagnose(base_url, outcome, facts, configured_model); + if !unreachable { + return vec![first]; + } + let Some(ipv4) = probe::ipv4_loopback_variant(base_url) else { + return vec![first]; + }; + let outcome = probe_once(&ipv4, api_key).await; + if matches!(outcome, ProbeOutcome::Unreachable { .. }) { + // Both spellings are dead: report the address the user knows. + return vec![first]; + } + vec![probe::diagnose(&ipv4, outcome, facts, configured_model)] + } + + /// One `GET {base_url}/models`, classified. + async fn probe_once(base_url: &str, api_key: Option<&str>) -> ProbeOutcome { + let url = probe::models_url(base_url); + match fetch_models(&url, api_key).await { + Some(Ok((status, body))) if (200..300).contains(&status) => { + if body.is_empty() { + return ProbeOutcome::BadBody( + "The server answered, but sent no body to read.".to_string(), + ); + } + match probe::parse_models(&body) { + Ok(models) => ProbeOutcome::Models(models), + Err(detail) => ProbeOutcome::BadBody(detail), + } + } + Some(Ok((status, body))) => ProbeOutcome::Status { status, body }, + // A failed fetch is ambiguous: no server, or a server whose + // answer the browser refused to hand us. The opaque probe tells + // them apart. + Some(Err(detail)) => match opaque_reachable(&url).await { + true => ProbeOutcome::CorsBlocked, + false => ProbeOutcome::Unreachable { detail }, + }, + None => ProbeOutcome::Unreachable { + detail: format!("no answer within {}s", PROBE_TIMEOUT_MS / 1000), + }, + } + } + + /// `Some(Ok(status, body))` for a real response, `Some(Err(message))` for + /// a fetch that never produced one, `None` on timeout. + async fn fetch_models( + url: &str, + api_key: Option<&str>, + ) -> Option> { + // No key ⇒ no request headers at all, which keeps this a CORS-simple + // GET: no preflight to fail on servers that ignore OPTIONS. + let mut request = Request::get(url).mode(RequestMode::Cors); + if let Some(key) = api_key { + request = request.header("authorization", &format!("Bearer {key}")); + } + let sent = match request.build() { + Ok(request) => request, + Err(e) => return Some(Err(format!("{e}"))), + }; + let response = match with_timeout(sent.send()).await? { + Ok(response) => response, + Err(e) => return Some(Err(fetch_failure_message(&e.to_string()))), + }; + let status = response.status(); + // An unreadable body is still an answer — the address and status are + // the useful facts, and `probe_once` reports the empty body honestly + // rather than as a malformed model list. + let body = with_timeout(response.text()).await?.unwrap_or_default(); + Some(Ok((status, body))) + } + + /// Whether the port accepts connections at all, read through an opaque + /// `no-cors` response (unreadable by design — resolving is the signal). + async fn opaque_reachable(url: &str) -> bool { + let Ok(request) = Request::get(url).mode(RequestMode::NoCors).build() else { + return false; + }; + matches!(with_timeout(request.send()).await, Some(Ok(_))) + } + + /// Run a future under [`PROBE_TIMEOUT_MS`]; `None` if it did not finish. + async fn with_timeout(future: impl core::future::Future) -> Option { + let future = core::pin::pin!(future); + match select(future, TimeoutFuture::new(PROBE_TIMEOUT_MS)).await { + Either::Left((value, _)) => Some(value), + Either::Right(_) => None, + } + } + + /// Browser fetch errors are deliberately uninformative ("TypeError: + /// Failed to fetch"). Keep them short — the diagnosis carries the + /// meaning, this is just the raw trace. + fn fetch_failure_message(raw: &str) -> String { + raw.split_once("error: ") + .map_or(raw, |(_, rest)| rest) + .trim() + .chars() + .take(120) + .collect() + } + + /// The page facts a diagnosis needs: our origin (what a server has to + /// allow), whether we are https (mixed-content and local-network rules), + /// and whether this is Safari (stricter than the rest about + /// http://localhost). + fn browser_facts() -> BrowserFacts { + let Some(window) = web_sys::window() else { + return BrowserFacts::default(); + }; + let location = window.location(); + let user_agent = window.navigator().user_agent().unwrap_or_default(); + BrowserFacts { + page_origin: location.origin().unwrap_or_default(), + page_is_https: location + .protocol() + .is_ok_and(|protocol| protocol.starts_with("https")), + // Chromium UAs also carry "Safari"; the Chrome/Chromium tokens + // are what tell them apart. + is_safari: user_agent.contains("Safari") + && !user_agent.contains("Chrome") + && !user_agent.contains("Chromium"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn running_labels_name_the_target() { + let state = running_state(&ProbeRequest::TestBaseUrl("localhost:11434".to_string())); + assert!(state.running); + assert_eq!( + state.running_label.as_deref(), + Some("Testing http://localhost:11434/v1…") + ); + let state = running_state(&ProbeRequest::ScanCommonPorts); + assert!(state.running_label.expect("label").contains(&format!( + "{} common ports", + probe::COMMON_LOCAL_SERVERS.len() + ))); + } + + #[test] + fn a_blank_url_asks_for_one_instead_of_probing() { + let state = empty_url_state(); + assert!(!state.running); + assert!(state.findings.is_empty()); + assert_eq!( + state.summary.expect("summary").headline, + "Enter a base URL first" + ); + } +} diff --git a/lp-app/lpa-studio-web/src/main.rs b/lp-app/lpa-studio-web/src/main.rs index 679d2a567..eb8ed8e25 100644 --- a/lp-app/lpa-studio-web/src/main.rs +++ b/lp-app/lpa-studio-web/src/main.rs @@ -5,6 +5,7 @@ pub mod core; pub mod exploration; #[cfg(target_arch = "wasm32")] mod library_host_opfs; +mod local_model_probe; mod local_store; mod openrouter_oauth; mod router; diff --git a/lp-app/lpa-studio-web/src/settings_io.rs b/lp-app/lpa-studio-web/src/settings_io.rs index 6641ded71..2529bb96d 100644 --- a/lp-app/lpa-studio-web/src/settings_io.rs +++ b/lp-app/lpa-studio-web/src/settings_io.rs @@ -13,7 +13,7 @@ //! Electron shell would replace this fetch with IPC/preload carrying the //! same JSON shape. -use lpa_studio_core::StudioSettings; +use lpa_studio_core::{AgentProvider, StudioSettings}; /// localStorage key holding the user settings layer (a `StudioSettings` /// JSON document). @@ -42,6 +42,53 @@ pub fn store_user_settings_json(json: &str) { } } +/// The effective API key for one provider (user layer > host layer), for +/// the connection test and the model picker. +/// +/// The view DTO carries only a masked preview — deliberately, so no raw key +/// rides on a snapshot — so these two features read the value from the two +/// layers this edge already owns instead. The user layer is re-read from +/// localStorage on each call, which is exactly current: the controller +/// persists it synchronously as the user changes it. +#[cfg_attr( + not(target_arch = "wasm32"), + allow(dead_code, reason = "only the wasm probe/picker read a credential") +)] +pub fn effective_api_key(provider: AgentProvider) -> Option { + let field = |settings: &StudioSettings| match provider { + AgentProvider::Anthropic => settings.agent.anthropic_api_key.clone(), + AgentProvider::OpenAi => settings.agent.openai_api_key.clone(), + AgentProvider::OpenRouter => settings.agent.openrouter_api_key.clone(), + AgentProvider::Custom => settings.agent.custom_api_key.clone(), + }; + let user = load_user_settings_json() + .and_then(|json| StudioSettings::from_json_str(&json).ok()) + .as_ref() + .and_then(field); + user.or_else(|| HOST_LAYER.with_borrow(|host| host.as_ref().and_then(field))) +} + +/// The Custom provider's key, for the local-server connection test. +#[cfg_attr( + not(target_arch = "wasm32"), + allow(dead_code, reason = "only the wasm probe reads a credential") +)] +pub fn effective_custom_api_key() -> Option { + effective_api_key(AgentProvider::Custom) +} + +thread_local! { + /// The host layer, kept for the credential lookup above (the store owns + /// the authoritative copy; this is a read-only echo of the same fetch). + static HOST_LAYER: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +/// Remember the fetched host layer for [`effective_custom_api_key`]. +pub fn remember_host_layer(settings: &StudioSettings) { + HOST_LAYER.with_borrow_mut(|host| *host = Some(settings.clone())); +} + /// Fetch the host-provided settings layer. A 404 or network error means the /// host supplies no layer (the deployed-site case) and resolves to `None` /// silently; an unreadable document also resolves to `None` but logs one diff --git a/lp-app/lpa-studio-web/src/web_app.rs b/lp-app/lpa-studio-web/src/web_app.rs index b26470777..7699cabd0 100644 --- a/lp-app/lpa-studio-web/src/web_app.rs +++ b/lp-app/lpa-studio-web/src/web_app.rs @@ -296,6 +296,7 @@ pub fn App() -> Element { let settings_tx = handle.tx.clone(); spawn(async move { if let Some(host) = crate::settings_io::fetch_dev_settings().await { + crate::settings_io::remember_host_layer(&host); settings_tx.send(StudioCommand::Settings(SettingsCommand::HostLayerLoaded( host, ))); diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-local-server__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-local-server__lg.png index b41fb3f22..4c424094a 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-local-server__lg.png and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-local-server__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-local-server__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-local-server__md.png index 5b8e902fa..71f3f8274 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-local-server__md.png and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-local-server__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-local-server__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-local-server__sm.png index de23eb200..3e8e45ea9 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-local-server__sm.png and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-local-server__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__lg.png new file mode 100644 index 000000000..4fb19bcfe Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__md.png new file mode 100644 index 000000000..ddad0cc22 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__sm.png new file mode 100644 index 000000000..d523d0222 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-a-server__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-a-server__lg.png new file mode 100644 index 000000000..782dc6c22 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-a-server__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-a-server__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-a-server__md.png new file mode 100644 index 000000000..0ed5d06fc Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-a-server__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-a-server__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-a-server__sm.png new file mode 100644 index 000000000..f12a11cb0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-a-server__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-nothing__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-nothing__lg.png new file mode 100644 index 000000000..ec33c96c0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-nothing__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-nothing__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-nothing__md.png new file mode 100644 index 000000000..63654190f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-nothing__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-nothing__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-nothing__sm.png new file mode 100644 index 000000000..af46c84b8 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-found-nothing__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-running__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-running__lg.png new file mode 100644 index 000000000..86403a753 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-running__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-running__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-running__md.png new file mode 100644 index 000000000..2cd39bc90 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-running__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-running__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-running__sm.png new file mode 100644 index 000000000..32f531ffb Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__custom-scan-running__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__model-fetch-error__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__model-fetch-error__lg.png index 144030d6b..60a13acd6 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__model-fetch-error__lg.png and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__model-fetch-error__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__model-fetch-error__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__model-fetch-error__md.png index 4feb414f6..c7ccb2902 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__model-fetch-error__md.png and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__model-fetch-error__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__model-fetch-error__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__model-fetch-error__sm.png index d860668ec..8c3dda74b 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__model-fetch-error__sm.png and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__model-fetch-error__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__agent-chat__needs-key-custom__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__agent-chat__needs-key-custom__lg.png index 0008bf664..9ec1162d2 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__agent-chat__needs-key-custom__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__agent-chat__needs-key-custom__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__agent-chat__needs-key-custom__md.png b/lp-app/lpa-studio-web/story-images/studio__node__agent-chat__needs-key-custom__md.png index 403eb70c2..f37332269 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__agent-chat__needs-key-custom__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__agent-chat__needs-key-custom__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__agent-chat__needs-key-custom__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__agent-chat__needs-key-custom__sm.png index 3dc4cd7f6..4b385ebe1 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__agent-chat__needs-key-custom__sm.png and b/lp-app/lpa-studio-web/story-images/studio__node__agent-chat__needs-key-custom__sm.png differ