mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
feat: explicit Assistant model picker for BYOK providers (#778)
* feat(x): ModelSelector liveCredentials — scoped live group from unsaved creds
A provider being configured right now has no store group (models.json
not saved yet) and openrouter/aigateway/ollama/openai-compatible have
no static catalog either. liveCredentials synthesizes the scoped live
group from the form's typed credentials, winning over a saved group
whose stored key may be stale. Same 'some credential present' bar as
the store; useProviderModels' debounce + cache prevent fetch spray.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): explicit Assistant model picker on the BYOK provider card
The four category fields said 'Same as assistant' while the assistant
model itself was invisible (silently auto-resolved at connect). Adds an
Assistant model ModelSelector above them: scoped to the card's flavor,
live-fetching with the CURRENT typed credentials (liveCredentials, so
unsaved keys work), allowCustom for arbitrary ids. The Auto sentinel
keeps today's silent resolve and shows what it would pick right now
('Auto (currently gpt-5.4)') once the live list settles. An explicit
pick writes through setPrimaryModel into models[0] (models[1..]
preserved) and connect uses it verbatim — no silent swap; Auto follows
exactly the old resolve-then-save flow including the on-demand fetch.
Replaces the openai-compatible-only free-text Model field (customModel
state + unconfirmed-model sync effect deleted): typing the id in the
picker's search covers the no-/models servers, for every provider.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d589325c3c
commit
40e450f0ae
3 changed files with 87 additions and 48 deletions
|
|
@ -162,6 +162,34 @@ describe('ModelSelector', () => {
|
|||
expect(onChange).toHaveBeenCalledWith({ provider: 'openai', model: 'my-local-model' })
|
||||
})
|
||||
|
||||
it('liveCredentials live-fetches a provider that is not saved anywhere', async () => {
|
||||
serveTwoProviders()
|
||||
// openrouter is absent from models.json AND the static catalog — only
|
||||
// the form's typed credentials can produce its list.
|
||||
handlers['models:listForProvider'] = async () => ({
|
||||
success: true,
|
||||
models: ['meituan/longcat-2.0', 'qwen/qwen-3'],
|
||||
})
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
providerFilter="openrouter"
|
||||
liveCredentials={{ flavor: 'openrouter', apiKey: 'sk-or-typed', baseURL: '' }}
|
||||
allowCustom
|
||||
defaultOption={{ label: 'Auto (recommended)' }}
|
||||
/>,
|
||||
)
|
||||
await openMenu()
|
||||
// 600ms debounce in useProviderModels before the fetch fires.
|
||||
const row = await screen.findByText('meituan/longcat-2.0', undefined, { timeout: 3000 })
|
||||
expect(screen.queryByText('gpt-5.4')).toBeNull()
|
||||
fireEvent.click(row)
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: 'openrouter', model: 'meituan/longcat-2.0' })
|
||||
})
|
||||
|
||||
it('staticOptions renders only the supplied rows and round-trips ids and null', async () => {
|
||||
serveTwoProviders()
|
||||
const onChange = vi.fn()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useModels, type ModelPickerGroup, type ModelRef } from '@/hooks/use-models'
|
||||
import { useProviderModels } from '@/hooks/use-provider-models'
|
||||
import { useProviderModels, type ProviderModelsFlavor } from '@/hooks/use-provider-models'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type { ModelRef } from '@/hooks/use-models'
|
||||
|
|
@ -148,6 +148,17 @@ export interface ModelSelectorProps {
|
|||
* catalog so a provider mid-setup still lists models.
|
||||
*/
|
||||
providerFilter?: string
|
||||
/**
|
||||
* Live-fetch credentials for a provider being configured right now (typed
|
||||
* but possibly unsaved). Store groups only exist for providers saved in
|
||||
* models.json and the catalog fallback only covers openai/anthropic/
|
||||
* google — this synthesizes the scoped live group from the form's current
|
||||
* inputs instead (and wins over a saved group, whose stored key may be
|
||||
* stale). Requires providerFilter set to the same flavor; ignored until
|
||||
* some credential is typed. useProviderModels debounces + caches, so
|
||||
* keystrokes don't spray fetches.
|
||||
*/
|
||||
liveCredentials?: { flavor: ProviderModelsFlavor; apiKey: string; baseURL: string }
|
||||
/**
|
||||
* When the search text matches no rows, offer a `Use "<text>"` row that
|
||||
* selects the typed id — arbitrary ids for ollama / openai-compatible.
|
||||
|
|
@ -219,6 +230,7 @@ export function ModelSelector({
|
|||
inheritDefault,
|
||||
variant = 'pill',
|
||||
providerFilter,
|
||||
liveCredentials,
|
||||
allowCustom = false,
|
||||
staticOptions,
|
||||
triggerTitle,
|
||||
|
|
@ -233,13 +245,21 @@ export function ModelSelector({
|
|||
const sentinel = defaultOption ?? inheritDefault
|
||||
const sentinelMuted = !defaultOption && Boolean(inheritDefault)
|
||||
|
||||
const liveFlavor = liveCredentials?.flavor
|
||||
const liveApiKey = liveCredentials?.apiKey.trim() ?? ''
|
||||
const liveBaseURL = liveCredentials?.baseURL.trim() ?? ''
|
||||
const groups = useMemo<ModelPickerGroup[]>(() => {
|
||||
if (!providerFilter) return allGroups
|
||||
// The form's typed credentials trump anything saved — same "configured"
|
||||
// bar as the store (some credential present).
|
||||
if (liveFlavor === providerFilter && (liveApiKey || liveBaseURL)) {
|
||||
return [{ kind: 'live', flavor: liveFlavor, apiKey: liveApiKey, baseURL: liveBaseURL, savedModel: '' }]
|
||||
}
|
||||
const scoped = allGroups.filter((g) => g.flavor === providerFilter)
|
||||
if (scoped.length > 0) return scoped
|
||||
const catalogModels = catalogByProvider[providerFilter] || []
|
||||
return catalogModels.length > 0 ? [{ kind: 'catalog', flavor: providerFilter, models: catalogModels }] : []
|
||||
}, [allGroups, providerFilter, catalogByProvider])
|
||||
}, [allGroups, providerFilter, catalogByProvider, liveFlavor, liveApiKey, liveBaseURL])
|
||||
|
||||
// Search filter for the model dropdown. Reset each time the menu opens;
|
||||
// matching is a case-insensitive substring test on the model id. Live
|
||||
|
|
|
|||
|
|
@ -544,9 +544,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
// auto-enable) has ever set it, so we only auto-enable once.
|
||||
const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false)
|
||||
const [deferExplicit, setDeferExplicit] = useState(false)
|
||||
// openai-compatible only: free-text model that takes precedence over the
|
||||
// fetched list (many such servers don't implement /models at all).
|
||||
const [customModel, setCustomModel] = useState("")
|
||||
|
||||
const activeConfig = providerConfigs[provider]
|
||||
// Live per-key model list for the active provider — drives the primary
|
||||
|
|
@ -567,23 +564,21 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
const isMoreProvider = moreProviders.some(p => p.id === provider)
|
||||
|
||||
const primaryModel = activeConfig.models[0] || ""
|
||||
// Settings no longer exposes model selection — the model is resolved
|
||||
// silently when the user connects (the config schema still requires one;
|
||||
// background agents/channels read it). Precedence: a typed escape-hatch
|
||||
// value (openai-compatible Model field, offline manual input) > the saved
|
||||
// model when the fetched list still has it > the flavor's preferred
|
||||
// default > the first fetched id.
|
||||
const resolvedModel = (() => {
|
||||
if (provider === "openai-compatible" && customModel.trim()) return customModel.trim()
|
||||
const saved = primaryModel.trim()
|
||||
if (providerModels.status === "loaded" && providerModels.models.length > 0) {
|
||||
if (saved && providerModels.models.includes(saved)) return saved
|
||||
const preferred = preferredDefaults[provider]
|
||||
if (preferred && providerModels.models.includes(preferred)) return preferred
|
||||
return providerModels.models[0]
|
||||
}
|
||||
return saved
|
||||
// What "Auto" resolves to right now: the flavor's preferred default when
|
||||
// the fetched list carries it, else the first fetched id. Empty while the
|
||||
// live list hasn't settled — the sentinel label and handleTestAndSave's
|
||||
// on-demand fetch cover that window.
|
||||
const autoResolvePreview = (() => {
|
||||
if (providerModels.status !== "loaded" || providerModels.models.length === 0) return ""
|
||||
const preferred = preferredDefaults[provider]
|
||||
return preferred && providerModels.models.includes(preferred) ? preferred : providerModels.models[0]
|
||||
})()
|
||||
// The model chats run on. An explicit pick (the Assistant model field,
|
||||
// including its typed-id escape hatch and the offline manual inputs)
|
||||
// lives in models[0] and always wins — connecting must never silently
|
||||
// swap a model the user chose; models:test reports honestly if it's bad.
|
||||
// Empty ("Auto") keeps the silent resolve this card has always done.
|
||||
const resolvedModel = primaryModel.trim() || autoResolvePreview
|
||||
// Gate Connect on credentials only, NOT on resolvedModel — that derives
|
||||
// from the live fetch, which hasn't settled the instant a key is pasted, so
|
||||
// gating on it made the first click a no-op (the model is resolved, fetching
|
||||
|
|
@ -703,16 +698,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
}
|
||||
}, [])
|
||||
|
||||
// A saved openai-compatible model that the server's list doesn't confirm
|
||||
// (not listed, or /models unreachable) belongs in the visible Model field,
|
||||
// where it stays editable and wins over the silent pick.
|
||||
useEffect(() => {
|
||||
if (provider !== "openai-compatible" || customModel || !primaryModel) return
|
||||
if (providerModels.status === "error" || (providerModels.status === "loaded" && !providerModels.models.includes(primaryModel))) {
|
||||
setCustomModel(primaryModel)
|
||||
}
|
||||
}, [provider, providerModels.status, providerModels.models, primaryModel, customModel])
|
||||
|
||||
const handleTestAndSave = useCallback(async () => {
|
||||
if (!canTest) return
|
||||
setTestState({ status: "testing" })
|
||||
|
|
@ -721,8 +706,8 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
// set. But a Connect click right after pasting a key can beat the
|
||||
// debounced key-change fetch — so when nothing is resolved yet, fetch
|
||||
// the list on demand (one fetch, then save) instead of forcing a second
|
||||
// click. Same silent precedence as resolvedModel, minus the customModel
|
||||
// branch (that path only runs when resolvedModel is already empty).
|
||||
// click. Same silent precedence as the Auto path (this branch can only
|
||||
// run when no explicit model is set — an explicit pick IS resolved).
|
||||
let model = resolvedModel
|
||||
if (!model) {
|
||||
const listRes = await window.ipc.invoke("models:listForProvider", {
|
||||
|
|
@ -1185,21 +1170,27 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* openai-compatible escape hatch: its /models often doesn't exist, and
|
||||
a typed model always wins over the silent pick */}
|
||||
{provider === "openai-compatible" && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
|
||||
<Input
|
||||
value={customModel}
|
||||
onChange={(e) => {
|
||||
setCustomModel(e.target.value)
|
||||
setPrimaryModel(provider, e.target.value)
|
||||
}}
|
||||
placeholder="Model ID (leave empty to auto-select)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* The model chats run on — the anchor the category fields' "Same as
|
||||
assistant" points at. Auto keeps the silent resolve; the picker
|
||||
live-fetches with the card's CURRENT inputs (typed, maybe unsaved),
|
||||
and allowCustom subsumes the old openai-compatible free-text field
|
||||
(its /models often doesn't exist — type the id in the search).
|
||||
Explicit picks land in models[0] via setPrimaryModel (preserving
|
||||
models[1..]); the offline manual inputs above write the same slot. */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{rowboatConnected ? "Model" : "Assistant model"}
|
||||
</span>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
providerFilter={provider}
|
||||
liveCredentials={{ flavor: provider, apiKey: activeConfig.apiKey, baseURL: activeConfig.baseURL }}
|
||||
allowCustom
|
||||
defaultOption={{ label: autoResolvePreview ? `Auto (currently ${autoResolvePreview})` : "Auto (recommended)" }}
|
||||
value={primaryModel.trim() ? { provider, model: primaryModel.trim() } : null}
|
||||
onChange={(ref) => setPrimaryModel(provider, ref ? ref.model : "")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per-function model overrides. Persisted as bare model-id strings
|
||||
inside providers[flavor] ('' = "Same as assistant"), so the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue