/** * Provider + model dropdowns backed by the gateway's `model.options` * inventory, plus the bounded fetch that keeps a wedged bot socket from * spinning the picker forever. * * Shared by the advanced profile editor and the create dialog. */ import { Button, GlyphSpinner, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, useQuery } from '@hermes/plugin-sdk' import { useState } from 'react' import { labeled } from './dialog-parts' import { botRouteKey, requestForBot, resolveBotConnectionRoute } from './routing' import { ID } from './shared' import type { RosterRow } from './types' // ── model picker (provider/model dropdowns via model.options) ─────────────── // #95279: the picker's catalog read rides the BOT's own socket — a lazily // dialed second backend that can wedge (cold pool spawn, dropped remote hop) // without the primary socket ever noticing. An unbounded RPC there left the // query pending forever and the picker spinning ("never settles"). Bound every // attempt: past the budget the query rejects and ModelPicker falls back to its // free-text inputs instead of an eternal GlyphSpinner. const MODEL_OPTIONS_SETTLE_MS = 20000 function boundedModelOptionsFetch(fetch: Promise, settleMs = MODEL_OPTIONS_SETTLE_MS): Promise { // window.setTimeout (not bare setTimeout): bare vm test harnesses expose // timers only through the window shim. With no scheduler at all, degrade to // the old unbounded behavior rather than not fetching. const scope = typeof window === 'undefined' ? null : window if (!scope || typeof scope.setTimeout !== 'function') { return fetch } // `any`: the timer id is assigned synchronously by the executor below, but // it is still `null` on the declaration TypeScript sees from the closure, // and clearTimeout's signature takes `number | undefined`. let timerId: any = null const deadline = new Promise((_, reject) => { timerId = scope.setTimeout(() => { reject(new Error(`model.options did not answer within ${Math.round(settleMs / 1000)}s (#95279 settle guard)`)) }, settleMs) }) return Promise.race([fetch, deadline]).finally(() => scope.clearTimeout(timerId)) } /** One provider row of the gateway's `model.options` inventory. Entries in * `models` are bare slugs on current gateways and objects on older ones. */ interface ModelProviderOption { models?: Array name?: string slug: string } interface ModelOptionsResponse { providers?: ModelProviderOption[] } function useModelOptions(bot: null | RosterRow = null) { // Hook body runs during render: an orphaned row must paint the picker // disabled/erroring, not throw into the pane's error boundary. const resolved = bot ? resolveBotConnectionRoute(bot) : null const route = resolved?.status === 'resolved' ? resolved.route : null const orphaned = resolved?.status === 'owner_removed' return useQuery({ queryKey: [ID, 'model-options', route ? botRouteKey(route) : 'active'], // No forced `refresh`: forcing a network read on EVERY mount bypassed the // staleTime cache, so each Bots view remount (tab re-front, dialog reopen, // pane visibility flip) knocked the picker back into its loading state and // discarded the user's staged selection mid-edit (#95279). The cached read // still refreshes per staleTime like every other surface's catalog. queryFn: () => boundedModelOptionsFetch( requestForBot(bot, 'model.options', { include_unconfigured: true, explicit_only: false }) as Promise ), enabled: !orphaned, staleTime: 120000, retry: false }) } /** * Provider + model dropdowns from the gateway's configured inventory — the * same data the core model picker shows. `value = {provider, model}`; * onChange receives the merged patch. */ /** The two fields a profile pins for its model. */ interface ModelSelection { model: string provider: string } /** ModelPicker only ever emits the field(s) it just changed, so consumers can * test membership with `in` and leave the rest of their state untouched. */ type ModelSelectionPatch = { model: string } | { model: string; provider: string } | { provider: string } interface ModelPickerProps { bot?: null | RosterRow onChange: (patch: ModelSelectionPatch) => void placeholderModel?: string value: ModelSelection } export function ModelPicker({ bot = null, value, onChange, placeholderModel = 'gateway default' }: ModelPickerProps) { const { data, isLoading, error } = useModelOptions(bot) // Hooks are ALWAYS declared up front, before any conditional return. // Declaring them after a return trips React error #310. const NONE = '__default__' const CUSTOM = '__custom__' const providers = (data?.providers || []).filter(p => p && p.slug) const isKnown = !value.provider || value.provider === NONE || providers.some(p => p.slug === value.provider) const [useFreeText, setUseFreeText] = useState(!isKnown) if (isLoading) { return (
) } if (error || !providers.length) { // Fallback: free text (older gateway or empty inventory). return (
{labeled( 'Provider', onChange({ provider: event.target.value }) } placeholder="omnirouter / 9router / nous …" value={value.provider} /> )} {labeled( 'Model', onChange({ model: event.target.value }) } placeholder="antigravity/gemini-3.6-flash-high" value={value.model} /> )}
) } if (useFreeText) { return (
{labeled( 'Provider (Custom)', onChange({ provider: event.target.value }) } placeholder="e.g. omnirouter, inferx, 9router" value={value.provider} /> )} {labeled( 'Model (Custom)', onChange({ model: event.target.value }) } placeholder="e.g. antigravity/gemini-3.6-flash-high" value={value.model} /> )}
) } const activeProvider = providers.find(p => p.slug === value.provider) || null const models = activeProvider ? (activeProvider.models || []).map(m => (typeof m === 'string' ? m : m.id || m.name || '')) : [] return (
{labeled( 'Provider', )} {labeled( 'Model', activeProvider && models.length > 0 ? ( ) : ( onChange({ model: event.target.value }) } placeholder={placeholderModel || 'e.g. model name'} value={value.model} /> ) )}
) }