fix: test chat uses OIDC auth via /api/chat + /api/models, no key needed
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
const CHAT_KEY_STORAGE = 'chat_api_key'
|
||||
import { getToken } from '../lib/api'
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
@@ -10,12 +9,7 @@ interface Message {
|
||||
|
||||
interface ModelOption { id: string }
|
||||
|
||||
function getChatKey() { return localStorage.getItem(CHAT_KEY_STORAGE) ?? '' }
|
||||
function setChatKey(k: string) { localStorage.setItem(CHAT_KEY_STORAGE, k) }
|
||||
|
||||
export default function Chat() {
|
||||
const [apiKey, setApiKey] = useState(getChatKey)
|
||||
const [keyInput, setKeyInput] = useState('')
|
||||
const [models, setModels] = useState<ModelOption[]>([])
|
||||
const [model, setModel] = useState('')
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
@@ -26,34 +20,20 @@ export default function Chat() {
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiKey) return
|
||||
fetch('/v1/models', { headers: { Authorization: `Bearer ${apiKey}` } })
|
||||
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
||||
fetch('/api/models', { headers: { Authorization: `Bearer ${getToken()}` } })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const list: ModelOption[] = d.data ?? []
|
||||
setModels(list)
|
||||
if (list.length > 0) setModel(list[0].id)
|
||||
setError('')
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Could not load models — check your API key.')
|
||||
setModels([])
|
||||
})
|
||||
}, [apiKey])
|
||||
.catch(() => setError('Failed to load models'))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
function saveKey() {
|
||||
const k = keyInput.trim()
|
||||
if (!k) return
|
||||
setChatKey(k)
|
||||
setApiKey(k)
|
||||
setKeyInput('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = input.trim()
|
||||
if (!text || streaming || !model) return
|
||||
@@ -68,12 +48,12 @@ export default function Chat() {
|
||||
setStreaming(true)
|
||||
|
||||
try {
|
||||
const res = await fetch('/v1/chat/completions', {
|
||||
const res = await fetch('/api/chat/completions', {
|
||||
method: 'POST',
|
||||
signal: abort.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
@@ -82,9 +62,7 @@ export default function Chat() {
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text().catch(() => res.statusText))
|
||||
}
|
||||
if (!res.ok) throw new Error(await res.text().catch(() => res.statusText))
|
||||
|
||||
const reader = res.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
@@ -103,24 +81,16 @@ export default function Chat() {
|
||||
if (data === '[DONE]') break
|
||||
try {
|
||||
const chunk = JSON.parse(data)
|
||||
const delta = chunk.choices?.[0]?.delta?.content
|
||||
if (delta) {
|
||||
reply += delta
|
||||
setMessages([...next, { role: 'assistant', content: reply }])
|
||||
}
|
||||
const delta = chunk.choices?.[0]?.delta?.content ?? chunk.choices?.[0]?.delta?.reasoning_content ?? ''
|
||||
if (delta) { reply += delta; setMessages([...next, { role: 'assistant', content: reply }]) }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
setMessages([...next, { role: 'assistant', content: reply || '(empty response)' }])
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') {
|
||||
setError(e.message)
|
||||
setMessages(next)
|
||||
} else {
|
||||
setMessages(prev => prev.map((m, i) =>
|
||||
i === prev.length - 1 ? { ...m, pending: false } : m))
|
||||
}
|
||||
if (e.name !== 'AbortError') { setError(e.message); setMessages(next) }
|
||||
else setMessages(prev => prev.map((m, i) => i === prev.length - 1 ? { ...m, pending: false } : m))
|
||||
} finally {
|
||||
setStreaming(false)
|
||||
abortRef.current = null
|
||||
@@ -131,36 +101,6 @@ export default function Chat() {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() }
|
||||
}
|
||||
|
||||
// ── Key setup screen ────────────────────────────────────────────────────────
|
||||
if (!apiKey) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ width: 380, background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, padding: 24 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff', marginBottom: 6 }}>Enter an API key to use Test Chat</div>
|
||||
<div style={{ fontSize: 12, color: 'rgba(237,237,240,0.4)', marginBottom: 16 }}>
|
||||
Create a key on the API Keys page, then paste it here. It's stored in your browser only.
|
||||
</div>
|
||||
<input
|
||||
type="password" value={keyInput} onChange={e => setKeyInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && saveKey()}
|
||||
placeholder="sk-…" autoFocus
|
||||
style={{
|
||||
width: '100%', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.09)',
|
||||
borderRadius: 6, padding: '8px 12px', fontSize: 13, color: '#ededf0', outline: 'none',
|
||||
fontFamily: "'DM Mono',monospace", marginBottom: 10, boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<button onClick={saveKey} disabled={!keyInput.trim()} style={{
|
||||
width: '100%', background: '#7c3aed', border: 'none', borderRadius: 7,
|
||||
padding: '9px 0', fontSize: 13, fontWeight: 500, color: 'white', cursor: 'pointer',
|
||||
opacity: keyInput.trim() ? 1 : 0.4,
|
||||
}}>Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Chat UI ─────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, maxWidth: 780, width: '100%', margin: '0 auto' }}>
|
||||
{/* Toolbar */}
|
||||
@@ -172,8 +112,7 @@ export default function Chat() {
|
||||
}}>
|
||||
{models.length === 0
|
||||
? <option value="">No models available</option>
|
||||
: models.map(m => <option key={m.id} value={m.id}>{m.id}</option>)
|
||||
}
|
||||
: models.map(m => <option key={m.id} value={m.id}>{m.id}</option>)}
|
||||
</select>
|
||||
{messages.length > 0 && (
|
||||
<button onClick={() => { setMessages([]); setError('') }} disabled={streaming} style={{
|
||||
@@ -181,10 +120,6 @@ export default function Chat() {
|
||||
padding: '7px 12px', fontSize: 12, color: 'rgba(237,237,240,0.4)', cursor: 'pointer',
|
||||
}}>Clear</button>
|
||||
)}
|
||||
<button onClick={() => { setChatKey(''); setApiKey(''); setModels([]); setMessages([]) }} style={{
|
||||
background: 'transparent', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 7,
|
||||
padding: '7px 10px', fontSize: 11, color: 'rgba(237,237,240,0.25)', cursor: 'pointer',
|
||||
}} title="Change API key">Key</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
|
||||
@@ -89,6 +89,9 @@ func (a *API) Mount(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAuth)
|
||||
r.Get("/api/me", a.apiMe)
|
||||
// Dashboard test chat — OIDC auth, synthetic allow-all key injected.
|
||||
r.With(a.dashboardKeyAuth).Post("/api/chat/completions", a.chatCompletions)
|
||||
r.Get("/api/models", a.listModels)
|
||||
|
||||
r.Route("/api/admin", func(r chi.Router) {
|
||||
// Keys and logs are user-scoped (admins see all via nil filter).
|
||||
|
||||
@@ -73,6 +73,15 @@ func (a *API) requireAuth(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// dashboardKeyAuth injects a synthetic allow-all virtual key so the chat handler
|
||||
// works for OIDC-authenticated dashboard users without a real API key.
|
||||
func (a *API) dashboardKeyAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
synthetic := &store.VirtualKey{AllowAllModels: true, KeyPrefix: "dashboard"}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyCtxKey, synthetic)))
|
||||
})
|
||||
}
|
||||
|
||||
// requireAdmin rejects requests from non-admin users. Must be composed after requireAuth.
|
||||
func (a *API) requireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user