1use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak};
7
8use futures::future::BoxFuture;
9use indexmap::IndexMap;
10use oino_ai::JsonValue;
11use oino_models::{ModelRegistry, ModelRegistryOptions, ProviderRegistration};
12use oino_resources::trust::{TrustRequest, TrustResolution, TrustStore, resolve_project_trust};
13use oino_resources::{ResourceLoader, ResourceLoaderOptions};
14use oino_settings::SettingsManager;
15use serde_json::json;
16
17use crate::extensions::api::{
18 CallContext, CallKind, ExtensionInstance, ExtensionKind, ExtensionMode,
19};
20use crate::extensions::host::{HostApi, HostError, HostOp};
21use crate::extensions::runner::{ExtensionSet, LoadedExtension};
22use crate::extensions::settings::{ExtensionSettings, ExtensionSettingsError, SettingsChange};
23use crate::provider::ProviderStream;
24use crate::safe_mode::{SafeModePlan, banner as safe_mode_banner};
25use crate::session::{AgentSession, Inner, lock};
26
27pub trait ExtensionLoader: Send + Sync {
29 fn load(
32 &self,
33 host: Arc<dyn HostApi>,
34 project_trusted: bool,
35 ) -> BoxFuture<'_, Vec<Arc<dyn ExtensionInstance>>>;
36
37 fn load_enabled<'a>(
41 &'a self,
42 host: Arc<dyn HostApi>,
43 project_trusted: bool,
44 disabled: &'a [String],
45 ) -> BoxFuture<'a, Vec<Arc<dyn ExtensionInstance>>> {
46 let _ = disabled;
47 self.load(host, project_trusted)
48 }
49}
50
51pub struct StaticLoader(pub Vec<Arc<dyn ExtensionInstance>>);
53
54impl std::fmt::Debug for StaticLoader {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 f.debug_struct("StaticLoader")
57 .field("instances", &self.0.len())
58 .finish()
59 }
60}
61
62impl ExtensionLoader for StaticLoader {
63 fn load(
64 &self,
65 _host: Arc<dyn HostApi>,
66 _project_trusted: bool,
67 ) -> BoxFuture<'_, Vec<Arc<dyn ExtensionInstance>>> {
68 let instances = self.0.clone();
69 Box::pin(async move { instances })
70 }
71}
72
73#[allow(clippy::struct_excessive_bools, reason = "one field per CLI switch")]
75#[derive(Clone, Debug, Default, PartialEq, Eq)]
76pub struct ResourceFlags {
77 pub no_skills: bool,
79 pub no_prompt_templates: bool,
81 pub no_themes: bool,
83 pub no_context_files: bool,
85 pub skill_paths: Vec<String>,
87 pub prompt_template_paths: Vec<String>,
89 pub theme_paths: Vec<String>,
91 pub system_prompt: Option<String>,
93 pub append_system_prompt: Option<Vec<String>>,
95 pub no_extensions: bool,
97 pub safe_mode: Option<SafeModePlan>,
100 pub offline: bool,
102}
103
104#[must_use]
106pub fn offline_env() -> bool {
107 std::env::var("OINO_OFFLINE")
108 .is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
109}
110
111pub struct ServicesOptions {
113 pub cwd: PathBuf,
115 pub agent_dir: PathBuf,
117 pub docs_root: PathBuf,
120 pub mode: ExtensionMode,
122 pub has_ui: bool,
124 pub flags: ResourceFlags,
126 pub extension_flag_values: IndexMap<String, JsonValue>,
128 pub loader: Arc<dyn ExtensionLoader>,
130 pub approve: Option<bool>,
132 pub models: Option<ModelRegistry>,
134 pub settings: Option<SettingsManager>,
136}
137
138#[derive(Debug, thiserror::Error)]
140#[non_exhaustive]
141pub enum ServicesError {
142 #[error("{0}")]
144 Trust(String),
145 #[error("{0}")]
147 Session(String),
148 #[error("{0}")]
150 Failed(String),
151}
152
153#[derive(Default)]
155pub struct HostRouter {
156 sessions: Mutex<HashMap<String, Weak<Inner>>>,
157 extensions: OnceLock<Arc<ExtensionSet>>,
158 models: OnceLock<ModelRegistry>,
159 settings: OnceLock<Arc<ExtensionSettings>>,
160 base_cx: OnceLock<CallContext>,
161 topics: crate::topics::TopicStore,
162}
163
164pub const SETTINGS_CHANGED_EVENT: &str = "oino_settings_changed";
166
167fn settings_error(error: ExtensionSettingsError) -> HostError {
168 match error {
169 ExtensionSettingsError::Settings(_) | ExtensionSettingsError::Secrets(_) => {
170 HostError::Failed(error.to_string())
171 }
172 other => HostError::Invalid(other.to_string()),
173 }
174}
175
176impl std::fmt::Debug for HostRouter {
177 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178 f.debug_struct("HostRouter").finish_non_exhaustive()
179 }
180}
181
182impl HostRouter {
183 pub fn attach(&self, session: &AgentSession) {
185 lock(&self.sessions).insert(session.session_id(), Arc::downgrade(&session.inner));
186 }
187
188 pub fn detach(&self, session_id: &str) {
190 lock(&self.sessions).remove(session_id);
191 }
192
193 fn session(&self, id: &str) -> Option<AgentSession> {
194 let mut sessions = lock(&self.sessions);
195 if let Some(inner) = sessions.get(id).and_then(Weak::upgrade) {
196 Some(AgentSession::from_inner(inner))
197 } else {
198 sessions.remove(id);
199 None
200 }
201 }
202
203 #[must_use]
205 pub fn extension_settings(&self) -> Option<&Arc<ExtensionSettings>> {
206 self.settings.get()
207 }
208
209 pub fn settings_call(&self, extension: &str, op: HostOp) -> Result<JsonValue, HostError> {
215 let registry = self
216 .settings
217 .get()
218 .ok_or_else(|| HostError::Failed("extension settings are not available".into()))?;
219 match op {
220 HostOp::RegisterSettings(schema) => registry
221 .register(extension, schema)
222 .map(|()| JsonValue::Null)
223 .map_err(settings_error),
224 HostOp::GetExtensionSettings { namespace, layer } => {
225 Ok(registry.values_for(extension, &namespace, layer))
226 }
227 HostOp::SetExtensionSetting {
228 namespace,
229 key,
230 value,
231 scope,
232 } => registry
233 .set_as(extension, &namespace, &key, value, scope)
234 .map(|()| JsonValue::Null)
235 .map_err(settings_error),
236 HostOp::GetAllExtensionSettings => Ok(registry.all_for(extension)),
237 HostOp::GetSettings => Ok(JsonValue::Object(registry.merged_settings())),
238 other => Err(HostError::Invalid(format!(
239 "not a settings operation: {other:?}"
240 ))),
241 }
242 }
243
244 async fn deliver_settings_change(&self, change: SettingsChange) {
247 self.publish_topic(
250 crate::topics::CORE_PUBLISHER,
251 &crate::topics::settings_topic(&change.namespace),
252 json!({
253 "namespace": change.namespace.clone(),
254 "keys": change.keys,
255 "scope": change.scope,
256 "extensionId": change.extension_id,
257 }),
258 true,
259 );
260 let Some(owner) = change.extension_id.as_deref() else {
261 return;
262 };
263 let Some(extension) = self
264 .extensions
265 .get()
266 .and_then(|set| set.get(owner).cloned())
267 else {
268 return;
269 };
270 if !extension.state().events.contains(SETTINGS_CHANGED_EVENT) {
271 return;
272 }
273 let mut cx = self.base_cx.get().cloned().unwrap_or_else(|| CallContext {
274 session_id: String::new(),
275 mode: ExtensionMode::Print,
276 has_ui: false,
277 cwd: PathBuf::new(),
278 is_child: false,
279 child_role: None,
280 call_kind: CallKind::Event,
281 });
282 if let Some(id) = self.any_root_session() {
283 cx.session_id = id;
284 }
285 let payload = json!({
286 "type": SETTINGS_CHANGED_EVENT,
287 "namespace": change.namespace,
288 "keys": change.keys,
289 "scope": change.scope,
290 });
291 let deadline = crate::extensions::runner::deadline_for(SETTINGS_CHANGED_EVENT);
292 match tokio::time::timeout(
293 deadline,
294 extension
295 .instance
296 .on_event(SETTINGS_CHANGED_EVENT, payload, cx),
297 )
298 .await
299 {
300 Ok(Ok(_)) => {}
301 Ok(Err(error)) => {
302 tracing::warn!(
303 extension = owner,
304 "{SETTINGS_CHANGED_EVENT}: {}",
305 error.message
306 );
307 }
308 Err(_) => tracing::warn!(extension = owner, "{SETTINGS_CHANGED_EVENT} timed out"),
309 }
310 }
311
312 #[must_use]
314 pub fn topics(&self) -> &crate::topics::TopicStore {
315 &self.topics
316 }
317
318 fn root_sessions(&self) -> Vec<AgentSession> {
320 lock(&self.sessions)
321 .values()
322 .filter_map(Weak::upgrade)
323 .filter(|inner| inner.agents.link.is_none())
324 .map(AgentSession::from_inner)
325 .collect()
326 }
327
328 pub fn publish_topic(&self, publisher: &str, topic: &str, data: JsonValue, sticky: bool) {
330 if let Some(value) = self.topics.publish(publisher, topic, data, sticky) {
331 for session in self.root_sessions() {
332 session.offer_topic(&value);
333 }
334 }
335 }
336
337 pub fn bus_call(&self, extension: &str, op: HostOp) -> Result<JsonValue, HostError> {
343 match op {
344 HostOp::BusPublish {
345 topic,
346 data,
347 sticky,
348 } => {
349 self.publish_topic(extension, &topic, data, sticky);
350 Ok(JsonValue::Null)
351 }
352 HostOp::BusGet(topic) => Ok(self
353 .topics
354 .get(&topic)
355 .map_or(JsonValue::Null, |value| value.data)),
356 other => Err(HostError::Invalid(format!(
357 "not a bus operation: {other:?}"
358 ))),
359 }
360 }
361
362 fn any_root_session(&self) -> Option<String> {
364 let sessions = lock(&self.sessions);
365 sessions
366 .iter()
367 .filter_map(|(id, weak)| weak.upgrade().map(|inner| (id, inner)))
368 .find(|(_, inner)| inner.agents.link.is_none())
369 .map(|(id, _)| id.clone())
370 }
371
372 fn without_session(&self, extension: &str, op: HostOp) -> Result<JsonValue, HostError> {
375 let loaded = self
376 .extensions
377 .get()
378 .and_then(|set| set.get(extension).cloned());
379 let models = self.models.get();
380 let need_loaded =
381 || HostError::StaleContext(crate::extensions::runner::STALE_CONTEXT_MESSAGE.to_owned());
382 match op {
383 HostOp::Subscribe(kinds) => loaded
384 .ok_or_else(need_loaded)?
385 .update(|s| s.events.extend(kinds)),
386 HostOp::RegisterTool(spec) => {
387 loaded
388 .ok_or_else(need_loaded)?
389 .update(|s| s.tools.insert(spec.name.clone(), spec));
390 }
391 HostOp::RegisterCommand(spec) => loaded
392 .ok_or_else(need_loaded)?
393 .update(|s| s.commands.push(spec)),
394 HostOp::SetArbiterRoles(roles) => loaded
395 .ok_or_else(need_loaded)?
396 .update(|s| s.arbiter = roles),
397 HostOp::RegisterProvider(config) => {
398 let registration: ProviderRegistration = serde_json::from_value(config)
399 .map_err(|e| HostError::Invalid(e.to_string()))?;
400 models
401 .ok_or_else(need_loaded)?
402 .register_provider(extension, registration)
403 .map_err(|e| HostError::Failed(e.to_string()))?;
404 }
405 _ => return Err(need_loaded()),
406 }
407 Ok(JsonValue::Null)
408 }
409}
410
411impl HostApi for HostRouter {
412 fn call<'a>(
413 &'a self,
414 extension: &'a str,
415 cx: &'a CallContext,
416 op: HostOp,
417 ) -> BoxFuture<'a, Result<JsonValue, HostError>> {
418 Box::pin(async move {
419 if op.is_settings_op() {
420 return self.settings_call(extension, op);
421 }
422 if op.is_bus_op() {
423 return self.bus_call(extension, op);
424 }
425 match self.session(&cx.session_id) {
426 Some(session) => session.host_call(extension, cx, op).await,
427 None => self.without_session(extension, op),
428 }
429 })
430 }
431
432 fn model_stream<'a>(
433 &'a self,
434 _extension: &'a str,
435 cx: &'a CallContext,
436 request: crate::extensions::host::ModelRequest,
437 ) -> BoxFuture<'a, Result<futures::stream::BoxStream<'static, JsonValue>, HostError>> {
438 Box::pin(async move {
439 let session = self.session(&cx.session_id).ok_or_else(|| {
440 HostError::StaleContext(crate::extensions::runner::STALE_CONTEXT_MESSAGE.into())
441 })?;
442 session.model_stream(request).await
443 })
444 }
445
446 fn detach_bash<'a>(
447 &'a self,
448 _extension: &'a str,
449 cx: &'a CallContext,
450 tool_call_id: &'a str,
451 task_id: &'a str,
452 reason: &'a str,
453 ) -> BoxFuture<'a, Result<crate::session::DetachedBash, HostError>> {
454 Box::pin(async move {
455 let session = self.session(&cx.session_id).ok_or_else(|| {
456 HostError::StaleContext(crate::extensions::runner::STALE_CONTEXT_MESSAGE.into())
457 })?;
458 session.detach_bash(tool_call_id, task_id, reason).await
459 })
460 }
461
462 fn spawn_agent<'a>(
463 &'a self,
464 extension: &'a str,
465 cx: &'a CallContext,
466 spec: JsonValue,
467 ) -> BoxFuture<'a, Result<Arc<crate::agents::ChildAgent>, HostError>> {
468 Box::pin(async move {
469 let session = self.session(&cx.session_id).ok_or_else(|| {
470 HostError::StaleContext(crate::extensions::runner::STALE_CONTEXT_MESSAGE.into())
471 })?;
472 let spec: crate::agents::AgentSpec = serde_json::from_value(spec)
473 .map_err(|e| HostError::Invalid(format!("AgentSpec: {e}")))?;
474 session.spawn_agent(extension, spec).await
475 })
476 }
477}
478
479pub struct Services {
481 pub cwd: PathBuf,
483 pub agent_dir: PathBuf,
485 pub docs_root: PathBuf,
487 pub mode: ExtensionMode,
489 pub has_ui: bool,
491 pub project_trusted: bool,
493 pub settings: Arc<Mutex<SettingsManager>>,
495 pub extension_settings: Arc<ExtensionSettings>,
497 pub resources: Arc<RwLock<ResourceLoader>>,
499 pub models: ModelRegistry,
501 pub extensions: Arc<ExtensionSet>,
503 pub stream: Arc<ProviderStream>,
505 pub host: Arc<HostRouter>,
507 pub safe_mode: Option<SafeModePlan>,
511 pub flag_values: IndexMap<String, JsonValue>,
513 pub diagnostics: Vec<String>,
515}
516
517impl std::fmt::Debug for Services {
518 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
519 f.debug_struct("Services")
520 .field("cwd", &self.cwd)
521 .field("extensions", &self.extensions.len())
522 .finish_non_exhaustive()
523 }
524}
525
526fn resolve_trust(
527 options: &ServicesOptions,
528 settings: &oino_settings::Settings,
529) -> Result<bool, ServicesError> {
530 let store = TrustStore::new(&options.agent_dir);
531 let home = oino_settings::paths::home_dir();
532 let request = TrustRequest {
533 cwd: &options.cwd,
534 home: home.as_deref(),
535 cli_override: options.approve,
536 extension_decision: None,
537 default_trust: settings.default_project_trust(),
538 has_ui: false,
540 };
541 match resolve_project_trust(&request, &store)
542 .map_err(|e| ServicesError::Trust(e.to_string()))?
543 {
544 TrustResolution::Decided { trusted, .. } => Ok(trusted),
545 TrustResolution::Ask { .. } => Ok(false),
546 }
547}
548
549fn safe_mode_start(
552 flags: &ResourceFlags,
553 diagnostics: &mut Vec<String>,
554) -> (Option<SafeModePlan>, bool) {
555 let safe_mode = flags.safe_mode.clone();
556 let no_extensions = flags.no_extensions
557 || safe_mode
558 .as_ref()
559 .is_some_and(|plan| plan.disable_extensions);
560 if let Some(plan) = &safe_mode
561 && plan.disable_extensions
562 {
563 diagnostics.push(safe_mode_banner(plan));
564 }
565 (safe_mode, no_extensions)
566}
567
568impl Services {
569 pub async fn create(mut options: ServicesOptions) -> Result<Arc<Self>, ServicesError> {
575 let mut diagnostics = Vec::new();
576 let (safe_mode, no_extensions) = safe_mode_start(&options.flags, &mut diagnostics);
577 let mut settings = options
578 .settings
579 .take()
580 .unwrap_or_else(|| SettingsManager::create(&options.cwd, &options.agent_dir, false));
581 let trusted = resolve_trust(&options, settings.settings())?;
582 settings.set_project_trusted(trusted);
583 diagnostics.extend(settings.drain_errors().into_iter().map(|e| e.to_string()));
584 let mut resources = ResourceLoader::new(ResourceLoaderOptions {
585 cwd: options.cwd.clone(),
586 agent_dir: options.agent_dir.clone(),
587 home_dir: oino_settings::paths::home_dir(),
588 no_skills: options.flags.no_skills,
589 no_prompt_templates: options.flags.no_prompt_templates,
590 no_themes: options.flags.no_themes,
591 no_context_files: options.flags.no_context_files,
592 skill_paths: options.flags.skill_paths.clone(),
593 prompt_template_paths: options.flags.prompt_template_paths.clone(),
594 theme_paths: options.flags.theme_paths.clone(),
595 system_prompt: options.flags.system_prompt.clone(),
596 append_system_prompt: options.flags.append_system_prompt.clone(),
597 });
598 resources.reload(settings.settings(), trusted);
599 diagnostics.extend(resources.diagnostics().iter().map(|d| format!("{d:?}")));
600 let models = options.models.clone().unwrap_or_else(|| {
601 let auth = Arc::new(oino_models::AuthStorage::open(
602 options.agent_dir.join("auth.json"),
603 ));
604 ModelRegistry::new(
605 auth,
606 ModelRegistryOptions::for_agent_dir(&options.agent_dir),
607 )
608 });
609 let settings = Arc::new(Mutex::new(settings));
610 let extension_settings = Arc::new(ExtensionSettings::new(
611 Arc::clone(&settings),
612 Some(oino_settings::SecretStore::new(&options.agent_dir)),
613 ));
614 let host = Arc::new(HostRouter::default());
615 let _ = host.models.set(models.clone());
616 let _ = host.settings.set(Arc::clone(&extension_settings));
617 let disabled = lock(&settings).settings().disabled_extensions();
618 let instances: Vec<Arc<dyn ExtensionInstance>> = options
619 .loader
620 .load_enabled(Arc::clone(&host) as Arc<dyn HostApi>, trusted, &disabled)
621 .await
622 .into_iter()
623 .filter(|instance| !disabled.iter().any(|id| id == instance.id()))
624 .collect();
625 let cx = CallContext {
626 session_id: String::new(),
627 mode: options.mode,
628 has_ui: options.has_ui,
629 cwd: options.cwd.clone(),
630 is_child: false,
631 child_role: None,
632 call_kind: CallKind::Event,
633 };
634 let _ = host.base_cx.set(cx.clone());
635 let loaded = init_extensions(
636 instances,
637 &cx,
638 &models,
639 &extension_settings,
640 no_extensions,
641 &mut diagnostics,
642 )
643 .await;
644 let extensions = Arc::new(ExtensionSet::new(loaded));
645 let _ = host.extensions.set(Arc::clone(&extensions));
646 models.set_oauth_refresher(Arc::new(ComponentOAuth {
647 extensions: Arc::downgrade(&extensions),
648 models: models.clone(),
649 }));
650 forward_settings_changes(&extension_settings, &host);
651 start_model_refresh(&options, &extensions, &models).await;
652 Ok(Arc::new(Self {
653 cwd: options.cwd,
654 agent_dir: options.agent_dir,
655 docs_root: options.docs_root,
656 mode: options.mode,
657 has_ui: options.has_ui,
658 project_trusted: trusted,
659 settings,
660 extension_settings,
661 resources: Arc::new(RwLock::new(resources)),
662 models,
663 stream: Arc::new(ProviderStream::new(Arc::clone(&extensions))),
664 extensions,
665 host,
666 safe_mode,
667 flag_values: options.extension_flag_values,
668 diagnostics,
669 }))
670 }
671
672 #[must_use]
675 pub fn sessions_root(&self, cli: Option<&Path>) -> PathBuf {
676 if let Some(dir) = cli {
677 return dir.to_path_buf();
678 }
679 if let Some(dir) = std::env::var_os("OINO_SESSION_DIR").filter(|d| !d.is_empty()) {
680 return PathBuf::from(dir);
681 }
682 let setting = lock(&self.settings).settings().session_dir();
683 setting.map_or_else(
684 || self.agent_dir.join("sessions"),
685 |dir| {
686 if dir.is_absolute() {
687 dir
688 } else {
689 self.cwd.join(dir)
690 }
691 },
692 )
693 }
694}
695
696async fn init_extensions(
698 instances: Vec<Arc<dyn ExtensionInstance>>,
699 cx: &CallContext,
700 models: &ModelRegistry,
701 settings: &ExtensionSettings,
702 no_extensions: bool,
703 diagnostics: &mut Vec<String>,
704) -> Vec<Arc<LoadedExtension>> {
705 let mut loaded: Vec<Arc<LoadedExtension>> = Vec::new();
706 for instance in instances {
707 if no_extensions && instance.kind() == ExtensionKind::Extension {
708 continue;
709 }
710 if loaded.iter().any(|l| l.instance.id() == instance.id()) {
711 diagnostics.push(format!("Duplicate extension id {} ignored", instance.id()));
712 continue;
713 }
714 match instance.init(cx.clone()).await {
715 Ok(mut registration) => {
716 let overrides = instance.tool_overrides();
719 registration.renderers.retain(|spec| {
720 let allowed = crate::session::check_render_override(spec, &overrides).is_ok();
721 if !allowed {
722 diagnostics.push(format!(
723 "{}: render spec {spec} ignored (tools.override)",
724 instance.id()
725 ));
726 }
727 allowed
728 });
729 for api in ®istration.apis {
730 models.register_api_implementor(api, instance.id());
731 }
732 for provider in ®istration.providers {
733 match serde_json::from_value::<ProviderRegistration>(provider.clone()) {
734 Ok(registration) => {
735 if let Err(error) =
736 models.register_provider(instance.id(), registration)
737 {
738 diagnostics.push(format!("{}: {error}", instance.id()));
739 }
740 }
741 Err(error) => diagnostics
742 .push(format!("{}: invalid provider: {error}", instance.id())),
743 }
744 }
745 for namespace in ®istration.settings {
746 if let Err(error) = settings.register(instance.id(), namespace.clone()) {
747 diagnostics.push(format!("{}: settings: {error}", instance.id()));
748 }
749 }
750 for definition in ®istration.virtual_models {
751 match serde_json::from_value(definition.clone()) {
752 Ok(definition) => {
753 if let Err(error) =
754 models.register_virtual_model(instance.id(), definition)
755 {
756 diagnostics.push(format!("{}: {error}", instance.id()));
757 }
758 }
759 Err(error) => diagnostics
760 .push(format!("{}: invalid virtual model: {error}", instance.id())),
761 }
762 }
763 loaded.push(Arc::new(LoadedExtension::new(instance, registration)));
764 }
765 Err(error) => {
766 diagnostics.push(format!("{}: init failed: {}", instance.id(), error.message));
767 }
768 }
769 }
770 loaded
771}
772
773fn forward_settings_changes(registry: &ExtensionSettings, router: &Arc<HostRouter>) {
776 let Ok(handle) = tokio::runtime::Handle::try_current() else {
777 return;
778 };
779 let mut changes = registry.subscribe();
780 let router = Arc::downgrade(router);
781 handle.spawn(async move {
782 loop {
783 match changes.recv().await {
784 Ok(change) => {
785 let Some(router) = router.upgrade() else {
786 break;
787 };
788 router.deliver_settings_change(change).await;
789 }
790 Err(tokio::sync::broadcast::error::RecvError::Lagged(missed)) => {
791 tracing::warn!("{missed} settings change notifications dropped");
792 }
793 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
794 }
795 }
796 });
797}
798
799struct ComponentOAuth {
802 extensions: Weak<ExtensionSet>,
803 models: ModelRegistry,
804}
805
806const OAUTH_REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
808
809impl oino_models::OAuthRefresher for ComponentOAuth {
810 fn refresh<'a>(
811 &'a self,
812 provider: &'a str,
813 credential: JsonValue,
814 ) -> BoxFuture<'a, Result<JsonValue, String>> {
815 Box::pin(async move {
816 let extensions = self
817 .extensions
818 .upgrade()
819 .ok_or_else(|| "extensions are shut down".to_owned())?;
820 let registration = self
821 .models
822 .registration(provider)
823 .ok_or_else(|| format!("Unknown provider: {provider}"))?;
824 let component = provider_component(&extensions, ®istration.api)
825 .ok_or_else(|| format!("No provider component for {}", registration.api))?;
826 let provider_instance = component
827 .instance
828 .provider()
829 .ok_or_else(|| format!("{provider}: not a provider"))?;
830 match tokio::time::timeout(
831 OAUTH_REFRESH_TIMEOUT,
832 provider_instance.oauth_refresh(provider, credential),
833 )
834 .await
835 {
836 Ok(result) => result.map_err(|e| e.message),
837 Err(_) => Err(format!("OAuth refresh for {provider} timed out")),
838 }
839 })
840 }
841}
842
843fn provider_component(extensions: &ExtensionSet, api: &str) -> Option<Arc<LoadedExtension>> {
845 extensions
846 .extensions()
847 .iter()
848 .find(|e| e.instance.provider().is_some() && e.state().apis.iter().any(|a| a == api))
849 .cloned()
850}
851
852pub async fn refresh_provider_models(
856 extensions: &ExtensionSet,
857 models: &ModelRegistry,
858 provider_id: &str,
859 credential: Option<JsonValue>,
860) {
861 let Some(registration) = models.registration(provider_id) else {
862 return;
863 };
864 if !matches!(registration.models, oino_models::ProviderModels::Dynamic) {
865 return;
866 }
867 let Some(component) = provider_component(extensions, ®istration.api) else {
868 return;
869 };
870 let Some(provider) = component.instance.provider() else {
871 return;
872 };
873 match tokio::time::timeout(
874 LIST_MODELS_TIMEOUT,
875 provider.list_models(provider_id, credential),
876 )
877 .await
878 {
879 Ok(Ok(list)) => {
880 if let Err(error) = models.set_provider_models(provider_id, &list) {
881 tracing::warn!(provider = %provider_id, "storing models failed: {error}");
882 }
883 }
884 Ok(Err(error)) => {
885 tracing::warn!(provider = %provider_id, "list-models failed: {}", error.message);
886 }
887 Err(_) => tracing::warn!(provider = %provider_id, "list-models timed out"),
888 }
889}
890
891const LIST_MODELS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
894
895async fn start_model_refresh(
901 options: &ServicesOptions,
902 extensions: &Arc<ExtensionSet>,
903 models: &ModelRegistry,
904) {
905 if options.flags.offline || offline_env() {
906 return;
907 }
908 if matches!(options.mode, ExtensionMode::Desktop | ExtensionMode::Mobile) {
909 spawn_model_refresh(Arc::clone(extensions), models.clone());
910 } else {
911 refresh_dynamic_models(extensions, models).await;
912 }
913}
914
915fn spawn_model_refresh(extensions: Arc<ExtensionSet>, models: ModelRegistry) {
918 tokio::spawn(async move {
919 refresh_dynamic_models(&extensions, &models).await;
920 });
921}
922
923async fn refresh_dynamic_models(extensions: &ExtensionSet, models: &ModelRegistry) {
927 let mut calls = Vec::new();
928 for provider_id in models.registered_providers() {
929 let Some(registration) = models.registration(&provider_id) else {
930 continue;
931 };
932 if !matches!(registration.models, oino_models::ProviderModels::Dynamic) {
933 continue;
934 }
935 let Some(component) = extensions
936 .extensions()
937 .iter()
938 .find(|e| e.instance.provider().is_some() && e.state().apis.contains(®istration.api))
939 .cloned()
940 else {
941 continue;
942 };
943 let key = models.get_api_key(&provider_id).await;
944 let needs_key = !registration.auth_env.is_empty() || registration.oauth;
945 if needs_key && key.is_none() && !models.has_configured_auth(&provider_id) {
946 continue;
947 }
948 let credential = key.map(|key| json!({"type": "api_key", "key": key}));
949 calls.push(async move {
950 let provider = component.instance.provider()?;
951 let result = tokio::time::timeout(
952 LIST_MODELS_TIMEOUT,
953 provider.list_models(&provider_id, credential),
954 )
955 .await;
956 Some((provider_id, result))
957 });
958 }
959 for (provider_id, result) in futures::future::join_all(calls).await.into_iter().flatten() {
960 match result {
961 Ok(Ok(list)) => {
962 if let Err(error) = models.set_provider_models(&provider_id, &list) {
963 tracing::warn!(provider = %provider_id, "storing models failed: {error}");
964 }
965 }
966 Ok(Err(error)) => {
967 tracing::warn!(provider = %provider_id, "list-models failed: {}", error.message);
968 }
969 Err(_) => tracing::warn!(provider = %provider_id, "list-models timed out"),
970 }
971 }
972}