Add Ollama provider options

This commit is contained in:
2026-04-11 10:49:26 -04:00
parent 534b154a37
commit 4259d54154
11 changed files with 465 additions and 166 deletions

View File

@@ -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<String, AppError>> {
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<Message>,
) -> BoxFuture<'static, Result<String, AppError>> {
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<openai::CompletionsClient, AppError> {
fn build_openai_client(runtime: &AgentRuntimeConfig) -> Result<openai::CompletionsClient, AppError> {
let api_key = runtime.api_key.clone().unwrap_or_default();
openai::CompletionsClient::builder()
@@ -190,10 +174,81 @@ fn build_client(runtime: &AgentRuntimeConfig) -> Result<openai::CompletionsClien
.map_err(|error| AppError::ProviderInit(error.to_string()))
}
fn build_anthropic_client(runtime: &AgentRuntimeConfig) -> Result<anthropic::Client, AppError> {
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<Message>, history: Vec<Message>) -> Vec<Message> {
context_messages.into_iter().chain(history).collect()
}
async fn consume_stream<R, S>(
mut rig_stream: S,
tool_runtime: &AgentToolRuntimeContext,
) -> Result<String, AppError>
where
S: futures::Stream<Item = Result<MultiTurnStreamItem<R>, 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;

View File

@@ -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,
};

View File

