From 4259d5415484ff1e2e5f46bd357975c6ad1a9cd6 Mon Sep 17 00:00:00 2001 From: francy51 Date: Sat, 11 Apr 2026 10:49:26 -0400 Subject: [PATCH] Add Ollama provider options --- MosaicIQ/src-tauri/src/agent/gateway.rs | 229 ++++++++++------- MosaicIQ/src-tauri/src/agent/mod.rs | 11 +- MosaicIQ/src-tauri/src/agent/routing.rs | 22 +- MosaicIQ/src-tauri/src/agent/service.rs | 46 +++- MosaicIQ/src-tauri/src/agent/settings.rs | 13 +- MosaicIQ/src-tauri/src/agent/types.rs | 27 ++ .../components/Settings/AgentSettingsForm.tsx | 240 +++++++++++++----- .../src/components/Settings/ModelSelector.tsx | 9 +- .../Settings/SecEdgarSettingsCard.tsx | 1 + .../src/components/Settings/SettingsPage.tsx | 23 +- MosaicIQ/src/types/agentSettings.ts | 10 + 11 files changed, 465 insertions(+), 166 deletions(-) diff --git a/MosaicIQ/src-tauri/src/agent/gateway.rs b/MosaicIQ/src-tauri/src/agent/gateway.rs index df59ed7..1649293 100644 --- a/MosaicIQ/src-tauri/src/agent/gateway.rs +++ b/MosaicIQ/src-tauri/src/agent/gateway.rs @@ -6,18 +6,20 @@ use rig::{ client::completion::CompletionClient, completion::{Message, Prompt}, message::ToolChoice, - providers::openai, + providers::{anthropic, openai}, streaming::{StreamedAssistantContent, StreamingPrompt}, }; use crate::agent::stream_events::AgentStreamEmitter; use crate::agent::tools::terminal_command::{AgentCommandExecutor, RunTerminalCommandTool}; -use crate::agent::AgentRuntimeConfig; +use crate::agent::{AgentProviderKind, AgentRuntimeConfig}; use crate::error::AppError; use crate::state::PendingAgentToolApprovals; const SYSTEM_PROMPT: &str = "You are MosaicIQ's terminal chat assistant. Answer concisely in plain text. Use the available terminal command tool whenever current workspace data or live MosaicIQ terminal actions would improve the answer. Never claim to have run a command unless the tool actually ran it. If the request is unclear, ask a short clarifying question."; const MAX_TOOL_TURNS: usize = 4; +const CHAT_MAX_TOKENS: u64 = 1_024; +const SUMMARY_MAX_TOKENS: u64 = 512; const SUMMARY_SYSTEM_PROMPT: &str = "You maintain a rolling summary for MosaicIQ terminal chats. Return plain text only. Keep the summary under 1200 characters and roughly 10 bullets. Preserve concrete facts, user preferences, decisions, portfolio actions, unresolved questions, and symbols/tickers. Use exactly these sections: User goals, Established facts, Actions taken, Open threads."; #[derive(Clone)] @@ -62,88 +64,51 @@ impl ChatGateway for RigChatGateway { tool_runtime: AgentToolRuntimeContext, ) -> BoxFuture<'static, Result> { Box::pin(async move { - let api_key = runtime.api_key.unwrap_or_default(); - let client = openai::CompletionsClient::builder() - .api_key(api_key) - .base_url(&runtime.base_url) - .build() - .map_err(|error| AppError::ProviderInit(error.to_string()))?; - let history = compose_request_messages(context_messages, history); let tool = RunTerminalCommandTool { stream_emitter: tool_runtime.stream_emitter.clone(), - command_executor: tool_runtime.command_executor, - pending_approvals: tool_runtime.pending_approvals, - workspace_id: tool_runtime.workspace_id, + command_executor: tool_runtime.command_executor.clone(), + pending_approvals: tool_runtime.pending_approvals.clone(), + workspace_id: tool_runtime.workspace_id.clone(), }; - let mut rig_stream = client - .agent(runtime.model) - .preamble(SYSTEM_PROMPT) - .temperature(0.2) - .tool(tool) - .tool_choice(ToolChoice::Auto) - .default_max_turns(MAX_TOOL_TURNS) - .build() - .stream_prompt(prompt) - .with_history(history) - .multi_turn(MAX_TOOL_TURNS) - .await; + match runtime.provider_kind { + AgentProviderKind::Remote | AgentProviderKind::OllamaOpenAI => { + let client = build_openai_client(&runtime)?; + let rig_stream = client + .agent(runtime.model) + .preamble(SYSTEM_PROMPT) + .temperature(0.2) + .tool(tool) + .tool_choice(ToolChoice::Auto) + .default_max_turns(MAX_TOOL_TURNS) + .build() + .stream_prompt(prompt) + .with_history(history) + .multi_turn(MAX_TOOL_TURNS) + .await; - let mut reply = String::new(); - let mut saw_text = false; - let mut saw_reasoning_delta = false; + consume_stream(rig_stream, &tool_runtime).await + } + AgentProviderKind::OllamaAnthropic => { + let client = build_anthropic_client(&runtime)?; + let rig_stream = client + .agent(runtime.model) + .preamble(SYSTEM_PROMPT) + .temperature(0.2) + .max_tokens(CHAT_MAX_TOKENS) + .tool(tool) + .tool_choice(ToolChoice::Auto) + .default_max_turns(MAX_TOOL_TURNS) + .build() + .stream_prompt(prompt) + .with_history(history) + .multi_turn(MAX_TOOL_TURNS) + .await; - while let Some(item) = rig_stream.next().await { - match item { - Ok(MultiTurnStreamItem::StreamAssistantItem( - StreamedAssistantContent::Text(text), - )) => { - saw_text = true; - reply.push_str(&text.text); - tool_runtime.stream_emitter.text_delta(text.text)?; - } - Ok(MultiTurnStreamItem::StreamAssistantItem( - StreamedAssistantContent::Reasoning(reasoning), - )) => { - if saw_reasoning_delta { - continue; - } - - let text = reasoning_text(&reasoning); - if text.is_empty() { - continue; - } - - tool_runtime.stream_emitter.reasoning_delta(text)?; - } - Ok(MultiTurnStreamItem::StreamAssistantItem( - StreamedAssistantContent::ReasoningDelta { reasoning, .. }, - )) => { - saw_reasoning_delta = true; - tool_runtime.stream_emitter.reasoning_delta(reasoning)?; - } - Ok(MultiTurnStreamItem::StreamAssistantItem( - StreamedAssistantContent::ToolCall { .. }, - )) => {} - Ok(MultiTurnStreamItem::StreamAssistantItem( - StreamedAssistantContent::ToolCallDelta { .. }, - )) => {} - Ok(MultiTurnStreamItem::StreamUserItem(_)) => {} - Ok(MultiTurnStreamItem::FinalResponse(final_response)) => { - if !saw_text && !final_response.response().is_empty() { - reply.push_str(final_response.response()); - tool_runtime - .stream_emitter - .text_delta(final_response.response().to_string())?; - } - } - Ok(_) => {} - Err(error) => return Err(map_streaming_error(error)), + consume_stream(rig_stream, &tool_runtime).await } } - - Ok(reply) }) } @@ -154,7 +119,6 @@ impl ChatGateway for RigChatGateway { messages: Vec, ) -> BoxFuture<'static, Result> { Box::pin(async move { - let client = build_client(&runtime)?; let mut history = Vec::new(); if let Some(summary) = existing_summary.filter(|value| !value.trim().is_empty()) { @@ -165,22 +129,42 @@ impl ChatGateway for RigChatGateway { } history.extend(messages); - let response = client - .agent(runtime.model) - .preamble(SUMMARY_SYSTEM_PROMPT) - .temperature(0.1) - .build() - .prompt("Update the rolling summary using the conversation history.") - .with_history(history) - .await - .map_err(map_prompt_error)?; + match runtime.provider_kind { + AgentProviderKind::Remote | AgentProviderKind::OllamaOpenAI => { + let client = build_openai_client(&runtime)?; + let response = client + .agent(runtime.model) + .preamble(SUMMARY_SYSTEM_PROMPT) + .temperature(0.1) + .build() + .prompt("Update the rolling summary using the conversation history.") + .with_history(history) + .await + .map_err(map_prompt_error)?; - Ok(response.trim().to_string()) + Ok(response.trim().to_string()) + } + AgentProviderKind::OllamaAnthropic => { + let client = build_anthropic_client(&runtime)?; + let response = client + .agent(runtime.model) + .preamble(SUMMARY_SYSTEM_PROMPT) + .temperature(0.1) + .max_tokens(SUMMARY_MAX_TOKENS) + .build() + .prompt("Update the rolling summary using the conversation history.") + .with_history(history) + .await + .map_err(map_prompt_error)?; + + Ok(response.trim().to_string()) + } + } }) } } -fn build_client(runtime: &AgentRuntimeConfig) -> Result { +fn build_openai_client(runtime: &AgentRuntimeConfig) -> Result { let api_key = runtime.api_key.clone().unwrap_or_default(); openai::CompletionsClient::builder() @@ -190,10 +174,81 @@ fn build_client(runtime: &AgentRuntimeConfig) -> Result Result { + let api_key = runtime.api_key.clone().unwrap_or_default(); + + anthropic::Client::builder() + .api_key(api_key) + .base_url(&runtime.base_url) + .build() + .map_err(|error| AppError::ProviderInit(error.to_string())) +} + fn compose_request_messages(context_messages: Vec, history: Vec) -> Vec { context_messages.into_iter().chain(history).collect() } +async fn consume_stream( + mut rig_stream: S, + tool_runtime: &AgentToolRuntimeContext, +) -> Result +where + S: futures::Stream, rig::agent::StreamingError>> + Unpin, +{ + let mut reply = String::new(); + let mut saw_text = false; + let mut saw_reasoning_delta = false; + + while let Some(item) = rig_stream.next().await { + match item { + Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(text))) => { + saw_text = true; + reply.push_str(&text.text); + tool_runtime.stream_emitter.text_delta(text.text)?; + } + Ok(MultiTurnStreamItem::StreamAssistantItem( + StreamedAssistantContent::Reasoning(reasoning), + )) => { + if saw_reasoning_delta { + continue; + } + + let text = reasoning_text(&reasoning); + if text.is_empty() { + continue; + } + + tool_runtime.stream_emitter.reasoning_delta(text)?; + } + Ok(MultiTurnStreamItem::StreamAssistantItem( + StreamedAssistantContent::ReasoningDelta { reasoning, .. }, + )) => { + saw_reasoning_delta = true; + tool_runtime.stream_emitter.reasoning_delta(reasoning)?; + } + Ok(MultiTurnStreamItem::StreamAssistantItem( + StreamedAssistantContent::ToolCall { .. }, + )) => {} + Ok(MultiTurnStreamItem::StreamAssistantItem( + StreamedAssistantContent::ToolCallDelta { .. }, + )) => {} + Ok(MultiTurnStreamItem::StreamUserItem(_)) => {} + Ok(MultiTurnStreamItem::FinalResponse(final_response)) => { + if !saw_text && !final_response.response().is_empty() { + reply.push_str(final_response.response()); + tool_runtime + .stream_emitter + .text_delta(final_response.response().to_string())?; + } + } + Ok(_) => {} + Err(error) => return Err(map_streaming_error(error)), + } + } + + Ok(reply) +} + fn reasoning_text(reasoning: &rig::message::Reasoning) -> String { use rig::message::ReasoningContent; diff --git a/MosaicIQ/src-tauri/src/agent/mod.rs b/MosaicIQ/src-tauri/src/agent/mod.rs index 2926356..2f3be19 100644 --- a/MosaicIQ/src-tauri/src/agent/mod.rs +++ b/MosaicIQ/src-tauri/src/agent/mod.rs @@ -14,9 +14,10 @@ pub use service::AgentService; pub(crate) use settings::AgentSettingsService; pub use stream_events::AgentStreamEmitter; pub use types::{ - default_task_defaults, AgentConfigStatus, AgentRuntimeConfig, AgentStoredSettings, - AgentStreamItemEvent, AgentStreamItemKind, AgentTaskRoute, ChatPanelContext, ChatPromptRequest, - ChatStreamStart, PreparedChatTurn, RemoteProviderSettings, ResolveAgentToolApprovalRequest, - SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest, - AGENT_SETTINGS_STORE_PATH, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, + default_task_defaults, AgentConfigStatus, AgentProviderKind, AgentRuntimeConfig, + AgentStoredSettings, AgentStreamItemEvent, AgentStreamItemKind, AgentTaskRoute, + ChatPanelContext, ChatPromptRequest, ChatStreamStart, PreparedChatTurn, + RemoteProviderSettings, ResolveAgentToolApprovalRequest, SaveAgentRuntimeConfigRequest, + TaskProfile, UpdateRemoteApiKeyRequest, AGENT_SETTINGS_STORE_PATH, + DEFAULT_OLLAMA_COMPAT_API_KEY, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, }; diff --git a/MosaicIQ/src-tauri/src/agent/routing.rs b/MosaicIQ/src-tauri/src/agent/routing.rs index 36dd5bc..d476c92 100644 --- a/MosaicIQ/src-tauri/src/agent/routing.rs +++ b/MosaicIQ/src-tauri/src/agent/routing.rs @@ -1,4 +1,7 @@ -use crate::agent::{AgentRuntimeConfig, AgentStoredSettings, AgentTaskRoute, TaskProfile}; +use crate::agent::{ + AgentProviderKind, AgentRuntimeConfig, AgentStoredSettings, AgentTaskRoute, TaskProfile, + DEFAULT_OLLAMA_COMPAT_API_KEY, +}; use crate::error::AppError; pub fn resolve_runtime( @@ -16,14 +19,24 @@ pub fn resolve_runtime( } let api_key = settings.remote.api_key.trim().to_string(); - if api_key.is_empty() { + if settings.remote.provider_kind.uses_api_key() && api_key.is_empty() { return Err(AppError::RemoteApiKeyMissing); } Ok(AgentRuntimeConfig { + provider_kind: settings.remote.provider_kind, base_url: settings.remote.base_url.clone(), model: resolve_model(settings, task_profile, route, model_override)?, - api_key: Some(api_key), + api_key: Some(match settings.remote.provider_kind { + AgentProviderKind::Remote => api_key, + AgentProviderKind::OllamaOpenAI | AgentProviderKind::OllamaAnthropic => { + if api_key.is_empty() { + DEFAULT_OLLAMA_COMPAT_API_KEY.to_string() + } else { + api_key + } + } + }), }) } @@ -72,8 +85,9 @@ pub fn normalize_routes(settings: &mut AgentStoredSettings) -> Result<(), AppErr pub fn compute_remote_configured(settings: &AgentStoredSettings) -> bool { settings.remote.enabled && !settings.remote.base_url.trim().is_empty() - && !settings.remote.api_key.trim().is_empty() && !settings.default_remote_model.trim().is_empty() + && (!settings.remote.provider_kind.uses_api_key() + || !settings.remote.api_key.trim().is_empty()) } pub fn compute_overall_configured(settings: &AgentStoredSettings) -> bool { diff --git a/MosaicIQ/src-tauri/src/agent/service.rs b/MosaicIQ/src-tauri/src/agent/service.rs index b11600d..b13e83c 100644 --- a/MosaicIQ/src-tauri/src/agent/service.rs +++ b/MosaicIQ/src-tauri/src/agent/service.rs @@ -292,6 +292,7 @@ impl AgentService { let mut settings = self.settings.load()?; settings.remote = RemoteProviderSettings { enabled: request.remote_enabled, + provider_kind: request.provider_kind, base_url: request.remote_base_url.trim().to_string(), api_key: settings.remote.api_key, }; @@ -328,6 +329,7 @@ impl AgentService { configured: compute_overall_configured(&settings), remote_configured: compute_remote_configured(&settings), remote_enabled: settings.remote.enabled, + provider_kind: settings.remote.provider_kind, has_remote_api_key: !settings.remote.api_key.trim().is_empty(), has_sec_edgar_user_agent: !settings.sec_edgar_user_agent.trim().is_empty(), remote_base_url: settings.remote.base_url, @@ -364,9 +366,9 @@ mod tests { use super::SessionManager; use crate::agent::{ - default_task_defaults, AgentRuntimeConfig, AgentService, ChatPanelContext, - ChatPromptRequest, SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest, - DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, + default_task_defaults, AgentProviderKind, AgentRuntimeConfig, AgentService, + ChatPanelContext, ChatPromptRequest, SaveAgentRuntimeConfigRequest, TaskProfile, + UpdateRemoteApiKeyRequest, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, }; use crate::error::AppError; use crate::terminal::{Company, CompanyProfile, PanelPayload}; @@ -462,6 +464,7 @@ mod tests { let saved = service .save_runtime_config(SaveAgentRuntimeConfigRequest { remote_enabled: true, + provider_kind: AgentProviderKind::Remote, remote_base_url: "https://example.test/v4".to_string(), default_remote_model: "glm-test".to_string(), task_defaults: default_task_defaults("glm-test"), @@ -498,6 +501,7 @@ mod tests { service .save_runtime_config(SaveAgentRuntimeConfigRequest { remote_enabled: true, + provider_kind: AgentProviderKind::Remote, remote_base_url: "https://example.test/v4".to_string(), default_remote_model: "glm-test".to_string(), task_defaults: default_task_defaults("glm-test"), @@ -528,6 +532,7 @@ mod tests { service .save_runtime_config(SaveAgentRuntimeConfigRequest { remote_enabled: true, + provider_kind: AgentProviderKind::Remote, remote_base_url: "https://example.test/v4".to_string(), default_remote_model: "glm-test".to_string(), task_defaults: default_task_defaults("glm-test"), @@ -569,6 +574,7 @@ mod tests { let saved = service .save_runtime_config(SaveAgentRuntimeConfigRequest { remote_enabled: true, + provider_kind: AgentProviderKind::Remote, remote_base_url: "https://example.test/v4".to_string(), default_remote_model: "glm-test".to_string(), task_defaults, @@ -591,6 +597,7 @@ mod tests { service .save_runtime_config(SaveAgentRuntimeConfigRequest { remote_enabled: true, + provider_kind: AgentProviderKind::Remote, remote_base_url: "https://example.test/v4".to_string(), default_remote_model: "glm-test".to_string(), task_defaults: default_task_defaults("glm-test"), @@ -604,6 +611,37 @@ mod tests { }); } + #[test] + fn ollama_openai_provider_is_configured_without_a_stored_api_key() { + with_test_home("ollama-openai-config", || { + let app = build_test_app(); + let mut service = AgentService::new(app.handle()).unwrap(); + + let saved = service + .save_runtime_config(SaveAgentRuntimeConfigRequest { + remote_enabled: true, + provider_kind: AgentProviderKind::OllamaOpenAI, + remote_base_url: + super::super::types::DEFAULT_OLLAMA_OPENAI_BASE_URL.to_string(), + default_remote_model: "qwen3-coder".to_string(), + task_defaults: default_task_defaults("qwen3-coder"), + sec_edgar_user_agent: String::new(), + }) + .unwrap(); + + assert!(saved.configured); + assert!(saved.remote_configured); + assert!(!saved.has_remote_api_key); + + let prepared = prepare_turn(&mut service, request("hello")).unwrap(); + assert_eq!(prepared.runtime.provider_kind, AgentProviderKind::OllamaOpenAI); + assert_eq!( + prepared.runtime.api_key.as_deref(), + Some(crate::agent::DEFAULT_OLLAMA_COMPAT_API_KEY) + ); + }); + } + #[test] fn prepare_turn_without_panel_context_yields_no_context_messages() { with_test_home("context-none", || { @@ -678,6 +716,7 @@ mod tests { fn sample_runtime() -> AgentRuntimeConfig { AgentRuntimeConfig { + provider_kind: AgentProviderKind::Remote, base_url: "https://example.com".to_string(), model: "glm-5.1".to_string(), api_key: Some("key".to_string()), @@ -731,6 +770,7 @@ mod tests { service .save_runtime_config(SaveAgentRuntimeConfigRequest { remote_enabled: true, + provider_kind: AgentProviderKind::Remote, remote_base_url: "https://example.test/v4".to_string(), default_remote_model: "glm-test".to_string(), task_defaults: default_task_defaults("glm-test"), diff --git a/MosaicIQ/src-tauri/src/agent/settings.rs b/MosaicIQ/src-tauri/src/agent/settings.rs index 501be61..bc055a5 100644 --- a/MosaicIQ/src-tauri/src/agent/settings.rs +++ b/MosaicIQ/src-tauri/src/agent/settings.rs @@ -3,12 +3,13 @@ use tauri::{AppHandle, Runtime}; use tauri_plugin_store::StoreExt; use crate::agent::{ - default_task_defaults, AgentStoredSettings, RemoteProviderSettings, AGENT_SETTINGS_STORE_PATH, - DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, + default_task_defaults, AgentProviderKind, AgentStoredSettings, RemoteProviderSettings, + AGENT_SETTINGS_STORE_PATH, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, }; use crate::error::AppError; const REMOTE_ENABLED_KEY: &str = "remoteEnabled"; +const PROVIDER_KIND_KEY: &str = "providerKind"; const REMOTE_BASE_URL_KEY: &str = "remoteBaseUrl"; const REMOTE_API_KEY_KEY: &str = "remoteApiKey"; const DEFAULT_REMOTE_MODEL_KEY: &str = "defaultRemoteModel"; @@ -69,6 +70,10 @@ impl AgentSettingsService { .get(REMOTE_ENABLED_KEY) .and_then(|value| value.as_bool()) .unwrap_or(true), + provider_kind: store + .get(PROVIDER_KIND_KEY) + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .unwrap_or(AgentProviderKind::Remote), base_url: store .get(REMOTE_BASE_URL_KEY) .and_then(|value| value.as_str().map(ToOwned::to_owned)) @@ -119,6 +124,10 @@ impl AgentSettingsService { REMOTE_ENABLED_KEY.to_string(), json!(settings.remote.enabled), ); + store.set( + PROVIDER_KIND_KEY.to_string(), + json!(settings.remote.provider_kind), + ); store.set( REMOTE_BASE_URL_KEY.to_string(), json!(settings.remote.base_url), diff --git a/MosaicIQ/src-tauri/src/agent/types.rs b/MosaicIQ/src-tauri/src/agent/types.rs index 9acbb36..520d8db 100644 --- a/MosaicIQ/src-tauri/src/agent/types.rs +++ b/MosaicIQ/src-tauri/src/agent/types.rs @@ -8,9 +8,31 @@ use crate::terminal::{PanelPayload, TerminalCommandResponse}; pub const DEFAULT_REMOTE_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4"; /// Default model used for plain-text terminal chat. pub const DEFAULT_REMOTE_MODEL: &str = "glm-5.1"; +/// Default Ollama OpenAI compatibility endpoint used by the app. +#[cfg_attr(not(test), allow(dead_code))] +pub const DEFAULT_OLLAMA_OPENAI_BASE_URL: &str = "http://localhost:11434/v1"; +/// Default Ollama Anthropic compatibility endpoint used by the app. +#[allow(dead_code)] +pub const DEFAULT_OLLAMA_ANTHROPIC_BASE_URL: &str = "http://localhost:11434"; +/// Placeholder token required by Ollama compatibility endpoints but ignored by Ollama. +pub const DEFAULT_OLLAMA_COMPAT_API_KEY: &str = "ollama"; /// Store file used for agent settings and plaintext API key storage. pub const AGENT_SETTINGS_STORE_PATH: &str = "agent-settings.json"; +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AgentProviderKind { + Remote, + OllamaOpenAI, + OllamaAnthropic, +} + +impl AgentProviderKind { + pub const fn uses_api_key(self) -> bool { + matches!(self, Self::Remote) + } +} + /// Stable harness task profiles that can be routed independently. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -49,6 +71,7 @@ pub struct ChatPanelContext { /// Runtime provider configuration after settings resolution. #[derive(Debug, Clone)] pub struct AgentRuntimeConfig { + pub provider_kind: AgentProviderKind, pub base_url: String, pub model: String, pub api_key: Option, @@ -145,6 +168,7 @@ pub struct AgentConfigStatus { pub configured: bool, pub remote_configured: bool, pub remote_enabled: bool, + pub provider_kind: AgentProviderKind, pub has_remote_api_key: bool, pub has_sec_edgar_user_agent: bool, pub remote_base_url: String, @@ -158,6 +182,7 @@ pub struct AgentConfigStatus { #[serde(rename_all = "camelCase")] pub struct SaveAgentRuntimeConfigRequest { pub remote_enabled: bool, + pub provider_kind: AgentProviderKind, pub remote_base_url: String, pub default_remote_model: String, pub task_defaults: HashMap, @@ -176,6 +201,7 @@ pub struct UpdateRemoteApiKeyRequest { #[serde(rename_all = "camelCase")] pub struct RemoteProviderSettings { pub enabled: bool, + pub provider_kind: AgentProviderKind, pub base_url: String, pub api_key: String, } @@ -184,6 +210,7 @@ impl Default for RemoteProviderSettings { fn default() -> Self { Self { enabled: true, + provider_kind: AgentProviderKind::Remote, base_url: DEFAULT_REMOTE_BASE_URL.to_string(), api_key: String::new(), } diff --git a/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx b/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx index 8edef64..fff2c99 100644 --- a/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx +++ b/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx @@ -13,6 +13,8 @@ import { } from 'lucide-react'; import { agentSettingsBridge } from '../../lib/agentSettingsBridge'; import { + AGENT_PROVIDER_LABELS, + AgentProviderKind, AgentConfigStatus, AgentTaskRoute, TASK_LABELS, @@ -20,7 +22,7 @@ import { TaskProfile, } from '../../types/agentSettings'; import { ConfirmDialog } from './ConfirmDialog'; -import { ModelSelector } from './ModelSelector'; +import { DEFAULT_MODEL_OPTIONS, ModelSelector, OLLAMA_MODEL_OPTIONS } from './ModelSelector'; import { ValidatedInput, ValidationStatus } from './ValidatedInput'; import { HelpIcon } from './Tooltip'; @@ -31,6 +33,7 @@ interface AgentSettingsFormProps { interface FormState { remoteEnabled: boolean; + providerKind: AgentProviderKind; remoteBaseUrl: string; defaultRemoteModel: string; taskDefaults: Record; @@ -56,6 +59,47 @@ const mergeTaskDefaults = ( return acc; }, {} as Record); +const REMOTE_DEFAULT_BASE_URL = 'https://api.z.ai/api/coding/paas/v4'; +const OLLAMA_OPENAI_BASE_URL = 'http://localhost:11434/v1'; +const OLLAMA_ANTHROPIC_BASE_URL = 'http://localhost:11434'; + +const PROVIDER_BASE_URLS: Record = { + remote: REMOTE_DEFAULT_BASE_URL, + ollama_openai: OLLAMA_OPENAI_BASE_URL, + ollama_anthropic: OLLAMA_ANTHROPIC_BASE_URL, +}; + +const PROVIDER_MODEL_OPTIONS = { + remote: DEFAULT_MODEL_OPTIONS, + ollama_openai: OLLAMA_MODEL_OPTIONS, + ollama_anthropic: OLLAMA_MODEL_OPTIONS, +} satisfies Record; + +const PROVIDER_HELPERS: Record< + AgentProviderKind, + { description: string; baseUrlHelper: string; baseUrlPlaceholder: string } +> = { + remote: { + description: 'Connect to an external OpenAI-compatible AI service.', + baseUrlHelper: 'The API endpoint URL for your AI provider.', + baseUrlPlaceholder: 'https://api.example.com/v1', + }, + ollama_openai: { + description: 'Use Ollama through its OpenAI-compatible `/v1/chat/completions` interface.', + baseUrlHelper: 'Default Ollama OpenAI compatibility URL: `http://localhost:11434/v1`.', + baseUrlPlaceholder: OLLAMA_OPENAI_BASE_URL, + }, + ollama_anthropic: { + description: 'Use Ollama through its Anthropic-compatible `/v1/messages` interface.', + baseUrlHelper: 'Default Ollama Anthropic compatibility URL: `http://localhost:11434`.', + baseUrlPlaceholder: OLLAMA_ANTHROPIC_BASE_URL, + }, +}; + +const providerUsesApiKey = (providerKind: AgentProviderKind) => providerKind === 'remote'; +const shouldReplaceBaseUrl = (currentBaseUrl: string) => + !currentBaseUrl.trim() || Object.values(PROVIDER_BASE_URLS).includes(currentBaseUrl.trim()); + const validateUrl = (url: string): { valid: boolean; error?: string } => { if (!url.trim()) { return { valid: false, error: 'Base URL is required' }; @@ -92,6 +136,7 @@ export const AgentSettingsForm: React.FC = ({ }) => { const [formState, setFormState] = useState({ remoteEnabled: true, + providerKind: 'remote', remoteBaseUrl: '', defaultRemoteModel: '', taskDefaults: mergeTaskDefaults({}, ''), @@ -120,6 +165,7 @@ export const AgentSettingsForm: React.FC = ({ const newState: FormState = { remoteEnabled: status.remoteEnabled, + providerKind: status.providerKind, remoteBaseUrl: status.remoteBaseUrl, defaultRemoteModel: status.defaultRemoteModel, taskDefaults: mergeTaskDefaults(status.taskDefaults, status.defaultRemoteModel), @@ -146,6 +192,7 @@ export const AgentSettingsForm: React.FC = ({ const hasChanges = formState.remoteEnabled !== initialState.remoteEnabled || + formState.providerKind !== initialState.providerKind || formState.remoteBaseUrl !== initialState.remoteBaseUrl || formState.defaultRemoteModel !== initialState.defaultRemoteModel || JSON.stringify(formState.taskDefaults) !== JSON.stringify(initialState.taskDefaults) || @@ -217,6 +264,7 @@ export const AgentSettingsForm: React.FC = ({ const runtimeRequest = { remoteEnabled: formState.remoteEnabled, + providerKind: formState.providerKind, remoteBaseUrl: formState.remoteBaseUrl, defaultRemoteModel: formState.defaultRemoteModel, taskDefaults: formState.taskDefaults, @@ -335,9 +383,29 @@ export const AgentSettingsForm: React.FC = ({ }); }; + const handleProviderKindChange = (nextProviderKind: AgentProviderKind) => { + setFormState((current) => ({ + ...current, + providerKind: nextProviderKind, + remoteBaseUrl: shouldReplaceBaseUrl(current.remoteBaseUrl) + ? PROVIDER_BASE_URLS[nextProviderKind] + : current.remoteBaseUrl, + })); + }; + const isFormValid = (validation.baseUrl === 'valid' || validation.baseUrl === 'idle') && (validation.secEdgarUserAgent === 'valid' || validation.secEdgarUserAgent === 'idle'); + const providerHelpers = PROVIDER_HELPERS[formState.providerKind]; + const providerModelOptions = PROVIDER_MODEL_OPTIONS[formState.providerKind]; + const usesApiKey = providerUsesApiKey(formState.providerKind); + const apiKeyStatusLabel = usesApiKey + ? status.hasRemoteApiKey + ? 'Stored' + : 'Not set' + : status.hasRemoteApiKey + ? 'Unused stored key' + : 'Not required'; return (
@@ -353,11 +421,14 @@ export const AgentSettingsForm: React.FC = ({
- Remote ready: {status.remoteConfigured ? 'Yes' : 'No'} + + Provider: {AGENT_PROVIDER_LABELS[status.providerKind]} + {status.remoteConfigured ? ' / Ready' : ' / Needs setup'} +
- API key: {status.hasRemoteApiKey ? 'Stored' : 'Not set'} + API key: {apiKeyStatusLabel}
@@ -397,11 +468,11 @@ export const AgentSettingsForm: React.FC = ({
-

Remote Provider

- +

AI Provider

+

- Connect to an external AI service for enhanced capabilities + {providerHelpers.description}

+ + setFormState((prev) => ({ ...prev, remoteBaseUrl: e.target.value }))} - placeholder="https://api.example.com/v1" + placeholder={providerHelpers.baseUrlPlaceholder} validationStatus={validation.baseUrl} errorMessage={validation.baseUrlError} - helperText="The API endpoint URL for your AI provider" + helperText={providerHelpers.baseUrlHelper} disabled={isBusy} aria-required="true" /> -
+
@@ -437,6 +525,7 @@ export const AgentSettingsForm: React.FC = ({ value={formState.defaultRemoteModel} onChange={handleDefaultRemoteModelChange} placeholder="Select a model" + options={providerModelOptions} disabled={isBusy} />

@@ -475,6 +564,7 @@ export const AgentSettingsForm: React.FC = ({ value={formState.taskDefaults[task].model} onChange={(value) => setTaskRoute(task, () => ({ model: value }))} placeholder={formState.defaultRemoteModel || 'Use default model'} + options={providerModelOptions} disabled={isBusy} />

@@ -529,48 +619,92 @@ export const AgentSettingsForm: React.FC = ({

API Key

- +

- Authentication credential for your AI provider + {usesApiKey + ? 'Authentication credential for your AI provider.' + : 'Optional for Ollama. The documented placeholder token is supplied automatically.'}

-
- setFormState((prev) => ({ ...prev, remoteApiKey: e.target.value }))} - placeholder="Enter your API key" - validationStatus={validation.apiKey} - helperText={ - status.hasRemoteApiKey && !formState.remoteApiKey - ? 'A remote API key is currently stored.' - : 'Required for API requests' - } - disabled={isBusy} - fullWidth - className="pr-20" - /> - -
+ {usesApiKey ? ( + <> +
+ setFormState((prev) => ({ ...prev, remoteApiKey: e.target.value }))} + placeholder="Enter your API key" + validationStatus={validation.apiKey} + helperText={ + status.hasRemoteApiKey && !formState.remoteApiKey + ? 'A remote API key is currently stored.' + : 'Required for API requests.' + } + disabled={isBusy} + fullWidth + className="pr-20" + /> + +
-
-
+
+
+ {status.hasRemoteApiKey && ( + + )} +
+ +
+ + ) : ( +
+ + Ollama compatibility mode ignores stored API keys and uses the placeholder token + documented by Ollama. + {status.hasRemoteApiKey && ( )}
- -
+ )}
diff --git a/MosaicIQ/src/components/Settings/ModelSelector.tsx b/MosaicIQ/src/components/Settings/ModelSelector.tsx index c5ac3be..e86bdd2 100644 --- a/MosaicIQ/src/components/Settings/ModelSelector.tsx +++ b/MosaicIQ/src/components/Settings/ModelSelector.tsx @@ -18,7 +18,7 @@ export interface ModelSelectorProps { disabled?: boolean; } -const DEFAULT_MODEL_OPTIONS: ModelOption[] = [ +export const DEFAULT_MODEL_OPTIONS: ModelOption[] = [ { value: 'gpt-4o', label: 'GPT-4o', provider: 'OpenAI' }, { value: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'OpenAI' }, { value: 'gpt-4-turbo', label: 'GPT-4 Turbo', provider: 'OpenAI' }, @@ -29,6 +29,13 @@ const DEFAULT_MODEL_OPTIONS: ModelOption[] = [ { value: 'deepseek-chat', label: 'DeepSeek Chat', provider: 'DeepSeek' }, ]; +export const OLLAMA_MODEL_OPTIONS: ModelOption[] = [ + { value: 'qwen3-coder', label: 'Qwen3 Coder', provider: 'Ollama' }, + { value: 'llama3.2', label: 'Llama 3.2', provider: 'Ollama' }, + { value: 'mistral', label: 'Mistral', provider: 'Ollama' }, + { value: 'deepseek-r1', label: 'DeepSeek R1', provider: 'Ollama' }, +]; + export const ModelSelector: React.FC = ({ id, value, diff --git a/MosaicIQ/src/components/Settings/SecEdgarSettingsCard.tsx b/MosaicIQ/src/components/Settings/SecEdgarSettingsCard.tsx index 2f6c031..d559e7a 100644 --- a/MosaicIQ/src/components/Settings/SecEdgarSettingsCard.tsx +++ b/MosaicIQ/src/components/Settings/SecEdgarSettingsCard.tsx @@ -91,6 +91,7 @@ export const SecEdgarSettingsCard: React.FC = ({ try { const nextStatus = await agentSettingsBridge.saveRuntimeConfig({ remoteEnabled: status.remoteEnabled, + providerKind: status.providerKind, remoteBaseUrl: status.remoteBaseUrl, defaultRemoteModel: status.defaultRemoteModel, taskDefaults: status.taskDefaults, diff --git a/MosaicIQ/src/components/Settings/SettingsPage.tsx b/MosaicIQ/src/components/Settings/SettingsPage.tsx index 86bfa97..3eb1149 100644 --- a/MosaicIQ/src/components/Settings/SettingsPage.tsx +++ b/MosaicIQ/src/components/Settings/SettingsPage.tsx @@ -23,7 +23,10 @@ import { ClipboardList, Clock, } from "lucide-react"; -import { AgentConfigStatus } from "../../types/agentSettings"; +import { + AGENT_PROVIDER_LABELS, + AgentConfigStatus, +} from "../../types/agentSettings"; import { AgentSettingsForm } from "./AgentSettingsForm"; import { NewsFeedSettingsCard } from "./NewsFeedSettingsCard"; import { SecEdgarSettingsCard } from "./SecEdgarSettingsCard"; @@ -129,8 +132,12 @@ export const SettingsPage: React.FC = ({ ), }, { - label: "Remote provider", - value: status?.remoteEnabled ? "Enabled" : "Disabled", + label: "AI provider", + value: status?.remoteEnabled + ? status + ? AGENT_PROVIDER_LABELS[status.providerKind] + : "Enabled" + : "Disabled", icon: status?.remoteEnabled ? ( ) : ( @@ -139,7 +146,15 @@ export const SettingsPage: React.FC = ({ }, { label: "API key", - value: status?.hasRemoteApiKey ? "Stored" : "Missing", + value: status + ? status.providerKind === "remote" + ? status.hasRemoteApiKey + ? "Stored" + : "Missing" + : status.hasRemoteApiKey + ? "Unused" + : "Optional" + : "Missing", icon: status?.hasRemoteApiKey ? ( ) : ( diff --git a/MosaicIQ/src/types/agentSettings.ts b/MosaicIQ/src/types/agentSettings.ts index a0cf4a0..9f8dfcb 100644 --- a/MosaicIQ/src/types/agentSettings.ts +++ b/MosaicIQ/src/types/agentSettings.ts @@ -1,3 +1,5 @@ +export type AgentProviderKind = 'remote' | 'ollama_openai' | 'ollama_anthropic'; + export type TaskProfile = | 'interactiveChat' | 'analysis' @@ -16,6 +18,7 @@ export interface AgentConfigStatus { configured: boolean; remoteConfigured: boolean; remoteEnabled: boolean; + providerKind: AgentProviderKind; hasRemoteApiKey: boolean; hasSecEdgarUserAgent: boolean; remoteBaseUrl: string; @@ -26,6 +29,7 @@ export interface AgentConfigStatus { export interface SaveAgentRuntimeConfigRequest { remoteEnabled: boolean; + providerKind: AgentProviderKind; remoteBaseUrl: string; defaultRemoteModel: string; taskDefaults: Record; @@ -47,6 +51,12 @@ export const TASK_PROFILES: TaskProfile[] = [ 'memoStructuring', ]; +export const AGENT_PROVIDER_LABELS: Record = { + remote: 'Remote', + ollama_openai: 'Ollama (OpenAI Compat)', + ollama_anthropic: 'Ollama (Anthropic Compat)', +}; + export const TASK_LABELS: Record = { interactiveChat: 'Interactive Chat', analysis: 'Analysis',