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)
})
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'))
@@ -123,6 +123,7 @@
:style="modelPickerDropdownStyle"
@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">
<p class="text-xs font-semibold uppercase tracking-wider mb-1.5 px-1 text-white/40">
{{ 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<typeof setProvider>[0])) setModel(modelId)
showModelPicker.value = false
}
@@ -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<string, number>()
@@ -1,5 +1,9 @@
<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'">
API Keys
</h3>
@@ -93,10 +97,12 @@
</template>
<script setup lang="ts">
import { archyBridge } from '@/services/archyBridge'
import { ref, onMounted } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { storeApiKey, getApiKey, deleteApiKey, listProviders, maskApiKey } from '@/utils/key-vault'
const embedded = archyBridge.isInArchy()
const { isDark } = useTheme()
interface ProviderInfo {
@@ -156,5 +162,5 @@ async function removeKey(provider: string) {
await loadProviders()
}
onMounted(loadProviders)
onMounted(() => { if (!embedded) void loadProviders() })
</script>
+31 -28
View File
@@ -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<Provider>('claude')
const activeProvider = ref<Provider>(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,
}
}
+22 -1
View File
@@ -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<string, {
resolve: (value: unknown) => 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 */
+53 -34
View File
@@ -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();
"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 }))
}
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.
"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),
@@ -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<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
/// 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<dyn Backend>, 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 {
@@ -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
/// 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<Value> {
pub(super) fn message_to_wire(msg: &ChatMessage) -> Vec<Value> {
match msg.role {
Role::System => vec![],
Role::User => vec![json!({
+2
View File
@@ -9,3 +9,5 @@ pub mod session_policy;
pub mod transport;
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<{
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()
}
@@ -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
// 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 }) => {
+18 -1
View File
@@ -15,6 +15,8 @@
/>
</div>
<AIConnectionModal ref="connectionSetup" :show="showConnectionSetup" @close="showConnectionSetup = false" @configured="providerConfigured" />
<!-- Loading indicator while iframe loads. pointer-events:none on the
wrapper (see <style>) so this never blocks clicks reaching the
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 { ContextBroker } from '@/services/contextBroker'
import ToolConfirmModal from '@/components/ToolConfirmModal.vue'
import AIConnectionModal from '@/components/AIConnectionModal.vue'
import { AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
import { IS_DEMO } from '@/composables/useDemoIntro'
@@ -133,6 +136,13 @@ const router = useRouter()
const route = useRoute()
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
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
// loading overlay must never be able to wedge the UI permanently regardless
// of AIUI/backend state — a broken handshake, a misconfigured origin, or the
@@ -285,15 +295,21 @@ function openAISettings() {
}
function onAiuiMessage(event: MessageEvent) {
if (!aiuiUrl.value) return
if (!aiuiUrl.value || event.source !== aiuiFrame.value?.contentWindow) return
// Validate origin — only accept messages from AIUI
try {
const expected = new URL(aiuiUrl.value, window.location.origin).origin
if (event.origin !== expected) 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
if (event.data?.type === 'ready') {
aiuiConnected.value = true
if (!IS_DEMO) void connectionSetup.value?.syncSelection()
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
// A ⌘K ask that arrived before the handshake is waiting — send it now.
flushAsk()
@@ -362,6 +378,7 @@ onBeforeUnmount(() => {
</script>
<style scoped>
/* Teleported to body, so this is positioned against the viewport, not the
chat panel. Sits above the iframe but below the confirm modal — a
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 Chat from '../Chat.vue'
vi.mock('@/components/AIConnectionModal.vue', () => ({ default: { template: '<div />', methods: { checkNeeded: async () => false, syncSelection: async () => {} } } }))
const routerBackMock = vi.fn()
const routerPushMock = 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 } })
window.dispatchEvent(new MessageEvent('message', {
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
origin: 'http://localhost:5173',
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 } })
window.dispatchEvent(new MessageEvent('message', {
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
origin: 'http://localhost:5173',
data: { type: 'ready' },
}))
@@ -165,6 +169,7 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
const { wrapper } = mountChatInKeepAlive()
window.dispatchEvent(new MessageEvent('message', {
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
data: { type: 'ready' },
origin: 'http://evil.example',
}))
@@ -177,10 +182,22 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
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 () => {
const { wrapper, show } = mountChatInKeepAlive()
window.dispatchEvent(new MessageEvent('message', {
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
data: { type: 'ready' },
origin: 'http://localhost:5173',
}))