@@ -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 {

View File

@@ -292,6 +292,7 @@ impl<R: Runtime, G: ChatGateway> AgentService<R, G> {
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<R: Runtime, G: ChatGateway> AgentService<R, G> {
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"),

View File

@@ -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<R: Runtime> AgentSettingsService<R> {
.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<R: Runtime> AgentSettingsService<R> {
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),

View File

@@ -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<String>,
@@ -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<TaskProfile, AgentTaskRoute>,
@@ -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(),
}

View File

@@ -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<TaskProfile, AgentTaskRoute>;
@@ -56,6 +59,47 @@ const mergeTaskDefaults = (
return acc;
}, {} as Record<TaskProfile, AgentTaskRoute>);
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<AgentProviderKind, string> = {
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<AgentProviderKind, typeof DEFAULT_MODEL_OPTIONS>;
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<AgentSettingsFormProps> = ({
}) => {
const [formState, setFormState] = useState<FormState>({
remoteEnabled: true,
providerKind: 'remote',
remoteBaseUrl: '',
defaultRemoteModel: '',
taskDefaults: mergeTaskDefaults({}, ''),
@@ -120,6 +165,7 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
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<AgentSettingsFormProps> = ({
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<AgentSettingsFormProps> = ({
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<AgentSettingsFormProps> = ({
});
};
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 (
<div className="space-y-6">
@@ -353,11 +421,14 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
<div className="text-right text-xs font-mono">
<div className="flex items-center justify-end gap-2 text-term-text-muted">
<Globe className="h-3.5 w-3.5" />
<span>Remote ready: {status.remoteConfigured ? 'Yes' : 'No'}</span>
<span>
Provider: {AGENT_PROVIDER_LABELS[status.providerKind]}
{status.remoteConfigured ? ' / Ready' : ' / Needs setup'}
</span>
</div>
<div className="mt-1 flex items-center justify-end gap-2 text-term-text-muted">
<KeyRound className="h-3.5 w-3.5" />
<span>API key: {status.hasRemoteApiKey ? 'Stored' : 'Not set'}</span>
<span>API key: {apiKeyStatusLabel}</span>
</div>
</div>
</div>
@@ -397,11 +468,11 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
<div className="mb-6 flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-base font-mono font-semibold text-term-text">Remote Provider</h3>
<HelpIcon tooltip="Configure your OpenAI-compatible AI provider endpoint" />
<h3 className="text-base font-mono font-semibold text-term-text">AI Provider</h3>
<HelpIcon tooltip="Choose the chat provider runtime and configure its endpoint." />
</div>
<p className="mt-1.5 text-xs font-mono leading-6 text-term-text-muted">
Connect to an external AI service for enhanced capabilities
{providerHelpers.description}
</p>
</div>
<label className="flex items-center gap-2 text-xs font-mono text-term-text">
@@ -417,19 +488,36 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
</div>
<div className="grid gap-5 md:grid-cols-2">
<label className="block">
<span className="mb-2 block text-xs font-mono text-term-text-muted">Provider</span>
<select
value={formState.providerKind}
onChange={(event) => handleProviderKindChange(event.target.value as AgentProviderKind)}
disabled={isBusy}
className="w-full border border-term-border bg-term-surface px-3 py-2 text-sm font-mono text-term-text outline-none transition-all hover:border-term-border-focus focus:border-info disabled:cursor-not-allowed disabled:opacity-50 border-l-2 focus:border-l-info hover:border-l-term-border-focus"
>
<option value="remote">{AGENT_PROVIDER_LABELS.remote}</option>
<option value="ollama_openai">{AGENT_PROVIDER_LABELS.ollama_openai}</option>
<option value="ollama_anthropic">{AGENT_PROVIDER_LABELS.ollama_anthropic}</option>
</select>
<p className="mt-1.5 text-xs font-mono text-term-text-muted">
Switches the backend client and auth requirements.
</p>
</label>
<ValidatedInput
label="Remote Base URL"
value={formState.remoteBaseUrl}
onChange={(e) => 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"
/>
<div>
<div className="md:col-span-2">
<label className="mb-2 block text-xs font-mono text-term-text-muted">
Default Remote Model
</label>
@@ -437,6 +525,7 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
value={formState.defaultRemoteModel}
onChange={handleDefaultRemoteModelChange}
placeholder="Select a model"
options={providerModelOptions}
disabled={isBusy}
/>
<p className="mt-1.5 text-xs font-mono text-term-text-muted">
@@ -475,6 +564,7 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
value={formState.taskDefaults[task].model}
onChange={(value) => setTaskRoute(task, () => ({ model: value }))}
placeholder={formState.defaultRemoteModel || 'Use default model'}
options={providerModelOptions}
disabled={isBusy}
/>
</div>
@@ -529,48 +619,92 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
<div className="mb-5">
<div className="flex items-center gap-2">
<h3 className="text-base font-mono font-semibold text-term-text">API Key</h3>
<HelpIcon tooltip="Your API key is stored securely and used to authenticate with the remote provider" />
<HelpIcon
tooltip={
usesApiKey
? 'Your API key is stored securely and used to authenticate with the selected remote provider.'
: 'Ollama compatibility mode ignores API keys. MosaicIQ injects the placeholder token expected by the compatibility layer.'
}
/>
</div>
<p className="mt-1.5 text-xs font-mono leading-6 text-term-text-muted">
Authentication credential for your AI provider
{usesApiKey
? 'Authentication credential for your AI provider.'
: 'Optional for Ollama. The documented placeholder token is supplied automatically.'}
</p>
</div>
<div className="space-y-4">
<div className="relative">
<ValidatedInput
label={status.hasRemoteApiKey ? 'Update API Key' : 'API Key'}
type={showApiKey ? 'text' : 'password'}
value={formState.remoteApiKey}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-[2.1rem] text-term-text-tertiary transition-colors hover:text-info focus:outline-none focus:ring-2 focus:ring-info focus:ring-offset-2 focus:ring-offset-term-surface"
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
aria-pressed={showApiKey}
>
{showApiKey ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
</div>
{usesApiKey ? (
<>
<div className="relative">
<ValidatedInput
label={status.hasRemoteApiKey ? 'Update API Key' : 'API Key'}
type={showApiKey ? 'text' : 'password'}
value={formState.remoteApiKey}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-[2.1rem] text-term-text-tertiary transition-colors hover:text-info focus:outline-none focus:ring-2 focus:ring-info focus:ring-offset-2 focus:ring-offset-term-surface"
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
aria-pressed={showApiKey}
>
{showApiKey ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
</div>
<div className="flex flex-wrap justify-between gap-3">
<div>
<div className="flex flex-wrap justify-between gap-3">
<div>
{status.hasRemoteApiKey && (
<button
type="button"
onClick={handleClearApiKeyClick}
disabled={isBusy}
className="flex items-center gap-2 rounded border border-[var(--term-status-error-border)] bg-[var(--term-status-error)] px-4 py-2 text-xs font-mono text-negative transition-colors hover:border-negative hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
<XCircle className="h-3.5 w-3.5" />
Clear Key
</button>
)}
</div>
<button
type="button"
onClick={handleSaveRemoteApiKey}
disabled={isBusy || !formState.remoteApiKey.trim()}
className="rounded border border-info bg-[var(--term-status-info)] px-4 py-2 text-xs font-mono text-info transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{isBusy ? (
'Saving...'
) : status.hasRemoteApiKey ? (
'Update API Key'
) : (
'Save API Key'
)}
</button>
</div>
</>
) : (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-term-border-subtle bg-term-bg px-4 py-3 text-xs font-mono text-term-text-muted">
<span>
Ollama compatibility mode ignores stored API keys and uses the placeholder token
documented by Ollama.
</span>
{status.hasRemoteApiKey && (
<button
type="button"
@@ -579,25 +713,11 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
className="flex items-center gap-2 rounded border border-[var(--term-status-error-border)] bg-[var(--term-status-error)] px-4 py-2 text-xs font-mono text-negative transition-colors hover:border-negative hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
<XCircle className="h-3.5 w-3.5" />
Clear Key
Clear Stored Key
</button>
)}
</div>
<button
type="button"
onClick={handleSaveRemoteApiKey}
disabled={isBusy || !formState.remoteApiKey.trim()}
className="rounded border border-info bg-[var(--term-status-info)] px-4 py-2 text-xs font-mono text-info transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{isBusy ? (
'Saving...'
) : status.hasRemoteApiKey ? (
'Update API Key'
) : (
'Save API Key'
)}
</button>
</div>
)}
</div>
</section>

View File

@@ -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<ModelSelectorProps> = ({
id,
value,

View File

@@ -91,6 +91,7 @@ export const SecEdgarSettingsCard: React.FC<SecEdgarSettingsCardProps> = ({
try {
const nextStatus = await agentSettingsBridge.saveRuntimeConfig({
remoteEnabled: status.remoteEnabled,
providerKind: status.providerKind,
remoteBaseUrl: status.remoteBaseUrl,
defaultRemoteModel: status.defaultRemoteModel,
taskDefaults: status.taskDefaults,

View File

@@ -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<SettingsPageProps> = ({
),
},
{
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 ? (
<Wifi className="h-4 w-4" />
) : (
@@ -139,7 +146,15 @@ export const SettingsPage: React.FC<SettingsPageProps> = ({
},
{
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 ? (
<KeyRound className="h-4 w-4" />
) : (

View File

@@ -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<TaskProfile, AgentTaskRoute>;
@@ -47,6 +51,12 @@ export const TASK_PROFILES: TaskProfile[] = [
'memoStructuring',
];
export const AGENT_PROVIDER_LABELS: Record<AgentProviderKind, string> = {
remote: 'Remote',
ollama_openai: 'Ollama (OpenAI Compat)',
ollama_anthropic: 'Ollama (Anthropic Compat)',
};
export const TASK_LABELS: Record<TaskProfile, string> = {
interactiveChat: 'Interactive Chat',
analysis: 'Analysis',