diff --git a/src/routes/api/claude-proxy/$.ts b/src/routes/api/claude-proxy/$.ts index f4bd61d1a..dce642feb 100644 --- a/src/routes/api/claude-proxy/$.ts +++ b/src/routes/api/claude-proxy/$.ts @@ -2,6 +2,50 @@ import { createFileRoute } from '@tanstack/react-router' import { BEARER_TOKEN, CLAUDE_API } from '../../../server/gateway-capabilities' import { isAuthenticated } from '../../../server/auth-middleware' +/** + * Vanilla hermes-agent (any version through 2026-05) does not expose + * `/api/available-models` — that's a legacy fork-only endpoint. When the + * proxy gets a 404, synthesize a compatible response from `/v1/models` + * filtered by provider so the chat composer / settings dialog don't + * silently break for users on vanilla agent. + */ +async function fallbackAvailableModels( + provider: string, + authHeaders: Record, +): Promise { + try { + const res = await fetch(`${CLAUDE_API}/v1/models`, { headers: authHeaders }) + if (!res.ok) { + return new Response(JSON.stringify({ models: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + const data = (await res.json()) as { data?: Array> } + const list = Array.isArray(data?.data) ? data.data : [] + const wanted = provider.toLowerCase() + const models = list + .map((m) => { + const id = typeof m.id === 'string' ? m.id : '' + if (!id) return null + const owned = typeof m.owned_by === 'string' ? m.owned_by.toLowerCase() : '' + const idProvider = id.includes('/') ? id.split('/')[0].toLowerCase() : owned + if (wanted && idProvider !== wanted) return null + return { id } + }) + .filter((m): m is { id: string } => Boolean(m)) + return new Response(JSON.stringify({ models }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } catch { + return new Response(JSON.stringify({ models: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } +} + async function proxyRequest(request: Request, splat: string) { const incomingUrl = new URL(request.url) const targetPath = splat.startsWith('/') ? splat : `/${splat}` @@ -11,7 +55,10 @@ async function proxyRequest(request: Request, splat: string) { const headers = new Headers(request.headers) headers.delete('host') headers.delete('content-length') - if (BEARER_TOKEN) headers.set('Authorization', `Bearer ${BEARER_TOKEN}`) + // Read at request time — follows the same fix as PR #234. + const bearer = + process.env.HERMES_API_TOKEN || process.env.CLAUDE_API_TOKEN || BEARER_TOKEN + if (bearer) headers.set('Authorization', `Bearer ${bearer}`) const init: RequestInit = { method: request.method, @@ -24,6 +71,19 @@ async function proxyRequest(request: Request, splat: string) { } const upstream = await fetch(targetUrl, init) + // Vanilla agent fallback for /api/available-models — synthesize from /v1/models. + if ( + upstream.status === 404 && + request.method.toUpperCase() === 'GET' && + /\/api\/available-models\b/.test(targetPath) + ) { + const provider = incomingUrl.searchParams.get('provider') || '' + const authHeaders: Record = bearer + ? { Authorization: `Bearer ${bearer}` } + : {} + return fallbackAvailableModels(provider, authHeaders) + } + const body = await upstream.text() const responseHeaders = new Headers() const contentType = upstream.headers.get('content-type') diff --git a/src/routes/api/connection-status.ts b/src/routes/api/connection-status.ts index b524756c3..cd1acc069 100644 --- a/src/routes/api/connection-status.ts +++ b/src/routes/api/connection-status.ts @@ -6,6 +6,7 @@ import fs from 'node:fs' import path from 'node:path' import os from 'node:os' import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' import YAML from 'yaml' import { CLAUDE_API, @@ -52,8 +53,12 @@ export const Route = createFileRoute('/api/connection-status')({ server: { handlers: { GET: async ({ request }) => { - const authResult = isAuthenticated(request) - if (authResult !== true) return authResult as unknown as Response + // isAuthenticated() returns boolean. The previous "return authResult as + // unknown as Response" cast silenced TypeScript but threw at runtime + // because the framework received `false`, not a Response. See #261, #263. + if (!isAuthenticated(request)) { + return json({ error: 'Unauthorized' }, { status: 401 }) + } const caps = await ensureGatewayProbed() const activeModel = readActiveModel() diff --git a/src/routes/api/system-metrics.ts b/src/routes/api/system-metrics.ts index 1fd114ba1..b6d55f9a0 100644 --- a/src/routes/api/system-metrics.ts +++ b/src/routes/api/system-metrics.ts @@ -1,6 +1,7 @@ import fs from 'node:fs' import os from 'node:os' import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' import { isAuthenticated } from '../../server/auth-middleware' import { ensureGatewayProbed, @@ -92,8 +93,11 @@ export const Route = createFileRoute('/api/system-metrics')({ server: { handlers: { GET: async ({ request }) => { - const authResult = isAuthenticated(request) - if (authResult !== true) return authResult as unknown as Response + // isAuthenticated() returns boolean. Don't cast it to Response — + // that throws at runtime. Match the pattern used by adjacent routes. + if (!isAuthenticated(request)) { + return json({ error: 'Unauthorized' }, { status: 401 }) + } const caps = await ensureGatewayProbed() const status = getConnectionStatus()