Guide AI connection setup with private node credentials and explicit providers

This commit is contained in:
archipelago
2026-10-05 23:41:29 -04:00
parent 0c25449566
commit 83ba98ab42
20 changed files with 992 additions and 75 deletions
@@ -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()
})
})
@@ -249,6 +249,24 @@ describe('useAI', () => {
expect(chatStore.isStreaming).toBe(false) 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 () => { it('handles connection errors gracefully', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network failure')) globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network failure'))
@@ -123,6 +123,7 @@
:style="modelPickerDropdownStyle" :style="modelPickerDropdownStyle"
@click.stop @click.stop
> >
<button v-if="archyBridge.isInArchy()" class="w-full text-left rounded-lg px-3 py-2 text-sm text-white/90 hover:bg-white/10" @click="showModelPicker = false; archyBridge.requestAISetup()">AI connection</button>
<div v-for="provider in availableProviders" :key="provider.id"> <div v-for="provider in availableProviders" :key="provider.id">
<p class="text-xs font-semibold uppercase tracking-wider mb-1.5 px-1 text-white/40"> <p class="text-xs font-semibold uppercase tracking-wider mb-1.5 px-1 text-white/40">
{{ provider.name }} {{ provider.name }}
@@ -247,6 +248,7 @@ import { useAI } from '@/composables/useAI'
import { useContentPanel } from '@/composables/useContentPanel' import { useContentPanel } from '@/composables/useContentPanel'
import { downloadConversation, type ExportFormat } from '@/utils/conversation-export' import { downloadConversation, type ExportFormat } from '@/utils/conversation-export'
import { parseImportFile } from '@/utils/conversation-import' import { parseImportFile } from '@/utils/conversation-import'
import { archyBridge } from '@/services/archyBridge'
import { useComparisonMode } from '@/composables/useComparisonMode' import { useComparisonMode } from '@/composables/useComparisonMode'
defineProps<{ defineProps<{
@@ -332,8 +334,7 @@ const modelDisplayName = computed(() => {
}) })
function selectModel(providerId: string, modelId: string) { function selectModel(providerId: string, modelId: string) {
setProvider(providerId as 'routstr' | 'claude' | 'openrouter' | 'mock') if (setProvider(providerId as Parameters<typeof setProvider>[0])) setModel(modelId)
setModel(modelId)
showModelPicker.value = false showModelPicker.value = false
} }
@@ -186,8 +186,9 @@ defineEmits<{
}>() }>()
const chatStore = useChatStore() 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() const { updatePanelFromText, panelOpen, panelFilms, panelTitle, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel()
import { archyBridge } from '@/services/archyBridge'
import { useCodeContext } from '@/composables/useCodeContext' import { useCodeContext } from '@/composables/useCodeContext'
import { useVisualViewport } from '@/composables/useVisualViewport' import { useVisualViewport } from '@/composables/useVisualViewport'
const codeContext = useCodeContext() const codeContext = useCodeContext()
@@ -206,11 +207,19 @@ const showSettings = ref(false)
// without fixing anything). // without fixing anything).
watch(needsApiKey, (needs) => { watch(needsApiKey, (needs) => {
if (needs) { if (needs) {
showSettings.value = true if (archyBridge.isInArchy()) archyBridge.requestAISetup()
else showSettings.value = true
needsApiKey.value = false 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 // Scroll position memory per conversation
const scrollPositions = new Map<string, number>() const scrollPositions = new Map<string, number>()
@@ -1,5 +1,9 @@
<template> <template>
<div class="space-y-4"> <div v-if="embedded" class="space-y-3">
<p class="text-sm">AI connections and private keys are managed by this node.</p>
<button class="rounded-lg px-3 py-2 bg-white/10 text-sm" @click="archyBridge.requestAISetup()">Manage AI connection</button>
</div>
<div v-else class="space-y-4">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'"> <h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
API Keys API Keys
</h3> </h3>
@@ -93,10 +97,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { archyBridge } from '@/services/archyBridge'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import { storeApiKey, getApiKey, deleteApiKey, listProviders, maskApiKey } from '@/utils/key-vault' import { storeApiKey, getApiKey, deleteApiKey, listProviders, maskApiKey } from '@/utils/key-vault'
const embedded = archyBridge.isInArchy()
const { isDark } = useTheme() const { isDark } = useTheme()
interface ProviderInfo { interface ProviderInfo {
@@ -156,5 +162,5 @@ async function removeKey(provider: string) {
await loadProviders() await loadProviders()
} }
onMounted(loadProviders) onMounted(() => { if (!embedded) void loadProviders() })
</script> </script>
+31 -28
View File
@@ -13,7 +13,7 @@ import { useCodeContext } from '@/composables/useCodeContext'
import { apiFetch } from '@/utils/api-fetch' import { apiFetch } from '@/utils/api-fetch'
import { useSettingsStore } from '@/stores/settings' 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/) // API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/)
const BASE = import.meta.env.BASE_URL || '/' 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. Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out.
${librarySection}` ${librarySection}`
const activeProvider = ref<Provider>('claude') const activeProvider = ref<Provider>(archyBridge.isInArchy() ? 'auto' : 'claude')
const activeModel = ref('claude-haiku-4.5') const activeModel = ref('claude-haiku-4.5')
// One-shot signal a send/regenerate/edit failure looked like a missing or // Credentials require setup; network failures and provider outages require retry.
// 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).
const needsApiKey = ref(false) const needsApiKey = ref(false)
const needsFunding = ref(false)
function looksLikeMissingApiKey(err: string): boolean { function looksLikeMissingApiKey(err: string): boolean {
const lower = err.toLowerCase() return /\b(401|403)\b|api[ _-]?key|unauthorized|authentication_error|credential/i.test(err)
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')
)
} }
// ─── Routstr model catalog (fetched from the node's session-gated proxy) ─── // ─── Routstr model catalog (fetched from the node's session-gated proxy) ───
@@ -161,7 +142,7 @@ async function refreshRoutstrModels() {
routstrModelsFetched = true routstrModelsFetched = true
try { try {
const res = await apiFetch(ROUTSTR_MODELS_PATH) const res = await apiFetch(ROUTSTR_MODELS_PATH)
if (!res.ok) return if (!res.ok) { routstrModelsFetched = false; return }
const data = await res.json() const data = await res.json()
if (Array.isArray(data?.data)) { if (Array.isArray(data?.data)) {
routstrModels.value = data.data routstrModels.value = data.data
@@ -170,13 +151,21 @@ async function refreshRoutstrModels() {
id: m.id as string, id: m.id as string,
name: (m.name as string) || (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 { } catch {
routstrModelsFetched = false // allow a retry on the next send/open routstrModelsFetched = false // allow a retry on the next send/open
} }
} }
const availableProviders = computed(() => { 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 }[] }[] = [ const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
{ {
id: 'routstr', id: 'routstr',
@@ -187,7 +176,7 @@ const availableProviders = computed(() => {
}, },
{ {
id: 'claude', id: 'claude',
name: 'Claude (Max)', name: 'Claude API',
models: [ models: [
{ id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' }, { id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' },
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4' }, { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' },
@@ -207,24 +196,36 @@ const availableProviders = computed(() => {
}) })
providers.push({ providers.push({
id: 'mock', id: 'mock',
name: 'Local (no API)', name: 'Demo echo',
models: [{ id: 'echo', name: 'Echo (mirror input)' }], models: [{ id: 'echo', name: 'Echo (mirror input)' }],
}) })
return providers return providers
}) })
function setProvider(provider: Provider) { function setProvider(provider: Provider) {
if (archyBridge.isInArchy()) {
if (provider === 'routstr' && activeProvider.value === 'routstr') return true
archyBridge.requestAISetup(); return false
}
activeProvider.value = provider activeProvider.value = provider
const p = availableProviders.value.find((pp) => pp.id === provider) const p = availableProviders.value.find((pp) => pp.id === provider)
if (p && p.models.length > 0) { if (p && p.models.length > 0) {
activeModel.value = p.models[0].id activeModel.value = p.models[0].id
} }
return true
} }
function setModel(model: string) { function setModel(model: string) {
if (archyBridge.isInArchy() && activeProvider.value !== 'routstr') { archyBridge.requestAISetup(); return }
activeModel.value = model 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 { interface ChatMessage {
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
@@ -448,6 +449,7 @@ async function streamRoutstr(
}) })
const bodyText = await res.text().catch(() => '') const bodyText = await res.text().catch(() => '')
if (res.status === 402) needsFunding.value = true
if (!res.ok) { if (!res.ok) {
// The node's refusals carry a plain-language error.message (budget not // The node's refusals carry a plain-language error.message (budget not
// set, budget spent, wallet can't fund) — surface it verbatim. // set, budget spent, wallet can't fund) — surface it verbatim.
@@ -974,5 +976,6 @@ export function useAI() {
setProvider, setProvider,
setModel, setModel,
needsApiKey, needsApiKey,
needsFunding,
} }
} }
+22 -1
View File
@@ -55,6 +55,10 @@ interface ThemeInfo {
type PermissionsCallback = (categories: AIContextCategory[]) => void type PermissionsCallback = (categories: AIContextCategory[]) => void
type ThemeCallback = (theme: ThemeInfo) => 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 let requestId = 0
const pendingRequests = new Map<string, { const pendingRequests = new Map<string, {
resolve: (value: unknown) => void resolve: (value: unknown) => void
@@ -80,12 +84,19 @@ function postToParent(msg: unknown) {
function handleMessage(event: MessageEvent) { function handleMessage(event: MessageEvent) {
// Always validate origin — reject if not configured or mismatched // 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 const msg = event.data
if (!msg || typeof msg.type !== 'string') return if (!msg || typeof msg.type !== 'string') return
switch (msg.type) { 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': { case 'context:response': {
const pending = pendingRequests.get(msg.id) const pending = pendingRequests.get(msg.id)
if (pending) { 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 */ /** Clean up listeners */
destroy() { destroy() {
window.removeEventListener('message', handleMessage) window.removeEventListener('message', handleMessage)
pendingRequests.clear() pendingRequests.clear()
initialized = false initialized = false
currentProvider = null
allowedOrigin = null
}, },
/** Check if running inside Archy iframe */ /** Check if running inside Archy iframe */
+54 -35
View File
@@ -1025,10 +1025,46 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing key"))?; .ok_or_else(|| anyhow::anyhow!("Missing key"))?;
match key { match key {
"claude_api_key_set" => { "claude_api_key_set" | "openai_api_key_set" => {
let key_file = self.config.data_dir.join("secrets/claude-api-key"); let provider = if key == "claude_api_key_set" {
let has_key = tokio::fs::metadata(&key_file).await.is_ok(); "claude"
Ok(serde_json::json!({ "value": has_key })) } 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 })), _ => 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(""); let value = params.get("value").and_then(|v| v.as_str()).unwrap_or("");
match key { match key {
"claude_api_key" => { "claude_api_key" | "openai_api_key" => {
let secrets_dir = self.config.data_dir.join("secrets"); let provider = if key == "claude_api_key" {
tokio::fs::create_dir_all(&secrets_dir) "claude"
.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");
} else { } else {
// Save key "openai"
tokio::fs::write(&key_file, value) };
.await crate::settings::model_provider::save_key(&self.config.data_dir, provider, value)
.context("Failed to write API key")?; .await?;
#[cfg(unix)] info!(provider, "AI provider credential updated");
{ Ok(serde_json::json!({ "saved": true }))
use std::os::unix::fs::PermissionsExt; }
std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o600)) "ai_provider" => {
.ok(); let settings: crate::settings::model_provider::ModelProvider =
} serde_json::from_str(value).context("Invalid AI provider settings")?;
info!("Claude API key saved"); settings.save(&self.config.data_dir).await?;
}
// `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.
Ok(serde_json::json!({ "saved": true })) Ok(serde_json::json!({ "saved": true }))
} }
_ => anyhow::bail!("Unknown setting: {}", key), _ => anyhow::bail!("Unknown setting: {}", key),
@@ -11,6 +11,7 @@ use crate::api::rpc::RpcHandler;
pub mod claude; pub mod claude;
pub mod ollama; pub mod ollama;
pub mod openai;
pub mod routstr; pub mod routstr;
#[cfg(test)] #[cfg(test)]
pub mod scripted; pub mod scripted;
@@ -39,6 +40,8 @@ pub trait Backend: Send + Sync {
pub enum BackendId { pub enum BackendId {
Ollama, Ollama,
Claude, Claude,
Openai,
Unavailable,
/// 13-13: the third D-04 leg. Not currently returned as the "primary" /// 13-13: the third D-04 leg. Not currently returned as the "primary"
/// id by `select_backend` (mirroring the existing convention that the /// id by `select_backend` (mirroring the existing convention that the
/// returned id names the primary attempt, not necessarily which leg of /// returned id names the primary attempt, not necessarily which leg of
@@ -52,11 +55,23 @@ impl std::fmt::Display for BackendId {
match self { match self {
BackendId::Ollama => write!(f, "ollama"), BackendId::Ollama => write!(f, "ollama"),
BackendId::Claude => write!(f, "claude"), BackendId::Claude => write!(f, "claude"),
BackendId::Openai => write!(f, "openai"),
BackendId::Unavailable => write!(f, "unavailable"),
BackendId::Routstr => write!(f, "routstr"), BackendId::Routstr => write!(f, "routstr"),
} }
} }
} }
struct InvalidProviderSettings;
#[async_trait]
impl Backend for InvalidProviderSettings {
async fn send(&self, _: &str, _: &[ToolDef], _: &[ChatMessage]) -> Result<BackendTurn> {
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 /// 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 /// error fall through to `secondary` for that SAME call rather than
/// failing the whole turn — a local model that answers earlier turns and /// 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. /// tools-free degrade.
pub async fn select_backend(handler: &RpcHandler) -> (Box<dyn Backend>, BackendId) { pub async fn select_backend(handler: &RpcHandler) -> (Box<dyn Backend>, BackendId) {
let data_dir = handler.data_dir(); 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 (detected, _models) = crate::api::rpc::mesh::assistant::detect_ollama().await;
let model = ollama::OLLAMA_DEFAULT_MODEL; let model = ollama::OLLAMA_DEFAULT_MODEL;
let tool_capable = if detected { let tool_capable = if detected {
@@ -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<BackendTurn> {
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::<Vec<_>>());
body["parallel_tool_calls"] = json!(false);
}
let context = egress::EgressContext::from_turn(
history,
&tools.iter().map(|tool| tool.name).collect::<Vec<_>>(),
&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<BackendTurn> {
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<BackendTurn> {
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<Body>| {
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::<Value>(&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();
}
}
@@ -295,7 +295,7 @@ fn parse_openai_tool_calls(raw_calls: &[Value]) -> Vec<ToolCall> {
/// (the wire-format inverse of `parse_openai_tool_calls`), and tool-result /// (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 — /// 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. /// the OpenAI-shape contract this adapter's edge is responsible for.
fn message_to_wire(msg: &ChatMessage) -> Vec<Value> { pub(super) fn message_to_wire(msg: &ChatMessage) -> Vec<Value> {
match msg.role { match msg.role {
Role::System => vec![], Role::System => vec![],
Role::User => vec![json!({ Role::User => vec![json!({
+2
View File
@@ -9,3 +9,5 @@ pub mod session_policy;
pub mod transport; pub mod transport;
pub mod bitcoin_storage; pub mod bitcoin_storage;
pub mod model_provider;
@@ -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<Self> {
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");
}
}
+49
View File
@@ -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.
@@ -0,0 +1,135 @@
<template>
<BaseModal :show="show && !funding" title="Connect your AI" max-width="max-w-xl" @close="close">
<p class="text-sm text-white/60 mb-4">Choose how your assistant connects. API keys stay on this node and never enter your chat.</p>
<p v-if="loading" role="status" class="text-sm text-white/60 mb-3">Checking this node…</p>
<p v-if="error" role="alert" class="text-sm text-amber-200 mb-3">{{ error }}</p>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4">
<button v-for="choice in choices" :key="choice.id" class="glass-button rounded-lg px-3 py-3 text-sm text-white" :aria-pressed="mode === choice.id" @click="choose(choice.id)">{{ choice.label }}</button>
</div>
<form v-if="mode === 'claude' || mode === 'openai'" class="space-y-3" @submit.prevent="save">
<p class="text-xs text-white/60">{{ mode === 'openai' ? 'Use an OpenAI API key with API billing. Choose a chat model your project can access.' : 'Use your Anthropic API key. Usage is billed to that API account.' }}</p>
<p v-if="configured" class="text-sm text-white/70">A key is already configured. Leave this field empty to keep it.</p>
<label class="block text-sm text-white/80" for="ai-connection-key">{{ mode === 'openai' ? 'OpenAI' : 'Claude' }} API key</label>
<input id="ai-connection-key" v-model="key" type="password" autocomplete="off" spellcheck="false" class="input-glass w-full" placeholder="Paste API key" :disabled="saving" />
<template v-if="mode === 'openai'">
<label class="block text-sm text-white/80" for="ai-connection-model">Model ID</label>
<input id="ai-connection-model" v-model="model" type="text" spellcheck="false" class="input-glass w-full" placeholder="Enter your OpenAI model ID" :disabled="saving" />
</template>
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" :disabled="saving || loading || (!key.trim() && !configured) || (mode === 'openai' && !model.trim())">{{ saving ? 'Saving…' : 'Save and continue' }}</button>
</form>
<div v-else-if="mode === 'routstr'" class="space-y-3">
<p class="text-sm text-white/70">Pay for AI with ecash. Add funds to this node’s wallet and set the most it may spend.</p>
<p class="text-sm text-white/70">Ecash balance: {{ balance === null ? 'Unavailable' : balance.toLocaleString() + ' sats' }}</p>
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white" @click="funding = true">Add ecash</button>
<RoutstrBudgetSection />
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" @click="continueRoutstr">Use Routstr</button>
<p class="text-xs text-white/50">The selected model’s price and accepted mint still apply. No payment is sent by opening this setup.</p>
</div>
<button v-if="status?.local_ready" class="glass-button rounded-lg px-4 py-2 text-sm text-white mt-4 w-full" @click="useLocal">Use local AI</button>
<p class="text-xs text-white/40 mt-4">Your draft stays in place when you close this window.</p>
</BaseModal>
<ReceiveBitcoinModal :show="show && funding" initial-method="ecash" @close="finishFunding" @received="finishFunding" />
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from './BaseModal.vue'
import ReceiveBitcoinModal from './ReceiveBitcoinModal.vue'
import RoutstrBudgetSection from '@/views/settings/RoutstrBudgetSection.vue'
type Choice = 'claude' | 'openai' | 'routstr'
interface ProviderStatus {
schema: number
settings: { provider: 'auto' | 'local' | 'routstr' | 'claude' | 'openai'; openai_model: string }
claude_configured: boolean
openai_configured: boolean
local_ready: boolean | null
routstr_remaining_sats: number
}
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; configured: [provider: Choice | 'auto' | 'local', model?: string] }>()
const choices: { id: Choice; label: string }[] = [{ id: 'claude', label: 'Claude API' }, { id: 'openai', label: 'OpenAI API' }, { id: 'routstr', label: 'Routstr · sats' }]
const mode = ref<Choice | null>(null)
const key = ref('')
const model = ref('')
const status = ref<ProviderStatus | null>(null)
const balance = ref<number | null>(null)
const error = ref('')
const loading = ref(false)
const saving = ref(false)
const funding = ref(false)
const configured = computed(() => mode.value === 'claude' ? status.value?.claude_configured : status.value?.openai_configured)
let refreshPromise: Promise<ProviderStatus | null> | null = null
async function refresh(): Promise<ProviderStatus | null> {
if (refreshPromise) return refreshPromise
loading.value = true
refreshPromise = (async () => {
try {
const response = await rpcClient.call<{ value: ProviderStatus | null }>({ method: 'system.settings.get', params: { key: 'ai_provider_status' }, timeout: 8000 })
if (response.value?.schema !== 1) throw new Error('AI connection setup needs the matching node update.')
status.value = response.value
if (!model.value) model.value = response.value.settings.openai_model || ''
return response.value
} catch {
error.value = 'Could not check AI connections. Try again when this node is reachable.'
return null
} finally { loading.value = false; refreshPromise = null }
})()
return refreshPromise
}
async function checkNeeded(): Promise<boolean> {
const state = await refresh()
if (!state) return false // An unavailable status endpoint is not proof of missing configuration.
if (state.settings.provider === 'openai') return !state.openai_configured
if (state.settings.provider === 'claude') return !state.claude_configured
if (state.settings.provider === 'local') return state.local_ready === false
if (state.settings.provider === 'routstr' || (state.local_ready === false && !state.claude_configured)) {
if (state.routstr_remaining_sats <= 0) return true
await refreshBalance()
return balance.value === 0
}
return false
}
async function refreshBalance() {
try { const result = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 8000 }); balance.value = Number.isFinite(result.balance_sats) ? result.balance_sats : null }
catch { balance.value = null }
}
function choose(choice: Choice) { key.value = ''; error.value = ''; mode.value = choice; if (choice === 'routstr') void refreshBalance() }
function close() { key.value = ''; funding.value = false; emit('close') }
async function save() {
if ((mode.value !== 'claude' && mode.value !== 'openai') || saving.value) return
const provider = mode.value
saving.value = true; error.value = ''
try {
if (key.value.trim()) {
await rpcClient.call({ method: 'system.settings.set', params: { key: `${provider}_api_key`, value: key.value.trim() } })
key.value = ''
if (status.value) status.value[provider === 'claude' ? 'claude_configured' : 'openai_configured'] = true
} else if (!configured.value) return
await rpcClient.call({ method: 'system.settings.set', params: { key: 'ai_provider', value: JSON.stringify({ provider, openai_model: model.value.trim() }) } })
emit('configured', provider, provider === 'openai' ? model.value.trim() : undefined); close()
} catch { error.value = 'Could not save the connection. Check the key and model, then retry.' }
finally { saving.value = false }
}
async function useLocal() {
error.value = ''
try {
await rpcClient.call({ method: 'system.settings.set', params: { key: 'ai_provider', value: JSON.stringify({ provider: 'local', openai_model: model.value }) } })
emit('configured', 'local'); close()
} catch { error.value = 'Could not select local AI. Try again.' }
}
async function continueRoutstr() {
const [state] = await Promise.all([refresh(), refreshBalance()])
if (!state || state.routstr_remaining_sats <= 0) { error.value = 'Set a spending allowance before using Routstr.'; return }
if (balance.value === null || balance.value <= 0) { error.value = 'Add ecash to this node before using Routstr.'; return }
try {
await rpcClient.call({ method: 'system.settings.set', params: { key: 'ai_provider', value: JSON.stringify({ provider: 'routstr', openai_model: model.value }) } })
emit('configured', 'routstr'); close()
} catch { error.value = 'Could not select Routstr. Try again.' }
}
async function finishFunding() { funding.value = false; await refreshBalance() }
watch(() => props.show, open => { if (open) { error.value = ''; void refresh() } else { key.value = ''; funding.value = false } })
async function syncSelection() { const state = await refresh(); if (state) emit('configured', state.settings.provider, state.settings.provider === 'openai' ? state.settings.openai_model : undefined) }
defineExpose({ checkNeeded, syncSelection, showRoutstr: () => choose('routstr') })
</script>
@@ -147,6 +147,7 @@ const lightning = useLightningRequired()
const props = defineProps<{ const props = defineProps<{
show: boolean show: boolean
initialMethod?: 'lightning' | 'onchain' | 'ecash' | 'ark'
/** Optional info banner shown on the on-chain tab (e.g. Zeus channel limits) */ /** Optional info banner shown on the on-chain tab (e.g. Zeus channel limits) */
note?: string note?: string
/** Generate an on-chain address immediately when the modal opens */ /** 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 // 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- // invoice quietly carrying into a new receive flow is exactly the stale-
// state class the operator flagged on the send modal (2026-08-05). // state class the operator flagged on the send modal (2026-08-05).
receiveMethod.value = 'onchain' receiveMethod.value = props.initialMethod ?? 'onchain'
invoiceAmount.value = 0 invoiceAmount.value = 0
invoiceMemo.value = '' invoiceMemo.value = ''
invoiceResult.value = '' invoiceResult.value = ''
@@ -342,8 +343,8 @@ async function pollLnClaims() {
onUnmounted(stopLnClaimPoll) onUnmounted(stopLnClaimPoll)
// Fetch the address the first time the operator opens the ecash tab. // Fetch the address the first time the operator opens the ecash tab.
watch(receiveMethod, (m) => { watch([receiveMethod, () => props.show], ([m, open]) => {
if (m === 'ecash' && props.show) { if (m === 'ecash' && open) {
lnWatchStartedAt.value = Math.floor(Date.now() / 1000) lnWatchStartedAt.value = Math.floor(Date.now() / 1000)
void loadLnAddress() void loadLnAddress()
} }
@@ -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: '<div />' } }))
vi.mock('@/views/settings/RoutstrBudgetSection.vue', () => ({ default: { template: '<div />' } }))
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: '<div v-if="show"><slot /></div>' } } } }) }
function button(w: ReturnType<typeof mountModal>, 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()
})
})
@@ -40,6 +40,17 @@ beforeEach(() => {
// unmounts the dialog — but the RPC-eager tab switch is exactly the kind of // 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. // path a future change could regress, so it's worth pinning down.
describe('ReceiveBitcoinModal — ecash tab click', () => { 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 () => { it('offers authenticated setup for an unseeded wallet and retries the address after setup', async () => {
let active = false let active = false
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => { vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
+18 -1
View File
@@ -15,6 +15,8 @@
/> />
</div> </div>
<AIConnectionModal ref="connectionSetup" :show="showConnectionSetup" @close="showConnectionSetup = false" @configured="providerConfigured" />
<!-- Loading indicator while iframe loads. pointer-events:none on the <!-- Loading indicator while iframe loads. pointer-events:none on the
wrapper (see <style>) so this never blocks clicks reaching the wrapper (see <style>) so this never blocks clicks reaching the
iframe underneath even while shown; the bounded timeout below iframe underneath even while shown; the bounded timeout below
@@ -124,6 +126,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ContextBroker } from '@/services/contextBroker' import { ContextBroker } from '@/services/contextBroker'
import ToolConfirmModal from '@/components/ToolConfirmModal.vue' import ToolConfirmModal from '@/components/ToolConfirmModal.vue'
import AIConnectionModal from '@/components/AIConnectionModal.vue'
import { AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions' import { AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
import { IS_DEMO } from '@/composables/useDemoIntro' import { IS_DEMO } from '@/composables/useDemoIntro'
@@ -133,6 +136,13 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const aiuiFrame = ref<HTMLIFrameElement | null>(null) const aiuiFrame = ref<HTMLIFrameElement | null>(null)
const aiuiConnected = ref(false) const aiuiConnected = ref(false)
const connectionSetup = ref<InstanceType<typeof AIConnectionModal> | null>(null)
const showConnectionSetup = ref(false)
function providerConfigured(provider: 'claude' | 'openai' | 'routstr' | 'auto' | 'local', model?: string) {
if (!aiuiFrame.value?.contentWindow || !aiuiUrl.value) return
aiuiFrame.value.contentWindow.postMessage({ type: 'ai:provider-configured', provider, model }, new URL(aiuiUrl.value, window.location.origin).origin)
}
onMounted(async () => { if (!IS_DEMO && await connectionSetup.value?.checkNeeded()) showConnectionSetup.value = true })
// Belt-and-suspenders backstop (2026-07-30 live-testing follow-up): the // Belt-and-suspenders backstop (2026-07-30 live-testing follow-up): the
// loading overlay must never be able to wedge the UI permanently regardless // loading overlay must never be able to wedge the UI permanently regardless
// of AIUI/backend state — a broken handshake, a misconfigured origin, or the // of AIUI/backend state — a broken handshake, a misconfigured origin, or the
@@ -285,15 +295,21 @@ function openAISettings() {
} }
function onAiuiMessage(event: MessageEvent) { function onAiuiMessage(event: MessageEvent) {
if (!aiuiUrl.value) return if (!aiuiUrl.value || event.source !== aiuiFrame.value?.contentWindow) return
// Validate origin — only accept messages from AIUI // Validate origin — only accept messages from AIUI
try { try {
const expected = new URL(aiuiUrl.value, window.location.origin).origin const expected = new URL(aiuiUrl.value, window.location.origin).origin
if (event.origin !== expected) return if (event.origin !== expected) return
} catch { return } } catch { return }
if (event.data?.type === 'ai:setup-request') {
showConnectionSetup.value = true
if (event.data.reason === 'funding') connectionSetup.value?.showRoutstr()
return
}
// Listen for ready messages from AIUI iframe // Listen for ready messages from AIUI iframe
if (event.data?.type === 'ready') { if (event.data?.type === 'ready') {
aiuiConnected.value = true aiuiConnected.value = true
if (!IS_DEMO) void connectionSetup.value?.syncSelection()
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null } if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
// A ⌘K ask that arrived before the handshake is waiting — send it now. // A ⌘K ask that arrived before the handshake is waiting — send it now.
flushAsk() flushAsk()
@@ -362,6 +378,7 @@ onBeforeUnmount(() => {
</script> </script>
<style scoped> <style scoped>
/* Teleported to body, so this is positioned against the viewport, not the /* Teleported to body, so this is positioned against the viewport, not the
chat panel. Sits above the iframe but below the confirm modal — a chat panel. Sits above the iframe but below the confirm modal — a
blocking decision must always win over a passive offer. */ blocking decision must always win over a passive offer. */
@@ -11,6 +11,8 @@ import { KeepAlive, defineComponent, h, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Chat from '../Chat.vue' import Chat from '../Chat.vue'
vi.mock('@/components/AIConnectionModal.vue', () => ({ default: { template: '<div />', methods: { checkNeeded: async () => false, syncSelection: async () => {} } } }))
const routerBackMock = vi.fn() const routerBackMock = vi.fn()
const routerPushMock = vi.fn() const routerPushMock = vi.fn()
const routerReplaceMock = vi.fn() const routerReplaceMock = vi.fn()
@@ -99,6 +101,7 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } }) Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
window.dispatchEvent(new MessageEvent('message', { window.dispatchEvent(new MessageEvent('message', {
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
origin: 'http://localhost:5173', origin: 'http://localhost:5173',
data: { type: 'ready' }, data: { type: 'ready' },
})) }))
@@ -125,6 +128,7 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } }) Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
window.dispatchEvent(new MessageEvent('message', { window.dispatchEvent(new MessageEvent('message', {
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
origin: 'http://localhost:5173', origin: 'http://localhost:5173',
data: { type: 'ready' }, data: { type: 'ready' },
})) }))
@@ -165,6 +169,7 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
const { wrapper } = mountChatInKeepAlive() const { wrapper } = mountChatInKeepAlive()
window.dispatchEvent(new MessageEvent('message', { window.dispatchEvent(new MessageEvent('message', {
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
data: { type: 'ready' }, data: { type: 'ready' },
origin: 'http://evil.example', origin: 'http://evil.example',
})) }))
@@ -177,10 +182,22 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
wrapper.unmount() wrapper.unmount()
}) })
it('ignores ready and setup messages from a different same-origin frame', async () => {
const { wrapper } = mountChatInKeepAlive()
for (const type of ['ready', 'ai:setup-request']) window.dispatchEvent(new MessageEvent('message', {
source: window, origin: 'http://localhost:5173', data: { type },
}))
await flushPromises()
expect(wrapper.find('.chat-loading').exists()).toBe(true)
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(false)
wrapper.unmount()
})
it('aiuiConnected survives a deactivate/reactivate cycle once set by a same-origin ready message', async () => { it('aiuiConnected survives a deactivate/reactivate cycle once set by a same-origin ready message', async () => {
const { wrapper, show } = mountChatInKeepAlive() const { wrapper, show } = mountChatInKeepAlive()
window.dispatchEvent(new MessageEvent('message', { window.dispatchEvent(new MessageEvent('message', {
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
data: { type: 'ready' }, data: { type: 'ready' },
origin: 'http://localhost:5173', origin: 'http://localhost:5173',
})) }))