feat(aiui): Routstr tops the model picker with the full live catalog

New 'Routstr (sats)' category sits FIRST in the model dropdown
(operator request 2026-08-14), listing every model the node's
/aiui/api/routstr/models proxy returns (432 live today) — the picker
panel now scrolls (max-h 70vh) instead of overflowing. Selecting a
Routstr model routes the turn through the node's paid completions
proxy, and the explicit choice wins even when AIUI runs embedded in
Archy — a selection, not a fallback. Node refusals (no budget armed,
budget spent, wallet can't fund) surface verbatim in chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-14 13:41:17 -04:00
co-authored by Claude Fable 5
parent e3bd340725
commit a4be1b4b7d
3 changed files with 123 additions and 9 deletions
@@ -93,10 +93,11 @@ describe('useAI', () => {
expect(activeModel.value).toBe('echo') expect(activeModel.value).toBe('echo')
}) })
it('lists available providers with models', () => { it('lists available providers with models, Routstr first', () => {
const { availableProviders } = useAI() const { availableProviders } = useAI()
expect(availableProviders.value.length).toBe(3) expect(availableProviders.value.length).toBe(4)
const ids = availableProviders.value.map(p => p.id) const ids = availableProviders.value.map(p => p.id)
expect(ids[0]).toBe('routstr')
expect(ids).toContain('claude') expect(ids).toContain('claude')
expect(ids).toContain('openrouter') expect(ids).toContain('openrouter')
expect(ids).toContain('mock') expect(ids).toContain('mock')
@@ -119,7 +119,7 @@
<Transition name="picker"> <Transition name="picker">
<div <div
v-if="showModelPicker" v-if="showModelPicker"
class="fixed z-[9999] path-glass-card header-overlay-panel p-3 space-y-3 animate-fade-up-fast shadow-2xl min-w-[220px]" class="fixed z-[9999] path-glass-card header-overlay-panel p-3 space-y-3 animate-fade-up-fast shadow-2xl min-w-[220px] max-h-[70vh] overflow-y-auto"
:style="modelPickerDropdownStyle" :style="modelPickerDropdownStyle"
@click.stop @click.stop
> >
@@ -332,7 +332,7 @@ const modelDisplayName = computed(() => {
}) })
function selectModel(providerId: string, modelId: string) { function selectModel(providerId: string, modelId: string) {
setProvider(providerId as 'claude' | 'openrouter' | 'mock') setProvider(providerId as 'routstr' | 'claude' | 'openrouter' | 'mock')
setModel(modelId) setModel(modelId)
showModelPicker.value = false showModelPicker.value = false
} }
+118 -5
View File
@@ -13,12 +13,14 @@ import { useCodeContext } from '@/composables/useCodeContext'
import { apiFetch } from '@/utils/api-fetch' import { apiFetch } from '@/utils/api-fetch'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
type Provider = 'claude' | 'openrouter' | 'mock' type Provider = 'routstr' | 'claude' | 'openrouter' | 'mock'
// API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/) // API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/)
const BASE = import.meta.env.BASE_URL || '/' const BASE = import.meta.env.BASE_URL || '/'
const CLAUDE_PATH = `${BASE}api/claude/v1/messages` const CLAUDE_PATH = `${BASE}api/claude/v1/messages`
const OPENROUTER_PATH = `${BASE}api/openrouter` const OPENROUTER_PATH = `${BASE}api/openrouter`
const ROUTSTR_MODELS_PATH = `${BASE}api/routstr/models`
const ROUTSTR_CHAT_PATH = `${BASE}api/routstr/chat/completions`
import { mockFilms } from '@/mocks/films' import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs' import { mockSongs } from '@/mocks/songs'
@@ -148,8 +150,41 @@ function looksLikeMissingApiKey(err: string): boolean {
) )
} }
// ─── Routstr model catalog (fetched from the node's session-gated proxy) ───
// The node forwards the live Routstr aggregator's /v1/models; entries carry
// sats_pricing so completions are Cashu-paid against the operator's budget.
const routstrModels = ref<{ id: string; name: string }[]>([])
let routstrModelsFetched = false
async function refreshRoutstrModels() {
if (routstrModelsFetched) return
routstrModelsFetched = true
try {
const res = await apiFetch(ROUTSTR_MODELS_PATH)
if (!res.ok) return
const data = await res.json()
if (Array.isArray(data?.data)) {
routstrModels.value = data.data
.filter((m: Record<string, unknown>) => typeof m.id === 'string')
.map((m: Record<string, unknown>) => ({
id: m.id as string,
name: (m.name as string) || (m.id as string),
}))
}
} catch {
routstrModelsFetched = false // allow a retry on the next send/open
}
}
const availableProviders = computed(() => { const availableProviders = computed(() => {
const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [ const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
{
id: 'routstr',
name: 'Routstr (sats)',
models: routstrModels.value.length > 0
? routstrModels.value
: [{ id: 'routstr-unavailable', name: 'No models — node offline?' }],
},
{ {
id: 'claude', id: 'claude',
name: 'Claude (Max)', name: 'Claude (Max)',
@@ -381,6 +416,71 @@ async function streamOpenRouter(
}, onError, signal) }, onError, signal)
} }
/**
* Routstr: one paid, NON-streaming, OpenAI-shaped completion through the
* node's session-gated `/aiui/api/routstr/` forwarder. The node quotes a
* price from the live catalog, pays with a Cashu token against the
* operator's budget (Settings → System → Routstr AI budget), redeems the
* change, and passes the provider's JSON back. The full answer is emitted
* as a single token — streaming across a paid hop is the planned follow-up.
*/
async function streamRoutstr(
messages: ChatMessage[],
onToken: (text: string) => void,
onError: (err: string) => void,
systemPrompt: string,
signal?: AbortSignal,
): Promise<void> {
const wireMessages = [
{ role: 'system' as const, content: systemPrompt },
...messages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })),
]
const res = await apiFetch(ROUTSTR_CHAT_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: activeModel.value,
messages: wireMessages,
stream: false,
}),
signal,
})
const bodyText = await res.text().catch(() => '')
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.
let msg = `Routstr error ${res.status}`
try {
const parsed = JSON.parse(bodyText)
// Node refusals use {error:{message}}; the upstream provider nests
// its own as {detail:{error:{message}}} or a plain {detail:"..."}.
const detail = parsed?.detail
msg =
parsed?.error?.message ??
detail?.error?.message ??
(typeof detail === 'string' ? detail : undefined) ??
msg
} catch { /* keep the status-only message */ }
onError(msg)
return
}
if (signal?.aborted) return
try {
const parsed = JSON.parse(bodyText)
const text = parsed?.choices?.[0]?.message?.content
if (typeof text === 'string' && text.length > 0) {
onToken(text)
} else {
onError('Routstr returned an empty response')
}
} catch {
onError('Routstr returned a malformed response')
}
}
/** /**
* Embedded-mode chat delegation (D-01/D-17): when AIUI is running inside * Embedded-mode chat delegation (D-01/D-17): when AIUI is running inside
* Archy, the model call, the tool-calling loop, and the model key all live * Archy, the model call, the tool-calling loop, and the model key all live
@@ -619,7 +719,11 @@ export async function streamWithModel(
activeModel.value = model activeModel.value = model
try { try {
if (useArchy().isEmbedded.value) { if (provider === 'routstr') {
// Explicitly chosen Routstr wins even embedded in Archy — the whole
// point of the picker entry is that it is a selection, not a fallback.
await streamRoutstr(history, onToken, onError, 'You are a helpful assistant.', signal)
} else if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to // D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side. // Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal) await streamViaArchy(history, onToken, onError, signal)
@@ -638,8 +742,9 @@ export async function streamWithModel(
export function useAI() { export function useAI() {
const chatStore = useChatStore() const chatStore = useChatStore()
// Fetch Wavlake catalog on first use (non-blocking) // Fetch Wavlake + Routstr catalogs on first use (non-blocking)
refreshWavlakeCatalog() refreshWavlakeCatalog()
refreshRoutstrModels()
function stopGeneration() { function stopGeneration() {
if (currentAbort) { if (currentAbort) {
@@ -712,7 +817,11 @@ export function useAI() {
const genParams = getConversationParams(chatStore) const genParams = getConversationParams(chatStore)
try { try {
if (useArchy().isEmbedded.value) { if (provider === 'routstr') {
// Explicitly chosen Routstr wins even embedded in Archy — a
// selection, not a fallback.
await streamRoutstr(history, onToken, onError, systemPrompt, signal)
} else if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to // D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side. // Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal) await streamViaArchy(history, onToken, onError, signal)
@@ -828,7 +937,11 @@ export function useAI() {
const genParams = getConversationParams(chatStore) const genParams = getConversationParams(chatStore)
try { try {
if (useArchy().isEmbedded.value) { if (provider === 'routstr') {
// Explicitly chosen Routstr wins even embedded in Archy — a
// selection, not a fallback.
await streamRoutstr(history, onToken, onError, systemPrompt, signal)
} else if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to // D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side. // Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal) await streamViaArchy(history, onToken, onError, signal)