2026-09-19 22:49:52 +03:00
"use client" ;
2026-10-25 17:18:44 +03:00
import { translator , type Translator } from "@/src/core" ;
2026-10-31 13:45:06 +03:00
import { LanguageSelect , useI18n } from "@/components/i18n" ;
2026-10-25 17:18:44 +03:00
2026-09-19 22:49:52 +03:00
/**
2026-09-20 13:53:56 +03:00
* The Keys section (wave-5 polish, 2026-09-21 — owner design): a MINIMAL
* key-value table. Provider left, masked key right, no description hints
* (they took too much space), the "Get a key ↗" link a tiny muted guest.
* Clicking a writable row unfolds a paste field + Save (+ Delete for a
* file-sourced value) beneath it; Esc or a second click folds it away.
* The Add-provider control is the LAST ROW of the table and uses the
* app's shared dropdown look (ProviderMenuList — fuzzy search over the
* 33 pi-ai providers).
2026-09-19 22:49:52 +03:00
*
2026-09-20 13:53:56 +03:00
* Owner rulings carried in:
* - Env-owned rows (a real environment variable owns the name — dev
* .env.local or launch env) render READ-ONLY + dimmed, no paste field:
* a paste box on a key the UI cannot make live would mislead.
* - OpenRouter is NOT special: one provider among 33, a row only when
* stored, otherwise it lives in the Add dropdown like everyone else.
2026-10-24 15:28:26 +03:00
* - Providers groups API keys, subscriptions, dictation and search.
* Unknown variables stay masked/read-only at the end of Model API keys.
2026-09-19 22:49:52 +03:00
*
2026-09-20 13:53:56 +03:00
* API: GET /api/keys (masked statuses + the providers array), PUT
* /api/keys/:id (shape-gated 400s land inline), DELETE. Saves take
* effect immediately. Plaintext never returns from the store.
2026-09-19 22:49:52 +03:00
*/
2026-09-20 13:53:56 +03:00
import { useEffect , useRef , useState } from "react" ;
2026-10-27 16:25:03 -07:00
import {
AUTH_KEYS ,
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW ,
CUSTOM_MODEL_DEFAULT_MAX_TOKENS ,
CUSTOM_PROVIDER ,
2026-10-29 04:43:53 +02:00
normalizeBaseUrl ,
2026-10-27 16:25:03 -07:00
type AuthKeyStatus ,
type CustomModel ,
type CustomModelOverride ,
} from "@/src/core" ;
2026-10-31 13:45:06 +03:00
import { SettingsGroup , SettingsRow } from "./controls.tsx" ;
2026-10-25 00:27:52 +03:00
import { SubscriptionConnections } from "./subscription-connections.tsx" ;
2026-10-28 19:10:47 +02:00
import { ClaudeCodeInstallationStatus } from "./claude-code-status.tsx" ;
2026-09-20 13:53:56 +03:00
import { ProviderMenuList } from "./model-menu.tsx" ;
2026-09-22 16:56:44 +03:00
import { MenuShell } from "@/components/menu-shell" ;
2026-09-24 23:20:59 +03:00
import { notifySettingsSaved } from "@/components/settings-signal" ;
2026-09-19 22:49:52 +03:00
2026-09-20 13:53:56 +03:00
/** One pi-ai provider with plain API-key auth (the Platform seam —
* derived from pi-ai's own env-key table; no drift possible). */
2026-09-19 22:49:52 +03:00
export interface ProviderKeyInfo {
id : string ;
envVar : string ;
label : string ;
}
interface KeysResponse {
keys : AuthKeyStatus [];
providers? : ProviderKeyInfo [];
}
2026-10-24 10:18:13 -07:00
/** The custom endpoint's non-secret status (GET /api/custom-provider). */
interface CustomStatus {
2026-10-29 04:43:53 +02:00
keyReuseRequired : boolean ;
revision : string ;
authMode : "keyless" | "apiKey" ;
keySource : "env" | "file" | "none" ;
2026-10-24 10:18:13 -07:00
baseUrl : string ;
models : CustomModel [];
2026-10-27 16:25:03 -07:00
/** User pins by model id (context / max output / thinking). */
overrides : Record < string , CustomModelOverride >;
2026-10-28 16:16:30 -04:00
manualModels : string [];
2026-10-24 10:18:13 -07:00
keyPresent : boolean ;
}
2026-10-29 04:43:53 +02:00
async function readCustomStatus ( inspectKey = false ) : Promise < CustomStatus > {
const response = await fetch ( `/api/custom-provider ${ inspectKey ? "?authMode=apiKey" : "" } ` );
if ( ! response . ok ) throw new Error ( "Unable to load custom configuration." );
const cp = await response . json () as Partial < CustomStatus >;
return { keyReuseRequired : Boolean ( cp . keyReuseRequired ), revision : response.headers.get ( "etag" ) ?? "" , authMode : cp.authMode ?? "keyless" , keySource : cp.keySource ?? "none" , baseUrl : cp.baseUrl ?? "" , models : cp.models ?? [], overrides : cp.overrides ?? {}, manualModels : cp.manualModels ?? [], keyPresent : Boolean ( cp . keyPresent ) };
}
2026-09-19 22:49:52 +03:00
/** The curated tools group, pinned by id (the catalog owns the copy;
2026-09-20 13:53:56 +03:00
* these ids are the contract). Derived from the catalog (search wave,
* 2026-09-20: the Google CSE pair retired, Tavily + Brave joined) — the
* section can never drift from core/auth-keys.ts again. */
2026-09-22 00:02:07 +03:00
const TOOL_IDS = AUTH_KEYS . map (( k ) => k . id ) as readonly string [];
2026-09-19 22:49:52 +03:00
2026-10-24 10:31:48 -07:00
/** The custom endpoint's synthetic Add-provider entry (offered only while unconfigured). */
export const CUSTOM_PROVIDER_INFO : ProviderKeyInfo = {
2026-10-24 10:18:13 -07:00
id : CUSTOM_PROVIDER.id ,
envVar : CUSTOM_PROVIDER.envVar ,
label : CUSTOM_PROVIDER.label ,
};
2026-09-20 13:53:56 +03:00
/** The classification driving the table groups. Pure — exported for
* tests. OpenRouter is un-pinned (wave-5 owner ruling): no synthesis,
* no special row — it rides the same provider classification. */
2026-09-19 22:49:52 +03:00
export function classifyKeys (
statuses : readonly AuthKeyStatus [],
providers : readonly ProviderKeyInfo [] | undefined ,
) : {
2026-09-20 13:53:56 +03:00
/** Stored provider keys (env or file), in GET order. */
providerRows : AuthKeyStatus [];
/** The curated tools rows (synthesized when absent — always shown,
* they carry the docs links a first boot needs). */
2026-09-19 22:49:52 +03:00
toolRows : AuthKeyStatus [];
/** Unknown file vars — preserved, read-only. */
unknownRows : AuthKeyStatus [];
/** Providers without a stored key — the Add dropdown's options. */
addable : ProviderKeyInfo [];
} {
const byId = new Map ( statuses . map (( s ) => [ s . id , s ]));
2026-09-20 13:53:56 +03:00
const isTool = ( id : string ) => ( TOOL_IDS as readonly string []). includes ( id );
const providerEnv = new Set (( providers ?? []). map (( p ) => p . envVar ));
2026-09-19 22:49:52 +03:00
const synth = ( id : string ) : AuthKeyStatus => ({
id ,
info : null ,
present : false ,
masked : null ,
source : "none" ,
shadowedByEnv : false ,
});
2026-09-20 13:53:56 +03:00
const providerRows : AuthKeyStatus [] = [];
2026-09-19 22:49:52 +03:00
const unknownRows : AuthKeyStatus [] = [];
for ( const s of statuses ) {
2026-09-20 13:53:56 +03:00
if ( isTool ( s . id )) continue ;
2026-10-24 09:53:10 -07:00
// The custom endpoint's key has its OWN home (the Custom endpoint
// section), so it never shows here — not as a provider row, not as an
// "other stored variable".
if ( s . id === CUSTOM_PROVIDER . envVar ) continue ;
2026-09-20 13:53:56 +03:00
if ( providerEnv . has ( s . id ) || ( ! s . info && providers === undefined )) providerRows . push ( s );
2026-09-19 22:49:52 +03:00
else if ( ! s . info ) unknownRows . push ( s );
2026-09-20 13:53:56 +03:00
// (pre-interlude catalogs: a curated-but-unclassified status still
// renders as a provider row so nothing disappears)
else providerRows . push ( s );
2026-09-19 22:49:52 +03:00
}
return {
providerRows ,
toolRows : TOOL_IDS.map (( id ) => byId . get ( id ) ?? synth ( id )),
unknownRows ,
2026-09-20 13:53:56 +03:00
addable : ( providers ?? []). filter (( p ) => ! byId . has ( p . envVar )),
2026-09-19 22:49:52 +03:00
};
}
2026-10-26 19:43:09 +02:00
/** A key row names its credential, not whichever provider sorts first.
* Pi exposes Zen and Go separately but both read OPENCODE_API_KEY. */
export function providerKeyLabel ( id : string , providers : readonly ProviderKeyInfo [] | undefined ) : string | undefined {
const matches = providers ? . filter (( p ) => p . envVar === id );
if ( matches ? . some (( p ) => p . id === "opencode" ) && matches . some (( p ) => p . id === "opencode-go" )) return "OpenCode (Zen / Go)" ;
return matches ? .[ 0 ] ? . label ;
}
2026-09-20 13:53:56 +03:00
/** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an
* environment-owned value — the row itself is read-only then). Pure. */
2026-10-25 17:18:44 +03:00
export function keyStatusLine ( status : AuthKeyStatus , t : Translator = translator ( "en" )) : string {
if ( ! status . present || ! status . masked ) return t ( "not set" );
2026-09-20 13:53:56 +03:00
const tail = status . masked . replace ( / \((\d+) chars\)$/ , "" );
const chars = status . masked . match ( /\((\d+) chars\)$/ ) ? .[ 1 ] ?? "?" ;
2026-10-25 17:18:44 +03:00
return ` ${ tail } · ${ chars }${ status . source === "env" ? t ( " · env" ) : "" } ` ;
2026-09-19 22:49:52 +03:00
}
2026-09-20 13:53:56 +03:00
/** One table row: label left, tiny docs link + status right. Writable
* rows click-unfold an editor beneath (owner interaction); env-owned
* rows are dimmed and read-only (a paste box there would mislead). */
function KeyTableRow ({
2026-09-19 22:49:52 +03:00
label ,
docsUrl ,
status ,
2026-09-20 13:53:56 +03:00
expanded ,
onToggle ,
2026-09-19 22:49:52 +03:00
onSave ,
onDelete ,
2026-09-23 00:57:28 +03:00
onCancel ,
2026-09-19 22:49:52 +03:00
testid ,
} : {
label : string ;
2026-09-20 13:53:56 +03:00
/** The "Get a key ↗" target — curated keys carry one. */
2026-09-19 22:49:52 +03:00
docsUrl? : string ;
status : AuthKeyStatus ;
2026-09-20 13:53:56 +03:00
expanded : boolean ;
onToggle : () => void ;
2026-09-19 22:49:52 +03:00
onSave : ( value : string ) => Promise < string | null >;
onDelete ?: () => Promise < void >;
2026-09-23 00:57:28 +03:00
/** Folds the editor (owner, fix wave: the way OUT of a paste prompt
* must be visible — Cancel beside Save/Delete, not only Esc). */
onCancel : () => void ;
2026-09-19 22:49:52 +03:00
testid : string ;
}) {
2026-10-25 17:18:44 +03:00
const { t } = useI18n ();
2026-09-20 13:53:56 +03:00
const envOwned = status . source === "env" ;
2026-09-19 22:49:52 +03:00
const [ value , setValue ] = useState ( "" );
const [ error , setError ] = useState < string | null >( null );
const [ busy , setBusy ] = useState ( false );
async function save() {
if ( ! value . trim () || busy ) return ;
setBusy ( true );
setError ( null );
const err = await onSave ( value );
if ( err ) setError ( err );
else setValue ( "" );
setBusy ( false );
}
2026-09-23 00:57:28 +03:00
// INLINE editing (owner, fix wave: "type the keys in line — more
// table-like"): the expanded row becomes the editor itself — label
// left, the paste field filling the middle, Save | Cancel (| Delete)
// right — instead of a second line beneath the row.
if ( expanded && ! envOwned ) {
return (
< div data-testid = { testid }>
< div className = "flex items-center gap-2 rounded-sm px-1 py-1.5" data-testid = { ` ${ testid } -editor` }>
< span className = "shrink-0 font-ui text-xs text-foreground" >{ label }</ span >
< input
type = "text"
value = { value }
onChange = {( e ) => setValue ( e . target . value )}
onKeyDown = {( e ) => {
if ( e . key === "Enter" ) void save ();
}}
2026-10-25 17:18:44 +03:00
placeholder = { t ( "Paste key…" )}
aria-label = { t ( "{v0} key" , { v0 : label })}
2026-09-23 00:57:28 +03:00
data-testid = { ` ${ testid } -input` }
disabled = { busy }
autoFocus
className = "h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
< button
type = "button"
onClick = {() => void save ()}
disabled = { busy || ! value . trim ()}
data-testid = { ` ${ testid } -save` }
className = "shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
2026-10-25 17:18:44 +03:00
{ t ( "Save" )}</ button >
2026-09-23 00:57:28 +03:00
< button
type = "button"
onClick = { onCancel }
disabled = { busy }
data-testid = { ` ${ testid } -cancel` }
className = "shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
2026-10-25 17:18:44 +03:00
{ t ( "Cancel" )}</ button >
2026-09-23 00:57:28 +03:00
{ status . present && status . source === "file" && onDelete && (
< button
type = "button"
onClick = {() => void onDelete ()}
disabled = { busy }
data-testid = { ` ${ testid } -delete` }
className = "shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
>
2026-10-25 17:18:44 +03:00
{ t ( "Delete" )}</ button >
2026-09-23 00:57:28 +03:00
)}
</ div >
{ error && (
< p role = "alert" data-testid = { ` ${ testid } -error` } className = "px-1 text-xs text-red-500" >
2026-10-25 17:18:44 +03:00
{ t ( error )}
2026-09-23 00:57:28 +03:00
</ p >
)}
</ div >
);
}
2026-09-19 22:49:52 +03:00
return (
2026-09-20 13:53:56 +03:00
< div data-testid = { testid } className = { envOwned ? "opacity-40" : undefined }>
< button
type = "button"
aria-expanded = { expanded }
data-testid = { ` ${ testid } -row` }
onClick = {() => ! envOwned && onToggle ()}
className = { `flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left ${
envOwned ? "cursor-default" : "cursor-pointer transition-colors hover:bg-accent/50"
} ` }
>
< span className = "font-ui text-xs text-foreground" >{ label }</ span >
< span className = "flex items-center gap-2" >
{ docsUrl && (
< a
href = { docsUrl }
target = "_blank"
rel = "noreferrer"
onClick = {( e ) => e . stopPropagation ()}
className = "text-[10px] text-muted-foreground/60 underline-offset-2 hover:text-foreground hover:underline"
>
2026-10-25 17:18:44 +03:00
{ t ( "key ↗" )}</ a >
2026-09-20 13:53:56 +03:00
)}
< span data-testid = { ` ${ testid } -status` } className = "font-mono text-[11px] text-muted-foreground" >
2026-10-25 17:18:44 +03:00
{ keyStatusLine ( status , t )}
2026-09-19 22:49:52 +03:00
</ span >
2026-09-20 13:53:56 +03:00
</ span >
</ button >
2026-09-19 22:49:52 +03:00
{ error && (
2026-09-20 13:53:56 +03:00
< p role = "alert" data-testid = { ` ${ testid } -error` } className = "pl-6 text-xs text-red-500" >
2026-10-25 17:18:44 +03:00
{ t ( error )}
2026-09-19 22:49:52 +03:00
</ p >
)}
</ div >
);
}
2026-09-20 13:53:56 +03:00
/** The Add-provider control — the table's last row, the app's shared
* dropdown look (portaled menu, the search + arrow-walk machine). */
function AddProviderRow ({
addable ,
onPick ,
} : {
addable : ProviderKeyInfo [];
onPick : ( p : ProviderKeyInfo ) => void ;
}) {
2026-10-25 17:18:44 +03:00
const { t } = useI18n ();
2026-09-20 13:53:56 +03:00
const [ open , setOpen ] = useState ( false );
const anchorRef = useRef < HTMLDivElement >( null );
useEffect (() => {
if ( ! open ) return ;
const onPointerDown = ( e : PointerEvent ) => {
const target = e . target as Node ;
if ( anchorRef . current ? . contains ( target )) return ;
if ( target instanceof Element && target . closest ( "[data-lh-menu]" )) return ;
if ( target instanceof Element && target . closest ( '[role="dialog"]' )) setOpen ( false );
};
const onKeyDown = ( e : KeyboardEvent ) => {
if ( e . key === "Escape" ) {
e . stopPropagation ();
setOpen ( false );
}
};
document . addEventListener ( "pointerdown" , onPointerDown );
document . addEventListener ( "keydown" , onKeyDown );
return () => {
document . removeEventListener ( "pointerdown" , onPointerDown );
document . removeEventListener ( "keydown" , onKeyDown );
};
}, [ open ]);
return (
< div ref = { anchorRef } className = "relative" >
< button
type = "button"
aria-expanded = { open }
data-testid = "add-provider"
onClick = {() => setOpen (( v ) => ! v )}
className = "flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left font-ui text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
2026-10-24 10:18:13 -07:00
< span > + Add a provider </ span >
2026-09-20 13:53:56 +03:00
{ addable . length > 0 && < span className = "font-mono text-[11px]" >{ addable . length }</ span >}
</ button >
2026-09-22 16:56:44 +03:00
{ open && (
< MenuShell
anchorRef = { anchorRef }
testid = "add-provider-menu"
preferredSide = "down"
menuClass = "flex w-72 flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
2026-09-20 13:53:56 +03:00
>
2026-09-23 00:57:28 +03:00
{ /* Close on pick (owner, fix wave): a chosen provider ends
the dropdown — the paste row is the next step. */ }
< ProviderMenuList
providers = { addable }
onPick = {( p ) => {
setOpen ( false );
onPick ( p );
}}
/>
2026-09-22 16:56:44 +03:00
</ MenuShell >
2026-09-20 13:53:56 +03:00
)}
</ div >
);
}
2026-09-24 23:20:59 +03:00
export function KeysSection ({
/** A deep-linked row to unfold on arrival (zero-keys wave): the
* dictation error's click lands here on DEEPGRAM_API_KEY. Followed
* while mounted too — the settings dialog's while-open retarget
* pattern. undefined = nothing seeded. */
initialRow ,
} : {
initialRow? : string ;
}) {
2026-10-25 17:18:44 +03:00
const { t } = useI18n ();
2026-09-19 22:49:52 +03:00
const [ statuses , setStatuses ] = useState < AuthKeyStatus [] | null >( null );
const [ providers , setProviders ] = useState < ProviderKeyInfo [] | undefined >( undefined );
2026-10-31 13:45:06 +03:00
const followedProvider = useRef < string | null >( null );
2026-09-19 22:49:52 +03:00
const [ loading , setLoading ] = useState ( true );
2026-09-24 23:20:59 +03:00
/** The unfolded row (its id), null = all folded. Seeded by the
* deep-link (initialRow) and re-followed when it retargets. */
const [ openRow , setOpenRow ] = useState < string | null >( initialRow ?? null );
2026-09-20 13:53:56 +03:00
/** The paste editor for a freshly chosen Add-provider target. */
const [ adding , setAdding ] = useState < ProviderKeyInfo | null >( null );
const [ addError , setAddError ] = useState < string | null >( null );
2026-10-24 10:18:13 -07:00
/** The custom endpoint's status + whether its connect editor is open. */
const [ custom , setCustom ] = useState < CustomStatus | null >( null );
const [ addingCustom , setAddingCustom ] = useState ( false );
2026-09-20 13:53:56 +03:00
// Escape folds any open editor (unfolded row or the Add-provider paste)
// WITHOUT closing the dialog. Claimed at document CAPTURE phase — the
// only stopPropagation that outruns both the input's React handler and
// the dialog's window-level listener.
useEffect (() => {
2026-10-24 10:18:13 -07:00
if ( openRow === null && adding === null && ! addingCustom ) return ;
2026-09-20 13:53:56 +03:00
const onKeyDown = ( e : KeyboardEvent ) => {
if ( e . key !== "Escape" ) return ;
2026-10-27 21:51:09 +02:00
// A language dropdown above this editor in the dismissal stack owns
// the first Escape; do not swallow it at document capture phase.
2026-10-31 13:45:06 +03:00
if ( document . querySelector ( '[data-testid="dictation-language-menu"], [data-testid="language-select-sttLanguage-menu"]' )) return ;
2026-09-20 13:53:56 +03:00
e . preventDefault ();
e . stopPropagation ();
setOpenRow ( null );
setAdding ( null );
2026-10-24 10:18:13 -07:00
setAddingCustom ( false );
2026-09-20 13:53:56 +03:00
};
document . addEventListener ( "keydown" , onKeyDown , true );
return () => document . removeEventListener ( "keydown" , onKeyDown , true );
2026-10-24 10:18:13 -07:00
}, [ openRow , adding , addingCustom ]);
2026-09-19 22:49:52 +03:00
2026-10-24 10:18:13 -07:00
const reload = async () => {
try {
const body = ( await fetch ( "/api/keys" ). then (( r ) => r . json ())) as KeysResponse ;
setStatuses ( body . keys ?? []);
if ( body . providers ) setProviders ( body . providers );
} catch {
setStatuses ([]);
} finally {
setLoading ( false );
}
// Best-effort and independent: the custom endpoint's status must never
// blank the provider-key list above if its fetch fails.
try {
2026-10-29 04:43:53 +02:00
setCustom ( await readCustomStatus ());
2026-10-24 10:18:13 -07:00
} catch {
/* custom status unavailable — leave it unset */
}
};
2026-09-19 22:49:52 +03:00
useEffect (() => {
void reload ();
}, []);
2026-09-24 23:20:59 +03:00
// The deep-link follow: a retarget while the section is open (the
// same while-open pattern the dialog's section nav uses) unfolds the
// named row; a fresh mount seeds it via the useState initializer.
useEffect (() => {
if ( initialRow !== undefined ) setOpenRow ( initialRow );
2026-10-26 23:42:44 +03:00
followedProvider . current = null ;
2026-09-24 23:20:59 +03:00
}, [ initialRow ]);
2026-10-26 23:42:44 +03:00
// A missing model-provider key has no table row yet. Deep-link into the
// existing Add-provider editor instead (for example, OpenRouter STT setup).
useEffect (() => {
if ( ! initialRow || ! statuses || ! providers || followedProvider . current === initialRow ) return ;
followedProvider . current = initialRow ;
if ( statuses . some (( status ) => status . id === initialRow )) return ;
const provider = providers . find (( provider ) => provider . envVar === initialRow );
if ( provider ) { setAdding ( provider ); setAddError ( null ); setOpenRow ( null ); }
}, [ initialRow , statuses , providers ]);
2026-09-24 23:20:59 +03:00
2026-09-19 22:49:52 +03:00
async function saveKey ( id : string , value : string ) : Promise < string | null > {
const res = await fetch ( `/api/keys/ ${ id } ` , {
method : "PUT" ,
headers : { "content-type" : "application/json" },
body : JSON.stringify ({ value }),
});
if ( ! res . ok ) {
const body = ( await res . json (). catch (() => ({}))) as { error? : string };
return body . error ?? `Save failed ( ${ res . status } )` ;
}
await reload ();
2026-09-24 23:20:59 +03:00
// The cross-surface keys signal (zero-keys wave): the zero-keys pill,
// the setup dialog's stage advance, and any other keys reader hear
// that a key landed — deletions included (see deleteKey below).
notifySettingsSaved ( "keys" );
2026-09-19 22:49:52 +03:00
return null ;
}
async function deleteKey ( id : string ) : Promise < void > {
await fetch ( `/api/keys/ ${ id } ` , { method : "DELETE" }). catch (() => undefined );
await reload ();
2026-09-24 23:20:59 +03:00
notifySettingsSaved ( "keys" );
2026-09-19 22:49:52 +03:00
}
2026-10-25 19:22:06 -07:00
/** Forget the custom endpoint (config + key server-side), same rhythm as
* deleteKey: fire, re-read, broadcast so the catalog narrows everywhere. */
2026-10-29 04:43:53 +02:00
async function disconnectCustom ( revision : string ) : Promise < string | null > {
const res = await fetch ( "/api/custom-provider" , { method : "DELETE" , headers : { "content-type" : "application/json" , "if-match" : revision }, body : JSON.stringify ({}) }). catch (() => null );
if ( ! res ? . ok ) return res ? (( await res . json (). catch (() => ({}))). error ?? "Disconnect failed." ) : "Disconnect failed. Check your connection." ;
2026-10-25 19:22:06 -07:00
setAddingCustom ( false );
await reload ();
notifySettingsSaved ( "keys" );
2026-10-29 04:43:53 +02:00
return null ;
2026-10-25 19:22:06 -07:00
}
2026-09-19 22:49:52 +03:00
if ( loading || statuses === null ) {
2026-10-25 17:18:44 +03:00
return < p className = "px-6 py-5 text-xs text-muted-foreground/70" >{ t ( "Loading keys…" )}</ p >;
2026-09-19 22:49:52 +03:00
}
2026-09-20 13:53:56 +03:00
const { providerRows , toolRows , unknownRows , addable } = classifyKeys ( statuses , providers );
async function saveNewProvider ( p : ProviderKeyInfo , value : string ) {
const err = await saveKey ( p . envVar , value );
if ( err ) setAddError ( err );
else {
setAdding ( null );
setAddError ( null );
}
}
2026-09-19 22:49:52 +03:00
return (
<>
2026-10-25 17:18:44 +03:00
< SettingsGroup label = { t ( "Model API keys" )} testid = "group-provider-keys" >
2026-09-20 13:53:56 +03:00
{ providerRows . map (( status ) => (
< KeyTableRow
2026-09-19 22:49:52 +03:00
key = { status . id }
2026-10-26 19:43:09 +02:00
label = { providerKeyLabel ( status . id , providers ) ?? status . info ? . label ?? status . id }
2026-09-19 22:49:52 +03:00
docsUrl = { status . info ? . docsUrl }
status = { status }
2026-09-20 13:53:56 +03:00
expanded = { openRow === status . id }
onToggle = {() => setOpenRow ( openRow === status . id ? null : status . id )}
onSave = { async ( v ) => {
const err = await saveKey ( status . id , v );
if ( ! err ) setOpenRow ( null ); // fold on success — rest state
return err ;
}}
2026-09-23 00:57:28 +03:00
onCancel = {() => setOpenRow ( null )}
2026-09-19 22:49:52 +03:00
onDelete = {() => deleteKey ( status . id )}
2026-09-20 13:53:56 +03:00
testid = { `key- ${ status . id . toLowerCase () } ` }
2026-09-19 22:49:52 +03:00
/>
))}
2026-10-24 10:18:13 -07:00
{ custom ? . baseUrl && (
< CustomEndpointRow
status = { custom }
expanded = { addingCustom }
onToggle = {() => setAddingCustom (( v ) => ! v )}
/>
)}
2026-10-24 10:31:48 -07:00
{ addingCustom && (
2026-10-24 10:18:13 -07:00
< CustomEndpointEditor
2026-10-29 04:43:53 +02:00
revision0 = { custom ? . revision ?? "" }
authMode0 = { custom ? . authMode ?? "keyless" }
keySource0 = { custom ? . keySource ?? "none" }
keyReuseRequired0 = { custom ? . keyReuseRequired ?? false }
onReload = { async () => { await reload (); setAddingCustom ( false ); }}
2026-10-24 10:31:48 -07:00
baseUrl0 = { custom ? . baseUrl ?? "" }
keyPresent = { custom ? . keyPresent ?? false }
2026-10-27 16:25:03 -07:00
models = { custom ? . models ?? []}
overrides = { custom ? . overrides ?? {}}
2026-10-28 16:16:30 -04:00
manualModels0 = { custom ? . manualModels ?? []}
2026-10-24 10:18:13 -07:00
onSaved = { async () => {
setAddingCustom ( false );
await reload ();
notifySettingsSaved ( "keys" );
}}
2026-10-27 16:25:03 -07:00
onPinsSaved = { async () => {
await reload ();
notifySettingsSaved ( "keys" );
}}
2026-10-25 19:22:06 -07:00
onDelete = { disconnectCustom }
2026-10-24 10:18:13 -07:00
onCancel = {() => setAddingCustom ( false )}
/>
)}
2026-09-20 13:53:56 +03:00
{ providers !== undefined && (
< AddProviderRow
2026-10-24 10:18:13 -07:00
addable = { custom ? . baseUrl ? addable : [... addable , CUSTOM_PROVIDER_INFO ]}
2026-09-20 13:53:56 +03:00
onPick = {( p ) => {
setAddError ( null );
2026-10-24 10:18:13 -07:00
if ( p . id === CUSTOM_PROVIDER . id ) {
setAdding ( null );
setAddingCustom ( true );
} else {
setAddingCustom ( false );
setAdding ( p );
}
2026-09-20 13:53:56 +03:00
}}
/>
2026-09-19 22:49:52 +03:00
)}
2026-09-20 13:53:56 +03:00
{ adding && (
2026-09-23 00:57:28 +03:00
< div className = "flex items-center gap-2 rounded-sm px-1 py-1.5" data-testid = { `key- ${ adding . id } -editor` }>
< span className = "shrink-0 font-ui text-xs text-foreground" >{ adding . label }</ span >
2026-09-20 13:53:56 +03:00
< AddProviderEditor provider = { adding } onSave = {( v ) => void saveNewProvider ( adding , v )} onCancel = {() => setAdding ( null )} error = { addError } />
</ div >
2026-09-19 22:49:52 +03:00
)}
2026-10-24 15:28:26 +03:00
{ unknownRows . length > 0 && < div data-testid = "group-unknown-keys" >
{ unknownRows . map (( status ) => < div key = { status . id } data-testid = { `unknown- ${ status . id } ` } className = "flex items-center justify-between gap-3 px-1 py-1.5 opacity-40" >
< span className = "font-mono text-xs text-foreground" >{ status . id }</ span >
< span className = "font-mono text-[11px] text-muted-foreground" >{ status . masked }</ span >
</ div >)}
</ div >}
2026-09-19 22:49:52 +03:00
</ SettingsGroup >
2026-10-25 17:18:44 +03:00
< SettingsGroup label = { t ( "Subscriptions" )} testid = "group-subscriptions" >
2026-10-25 00:27:52 +03:00
< SubscriptionConnections />
2026-10-24 15:28:26 +03:00
</ SettingsGroup >
2026-10-31 13:45:06 +03:00
< SettingsGroup label = { t ( "External CLI" )} testid = "group-external-cli" >
2026-10-28 19:10:47 +02:00
< ClaudeCodeInstallationStatus />
</ SettingsGroup >
2026-10-31 13:45:06 +03:00
{([ "Dictation" , "Search" ] as const ). map (( label ) => < SettingsGroup key = { label } label = { t ( label )} testid = { label === "Dictation" ? "group-dictation" : "group-search" }>
2026-10-24 15:28:26 +03:00
{ toolRows . filter (( status ) => label === "Dictation" ? status . id === "DEEPGRAM_API_KEY" : status . id !== "DEEPGRAM_API_KEY" ). map (( status ) => (
2026-09-20 13:53:56 +03:00
< KeyTableRow
2026-09-19 22:49:52 +03:00
key = { status . id }
label = { status . info ? . label ?? status . id }
docsUrl = { status . info ? . docsUrl }
status = { status }
2026-09-20 13:53:56 +03:00
expanded = { openRow === status . id }
onToggle = {() => setOpenRow ( openRow === status . id ? null : status . id )}
onSave = { async ( v ) => {
const err = await saveKey ( status . id , v );
if ( ! err ) setOpenRow ( null );
return err ;
}}
2026-09-23 00:57:28 +03:00
onCancel = {() => setOpenRow ( null )}
2026-09-19 22:49:52 +03:00
onDelete = {() => deleteKey ( status . id )}
2026-09-20 13:53:56 +03:00
testid = { `key- ${ status . id . toLowerCase () } ` }
2026-09-19 22:49:52 +03:00
/>
))}
2026-10-27 22:18:01 +02:00
{ label === "Dictation" && <>
2026-10-31 13:45:06 +03:00
< SettingsRow label = { t ( "Speech recognition language" )} testid = "row-dictation-language" >< LanguageSelect field = "sttLanguage" /></ SettingsRow >
2026-10-27 22:18:01 +02:00
< p data-testid = "dictation-setup-hint" className = "px-1 text-[11px] leading-relaxed text-muted-foreground/70" >
2026-10-27 22:32:55 +02:00
Create a < a
href = "https://console.deepgram.com/"
2026-10-27 22:18:01 +02:00
target = "_blank"
rel = "noreferrer"
className = "underline underline-offset-2 hover:text-foreground"
2026-10-27 22:32:55 +02:00
> Deepgram API key </ a > with the Member role or higher , then add it above .
2026-10-27 22:18:01 +02:00
</ p >
</>}
2026-10-24 15:28:26 +03:00
</ SettingsGroup >)}
2026-09-19 22:49:52 +03:00
</>
);
}
2026-09-20 13:53:56 +03:00
/** The paste field for a provider chosen in the Add dropdown — rendered
* beneath the Add row until saved or cancelled. */
function AddProviderEditor ({
provider ,
onSave ,
onCancel ,
error ,
} : {
provider : ProviderKeyInfo ;
onSave : ( value : string ) => void ;
onCancel : () => void ;
error : string | null ;
}) {
2026-10-25 17:18:44 +03:00
const { t } = useI18n ();
2026-09-20 13:53:56 +03:00
const [ value , setValue ] = useState ( "" );
return (
<>
< input
type = "text"
value = { value }
onChange = {( e ) => setValue ( e . target . value )}
onKeyDown = {( e ) => {
if ( e . key === "Enter" && value . trim ()) onSave ( value );
}}
2026-10-25 17:18:44 +03:00
placeholder = { t ( "Paste key…" )}
aria-label = { t ( "{v0} key" , { v0 : provider.label })}
2026-09-20 13:53:56 +03:00
data-testid = { `key- ${ provider . id } -input` }
2026-09-23 00:57:28 +03:00
autoFocus
className = "h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
2026-09-20 13:53:56 +03:00
/>
< button
type = "button"
onClick = {() => value . trim () && onSave ( value )}
disabled = { ! value . trim ()}
data-testid = { `key- ${ provider . id } -save` }
2026-09-23 00:57:28 +03:00
className = "shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
2026-09-20 13:53:56 +03:00
>
2026-10-25 17:18:44 +03:00
{ t ( "Save" )}</ button >
2026-09-20 13:53:56 +03:00
< button
type = "button"
onClick = { onCancel }
data-testid = { `key- ${ provider . id } -cancel` }
2026-09-23 00:57:28 +03:00
className = "shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
2026-09-20 13:53:56 +03:00
>
2026-10-25 17:18:44 +03:00
{ t ( "Cancel" )}</ button >
2026-09-20 13:53:56 +03:00
{ error && (
< p role = "alert" className = "w-full pl-0 text-xs text-red-500" data-testid = { `key- ${ provider . id } -error` }>
2026-10-25 17:18:44 +03:00
{ t ( error )}
2026-09-20 13:53:56 +03:00
</ p >
)}
</>
);
}
2026-10-24 09:53:10 -07:00
2026-10-29 04:43:53 +02:00
/** One donor editor for first setup and Settings; setup omits offline pin and
* disconnect controls, while authentication/revisions/manual IDs stay identical. */
export function CustomEndpointSetup ({ onSaved } : { onSaved : () => void | Promise < void > }) {
2026-10-31 13:45:06 +03:00
const { t } = useI18n ();
2026-10-29 04:43:53 +02:00
const [ status , setStatus ] = useState < CustomStatus | null >( null );
const [ error , setError ] = useState ( false );
async function reload() {
try { setStatus ( await readCustomStatus ()); setError ( false ); }
catch { setError ( true ); }
}
useEffect (() => { void reload (); }, []);
2026-10-31 13:45:06 +03:00
if ( ! status || error ) return < div className = "text-xs" >{ t ( error ? "Unable to load custom configuration." : "Loading custom configuration…" )}{ error && < button onClick = {() => void reload ()}>{ t ( "Retry" )}</ button >}</ div >;
2026-10-29 04:43:53 +02:00
return < CustomEndpointEditor key = { status . revision } setup revision0 = { status . revision } authMode0 = { status . authMode } keySource0 = { status . keySource } keyReuseRequired0 = { status . keyReuseRequired } baseUrl0 = { status . baseUrl } keyPresent = { status . keyPresent } models = { status . models } overrides = { status . overrides } manualModels0 = { status . manualModels } onSaved = { onSaved } onPinsSaved = { reload } onReload = { reload } onCancel = { reload } />;
}
2026-10-24 10:31:48 -07:00
/** The custom endpoint's status row (shown once configured); clicking re-opens the editor. */
2026-10-24 10:18:13 -07:00
function CustomEndpointRow ({
status ,
expanded ,
onToggle ,
} : {
status : CustomStatus ;
expanded : boolean ;
onToggle : () => void ;
}) {
2026-10-31 13:45:06 +03:00
const { t } = useI18n ();
2026-10-24 10:18:13 -07:00
const count = status . models . length ;
2026-10-29 04:43:53 +02:00
const line = ` ${ status . baseUrl } · ${ count } model ${ count === 1 ? "" : "s" } · ${ status . authMode === "keyless" ? "keyless" : status . keyPresent ? "API key" : "API key missing" } ` ;
2026-10-24 10:18:13 -07:00
return (
< div data-testid = "custom-endpoint-row" >
< button
type = "button"
data-testid = "custom-endpoint-row-btn"
aria-expanded = { expanded }
onClick = { onToggle }
className = "flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left cursor-pointer transition-colors hover:bg-accent/50"
>
2026-10-31 13:45:06 +03:00
< span className = "font-ui text-xs text-foreground" >{ t ( "Custom endpoint" )}</ span >
2026-10-24 10:18:13 -07:00
< span data-testid = "custom-endpoint-status" className = "max-w-[60%] truncate font-mono text-[11px] text-muted-foreground" >
{ line }
</ span >
</ button >
</ div >
);
}
2026-10-27 16:25:03 -07:00
/** A pin row's raw input state — "" means "no pin": the endpoint's own value,
* then the compiled default, decides. `reasoning` is tri-state. */
interface PinDraft {
context : string ;
maxTokens : string ;
reasoning : "" | "on" | "off" ;
}
const pinDraft = ( pin : CustomModelOverride | undefined ) : PinDraft => ({
context : pin?.context === undefined ? "" : String ( pin . context ),
maxTokens : pin?.maxTokens === undefined ? "" : String ( pin . maxTokens ),
reasoning : pin?.reasoning === undefined ? "" : pin . reasoning ? "on" : "off" ,
});
/** One draft row per discovered model, seeded from the stored pins. */
function draftsFor (
models : readonly CustomModel [],
overrides : Record < string , CustomModelOverride >,
) : Record < string , PinDraft > {
return Object . fromEntries ( models . map (( m ) => [ m . id , pinDraft ( overrides [ m . id ])]));
}
/** Drafts → the PATCH payload (replace-whole): a blank field sends nothing and
* a fully blank model is absent — which IS the un-pin. */
function pinsPayload ( drafts : Record < string , PinDraft >) : Record < string , CustomModelOverride > {
const out : Record < string , CustomModelOverride > = {};
for ( const [ id , d ] of Object . entries ( drafts )) {
const pin : CustomModelOverride = {};
if ( d . context . trim ()) pin . context = Number ( d . context );
if ( d . maxTokens . trim ()) pin . maxTokens = Number ( d . maxTokens );
if ( d . reasoning !== "" ) pin . reasoning = d . reasoning === "on" ;
2026-10-29 04:43:53 +02:00
if ( Object . keys ( pin ). length ) Object . defineProperty ( out , id , { value : pin , enumerable : true });
2026-10-27 16:25:03 -07:00
}
return out ;
}
/** testids must survive ids carrying slashes, colons, dots. */
const pinTestid = ( prefix : string , id : string ) => ` ${ prefix } - ${ id . replace ( /[^a-z0-9-]/gi , "_" ) } ` ;
2026-10-24 10:31:48 -07:00
/** The custom endpoint's connect editor: base URL + key → POST /api/custom-provider
2026-10-27 16:25:03 -07:00
* (validate + discover + register), and the per-model limits form → PATCH (pins,
* no probe, so it works with the local server switched off). Image capability is
* detected automatically. */
2026-10-24 10:18:13 -07:00
function CustomEndpointEditor ({
2026-10-29 04:43:53 +02:00
setup = false ,
revision0 , authMode0 , keySource0 , keyReuseRequired0 , onReload ,
2026-10-24 10:18:13 -07:00
baseUrl0 ,
2026-10-29 04:43:53 +02:00
keyPresent : initialKeyPresent ,
2026-10-27 16:25:03 -07:00
models ,
overrides ,
2026-10-28 16:16:30 -04:00
manualModels0 ,
2026-10-24 10:18:13 -07:00
onSaved ,
2026-10-27 16:25:03 -07:00
onPinsSaved ,
2026-10-24 10:18:13 -07:00
onCancel ,
2026-10-25 19:22:06 -07:00
onDelete ,
2026-10-24 10:18:13 -07:00
} : {
2026-10-29 04:43:53 +02:00
setup? : boolean ;
revision0 : string ;
authMode0 : "keyless" | "apiKey" ;
keySource0 : "env" | "file" | "none" ;
keyReuseRequired0 : boolean ;
onReload : () => void | Promise < void >;
2026-10-24 10:18:13 -07:00
baseUrl0 : string ;
keyPresent : boolean ;
2026-10-27 16:25:03 -07:00
/** The discovered models — one limits row each. */
models : readonly CustomModel [];
/** Their stored user pins. */
overrides : Record < string , CustomModelOverride >;
2026-10-28 16:16:30 -04:00
manualModels0 : readonly string [];
2026-10-24 10:18:13 -07:00
onSaved : () => void | Promise < void >;
2026-10-27 16:25:03 -07:00
/** Pins saved — the editor stays open (a form, not a connect). */
onPinsSaved : () => void | Promise < void >;
2026-10-24 10:18:13 -07:00
onCancel : () => void ;
2026-10-29 04:43:53 +02:00
onDelete ?: ( revision : string ) => Promise < string | null >;
2026-10-24 10:18:13 -07:00
}) {
2026-10-31 13:45:06 +03:00
const { t } = useI18n ();
2026-10-29 04:43:53 +02:00
const [ revision , setRevision ] = useState ( revision0 );
const [ authMode , setAuthMode ] = useState ( authMode0 );
const [ keySource , setKeySource ] = useState ( keySource0 );
const [ keyPresent , setKeyPresent ] = useState ( initialKeyPresent );
const [ keyReuseRequired , setKeyReuseRequired ] = useState ( keyReuseRequired0 );
const [ authLoading , setAuthLoading ] = useState ( false );
const [ reuseKey , setReuseKey ] = useState ( false );
useEffect (() => {
if ( authMode !== "apiKey" ) return ;
let cancelled = false ;
setAuthLoading ( true );
readCustomStatus ( true ). then (( status ) => {
if ( cancelled ) return ;
setKeySource ( status . keySource );
setKeyPresent ( status . keyPresent );
setKeyReuseRequired ( status . keyReuseRequired );
if ( status . keySource === "env" ) setKey ( "" );
}). catch (() => { if ( ! cancelled ) setError ( "Unable to inspect effective key. Reload configuration and retry." ); })
. finally (() => { if ( ! cancelled ) setAuthLoading ( false ); });
return () => { cancelled = true ; };
}, [ authMode ]);
2026-10-24 10:18:13 -07:00
const [ baseUrl , setBaseUrl ] = useState ( baseUrl0 );
2026-10-24 09:53:10 -07:00
const [ key , setKey ] = useState ( "" );
const [ keyTouched , setKeyTouched ] = useState ( false );
2026-10-28 16:16:30 -04:00
const [ manualModels , setManualModels ] = useState ( manualModels0 . join ( ", " ));
2026-10-24 09:53:10 -07:00
const [ busy , setBusy ] = useState ( false );
const [ error , setError ] = useState < string | null >( null );
2026-10-27 16:25:03 -07:00
// The limits form: edited drafts over the stored pins, plus the last saved
// payload as the dirty baseline (the server's echo is what gets stored).
const [ drafts , setDrafts ] = useState < Record < string , PinDraft >>(() => draftsFor ( models , overrides ));
const [ baseline , setBaseline ] = useState (() => JSON . stringify ( pinsPayload ( draftsFor ( models , overrides ))));
const [ pinsBusy , setPinsBusy ] = useState ( false );
const [ pinsError , setPinsError ] = useState < string | null >( null );
2026-10-29 04:43:53 +02:00
const changedTarget = normalizeBaseUrl ( baseUrl ) !== normalizeBaseUrl ( baseUrl0 );
const canConnect = Boolean ( baseUrl . trim ()) && ( authMode === "keyless" || ( ! authLoading && (( Boolean ( key . trim ()) && keySource !== "env" ) || ( keyPresent && ( ! ( changedTarget || keyReuseRequired ) || reuseKey )))));
2026-10-24 09:53:10 -07:00
2026-10-27 16:25:03 -07:00
const draftOf = ( m : CustomModel ) : PinDraft => drafts [ m . id ] ?? pinDraft ( overrides [ m . id ]);
const setDraft = ( m : CustomModel , patch : Partial < PinDraft >) =>
setDrafts (( prev ) => ({ ... prev , [ m . id ] : { ... draftOf ( m ), ... patch } }));
const current = Object . fromEntries ( models . map (( m ) => [ m . id , draftOf ( m )])) as Record < string , PinDraft >;
const dirty = JSON . stringify ( pinsPayload ( current )) !== baseline ;
async function savePins() {
2026-10-29 04:43:53 +02:00
if ( pinsBusy || busy || ! dirty ) return ;
2026-10-27 16:25:03 -07:00
setPinsBusy ( true );
setPinsError ( null );
const res = await fetch ( "/api/custom-provider" , {
method : "PATCH" ,
2026-10-29 04:43:53 +02:00
headers : { "content-type" : "application/json" , "if-match" : revision },
2026-10-27 16:25:03 -07:00
body : JSON.stringify ({ overrides : pinsPayload ( current ) }),
}). catch (() => null );
if ( ! res || ! res . ok ) {
const b = ( await res ? . json (). catch (() => ({}))) as { error? : string } | undefined ;
setPinsError ( res ? ( b ? . error ?? `Save failed ( ${ res . status } )` ) : "Save failed. Check your connection." );
2026-10-29 04:43:53 +02:00
setPinsBusy ( false );
2026-10-27 16:25:03 -07:00
return ;
}
2026-10-29 04:43:53 +02:00
setRevision ( res . headers . get ( "etag" ) ?? revision );
// The server's stored map is the truth — echo it back.
2026-10-27 16:25:03 -07:00
const body = ( await res . json (). catch (() => null )) as { overrides? : Record < string , CustomModelOverride > } | null ;
const next = draftsFor ( models , body ? . overrides ?? pinsPayload ( current ));
setDrafts ( next );
setBaseline ( JSON . stringify ( pinsPayload ( next )));
await onPinsSaved ();
2026-10-29 04:43:53 +02:00
setPinsBusy ( false );
2026-10-27 16:25:03 -07:00
}
2026-10-24 09:53:10 -07:00
async function save() {
2026-10-29 04:43:53 +02:00
if ( busy || pinsBusy || ! canConnect ) return ;
2026-10-24 09:53:10 -07:00
setBusy ( true );
setError ( null );
const res = await fetch ( "/api/custom-provider" , {
method : "POST" ,
2026-10-29 04:43:53 +02:00
headers : { "content-type" : "application/json" , "if-match" : revision },
body : JSON.stringify ({ baseUrl , authMode , reuseKey , manualModels , ...( authMode === "apiKey" && keyTouched && key . trim () && keySource !== "env" ? { key } : {}) }),
2026-10-24 09:53:10 -07:00
}). catch (() => null );
if ( ! res || ! res . ok ) {
const b = ( await res ? . json (). catch (() => ({}))) as { error? : string } | undefined ;
setError ( res ? ( b ? . error ?? `Connect failed ( ${ res . status } )` ) : "Connect failed. Check your connection." );
2026-10-29 04:43:53 +02:00
setBusy ( false );
2026-10-24 09:53:10 -07:00
return ;
}
2026-10-24 10:18:13 -07:00
await onSaved ();
2026-10-29 04:43:53 +02:00
setBusy ( false );
2026-10-24 09:53:10 -07:00
}
return (
2026-10-24 10:18:13 -07:00
< div data-testid = "custom-endpoint-editor" className = "flex flex-col gap-2 rounded-sm px-1 py-1.5" >
< div className = "flex items-center gap-2" >
2026-10-31 13:45:06 +03:00
< span className = "shrink-0 font-ui text-xs text-foreground" >{ t ( "Custom endpoint" )}</ span >
2026-10-24 10:18:13 -07:00
< input
type = "text"
value = { baseUrl }
onChange = {( e ) => setBaseUrl ( e . target . value )}
2026-10-31 13:45:06 +03:00
placeholder = { t ( "Base URL, e.g. http://localhost:11434/v1" )}
aria-label = { t ( "Custom endpoint base URL" )}
2026-10-24 10:18:13 -07:00
data-testid = "custom-baseurl-input"
disabled = { busy }
autoFocus
className = "h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
</ div >
2026-10-28 16:16:30 -04:00
< input
type = "text"
value = { manualModels }
onChange = {( e ) => setManualModels ( e . target . value )}
2026-10-31 13:45:06 +03:00
placeholder = { t ( "Model IDs, comma-separated — optional, needed when the endpoint has no /models list" )}
aria-label = { t ( "Custom endpoint model IDs" )}
2026-10-28 16:16:30 -04:00
data-testid = "custom-models-input"
disabled = { busy }
className = "h-7 min-w-0 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
2026-10-31 13:45:06 +03:00
< label className = "flex items-center gap-2 text-xs" >{ t ( "Authentication" )}
< select aria-label = { t ( "Custom endpoint authentication" )} data-testid = "custom-auth-mode" value = { authMode } disabled = { busy || pinsBusy } onChange = {( e ) => { setAuthMode ( e . target . value as "keyless" | "apiKey" ); setReuseKey ( false ); }}>
< option value = "keyless" >{ t ( "Keyless" )}</ option >< option value = "apiKey" >{ t ( "API key" )}</ option >
2026-10-29 04:43:53 +02:00
</ select >
</ label >
2026-10-31 13:45:06 +03:00
{ authMode === "apiKey" && keySource === "env" && < p className = "text-[11px] text-muted-foreground" data-testid = "custom-env-owned" >{ t ( "Environment supplies the effective key. Change the environment to replace it." )}</ p >}
{ authMode === "apiKey" && keyPresent && ( changedTarget || keyReuseRequired ) && < label className = "text-[11px]" >< input type = "checkbox" data-testid = "custom-reuse-key" checked = { reuseKey } onChange = {( e ) => setReuseKey ( e . target . checked )} /> { t ( "Allow reuse of the effective existing key for this URL" )}</ label >}
2026-10-24 10:18:13 -07:00
< div className = "flex items-center gap-2" >
2026-10-29 05:27:41 +02:00
{ authMode === "apiKey" && < input
2026-10-29 04:43:53 +02:00
type = "password"
2026-10-24 10:18:13 -07:00
value = { key }
onChange = {( e ) => {
setKey ( e . target . value );
setKeyTouched ( true );
2026-10-29 04:43:53 +02:00
if ( ! e . target . value . trim ()) setAuthMode ( "keyless" );
2026-10-24 10:18:13 -07:00
}}
2026-10-29 05:27:41 +02:00
placeholder = { keyPresent ? "Leave blank to keep effective key" : "API key" }
2026-10-31 13:45:06 +03:00
aria-label = { t ( "Custom endpoint API key" )}
2026-10-24 10:18:13 -07:00
data-testid = "custom-key-input"
2026-10-29 05:27:41 +02:00
disabled = { busy || pinsBusy || keySource === "env" }
2026-10-24 10:18:13 -07:00
className = "h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
2026-10-29 05:27:41 +02:00
/>}
2026-10-24 10:18:13 -07:00
< button
type = "button"
onClick = {() => void save ()}
2026-10-29 04:43:53 +02:00
disabled = { busy || pinsBusy || ! canConnect }
2026-10-24 10:18:13 -07:00
data-testid = "custom-save"
className = "shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
{ busy ? "Connecting…" : "Connect" }
</ button >
< button
type = "button"
onClick = { onCancel }
disabled = { busy }
data-testid = "custom-cancel"
className = "shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
2026-10-31 13:45:06 +03:00
{ t ( "Cancel" )}
2026-10-24 10:18:13 -07:00
</ button >
2026-10-25 19:22:06 -07:00
{ /* Disconnect rides beside Connect, exactly the stored-key rows' Delete:
quiet text, one click, no confirm — and only once there IS an
endpoint (a fresh add has nothing to delete). */ }
{ onDelete && baseUrl0 . trim () && (
< button
type = "button"
2026-10-29 04:43:53 +02:00
onClick = { async () => { setBusy ( true ); const err = await onDelete ( revision ); if ( err ) setError ( err ); setBusy ( false ); }}
disabled = { busy || pinsBusy }
2026-10-25 19:22:06 -07:00
data-testid = "custom-delete"
className = "shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
>
2026-10-31 13:45:06 +03:00
{ t ( "Disconnect" )}
2026-10-25 19:22:06 -07:00
</ button >
)}
2026-10-24 10:18:13 -07:00
</ div >
2026-10-29 04:43:53 +02:00
{ ! setup && models . length > 0 && (
2026-10-27 16:25:03 -07:00
< div className = "flex flex-col gap-1" data-testid = "custom-pins" >
< div className = "flex max-h-56 flex-col gap-1 overflow-y-auto pr-1" >
{ models . map (( m ) => {
const d = draftOf ( m );
return (
< div key = { m . id } className = "flex items-center gap-2" data-testid = { pinTestid ( "custom-pin-row" , m . id )}>
< span className = "min-w-0 flex-1 truncate font-mono text-[11px] text-foreground" title = { m . id }>
{ m . id }
2026-10-31 13:45:06 +03:00
< span className = "block text-[10px] text-muted-foreground" >{ t ( "Context:" )} { d . context . trim () ? "user pin" : m . context ? `reported ${ m . context } ` : `fallback ${ CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW } ` } { t ( "· Output:" )} { d . maxTokens . trim () ? "user pin" : m . maxTokens ? `reported ${ m . maxTokens } ` : `fallback ${ CUSTOM_MODEL_DEFAULT_MAX_TOKENS } ` }</ span >
2026-10-27 16:25:03 -07:00
</ span >
< input
type = "text"
inputMode = "numeric"
value = { d . context }
onChange = {( e ) => setDraft ( m , { context : e.target.value })}
placeholder = { String ( m . context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW )}
aria-label = { ` ${ m . id } context window in tokens` }
data-testid = { pinTestid ( "custom-pin-context" , m . id )}
disabled = { pinsBusy }
className = "h-7 w-20 shrink-0 rounded-sm border border-border bg-background px-1.5 text-right font-mono text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
< input
type = "text"
inputMode = "numeric"
value = { d . maxTokens }
onChange = {( e ) => setDraft ( m , { maxTokens : e.target.value })}
placeholder = { String ( m . maxTokens ?? CUSTOM_MODEL_DEFAULT_MAX_TOKENS )}
aria-label = { ` ${ m . id } max output tokens` }
data-testid = { pinTestid ( "custom-pin-maxtokens" , m . id )}
disabled = { pinsBusy }
className = "h-7 w-20 shrink-0 rounded-sm border border-border bg-background px-1.5 text-right font-mono text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
< select
value = { d . reasoning }
onChange = {( e ) => setDraft ( m , { reasoning : e.target.value as PinDraft [ "reasoning" ] })}
2026-10-29 05:27:41 +02:00
aria-label = { ` ${ m . id } reasoning effort support` }
2026-10-27 16:25:03 -07:00
data-testid = { pinTestid ( "custom-pin-reasoning" , m . id )}
disabled = { pinsBusy }
className = "h-7 shrink-0 rounded-sm border border-border bg-background px-1 font-ui text-[11px] text-foreground outline-none focus:border-muted-foreground/50"
>
2026-10-31 13:45:06 +03:00
< option value = "" >{ t ( "Reasoning effort: Use reported" )}</ option >
< option value = "on" >{ t ( "Reasoning effort: Supported" )}</ option >
< option value = "off" >{ t ( "Reasoning effort: Not supported" )}</ option >
2026-10-27 16:25:03 -07:00
</ select >
</ div >
);
})}
</ div >
< div className = "flex items-center gap-2" >
{ pinsError && (
< p role = "alert" data-testid = "custom-pins-error" className = "text-[11px] text-red-500" >
{ pinsError }
</ p >
)}
2026-10-29 05:27:41 +02:00
< button
type = "button"
onClick = {() => void savePins ()}
disabled = { pinsBusy || ! dirty }
data-testid = "custom-pins-save"
className = "ml-auto shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
{ pinsBusy ? "Saving…" : "Save limits" }
</ button >
2026-10-27 16:25:03 -07:00
</ div >
</ div >
)}
2026-10-29 05:27:41 +02:00
< div className = "flex flex-col gap-1 text-[11px] text-muted-foreground" data-testid = "custom-help" >
2026-10-31 13:45:06 +03:00
{ ! setup && models . length > 0 && < p data-testid = "custom-budget-help" >{ t ( "Budget pins do not resize backend context. Blank uses reported limits, then fallback (8192 context / 4096 output). Custom pricing is unknown." )}</ p >}
< p data-testid = "custom-reasoning-help" >{ t ( "Reasoning support is a capability declaration. The effort picker’ s Off may not disable a custom backend’ s default thinking." )}</ p >
2026-10-29 05:27:41 +02:00
</ div >
2026-10-31 13:45:06 +03:00
{( error || pinsError ) && < button type = "button" data-testid = "custom-reload" onClick = {() => void onReload ()} className = "text-left text-xs underline" >{ t ( "Reload configuration" )}</ button >}
2026-10-24 10:18:13 -07:00
{ error && (
< p role = "alert" data-testid = "custom-error" className = "text-xs text-red-500" >
{ error }
</ p >
)}
2026-10-24 09:53:10 -07:00
</ div >
);
}