Guide AI connection setup with private node credentials and explicit providers
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user