From c80b5d680d8d0981c320a99aaca2de0fc31ee821 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 20 Jul 2026 12:13:56 +0200 Subject: [PATCH] Persist discovered AI endpoint models in the database per endpoint. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- RUST_PLAN_CORE.md | 2 +- .../down.sql | 1 + .../up.sql | 11 ++ crates/bds-core/src/db/migrations.rs | 6 +- crates/bds-core/src/db/schema.rs | 20 ++- crates/bds-core/src/engine/ai.rs | 115 ++++++++++++++++++ crates/bds-ui/src/app.rs | 60 ++++----- specs/ai.allium | 11 ++ specs/schema.allium | 29 +++++ 10 files changed, 222 insertions(+), 35 deletions(-) create mode 100644 crates/bds-core/migrations/2026-07-20-100424-0000_add_ai_endpoint_models/down.sql create mode 100644 crates/bds-core/migrations/2026-07-20-100424-0000_add_ai_endpoint_models/up.sql diff --git a/README.md b/README.md index 161ae9e..7078793 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The project is under active development. Core blogging workflows are broadly ava - `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection. - Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups. - Local preview in the app or system browser. -- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using independent online and local OpenAI-compatible profiles. Each profile has secure credentials, discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and status-bar airplane-mode routing. +- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using independent online and local OpenAI-compatible profiles. Each profile has secure credentials, persistently discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and status-bar airplane-mode routing. - Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript. - SSH-agent-based SCP or rsync publishing. - Integrated Git workflow with repository initialization, Git LFS image tracking, status and diffs, branch/file history, commits, remotes, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode. diff --git a/RUST_PLAN_CORE.md b/RUST_PLAN_CORE.md index 210e3cc..d027305 100644 --- a/RUST_PLAN_CORE.md +++ b/RUST_PLAN_CORE.md @@ -101,7 +101,7 @@ Available: - Independent online and airplane-mode OpenAI-compatible profiles, selected by the status-bar airplane switch. - Secure keychain credentials for both profiles, optional for local endpoints. -- Model discovery without a preselected model; per-profile chat/title/image selections, explicit tool/vision overrides, and minimal chat tests. +- Model discovery without a preselected model; discovered model lists persist per profile in the database and survive restart, overwritten only by the next refresh. Per-profile chat/title/image selections, explicit tool/vision overrides, and minimal chat tests. - Post translation, media translation, image alt text, post analysis, taxonomy analysis, WordPress-import taxonomy mapping, and language detection. - Explicit offline gating and user-visible errors. - Parsed input, output, cache-read, and cache-write token usage returned from every one-shot operation; persistent chat accounting is tracked in the extension plan. diff --git a/crates/bds-core/migrations/2026-07-20-100424-0000_add_ai_endpoint_models/down.sql b/crates/bds-core/migrations/2026-07-20-100424-0000_add_ai_endpoint_models/down.sql new file mode 100644 index 0000000..ce824d4 --- /dev/null +++ b/crates/bds-core/migrations/2026-07-20-100424-0000_add_ai_endpoint_models/down.sql @@ -0,0 +1 @@ +DROP TABLE ai_endpoint_models; diff --git a/crates/bds-core/migrations/2026-07-20-100424-0000_add_ai_endpoint_models/up.sql b/crates/bds-core/migrations/2026-07-20-100424-0000_add_ai_endpoint_models/up.sql new file mode 100644 index 0000000..e42a003 --- /dev/null +++ b/crates/bds-core/migrations/2026-07-20-100424-0000_add_ai_endpoint_models/up.sql @@ -0,0 +1,11 @@ +CREATE TABLE ai_endpoint_models ( + kind TEXT NOT NULL, + model_id TEXT NOT NULL, + label TEXT NOT NULL, + context_window INTEGER, + max_output_tokens INTEGER, + supports_tools INTEGER NOT NULL DEFAULT 0, + supports_vision INTEGER NOT NULL DEFAULT 0, + updated_at BIGINT NOT NULL, + PRIMARY KEY (kind, model_id) +); diff --git a/crates/bds-core/src/db/migrations.rs b/crates/bds-core/src/db/migrations.rs index 115522f..5642f70 100644 --- a/crates/bds-core/src/db/migrations.rs +++ b/crates/bds-core/src/db/migrations.rs @@ -15,7 +15,8 @@ mod tests { use super::*; use crate::db::Database; use crate::db::schema::{ - ai_catalog_meta, ai_model_modalities, ai_models, ai_providers, chat_conversations, + ai_catalog_meta, ai_endpoint_models, ai_model_modalities, ai_models, ai_providers, + chat_conversations, chat_messages, db_notifications, dismissed_duplicate_pairs, embedding_keys, generated_file_hashes, import_definitions, mcp_proposals, media, media_translations, post_links, post_media, post_translations, posts, projects, scripts, settings, tags, @@ -36,7 +37,7 @@ mod tests { let applied = db .conn() .with_migrations(|conn| conn.applied_migrations().unwrap().len()); - assert_eq!(applied, 7); + assert_eq!(applied, 8); } #[test] @@ -73,6 +74,7 @@ mod tests { ai_models::table, ai_model_modalities::table, ai_catalog_meta::table, + ai_endpoint_models::table, embedding_keys::table, dismissed_duplicate_pairs::table, import_definitions::table, diff --git a/crates/bds-core/src/db/schema.rs b/crates/bds-core/src/db/schema.rs index fea662e..05d9688 100644 --- a/crates/bds-core/src/db/schema.rs +++ b/crates/bds-core/src/db/schema.rs @@ -7,6 +7,19 @@ diesel::table! { } } +diesel::table! { + ai_endpoint_models (kind, model_id) { + kind -> Text, + model_id -> Text, + label -> Text, + context_window -> Nullable, + max_output_tokens -> Nullable, + supports_tools -> Integer, + supports_vision -> Integer, + updated_at -> BigInt, + } +} + diesel::table! { ai_model_modalities (rowid) { rowid -> Integer, @@ -64,9 +77,9 @@ diesel::table! { title -> Text, model -> Nullable, copilot_session_id -> Nullable, - surface_state -> Nullable, created_at -> BigInt, updated_at -> BigInt, + surface_state -> Nullable, } } @@ -79,10 +92,10 @@ diesel::table! { tool_call_id -> Nullable, tool_calls -> Nullable, created_at -> BigInt, - cache_read_tokens -> Nullable, - cache_write_tokens -> Nullable, token_usage_input -> Nullable, token_usage_output -> Nullable, + cache_read_tokens -> Nullable, + cache_write_tokens -> Nullable, } } @@ -351,6 +364,7 @@ diesel::joinable!(templates -> projects (project_id)); diesel::allow_tables_to_appear_in_same_query!( ai_catalog_meta, + ai_endpoint_models, ai_model_modalities, ai_models, ai_providers, diff --git a/crates/bds-core/src/engine/ai.rs b/crates/bds-core/src/engine/ai.rs index c14af90..96c5b49 100644 --- a/crates/bds-core/src/engine/ai.rs +++ b/crates/bds-core/src/engine/ai.rs @@ -65,6 +65,7 @@ pub struct AiModeSettings { pub image_model: Option, pub chat_supports_tools: Option, pub image_supports_vision: Option, + pub models: Vec, } impl AiSettings { @@ -301,6 +302,78 @@ pub fn save_model_preferences( Ok(()) } +pub fn save_endpoint_models( + conn: &Connection, + kind: AiEndpointKind, + models: &[AiModelInfo], +) -> EngineResult<()> { + use crate::db::schema::ai_endpoint_models::dsl; + use diesel::prelude::*; + + let updated_at = now_unix_ms(); + conn.with(|connection| { + connection.transaction(|connection| { + diesel::delete(dsl::ai_endpoint_models.filter(dsl::kind.eq(kind.as_str()))) + .execute(connection)?; + for model in models { + diesel::insert_into(dsl::ai_endpoint_models) + .values(( + dsl::kind.eq(kind.as_str()), + dsl::model_id.eq(&model.id), + dsl::label.eq(&model.name), + dsl::context_window.eq(model.context_window.map(|value| value as i32)), + dsl::max_output_tokens + .eq(model.max_output_tokens.map(|value| value as i32)), + dsl::supports_tools.eq(i32::from(model.supports_tools)), + dsl::supports_vision.eq(i32::from(model.supports_vision)), + dsl::updated_at.eq(updated_at), + )) + .execute(connection)?; + } + diesel::QueryResult::Ok(()) + }) + })?; + Ok(()) +} + +pub fn load_endpoint_models( + conn: &Connection, + kind: AiEndpointKind, +) -> EngineResult> { + use crate::db::schema::ai_endpoint_models::dsl; + use diesel::prelude::*; + + let rows = conn.with(|connection| { + dsl::ai_endpoint_models + .filter(dsl::kind.eq(kind.as_str())) + .order(dsl::label.asc()) + .select(( + dsl::model_id, + dsl::label, + dsl::context_window, + dsl::max_output_tokens, + dsl::supports_tools, + dsl::supports_vision, + )) + .load::<(String, String, Option, Option, i32, i32)>(connection) + })?; + Ok(rows + .into_iter() + .map( + |(id, label, context_window, max_output_tokens, supports_tools, supports_vision)| { + AiModelInfo { + id, + name: label, + context_window: context_window.map(|value| value.max(0) as u64), + max_output_tokens: max_output_tokens.map(|value| value.max(0) as u64), + supports_tools: supports_tools != 0, + supports_vision: supports_vision != 0, + } + }, + ) + .collect()) +} + pub fn save_system_prompt(conn: &Connection, system_prompt: &str) -> EngineResult<()> { set_setting(conn, "ai.system_prompt", system_prompt, now_unix_ms()) } @@ -550,6 +623,7 @@ fn load_mode_settings( conn, &endpoint_setting_key(kind, "image_supports_vision"), )?, + models: load_endpoint_models(conn, kind)?, }) } @@ -1033,6 +1107,47 @@ mod tests { assert!(models[1].supports_vision); } + #[test] + fn endpoint_models_persist_per_endpoint_and_overwrite_on_refresh() { + let db = setup(); + let online = vec![AiModelInfo { + id: "gpt-4.1".to_string(), + name: "GPT 4.1".to_string(), + context_window: Some(128_000), + max_output_tokens: Some(8_192), + supports_tools: true, + supports_vision: true, + }]; + let airplane = vec![AiModelInfo { + id: "llama3.2".to_string(), + name: "Llama 3.2".to_string(), + context_window: None, + max_output_tokens: None, + supports_tools: false, + supports_vision: false, + }]; + save_endpoint_models(db.conn(), AiEndpointKind::Online, &online).unwrap(); + save_endpoint_models(db.conn(), AiEndpointKind::Airplane, &airplane).unwrap(); + + let settings = load_ai_settings(db.conn(), false).unwrap(); + assert_eq!(settings.online.models, online); + assert_eq!(settings.airplane.models, airplane); + + let refreshed = vec![AiModelInfo { + id: "gpt-5".to_string(), + name: "GPT 5".to_string(), + context_window: Some(256_000), + max_output_tokens: Some(16_384), + supports_tools: true, + supports_vision: false, + }]; + save_endpoint_models(db.conn(), AiEndpointKind::Online, &refreshed).unwrap(); + + let settings = load_ai_settings(db.conn(), false).unwrap(); + assert_eq!(settings.online.models, refreshed); + assert_eq!(settings.airplane.models, airplane); + } + #[test] fn model_preferences_are_independent_per_endpoint() { let db = setup(); diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 4ec6bf7..6e24482 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -3563,34 +3563,27 @@ impl BdsApp { } fn chat_model_options(&self) -> Vec { - let mut models = self - .settings_state - .as_ref() - .map(|state| { - let mode = if self.offline_mode { - &state.airplane_ai - } else { - &state.online_ai - }; - mode.model_options - .iter() - .map(|model| ChatModelChoice { - id: model.id.clone(), - label: model.label.clone(), - }) - .collect::>() + let Some(db) = &self.db else { + return Vec::new(); + }; + let Ok(settings) = ai::load_ai_settings(db.conn(), self.offline_mode) else { + return Vec::new(); + }; + let active = settings.active(); + let mut models = active + .models + .iter() + .map(|model| ChatModelChoice { + id: model.id.clone(), + label: model.name.clone(), }) - .unwrap_or_default(); - if let Some(db) = &self.db - && let Ok(settings) = ai::load_ai_settings(db.conn(), self.offline_mode) - { - let model = settings.active().endpoint.model.clone(); - if !model.trim().is_empty() && !models.iter().any(|choice| choice.id == model) { - models.push(ChatModelChoice { - id: model.clone(), - label: model, - }); - } + .collect::>(); + let model = active.endpoint.model.clone(); + if !model.trim().is_empty() && !models.iter().any(|choice| choice.id == model) { + models.push(ChatModelChoice { + id: model.clone(), + label: model, + }); } models.sort_by(|left, right| left.label.cmp(&right.label)); models.dedup_by(|left, right| left.id == right.id); @@ -8150,12 +8143,13 @@ impl BdsApp { } fn refresh_ai_models( - _db: &Database, + db: &Database, state: &mut SettingsViewState, kind: AiEndpointKind, ) -> Result<(), String> { let endpoint = Self::compose_ai_endpoint(state, kind)?; let models = ai::refresh_model_catalog(&endpoint).map_err(|error| error.to_string())?; + ai::save_endpoint_models(db.conn(), kind, &models).map_err(|error| error.to_string())?; let options = models .into_iter() .map(|model| AiModelOption { @@ -8273,6 +8267,16 @@ impl BdsApp { api_key_configured: settings.endpoint.api_key_configured, chat_supports_tools: settings.chat_supports_tools.unwrap_or(false), image_supports_vision: settings.image_supports_vision.unwrap_or(false), + model_options: settings + .models + .into_iter() + .map(|model| AiModelOption { + id: model.id, + label: model.name, + supports_tools: model.supports_tools, + supports_vision: model.supports_vision, + }) + .collect(), ..Default::default() } } diff --git a/specs/ai.allium b/specs/ai.allium index 25cc6bc..83389fd 100644 --- a/specs/ai.allium +++ b/specs/ai.allium @@ -354,7 +354,18 @@ rule RefreshEndpointModels { requires: url != "" requires: kind = airplane or api_key != null -- Discovery calls GET /models and does not require a selected model. + -- On success the discovered list replaces the persisted models for + -- this endpoint kind. ensures: EndpointModelsLoaded(kind) + ensures: EndpointModelsPersisted(kind) +} + +invariant EndpointModelPersistence { + -- Discovered endpoint models are persisted per endpoint kind + -- (online and airplane independently) and survive application + -- restart. Model dropdowns (settings and chat) are populated from + -- the persisted list; it is only overwritten by the next + -- successful RefreshEndpointModels for the same kind. } rule TestEndpointModels { diff --git a/specs/schema.allium b/specs/schema.allium index ef9cc4b..5c642f9 100644 --- a/specs/schema.allium +++ b/specs/schema.allium @@ -299,6 +299,21 @@ entity AiCatalogMeta { value: String } +entity AiEndpointModel { + -- Models discovered via GET /models on a configured endpoint. + -- Composite primary key: (kind, model_id). + -- Persisted per endpoint kind; replaced wholesale on the next + -- successful refresh for that kind, never cleared otherwise. + kind: String -- "online" | "airplane" + model_id: String + label: String + context_window: Integer? + max_output_tokens: Integer? + supports_tools: Boolean + supports_vision: Boolean + updated_at: Timestamp +} + -- ============================================================================ -- EMBEDDINGS TABLES -- ============================================================================ @@ -580,6 +595,20 @@ surface AiCatalogMetaRecordSurface { meta.value } +surface AiEndpointModelRecordSurface { + context endpoint_model: AiEndpointModel + + exposes: + endpoint_model.kind + endpoint_model.model_id + endpoint_model.label + endpoint_model.context_window when endpoint_model.context_window != null + endpoint_model.max_output_tokens when endpoint_model.max_output_tokens != null + endpoint_model.supports_tools + endpoint_model.supports_vision + endpoint_model.updated_at +} + surface EmbeddingKeyRecordSurface { context key: EmbeddingKey