From 83ba98ab42cb4c763c4b3944984d2d463f5bfbac Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 5 Oct 2026 23:41:29 -0400 Subject: [PATCH] Guide AI connection setup with private node credentials and explicit providers --- .../app/src/__tests__/providerBridge.test.ts | 28 ++ aiui/packages/app/src/__tests__/useAI.test.ts | 18 ++ .../app/src/components/chat/ChatHeader.vue | 5 +- .../app/src/components/chat/ChatWindow.vue | 13 +- .../src/components/settings/ApiKeyManager.vue | 10 +- aiui/packages/app/src/composables/useAI.ts | 59 ++-- aiui/packages/app/src/services/archyBridge.ts | 23 +- .../src/api/rpc/system/handlers.rs | 89 +++--- .../archipelago/src/assistant/backends/mod.rs | 63 ++++ .../src/assistant/backends/openai.rs | 279 ++++++++++++++++++ .../src/assistant/backends/routstr.rs | 2 +- core/archipelago/src/settings/mod.rs | 2 + .../src/settings/model_provider.rs | 178 +++++++++++ docs/aiui-provider-setup-followup.md | 49 +++ neode-ui/src/components/AIConnectionModal.vue | 135 +++++++++ .../src/components/ReceiveBitcoinModal.vue | 7 +- .../__tests__/AIConnectionModal.test.ts | 60 ++++ .../__tests__/ReceiveBitcoinModal.test.ts | 11 + neode-ui/src/views/Chat.vue | 19 +- .../src/views/__tests__/chatAiuiEmbed.test.ts | 17 ++ 20 files changed, 992 insertions(+), 75 deletions(-) create mode 100644 aiui/packages/app/src/__tests__/providerBridge.test.ts create mode 100644 core/archipelago/src/assistant/backends/openai.rs create mode 100644 core/archipelago/src/settings/model_provider.rs create mode 100644 docs/aiui-provider-setup-followup.md create mode 100644 neode-ui/src/components/AIConnectionModal.vue create mode 100644 neode-ui/src/components/__tests__/AIConnectionModal.test.ts diff --git a/aiui/packages/app/src/__tests__/providerBridge.test.ts b/aiui/packages/app/src/__tests__/providerBridge.test.ts new file mode 100644 index 00000000..b8e874a6 --- /dev/null +++ b/aiui/packages/app/src/__tests__/providerBridge.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { archyBridge } from '@/services/archyBridge' +const originalParent = window.parent +const origin = 'https://node.example' +afterEach(() => { archyBridge.destroy(); Object.defineProperty(window, 'parent', { value: originalParent, configurable: true }); vi.restoreAllMocks() }) +describe('trusted provider setup bridge', () => { + it('accepts configuration only from the embedding parent, rejects siblings and other origins', () => { + const parent = { postMessage: vi.fn() } + Object.defineProperty(window, 'parent', { value: parent, configurable: true }) + archyBridge.init(origin) + const listener = vi.fn(); const unsubscribe = archyBridge.onProviderConfigured(listener) + const send = (source: unknown, from: string, provider = 'openai') => window.dispatchEvent(new MessageEvent('message', { source: source as Window, origin: from, data: { type: 'ai:provider-configured', provider, model: 'test-model' } })) + send({}, origin); send(parent, 'https://evil.example'); send(parent, origin, 'arbitrary') + expect(listener).not.toHaveBeenCalled() + send(parent, origin) + expect(listener).toHaveBeenCalledExactlyOnceWith({ provider: 'openai', model: 'test-model' }) + archyBridge.requestAISetup() + expect(parent.postMessage).toHaveBeenLastCalledWith({ type: 'ai:setup-request' }, origin) + unsubscribe() + }) + it('replays the selection when the composer mounts after the handshake', () => { + const parent = { postMessage: vi.fn() }; Object.defineProperty(window, 'parent', { value: parent, configurable: true }) + archyBridge.init(origin) + window.dispatchEvent(new MessageEvent('message', { source: parent as unknown as Window, origin, data: { type: 'ai:provider-configured', provider: 'local' } })) + const listener = vi.fn(); const unsubscribe = archyBridge.onProviderConfigured(listener) + expect(listener).toHaveBeenCalledExactlyOnceWith({ provider: 'local', model: '' }); unsubscribe() + }) +}) diff --git a/aiui/packages/app/src/__tests__/useAI.test.ts b/aiui/packages/app/src/__tests__/useAI.test.ts index 330add7a..dcad6757 100644 --- a/aiui/packages/app/src/__tests__/useAI.test.ts +++ b/aiui/packages/app/src/__tests__/useAI.test.ts @@ -249,6 +249,24 @@ describe('useAI', () => { expect(chatStore.isStreaming).toBe(false) }) + it.each([502, 503, 429, 401])('distinguishes HTTP %s from a missing credential', async (status) => { + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status, text: async () => 'Provider request failed' }) + const store = useChatStore(); store.webSearchEnabled = false + const ai = useAI(); ai.needsApiKey.value = false + await ai.sendMessage('test') + expect(ai.needsApiKey.value).toBe(status === 401) + expect(store.isStreaming).toBe(false) + }) + + it('offers funding for a Routstr payment-required response without mislabeling it a key error', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 402, text: async () => JSON.stringify({ error: { message: 'Your spending allowance is exhausted' } }) }) + const store = useChatStore(); store.webSearchEnabled = false + const ai = useAI(); ai.setProvider('routstr'); ai.needsApiKey.value = false; ai.needsFunding.value = false + await ai.sendMessage('test') + expect(ai.needsFunding.value).toBe(true); expect(ai.needsApiKey.value).toBe(false) + expect(store.messages.find(m => m.role === 'assistant')?.content).toContain('spending allowance') + }) + it('handles connection errors gracefully', async () => { globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network failure')) diff --git a/aiui/packages/app/src/components/chat/ChatHeader.vue b/aiui/packages/app/src/components/chat/ChatHeader.vue index d51b3436..7f7c4ef8 100644 --- a/aiui/packages/app/src/components/chat/ChatHeader.vue +++ b/aiui/packages/app/src/components/chat/ChatHeader.vue @@ -123,6 +123,7 @@ :style="modelPickerDropdownStyle" @click.stop > +

{{ provider.name }} @@ -247,6 +248,7 @@ import { useAI } from '@/composables/useAI' import { useContentPanel } from '@/composables/useContentPanel' import { downloadConversation, type ExportFormat } from '@/utils/conversation-export' import { parseImportFile } from '@/utils/conversation-import' +import { archyBridge } from '@/services/archyBridge' import { useComparisonMode } from '@/composables/useComparisonMode' defineProps<{ @@ -332,8 +334,7 @@ const modelDisplayName = computed(() => { }) function selectModel(providerId: string, modelId: string) { - setProvider(providerId as 'routstr' | 'claude' | 'openrouter' | 'mock') - setModel(modelId) + if (setProvider(providerId as Parameters[0])) setModel(modelId) showModelPicker.value = false } diff --git a/aiui/packages/app/src/components/chat/ChatWindow.vue b/aiui/packages/app/src/components/chat/ChatWindow.vue index 0e921682..7b8d2008 100644 --- a/aiui/packages/app/src/components/chat/ChatWindow.vue +++ b/aiui/packages/app/src/components/chat/ChatWindow.vue @@ -186,8 +186,9 @@ defineEmits<{ }>() const chatStore = useChatStore() -const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel, needsApiKey } = useAI() +const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel, needsApiKey, needsFunding } = useAI() const { updatePanelFromText, panelOpen, panelFilms, panelTitle, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel() +import { archyBridge } from '@/services/archyBridge' import { useCodeContext } from '@/composables/useCodeContext' import { useVisualViewport } from '@/composables/useVisualViewport' const codeContext = useCodeContext() @@ -206,11 +207,19 @@ const showSettings = ref(false) // without fixing anything). watch(needsApiKey, (needs) => { if (needs) { - showSettings.value = true + if (archyBridge.isInArchy()) archyBridge.requestAISetup() + else showSettings.value = true needsApiKey.value = false } }) +watch(needsFunding, needed => { + if (!needed) return + if (archyBridge.isInArchy()) archyBridge.requestAISetup('funding') + else showSettings.value = true + needsFunding.value = false +}) + // Scroll position memory per conversation const scrollPositions = new Map() diff --git a/aiui/packages/app/src/components/settings/ApiKeyManager.vue b/aiui/packages/app/src/components/settings/ApiKeyManager.vue index 17783960..f091163b 100644 --- a/aiui/packages/app/src/components/settings/ApiKeyManager.vue +++ b/aiui/packages/app/src/components/settings/ApiKeyManager.vue @@ -1,5 +1,9 @@ diff --git a/aiui/packages/app/src/composables/useAI.ts b/aiui/packages/app/src/composables/useAI.ts index a65b922f..f1520a3a 100644 --- a/aiui/packages/app/src/composables/useAI.ts +++ b/aiui/packages/app/src/composables/useAI.ts @@ -13,7 +13,7 @@ import { useCodeContext } from '@/composables/useCodeContext' import { apiFetch } from '@/utils/api-fetch' import { useSettingsStore } from '@/stores/settings' -type Provider = 'routstr' | 'claude' | 'openrouter' | 'mock' +type Provider = 'routstr' | 'claude' | 'openrouter' | 'mock' | 'openai' | 'auto' | 'local' // API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/) const BASE = import.meta.env.BASE_URL || '/' @@ -120,34 +120,15 @@ Prioritize Podcasting 2.0–friendly platforms: Fountain.fm, Podcast Index, Cast Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out. ${librarySection}` -const activeProvider = ref('claude') +const activeProvider = ref(archyBridge.isInArchy() ? 'auto' : 'claude') const activeModel = ref('claude-haiku-4.5') -// One-shot signal a send/regenerate/edit failure looked like a missing or -// invalid API key (or an unreachable proxy) rather than a transient/server -// error — consumed by ChatWindow.vue to auto-open Settings so the user isn't -// left in a dead end with no obvious next step. Deliberately narrow (401/403, -// explicit "api key"/"unauthorized" text, or a connection-level failure to -// reach the proxy at all) so a rate-limited or momentarily-flaky provider -// response does NOT send the user to Settings for a problem Settings can't -// fix. Reset to false by the consumer immediately after acting on it, so it -// behaves as a pulse rather than sticky state (each new failure can re-fire). +// Credentials require setup; network failures and provider outages require retry. const needsApiKey = ref(false) - +const needsFunding = ref(false) function looksLikeMissingApiKey(err: string): boolean { - const lower = err.toLowerCase() - return ( - /\b(401|403)\b/.test(err) || - lower.includes('api key') || - lower.includes('x-api-key') || - lower.includes('unauthorized') || - lower.includes('authentication_error') || - lower.includes('failed to fetch') || - lower.includes('econnrefused') || - lower.includes(' 502') || - lower.includes(' 503') - ) + return /\b(401|403)\b|api[ _-]?key|unauthorized|authentication_error|credential/i.test(err) } // ─── Routstr model catalog (fetched from the node's session-gated proxy) ─── @@ -161,7 +142,7 @@ async function refreshRoutstrModels() { routstrModelsFetched = true try { const res = await apiFetch(ROUTSTR_MODELS_PATH) - if (!res.ok) return + if (!res.ok) { routstrModelsFetched = false; return } const data = await res.json() if (Array.isArray(data?.data)) { routstrModels.value = data.data @@ -170,13 +151,21 @@ async function refreshRoutstrModels() { id: m.id as string, name: (m.name as string) || (m.id as string), })) - } + if (activeProvider.value === 'routstr' && activeModel.value === 'routstr-unavailable' && routstrModels.value[0]) activeModel.value = routstrModels.value[0].id + } else { routstrModelsFetched = false } } catch { routstrModelsFetched = false // allow a retry on the next send/open } } const availableProviders = computed(() => { + if (archyBridge.isInArchy()) return [ + { id: 'local' as Provider, name: 'Local AI', models: [{ id: 'node', name: 'Node configuration' }] }, + { id: 'auto' as Provider, name: 'Node AI', models: [{ id: 'node', name: 'Node configuration' }] }, + { id: 'claude' as Provider, name: 'Claude API', models: [{ id: 'node', name: 'Node configuration' }] }, + { id: 'openai' as Provider, name: 'OpenAI API', models: [{ id: activeProvider.value === 'openai' ? activeModel.value : 'node', name: activeProvider.value === 'openai' ? activeModel.value : 'Configure model' }] }, + { id: 'routstr' as Provider, name: 'Routstr (sats)', models: routstrModels.value.length ? routstrModels.value : [{ id: 'routstr-unavailable', name: 'Models unavailable — retry' }] }, + ] const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [ { id: 'routstr', @@ -187,7 +176,7 @@ const availableProviders = computed(() => { }, { id: 'claude', - name: 'Claude (Max)', + name: 'Claude API', models: [ { id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' }, { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' }, @@ -207,24 +196,36 @@ const availableProviders = computed(() => { }) providers.push({ id: 'mock', - name: 'Local (no API)', + name: 'Demo echo', models: [{ id: 'echo', name: 'Echo (mirror input)' }], }) return providers }) function setProvider(provider: Provider) { + if (archyBridge.isInArchy()) { + if (provider === 'routstr' && activeProvider.value === 'routstr') return true + archyBridge.requestAISetup(); return false + } activeProvider.value = provider const p = availableProviders.value.find((pp) => pp.id === provider) if (p && p.models.length > 0) { activeModel.value = p.models[0].id } + return true } function setModel(model: string) { + if (archyBridge.isInArchy() && activeProvider.value !== 'routstr') { archyBridge.requestAISetup(); return } activeModel.value = model } +archyBridge.onProviderConfigured(({ provider, model }) => { + activeProvider.value = provider + activeModel.value = model || (provider === 'routstr' ? routstrModels.value[0]?.id || 'routstr-unavailable' : 'node') + if (provider === 'routstr') void refreshRoutstrModels() +}) + interface ChatMessage { role: 'user' | 'assistant' content: string @@ -448,6 +449,7 @@ async function streamRoutstr( }) const bodyText = await res.text().catch(() => '') + if (res.status === 402) needsFunding.value = true if (!res.ok) { // The node's refusals carry a plain-language error.message (budget not // set, budget spent, wallet can't fund) — surface it verbatim. @@ -974,5 +976,6 @@ export function useAI() { setProvider, setModel, needsApiKey, + needsFunding, } } diff --git a/aiui/packages/app/src/services/archyBridge.ts b/aiui/packages/app/src/services/archyBridge.ts index c4fb3f5f..3cd2e71e 100644 --- a/aiui/packages/app/src/services/archyBridge.ts +++ b/aiui/packages/app/src/services/archyBridge.ts @@ -55,6 +55,10 @@ interface ThemeInfo { type PermissionsCallback = (categories: AIContextCategory[]) => void type ThemeCallback = (theme: ThemeInfo) => void +export interface AIProviderSelection { provider: 'auto' | 'local' | 'claude' | 'openai' | 'routstr'; model: string } +const providerCallbacks = new Set<(selection: AIProviderSelection) => void>() +let currentProvider: AIProviderSelection | null = null + let requestId = 0 const pendingRequests = new Map void @@ -80,12 +84,19 @@ function postToParent(msg: unknown) { function handleMessage(event: MessageEvent) { // Always validate origin — reject if not configured or mismatched - if (!allowedOrigin || event.origin !== allowedOrigin) return + if (!allowedOrigin || event.origin !== allowedOrigin || event.source !== window.parent) return const msg = event.data if (!msg || typeof msg.type !== 'string') return switch (msg.type) { + case 'ai:provider-configured': { + if (!['auto', 'local', 'claude', 'openai', 'routstr'].includes(msg.provider)) break + const selection = { provider: msg.provider as AIProviderSelection['provider'], model: typeof msg.model === 'string' ? msg.model : '' } + currentProvider = selection + for (const callback of providerCallbacks) callback(selection) + break + } case 'context:response': { const pending = pendingRequests.get(msg.id) if (pending) { @@ -223,11 +234,21 @@ export const archyBridge = { } }, + requestAISetup(reason?: 'funding') { postToParent({ type: 'ai:setup-request', ...(reason ? { reason } : {}) }) }, + + onProviderConfigured(callback: (selection: AIProviderSelection) => void) { + providerCallbacks.add(callback) + if (currentProvider) callback(currentProvider) + return () => { providerCallbacks.delete(callback) } + }, + /** Clean up listeners */ destroy() { window.removeEventListener('message', handleMessage) pendingRequests.clear() initialized = false + currentProvider = null + allowedOrigin = null }, /** Check if running inside Archy iframe */ diff --git a/core/archipelago/src/api/rpc/system/handlers.rs b/core/archipelago/src/api/rpc/system/handlers.rs index 9f89721b..c1ec24e6 100644 --- a/core/archipelago/src/api/rpc/system/handlers.rs +++ b/core/archipelago/src/api/rpc/system/handlers.rs @@ -1025,10 +1025,46 @@ impl RpcHandler { .ok_or_else(|| anyhow::anyhow!("Missing key"))?; match key { - "claude_api_key_set" => { - let key_file = self.config.data_dir.join("secrets/claude-api-key"); - let has_key = tokio::fs::metadata(&key_file).await.is_ok(); - Ok(serde_json::json!({ "value": has_key })) + "claude_api_key_set" | "openai_api_key_set" => { + let provider = if key == "claude_api_key_set" { + "claude" + } else { + "openai" + }; + Ok( + serde_json::json!({ "value": crate::settings::model_provider::has_key(&self.config.data_dir, provider).await }), + ) + } + "ai_provider" => { + let settings = + crate::settings::model_provider::ModelProvider::load(&self.config.data_dir) + .await?; + Ok(serde_json::json!({ "value": settings })) + } + "ai_provider_status" => { + let settings = + crate::settings::model_provider::ModelProvider::load(&self.config.data_dir) + .await?; + let local = tokio::time::timeout(std::time::Duration::from_secs(4), async { + let (detected, _) = crate::api::rpc::mesh::assistant::detect_ollama().await; + detected + && crate::assistant::backends::ollama::model_supports_tools( + crate::assistant::backends::ollama::OLLAMA_BASE_URL, + crate::assistant::backends::ollama::OLLAMA_DEFAULT_MODEL, + ) + .await + }); + let (claude, openai, local) = tokio::join!( + crate::settings::model_provider::has_key(&self.config.data_dir, "claude"), + crate::settings::model_provider::has_key(&self.config.data_dir, "openai"), + local, + ); + let budget = crate::assistant::AssistantBudget::load(&self.config.data_dir).await; + Ok(serde_json::json!({ "value": { + "schema": 1, "settings": settings, "claude_configured": claude, + "openai_configured": openai, "local_ready": local.ok(), + "routstr_remaining_sats": budget.remaining_sats(), + }})) } _ => Ok(serde_json::json!({ "value": null })), } @@ -1210,38 +1246,21 @@ impl RpcHandler { let value = params.get("value").and_then(|v| v.as_str()).unwrap_or(""); match key { - "claude_api_key" => { - let secrets_dir = self.config.data_dir.join("secrets"); - tokio::fs::create_dir_all(&secrets_dir) - .await - .context("Failed to create secrets dir")?; - let key_file = secrets_dir.join("claude-api-key"); - - if value.is_empty() { - // Remove key - tokio::fs::remove_file(&key_file).await.ok(); - info!("Claude API key removed"); + "claude_api_key" | "openai_api_key" => { + let provider = if key == "claude_api_key" { + "claude" } else { - // Save key - tokio::fs::write(&key_file, value) - .await - .context("Failed to write API key")?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o600)) - .ok(); - } - info!("Claude API key saved"); - } - - // `secrets/claude-api-key` (above) is deliberately the ONLY - // Claude key ledger on this node (13-02-PLAN.md). A second - // copy used to be written alongside it for a standalone, - // unauthenticated sidecar process on port 3142 — that - // sidecar and its key copy are retired; the session-gated - // Rust daemon reads this one file directly. - + "openai" + }; + crate::settings::model_provider::save_key(&self.config.data_dir, provider, value) + .await?; + info!(provider, "AI provider credential updated"); + Ok(serde_json::json!({ "saved": true })) + } + "ai_provider" => { + let settings: crate::settings::model_provider::ModelProvider = + serde_json::from_str(value).context("Invalid AI provider settings")?; + settings.save(&self.config.data_dir).await?; Ok(serde_json::json!({ "saved": true })) } _ => anyhow::bail!("Unknown setting: {}", key), diff --git a/core/archipelago/src/assistant/backends/mod.rs b/core/archipelago/src/assistant/backends/mod.rs index d8f24057..72f1245c 100644 --- a/core/archipelago/src/assistant/backends/mod.rs +++ b/core/archipelago/src/assistant/backends/mod.rs @@ -11,6 +11,7 @@ use crate::api::rpc::RpcHandler; pub mod claude; pub mod ollama; +pub mod openai; pub mod routstr; #[cfg(test)] pub mod scripted; @@ -39,6 +40,8 @@ pub trait Backend: Send + Sync { pub enum BackendId { Ollama, Claude, + Openai, + Unavailable, /// 13-13: the third D-04 leg. Not currently returned as the "primary" /// id by `select_backend` (mirroring the existing convention that the /// returned id names the primary attempt, not necessarily which leg of @@ -52,11 +55,23 @@ impl std::fmt::Display for BackendId { match self { BackendId::Ollama => write!(f, "ollama"), BackendId::Claude => write!(f, "claude"), + BackendId::Openai => write!(f, "openai"), + BackendId::Unavailable => write!(f, "unavailable"), BackendId::Routstr => write!(f, "routstr"), } } } +struct InvalidProviderSettings; +#[async_trait] +impl Backend for InvalidProviderSettings { + async fn send(&self, _: &str, _: &[ToolDef], _: &[ChatMessage]) -> Result { + anyhow::bail!( + "AI connection settings could not be loaded. Review them before sending a message." + ) + } +} + /// D-04's per-call fallback: try `primary`'s `send()`, and on a transport /// error fall through to `secondary` for that SAME call rather than /// failing the whole turn — a local model that answers earlier turns and @@ -115,6 +130,54 @@ fn ollama_is_selectable(detected: bool, tool_capable: bool) -> bool { /// tools-free degrade. pub async fn select_backend(handler: &RpcHandler) -> (Box, BackendId) { let data_dir = handler.data_dir(); + // An explicit provider is a privacy and billing choice. Never silently + // fall through to another provider if its credentials or network fail. + match crate::settings::model_provider::ModelProvider::load(data_dir).await { + Ok(settings) => match settings.provider { + crate::settings::model_provider::Provider::Openai => { + return ( + Box::new(openai::OpenaiBackend::new( + data_dir.to_path_buf(), + settings.openai_model, + )), + BackendId::Openai, + ) + } + crate::settings::model_provider::Provider::Claude => { + return ( + Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf())), + BackendId::Claude, + ) + } + crate::settings::model_provider::Provider::Local => { + return ( + Box::new(ollama::OllamaBackend::new( + ollama::OLLAMA_BASE_URL.to_string(), + ollama::OLLAMA_DEFAULT_MODEL.to_string(), + )), + BackendId::Ollama, + ) + } + crate::settings::model_provider::Provider::Routstr => { + let budget = crate::assistant::AssistantBudget::load(data_dir).await; + let mints = crate::wallet::ecash::load_accepted_mints(data_dir) + .await + .map(|m| m.mints) + .unwrap_or_default(); + return ( + Box::new(routstr::RoutstrBackend::new( + data_dir.to_path_buf(), + budget.payment_policy(), + mints, + handler.nostr_tor_proxy(), + )), + BackendId::Routstr, + ); + } + crate::settings::model_provider::Provider::Auto => {} + }, + Err(_) => return (Box::new(InvalidProviderSettings), BackendId::Unavailable), + } let (detected, _models) = crate::api::rpc::mesh::assistant::detect_ollama().await; let model = ollama::OLLAMA_DEFAULT_MODEL; let tool_capable = if detected { diff --git a/core/archipelago/src/assistant/backends/openai.rs b/core/archipelago/src/assistant/backends/openai.rs new file mode 100644 index 00000000..04ce733a --- /dev/null +++ b/core/archipelago/src/assistant/backends/openai.rs @@ -0,0 +1,279 @@ +//! Explicit OpenAI API selection using the shared tool loop and egress policy. +//! Keys stay node-side; no redirects, automatic retries, or provider fallback. +use super::{Backend, BackendTurn}; +use crate::assistant::{ + egress::{self, EgressVerdict}, + tools::{ChatMessage, ToolCall, ToolDef}, +}; +use anyhow::{Context, Result}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::{path::PathBuf, time::Duration}; + +const URL: &str = "https://api.openai.com/v1/chat/completions"; +const RESPONSE_LIMIT: usize = 2 * 1024 * 1024; +pub struct OpenaiBackend { + data_dir: PathBuf, + model: String, +} +impl OpenaiBackend { + pub fn new(data_dir: PathBuf, model: String) -> Self { + Self { data_dir, model } + } + async fn send_at( + &self, + url: &str, + system: &str, + tools: &[ToolDef], + history: &[ChatMessage], + ) -> Result { + let key = tokio::fs::read_to_string(self.data_dir.join("secrets/openai-api-key")) + .await + .map_err(|_| { + anyhow::anyhow!("OpenAI API key is not configured. Open AI connection settings.") + })?; + anyhow::ensure!(!key.trim().is_empty(), "OpenAI API key is not configured"); + anyhow::ensure!( + !self.model.is_empty(), + "Choose an OpenAI model in AI connection settings" + ); + let mut messages = vec![json!({"role": "system", "content": system})]; + messages.extend(history.iter().flat_map(super::routstr::message_to_wire)); + let mut body = json!({"model": self.model, "messages": messages, "stream": false, + "store": false, "max_completion_tokens": 2048, "n": 1}); + if !tools.is_empty() { + body["tools"] = json!(tools.iter().map(|tool| json!({"type": "function", "function": { + "name": tool.name, "description": tool.description, "parameters": tool.parameters, + }})).collect::>()); + body["parallel_tool_calls"] = json!(false); + } + let context = egress::EgressContext::from_turn( + history, + &tools.iter().map(|tool| tool.name).collect::>(), + &self.data_dir.join("secrets"), + ) + .await; + match egress::screen_outbound(&body.to_string(), &context) { + EgressVerdict::Allow => {} + EgressVerdict::Truncate(value) => { + body = serde_json::from_str(&value) + .context("Could not apply outbound privacy filter")?; + } + EgressVerdict::BlockFallBackLocal => { + crate::assistant::global_counters().note_blocked_egress(); + anyhow::bail!("This message contains private key or recovery material and was not sent to OpenAI"); + } + } + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(180)) + .connect_timeout(Duration::from_secs(15)) + .redirect(reqwest::redirect::Policy::none()) + .build()?; + let mut response = client.post(url).bearer_auth(key.trim()).json(&body).send().await + .map_err(|_| anyhow::anyhow!("OpenAI is temporarily unreachable. Your request was not retried automatically."))?; + if !response.status().is_success() { + anyhow::bail!("{}", error_message(response.status().as_u16())); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .context("OpenAI response interrupted")? + { + anyhow::ensure!( + bytes.len().saturating_add(chunk.len()) <= RESPONSE_LIMIT, + "OpenAI response exceeded the size limit" + ); + bytes.extend_from_slice(&chunk); + } + parse_response( + &serde_json::from_slice(&bytes).context("OpenAI returned an invalid response")?, + ) + } +} +#[async_trait] +impl Backend for OpenaiBackend { + async fn send( + &self, + system: &str, + tools: &[ToolDef], + history: &[ChatMessage], + ) -> Result { + self.send_at(URL, system, tools, history).await + } +} +fn error_message(status: u16) -> &'static str { + match status { + 401 | 403 => "OpenAI rejected the API key or project access. Check AI connection settings.", + 404 => "This OpenAI model is unavailable for your account. Choose another model in AI connection settings.", + 429 => "OpenAI usage or rate limit reached. Check your API billing and retry later.", + 500..=599 => "OpenAI is temporarily unavailable. Retry later.", + _ => "OpenAI rejected the request. Check the selected model and retry.", + } +} +fn parse_response(value: &Value) -> Result { + let choice = value["choices"] + .as_array() + .and_then(|items| items.first()) + .context("OpenAI returned no answer")?; + anyhow::ensure!( + choice["finish_reason"] != "length", + "OpenAI reached the response limit. Try a shorter request." + ); + let message = &choice["message"]; + if let Some(calls) = message["tool_calls"] + .as_array() + .filter(|calls| !calls.is_empty()) + { + let mut parsed = Vec::new(); + for call in calls { + anyhow::ensure!( + call["type"] == "function", + "Unsupported OpenAI tool response" + ); + let id = call["id"] + .as_str() + .filter(|id| !id.is_empty()) + .context("Missing OpenAI tool call ID")?; + let name = call["function"]["name"] + .as_str() + .filter(|name| !name.is_empty()) + .context("Missing OpenAI tool name")?; + let arguments: Value = serde_json::from_str( + call["function"]["arguments"] + .as_str() + .context("Invalid OpenAI tool arguments")?, + ) + .context("Invalid OpenAI tool arguments")?; + anyhow::ensure!( + arguments.is_object() + && !parsed.iter().any(|previous: &ToolCall| previous.id == id), + "Invalid OpenAI tool call" + ); + parsed.push(ToolCall { + id: id.into(), + name: name.into(), + arguments, + }); + } + return Ok(BackendTurn::ToolCalls(parsed)); + } + let text = message["content"] + .as_str() + .or_else(|| message["refusal"].as_str()) + .filter(|text| !text.trim().is_empty()) + .context("OpenAI returned no text; check model compatibility")?; + Ok(BackendTurn::Text(text.into())) +} +#[cfg(test)] +mod tests { + use super::*; + use crate::assistant::tools::{Role, ToolResult}; + #[test] + fn parses_text_and_rejects_incomplete_or_malformed_tool_calls() { + assert!( + matches!(parse_response(&json!({"choices":[{"message":{"content":"hello"}}]})).unwrap(), BackendTurn::Text(text) if text == "hello") + ); + let valid = json!({"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"status","arguments":"{\"count\":1}"}}]}}]}); + assert!( + matches!(parse_response(&valid).unwrap(), BackendTurn::ToolCalls(calls) if calls[0].arguments["count"] == 1) + ); + for args in ["{", "null", "[]"] { + let mut invalid = valid.clone(); + invalid["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] = + json!(args); + assert!(parse_response(&invalid).is_err()); + } + assert!(parse_response( + &json!({"choices":[{"finish_reason":"length","message":{"content":"partial"}}]}) + ) + .is_err()); + assert!(parse_response(&json!({"choices":[]})).is_err()); + } + #[test] + fn errors_distinguish_credentials_limits_and_outages_without_raw_provider_data() { + assert!(error_message(401).contains("API key")); + assert!(error_message(429).contains("limit")); + assert!(!error_message(503).contains("key")); + let wire = super::super::routstr::message_to_wire(&ChatMessage { + role: Role::Tool, + text: None, + tool_calls: vec![], + tool_results: vec![ToolResult { + call_id: "call_1".into(), + content: "result".into(), + is_error: false, + }], + }); + assert_eq!(wire[0]["tool_call_id"], "call_1"); + } + #[tokio::test] + async fn real_http_adapter_sends_private_key_only_in_header_and_never_follows_redirect() { + use hyper::{ + service::{make_service_fn, service_fn}, + Body, Response, Server, + }; + use std::sync::{Arc, Mutex}; + let captured = Arc::new(Mutex::new(Vec::new())); + let capture = captured.clone(); + let server = Server::bind(&([127, 0, 0, 1], 0).into()).serve(make_service_fn(move |_| { + let capture = capture.clone(); + async move { + Ok::<_, hyper::Error>(service_fn(move |request: hyper::Request| { + let capture = capture.clone(); + async move { + let (parts, body) = request.into_parts(); + let body = hyper::body::to_bytes(body).await?; + capture.lock().unwrap().push(( + parts.headers, + serde_json::from_slice::(&body).unwrap(), + )); + Ok::<_, hyper::Error>( + Response::builder() + .status(302) + .header("Location", "/leak") + .body(Body::empty()) + .unwrap(), + ) + } + })) + } + })); + let url = format!("http://{}/v1/chat/completions", server.local_addr()); + let task = tokio::spawn(server); + let dir = tempfile::tempdir().unwrap(); + crate::settings::model_provider::save_key(dir.path(), "openai", "fixture-private-key") + .await + .unwrap(); + let backend = OpenaiBackend::new(dir.path().into(), "test-model".into()); + let history = [ChatMessage { + role: Role::User, + text: Some("Hello".into()), + tool_calls: vec![], + tool_results: vec![], + }]; + assert!(backend + .send_at(&url, "Be helpful", &[], &history) + .await + .is_err()); + let requests = captured.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].0["authorization"], "Bearer fixture-private-key"); + assert_eq!(requests[0].1["store"], false); + assert_eq!(requests[0].1["max_completion_tokens"], 2048); + assert!(!requests[0].1.to_string().contains("fixture-private-key")); + drop(requests); + let private = [ChatMessage { + role: Role::User, + text: Some("fixture-private-key".into()), + tool_calls: vec![], + tool_results: vec![], + }]; + assert!(backend + .send_at(&url, "Be helpful", &[], &private) + .await + .is_err()); + assert_eq!(captured.lock().unwrap().len(), 1); + task.abort(); + } +} diff --git a/core/archipelago/src/assistant/backends/routstr.rs b/core/archipelago/src/assistant/backends/routstr.rs index 10331833..f751d6f0 100644 --- a/core/archipelago/src/assistant/backends/routstr.rs +++ b/core/archipelago/src/assistant/backends/routstr.rs @@ -295,7 +295,7 @@ fn parse_openai_tool_calls(raw_calls: &[Value]) -> Vec { /// (the wire-format inverse of `parse_openai_tool_calls`), and tool-result /// turns carry `tool_call_id` so each call's id is echoed back exactly — /// the OpenAI-shape contract this adapter's edge is responsible for. -fn message_to_wire(msg: &ChatMessage) -> Vec { +pub(super) fn message_to_wire(msg: &ChatMessage) -> Vec { match msg.role { Role::System => vec![], Role::User => vec![json!({ diff --git a/core/archipelago/src/settings/mod.rs b/core/archipelago/src/settings/mod.rs index 8ffaf648..9c1237e8 100644 --- a/core/archipelago/src/settings/mod.rs +++ b/core/archipelago/src/settings/mod.rs @@ -9,3 +9,5 @@ pub mod session_policy; pub mod transport; pub mod bitcoin_storage; + +pub mod model_provider; diff --git a/core/archipelago/src/settings/model_provider.rs b/core/archipelago/src/settings/model_provider.rs new file mode 100644 index 00000000..f7b33d17 --- /dev/null +++ b/core/archipelago/src/settings/model_provider.rs @@ -0,0 +1,178 @@ +//! Owner-selected chat provider. API keys remain in the node's private secret +//! ledger and are never returned by settings or included in chat context. +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use tokio::{fs, io::AsyncWriteExt}; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Provider { + #[default] + Auto, + Claude, + Openai, + Local, + Routstr, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ModelProvider { + #[serde(default)] + pub provider: Provider, + #[serde(default)] + pub openai_model: String, +} + +impl ModelProvider { + pub fn validate(&self) -> Result<()> { + anyhow::ensure!( + !self.openai_model.starts_with("sk-") + && self.openai_model.len() <= 128 + && self + .openai_model + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.:".contains(&b)), + "Invalid OpenAI model name" + ); + anyhow::ensure!( + self.provider != Provider::Openai || !self.openai_model.is_empty(), + "Choose an OpenAI model before connecting" + ); + Ok(()) + } + pub async fn load(data_dir: &Path) -> Result { + match fs::read(data_dir.join("settings/model-provider.json")).await { + Ok(bytes) => { + let settings: Self = serde_json::from_slice(&bytes) + .context("Invalid AI provider settings; preserved for recovery")?; + settings.validate()?; + Ok(settings) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(error) => Err(error.into()), + } + } + pub async fn save(&self, data_dir: &Path) -> Result<()> { + self.validate()?; + write_private( + &data_dir.join("settings/model-provider.json"), + &serde_json::to_vec(self)?, + ) + .await + } +} + +pub fn key_name(provider: &str) -> Result<&'static str> { + match provider { + "claude" => Ok("claude-api-key"), + "openai" => Ok("openai-api-key"), + _ => anyhow::bail!("Unsupported AI provider"), + } +} +pub async fn has_key(data_dir: &Path, provider: &str) -> bool { + let Ok(name) = key_name(provider) else { + return false; + }; + fs::read_to_string(data_dir.join("secrets").join(name)) + .await + .is_ok_and(|key| !key.trim().is_empty()) +} +pub async fn save_key(data_dir: &Path, provider: &str, value: &str) -> Result<()> { + let path = data_dir.join("secrets").join(key_name(provider)?); + let value = value.trim(); + anyhow::ensure!( + value.len() <= 4096 && value.bytes().all(|b| b.is_ascii_graphic()), + "Invalid API key format" + ); + if value.is_empty() { + match fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } + } else { + write_private(&path, value.as_bytes()).await + } +} +async fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().context("Missing settings directory")?; + fs::create_dir_all(parent).await?; + let temporary = parent.join(format!(".provider-{}.tmp", uuid::Uuid::new_v4())); + let result = async { + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&temporary) + .await?; + file.write_all(bytes).await?; + file.sync_all().await?; + drop(file); + fs::rename(&temporary, path).await?; + fs::File::open(parent).await?.sync_all().await?; + Ok::<_, anyhow::Error>(()) + } + .await; + if result.is_err() { + let _ = fs::remove_file(temporary).await; + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn private_keys_replace_atomically_and_never_enter_public_settings() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + assert!(!has_key(dir.path(), "openai").await); + save_key(dir.path(), "openai", "test-key-one") + .await + .unwrap(); + save_key(dir.path(), "openai", "test-key-two") + .await + .unwrap(); + assert!(has_key(dir.path(), "openai").await); + let key_path = dir.path().join("secrets/openai-api-key"); + assert_eq!( + fs::metadata(&key_path).await.unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(fs::read_to_string(&key_path).await.unwrap(), "test-key-two"); + let settings = ModelProvider { + provider: Provider::Openai, + openai_model: "test-model".into(), + }; + settings.save(dir.path()).await.unwrap(); + let body = serde_json::to_string(&ModelProvider::load(dir.path()).await.unwrap()).unwrap(); + assert!(!body.contains("test-key")); + assert!(save_key(dir.path(), "../openai", "key").await.is_err()); + assert!(save_key(dir.path(), "openai", "key\nInjected: bad") + .await + .is_err()); + assert_eq!(fs::read_to_string(&key_path).await.unwrap(), "test-key-two"); + save_key(dir.path(), "openai", "").await.unwrap(); + assert!(!has_key(dir.path(), "openai").await); + } + #[tokio::test] + async fn invalid_settings_preserve_existing_configuration() { + let dir = tempfile::tempdir().unwrap(); + ModelProvider::default().save(dir.path()).await.unwrap(); + let invalid = ModelProvider { + provider: Provider::Openai, + openai_model: String::new(), + }; + assert!(invalid.save(dir.path()).await.is_err()); + assert_eq!( + ModelProvider::load(dir.path()).await.unwrap().provider, + Provider::Auto + ); + let path = dir.path().join("settings/model-provider.json"); + fs::write(&path, b"broken").await.unwrap(); + assert!(ModelProvider::load(dir.path()).await.is_err()); + assert_eq!(fs::read(&path).await.unwrap(), b"broken"); + } +} diff --git a/docs/aiui-provider-setup-followup.md b/docs/aiui-provider-setup-followup.md new file mode 100644 index 00000000..f3faa7c2 --- /dev/null +++ b/docs/aiui-provider-setup-followup.md @@ -0,0 +1,49 @@ +# AIUI provider setup follow-up + +Status: implementation in progress; not deployed or accepted. + +The app's browser key vault did not configure the node's authoritative Claude +ledger. Embedded chat delegates to the node's tool loop, whose provider selection +also ignored the frontend's Claude/OpenRouter picker. The backend retired the +OpenRouter relay while that picker still offered it. Generic502/503 errors were +classified as missing keys and sent users back to settings. + +Implementation scope: offer setup in trusted dashboard chrome before first use; +keep credentials out of the iframe's chat, prompts, history and browser storage; +use private atomic node credential writes; persist an explicit provider choice; +retain the existing tool permissions and outbound privacy screen. Explicit +provider selection must not silently send a failed request to a different cloud +provider. Routstr funding and allowance remain separate, deliberate actions. + +OpenAI support uses its standard API key, not a presumed Codex subscription key. +For the existing node tool loop, the Chat Completions API retains the same +message/tool-result representation and existing egress checks. This is an +intentional integration choice, not a claim that it is the newer Responses API. +The HTTP adapter has a fixed HTTPS destination, no redirects or retries, a bounded +completion and response, store:false, and errors that do not echo upstream bodies. +The operator supplies the model ID; no inference is issued merely by saving a key. + +Official documentation checked2026-10-06: +- https://developers.openai.com/api/reference/overview (server-side bearer credentials) +- https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create + (max_completion_tokens, tool calls, store) +- https://developers.openai.com/api/docs/guides/streaming-responses + (Responses recommendation and distinction from Chat Completions) + +Qualification so far: all 1,244 dashboard tests and 363 AIUI tests pass before +final toolbar placement/funding refinements; latest focused checks pass 15 +dashboard and 24 AIUI tests. Both production bundles build. Chromium390/1440px +verifies first-use setup, private fixture key/model save, no key in localStorage, +retained unsent draft and no page errors. Visual review found an overlapping +mobile setup button; moved setup into the existing model menu. That final layout +still needs rebuilt-browser qualification. No fixture key reached a real provider. + +Explicit local selection now stays local; Claude/OpenAI selection cannot silently +fall through. Routstr selection persists and retains the existing budget checks. +Payment-required responses request the funding view, while temporary502/503 and +rate limits do not claim credentials are missing. Reopening ecash funding reloads +the address, including repeated opens on the same tab. + +Remaining: isolated backend results, final browser/funding/key-error checks, +actual provider compatibility and node deployment. No paid inference tests or new +wallet spending have been performed or authorized by this implementation work. diff --git a/neode-ui/src/components/AIConnectionModal.vue b/neode-ui/src/components/AIConnectionModal.vue new file mode 100644 index 00000000..cdf574f4 --- /dev/null +++ b/neode-ui/src/components/AIConnectionModal.vue @@ -0,0 +1,135 @@ + + + diff --git a/neode-ui/src/components/ReceiveBitcoinModal.vue b/neode-ui/src/components/ReceiveBitcoinModal.vue index 432efedd..12bde4c0 100644 --- a/neode-ui/src/components/ReceiveBitcoinModal.vue +++ b/neode-ui/src/components/ReceiveBitcoinModal.vue @@ -147,6 +147,7 @@ const lightning = useLightningRequired() const props = defineProps<{ show: boolean + initialMethod?: 'lightning' | 'onchain' | 'ecash' | 'ark' /** Optional info banner shown on the on-chain tab (e.g. Zeus channel limits) */ note?: string /** Generate an on-chain address immediately when the modal opens */ @@ -168,7 +169,7 @@ watch(() => props.show, (open) => { // Blank slate on every open: a leftover amount/memo/token or a previous // invoice quietly carrying into a new receive flow is exactly the stale- // state class the operator flagged on the send modal (2026-08-05). - receiveMethod.value = 'onchain' + receiveMethod.value = props.initialMethod ?? 'onchain' invoiceAmount.value = 0 invoiceMemo.value = '' invoiceResult.value = '' @@ -342,8 +343,8 @@ async function pollLnClaims() { onUnmounted(stopLnClaimPoll) // Fetch the address the first time the operator opens the ecash tab. -watch(receiveMethod, (m) => { - if (m === 'ecash' && props.show) { +watch([receiveMethod, () => props.show], ([m, open]) => { + if (m === 'ecash' && open) { lnWatchStartedAt.value = Math.floor(Date.now() / 1000) void loadLnAddress() } diff --git a/neode-ui/src/components/__tests__/AIConnectionModal.test.ts b/neode-ui/src/components/__tests__/AIConnectionModal.test.ts new file mode 100644 index 00000000..9210973f --- /dev/null +++ b/neode-ui/src/components/__tests__/AIConnectionModal.test.ts @@ -0,0 +1,60 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AIConnectionModal from '../AIConnectionModal.vue' +import { rpcClient } from '@/api/rpc-client' +vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } })) +vi.mock('../ReceiveBitcoinModal.vue', () => ({ default: { props: ['show', 'initialMethod'], template: '

' } })) +vi.mock('@/views/settings/RoutstrBudgetSection.vue', () => ({ default: { template: '
' } })) +const state = () => ({ schema: 1, settings: { provider: 'auto', openai_model: '' }, claude_configured: false, openai_configured: false, local_ready: false, routstr_remaining_sats: 0 }) +function mountModal() { return mount(AIConnectionModal, { props: { show: false }, global: { stubs: { BaseModal: { props: ['show'], template: '
' } } } }) } +function button(w: ReturnType, label: string) { return w.findAll('button').find(b => b.text() === label)! } +beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'system.settings.get' ? { value: state() } : {}) }) +describe('AI connection setup', () => { + it('detects absent configuration without treating a failed status query as missing keys', async () => { + const w = mountModal() + expect(await (w.vm as any).checkNeeded()).toBe(true) + vi.mocked(rpcClient.call).mockRejectedValueOnce(new Error('offline')) + expect(await (w.vm as any).checkNeeded()).toBe(false) + expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'system.settings.set' })) + w.unmount() + }) + it('sends a key only to private settings, clears the field, and selects the persisted model', async () => { + const w = mountModal(); await w.setProps({ show: true }); await flushPromises() + await button(w, 'OpenAI API').trigger('click') + await w.get('#ai-connection-key').setValue('test-private-key') + await w.get('#ai-connection-model').setValue('test-chat-model') + await w.get('form').trigger('submit'); await flushPromises() + const writes = vi.mocked(rpcClient.call).mock.calls.map(([r]) => r).filter(r => r.method === 'system.settings.set') + expect(writes.map(r => r.params)).toEqual([{ key: 'openai_api_key', value: 'test-private-key' }, { key: 'ai_provider', value: JSON.stringify({ provider: 'openai', openai_model: 'test-chat-model' }) }]) + expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('') + expect(w.emitted('configured')).toEqual([['openai', 'test-chat-model']]) + expect(JSON.stringify(w.emitted())).not.toContain('test-private-key') + w.unmount() + }) + it('clears unsaved keys when switching provider and closing', async () => { + const w = mountModal(); await w.setProps({ show: true }); await flushPromises() + await button(w, 'Claude API').trigger('click'); await w.get('#ai-connection-key').setValue('unsaved') + await button(w, 'OpenAI API').trigger('click') + expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('') + await w.get('#ai-connection-key').setValue('unsaved-again'); await w.setProps({ show: false }); await w.setProps({ show: true }) + expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('') + expect(w.emitted('configured')).toBeUndefined(); w.unmount() + }) + it('does not reuse an OpenAI model ID when restoring a Routstr connection', async () => { + const value = { ...state(), settings: { provider: 'routstr', openai_model: 'previous-openai-model' } } + vi.mocked(rpcClient.call).mockResolvedValue({ value }) + const w = mountModal(); await (w.vm as any).syncSelection() + expect(w.emitted('configured')).toEqual([['routstr', undefined]]) + w.unmount() + }) + + it('does not authorize Routstr spending from setup when allowance is zero', async () => { + const w = mountModal(); await w.setProps({ show: true }); await flushPromises() + await button(w, 'Routstr · sats').trigger('click'); await flushPromises() + await button(w, 'Use Routstr').trigger('click'); await flushPromises() + expect(w.text()).toContain('Set a spending allowance') + expect(w.emitted('configured')).toBeUndefined() + expect(vi.mocked(rpcClient.call).mock.calls.every(([r]) => !['assistant.budget-set', 'system.settings.set'].includes(r.method))).toBe(true) + w.unmount() + }) +}) diff --git a/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts index 6c4dbcf0..da3f6f62 100644 --- a/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts +++ b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts @@ -40,6 +40,17 @@ beforeEach(() => { // unmounts the dialog — but the RPC-eager tab switch is exactly the kind of // path a future change could regress, so it's worth pinning down. describe('ReceiveBitcoinModal — ecash tab click', () => { + it('loads the ecash address on each open when funding starts on the ecash tab', async () => { + vi.mocked(rpcClient.call).mockResolvedValue({ address: 'funding@minibits.cash' } as never) + const wrapper = mount(ReceiveBitcoinModal, { props: { show: false, initialMethod: 'ecash' }, attachTo: document.body }) + await wrapper.setProps({ show: true }); await flushPromises() + expect(document.body.textContent).toContain('funding@minibits.cash') + await wrapper.setProps({ show: false }); await wrapper.setProps({ show: true }); await flushPromises() + expect(document.body.textContent).toContain('funding@minibits.cash') + expect(vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'wallet.ecash-lnaddress')).toHaveLength(2) + wrapper.unmount() + }) + it('offers authenticated setup for an unseeded wallet and retries the address after setup', async () => { let active = false vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => { diff --git a/neode-ui/src/views/Chat.vue b/neode-ui/src/views/Chat.vue index d4a07b6a..35be8fb8 100644 --- a/neode-ui/src/views/Chat.vue +++ b/neode-ui/src/views/Chat.vue @@ -15,6 +15,8 @@ />
+ +