using Invercargill; using Invercargill.DataStructures; using Invercargill.Expressions; using InvercargillSql.Orm; using Inversion; using ClankerSaddle.Data; namespace ClankerSaddle.Services { /** * The deployment's model configuration (the Admin → Model page): * the ONE source for the OpenAI-compatible endpoint every saddle * assignment is sent to and the advisor's (optionally overridden) * endpoint. Stored as the `model_config` singleton row. * * A SINGLETON (the advisor service — itself a singleton — and the * dispatcher both read it outside request scopes), so every * operation opens its own container scope for the ORM session * (the Dispatcher's create_scope discipline). * * BOOTSTRAP: the very first read on an empty table seeds the row * from the legacy environment (SADDLE_LLM_BASE_URL / _API_KEY / * _MODEL, ADVISOR_LLM_* the same way) so a pre-page deployment * keeps dispatching through its upgrade — once, ever. From then on * the database is the only source: environment changes are * ignored, and the page is where configuration changes land. */ public class ModelConfigService : Object { private Container container = inject(); /** * The configuration row, creating and seeding it from the * legacy environment when the table is still empty (the * one-time upgrade bridge — never again afterwards). */ public async ModelConfig current() throws Error { var scope = container.create_scope(); try { var db = scope.resolve(); var row = yield db.query() .where(expr("id == $0", elem(1))) .first_async(); if (row != null) { return (!)row; } var seeded = new ModelConfig() { id = 1, base_url = legacy_env("SADDLE_LLM_BASE_URL"), api_key = legacy_env("SADDLE_LLM_API_KEY"), model = legacy_env("SADDLE_LLM_MODEL"), advisor_base_url = legacy_env("ADVISOR_LLM_BASE_URL"), advisor_api_key = legacy_env("ADVISOR_LLM_API_KEY"), advisor_model = legacy_env("ADVISOR_LLM_MODEL"), updated_at = new DateTime.now_local() }; yield db.insert_async(seeded); return seeded; } finally { scope.release(); } } /** One legacy environment value, trimmed ("" when unset). */ private static string legacy_env(string name) { return (Environment.get_variable(name) ?? "").strip(); } /** * Persists the page's form fields. The two API keys use the * keep-when-blank contract (the page never echoes a key back): * a blank field keeps the stored value unless its explicit * clear checkbox is set. */ public async ModelConfig save(string base_url, string api_key, string model, string advisor_base_url, string advisor_api_key, string advisor_model, bool clear_api_key, bool clear_advisor_api_key) throws Error { var row = yield current(); row.base_url = base_url.strip(); if (api_key.strip().length > 0 || clear_api_key) { row.api_key = api_key.strip(); } row.model = model.strip(); row.advisor_base_url = advisor_base_url.strip(); if (advisor_api_key.strip().length > 0 || clear_advisor_api_key) { row.advisor_api_key = advisor_api_key.strip(); } row.advisor_model = advisor_model.strip(); row.updated_at = new DateTime.now_local(); var scope = container.create_scope(); try { var db = scope.resolve(); yield db.update_async(row); } finally { scope.release(); } return row; } /** * The saddle assignments' endpoint, or null while no base URL * is configured (dispatch pauses — the Admin → Model page is * where it gets set). */ public async LlmEndpoint? saddle_endpoint() throws Error { var row = yield current(); return LlmEndpoint.from_config( row.base_url, row.api_key, row.model); } /** * The advisor's endpoint: the advisor-specific override when * its base URL is set, else the main fields; null while * neither carries a base URL (the advisor reports itself * unconfigured). */ public async AdvisorLlmEndpoint? advisor_endpoint() throws Error { var row = yield current(); if (row.advisor_base_url.length > 0) { return AdvisorLlmEndpoint.from_config(row.advisor_base_url, row.advisor_api_key, row.advisor_model); } return AdvisorLlmEndpoint.from_config(row.base_url, row.api_key, row.model); } } }