mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
Merge pull request #791 from rowboatlabs/model-selection
One model-selection experience: providers own catalogs, one Assistant model, split-view picker
This commit is contained in:
commit
5c330de3fa
52 changed files with 3624 additions and 2511 deletions
|
|
@ -28,7 +28,7 @@ Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run
|
|||
| `sub_use_case` | string? | Refines `use_case` — see taxonomy table below |
|
||||
| `agent_name` | string? | Present when the call goes through an agent run (`createRun`); omitted for direct `generateText`/`generateObject` |
|
||||
| `model` | string | e.g. `claude-sonnet-4-6` |
|
||||
| `provider` | string | `rowboat` = cloud LLM gateway; otherwise the BYOK provider (`openai`, `anthropic`, `ollama`, etc.) |
|
||||
| `provider` | string | The provider FLAVOR: `rowboat` = cloud LLM gateway, `codex` = ChatGPT subscription, else the BYOK flavor (`openai`, `anthropic`, `ollama`, …). Call sites pass instance ids; `captureLlmUsage` maps id → flavor so charts never fracture if user-named provider instances ship (ids never leave the app) |
|
||||
| `input_tokens` | number | |
|
||||
| `output_tokens` | number | |
|
||||
| `total_tokens` | number | |
|
||||
|
|
@ -84,6 +84,14 @@ Emitted on rowboat disconnect. No properties. Followed immediately by `posthog.r
|
|||
|
||||
Emit points: `apps/main/src/oauth-handler.ts:369` and `apps/renderer/src/hooks/useAnalyticsIdentity.ts:82`.
|
||||
|
||||
### Model-provider lifecycle
|
||||
|
||||
Privacy rules (enforced in `packages/core/src/analytics/model-providers.ts`): only provider **flavors** are captured — never instance ids (future-proofing for user-named instances), never `apiKey`/`headers`, and never `baseURL` (local endpoints can carry internal hostnames). Model ids are allowed.
|
||||
|
||||
- `llm_provider_connected` / `llm_provider_disconnected` — `{ flavor }` — one event family across every surface. BYOK fires from `FSModelConfigRepo.setProvider` (new entries only — key rotation is not a connect) / `removeProvider`; `rowboat` from sign-in/out (`apps/main/src/oauth-handler.ts`); `codex` from ChatGPT sign-in/out (`apps/main/src/ipc.ts`).
|
||||
- `llm_initial_model_selected` — `{ flavor, model, recommended, task_overrides_seeded, source: 'connect' | 'onboarding' | 'sign_in' }` — a connect seeded the assistant model (only when none was configured). `recommended: false` = first-listed fallback; the hit rate measures backend recommendation quality. `task_overrides_seeded` counts the per-task recommendations written alongside (the server-controlled lite-tier task models — 0 when the provider has none). Emit points: `apps/renderer/src/components/settings/providers-section.tsx` (connect/onboarding) and `packages/core/src/models/rowboat-selection.ts` / `chatgpt-selection.ts` (sign-in).
|
||||
- `models_config_migrated` — `{ had_assistant, materialized_overrides, provider_count }` — one-shot per install at the models.json v1 → v2 boot migration (`FSModelConfigRepo.ensureConfig`); rollout health for the schema change.
|
||||
|
||||
### Other events (pre-existing, not added by the LLM-usage work)
|
||||
|
||||
All in `apps/renderer/src/lib/analytics.ts`:
|
||||
|
|
@ -210,6 +218,9 @@ Persistent across sessions for the same user. Set via `posthog.people.set` or as
|
|||
| `has_used_search`, `has_used_voice` | renderer | One-shot first-use flags |
|
||||
| `has_used_email`, `has_used_meetings`, `has_used_live_notes`, `has_used_bg_agents`, `has_used_apps`, `has_used_code` | renderer (`view_opened`) | One-shot first-use flags per feature view |
|
||||
| `has_created_bg_agent` | renderer | One-shot: user set up a background agent |
|
||||
| `llm_provider_flavors` | main | Sorted array of connected provider flavors incl. `rowboat`/`codex` from auth state (e.g. `["openai","openrouter","rowboat"]`). Synced on every launch and after any provider/assistant change (`packages/core/src/analytics/model-providers.ts`) |
|
||||
| `llm_provider_count` | main | Size of `llm_provider_flavors` |
|
||||
| `assistant_model`, `assistant_model_flavor` | main | The configured primary model (complements `llm_usage`, which reports actual usage). Absent until an assistant is configured |
|
||||
|
||||
## How to add a new event
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Server } from 'http';
|
|||
import { createAuthServer } from './auth-server.js';
|
||||
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
||||
import { exchangeChatGPTCode, getChatGPTStatus } from '@x/core/dist/auth/chatgpt-auth.js';
|
||||
import { applyCodexInitialSelection } from '@x/core/dist/models/chatgpt-selection.js';
|
||||
import {
|
||||
CHATGPT_AUTHORIZE_URL,
|
||||
CHATGPT_CALLBACK_PATH,
|
||||
|
|
@ -65,7 +66,14 @@ export async function signInWithChatGPT(): Promise<ChatGPTSignInResult> {
|
|||
void attempt.promise.finally(() => {
|
||||
if (activeAttempt === attempt) activeAttempt = null;
|
||||
});
|
||||
return attempt.promise;
|
||||
const result = await attempt.promise;
|
||||
if (result.signedIn) {
|
||||
// Signing in connects the codex provider: if no assistant model is
|
||||
// saved yet, pick the initial one (recommendation if the subscription
|
||||
// lists it, else first listed). Never replaces a saved choice.
|
||||
await applyCodexInitialSelection();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -33,15 +33,14 @@ import { isDurableTurnEvent } from '@x/shared/dist/turns.js';
|
|||
import type { ISessions, EmitterSessionBus } from '@x/core/dist/runtime/sessions/index.js';
|
||||
import type { ITurnEventBus } from '@x/core/dist/runtime/turns/event-hub.js';
|
||||
import container from '@x/core/dist/di/container.js';
|
||||
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
|
||||
import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js';
|
||||
import { getModelCatalog } from '@x/core/dist/models/catalog.js';
|
||||
import { captureProviderConnected, captureProviderDisconnected } from '@x/core/dist/analytics/model-providers.js';
|
||||
import { getDefaultModelAndProvider } from '@x/core/dist/models/defaults.js';
|
||||
import { isSignedIn } from '@x/core/dist/account/account.js';
|
||||
import { listGatewayModels } from '@x/core/dist/models/gateway.js';
|
||||
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
||||
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||
import { getChatGPTStatus, signOutChatGPT } from '@x/core/dist/auth/chatgpt-auth.js';
|
||||
import { listCodexModels } from '@x/core/dist/models/codex.js';
|
||||
import { signInWithChatGPT, cancelChatGPTSignIn } from './chatgpt-signin.js';
|
||||
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
||||
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
|
||||
|
|
@ -1249,22 +1248,8 @@ export function setupIpcHandlers() {
|
|||
return { success: false, error: message };
|
||||
}
|
||||
},
|
||||
'models:list': async () => {
|
||||
const base = (await isSignedIn())
|
||||
? await listGatewayModels()
|
||||
: await listOnboardingModels();
|
||||
// ChatGPT-subscription (codex) models are additive and independent of
|
||||
// Rowboat sign-in; their failure must never break the main list.
|
||||
try {
|
||||
const chatgpt = await getChatGPTStatus();
|
||||
if (chatgpt.signedIn) {
|
||||
const codex = await listCodexModels();
|
||||
return { providers: [...base.providers, ...codex.providers] };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Codex] Listing subscription models failed:', error);
|
||||
}
|
||||
return base;
|
||||
'models:list': async (_event, args) => {
|
||||
return await getModelCatalog({ refreshProvider: args?.refreshProvider });
|
||||
},
|
||||
'models:test': async (_event, args) => {
|
||||
return await testModelConnection(args.provider, args.model);
|
||||
|
|
@ -1287,9 +1272,38 @@ export function setupIpcHandlers() {
|
|||
console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`);
|
||||
return result;
|
||||
},
|
||||
'models:saveConfig': async (_event, args) => {
|
||||
'models:getConfig': async () => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
await repo.setConfig(args);
|
||||
const cfg = await repo.getConfig().catch(() => null);
|
||||
const tasks = cfg?.taskModels ?? {};
|
||||
return {
|
||||
providers: Object.entries(cfg?.providers ?? {}).map(([id, entry]) => ({
|
||||
id,
|
||||
flavor: entry.flavor,
|
||||
...(entry.baseURL ? { baseURL: entry.baseURL } : {}),
|
||||
hasApiKey: Boolean(entry.apiKey),
|
||||
})),
|
||||
assistantModel: cfg?.assistantModel ?? null,
|
||||
taskModels: {
|
||||
knowledgeGraph: tasks.knowledgeGraph ?? null,
|
||||
meetingNotes: tasks.meetingNotes ?? null,
|
||||
liveNoteAgent: tasks.liveNoteAgent ?? null,
|
||||
autoPermissionDecision: tasks.autoPermissionDecision ?? null,
|
||||
chatTitle: tasks.chatTitle ?? null,
|
||||
backgroundTask: tasks.backgroundTask ?? null,
|
||||
subagent: tasks.subagent ?? null,
|
||||
},
|
||||
deferBackgroundTasks: cfg?.deferBackgroundTasks === true,
|
||||
};
|
||||
},
|
||||
'models:setProvider': async (_event, args) => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
await repo.setProvider(args.id, args.provider);
|
||||
return { success: true };
|
||||
},
|
||||
'models:removeProvider': async (_event, args) => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
await repo.removeProvider(args.id);
|
||||
return { success: true };
|
||||
},
|
||||
'models:updateConfig': async (_event, args) => {
|
||||
|
|
@ -1323,6 +1337,7 @@ export function setupIpcHandlers() {
|
|||
// Model lists gate on sign-in state (composer picker, models:list) —
|
||||
// push the change so they refresh without polling.
|
||||
broadcastToWindows('chatgpt:statusChanged', { signedIn: true });
|
||||
captureProviderConnected('codex');
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
|
@ -1334,6 +1349,7 @@ export function setupIpcHandlers() {
|
|||
try {
|
||||
await signOutChatGPT();
|
||||
broadcastToWindows('chatgpt:statusChanged', { signedIn: false });
|
||||
captureProviderDisconnected('codex');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[ChatGPTAuth] Sign-out failed:', error);
|
||||
|
|
@ -1343,18 +1359,21 @@ export function setupIpcHandlers() {
|
|||
'account:getRowboat': async () => {
|
||||
const signedIn = await isSignedIn();
|
||||
if (!signedIn) {
|
||||
return { signedIn: false, accessToken: null, config: null };
|
||||
return { signedIn: false, accessToken: null };
|
||||
}
|
||||
|
||||
const config = await getRowboatConfig();
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
return { signedIn: true, accessToken, config };
|
||||
return { signedIn: true, accessToken };
|
||||
} catch {
|
||||
return { signedIn: true, accessToken: null, config };
|
||||
return { signedIn: true, accessToken: null };
|
||||
}
|
||||
},
|
||||
// Unauthenticated /v1/config bootstrap, independent of sign-in (signed-out
|
||||
// BYOK users need its model recommendations when connecting a provider).
|
||||
// getRowboatConfig caches once per app run; best-effort null on failure.
|
||||
'rowboat:getConfig': async () => {
|
||||
return await getRowboatConfig().catch(() => null);
|
||||
},
|
||||
'granola:getConfig': async () => {
|
||||
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { setTokenCipher as setGithubTokenCipher } from "@x/core/dist/apps/github
|
|||
import { setTokenCipher as setChatGPTTokenCipher } from "@x/core/dist/auth/chatgpt-auth.js";
|
||||
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
|
||||
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
||||
import { syncModelProviderPersonProperties } from "@x/core/dist/analytics/model-providers.js";
|
||||
import { migrateRuns } from "@x/core/dist/migrations/runs/migrate.js";
|
||||
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
|
|
@ -511,6 +512,11 @@ app.whenReady().then(async () => {
|
|||
identifyIfSignedIn().catch((error) => {
|
||||
console.error('[Analytics] Failed to identify on startup:', error);
|
||||
});
|
||||
// Baseline the provider person properties (llm_provider_flavors et al) on
|
||||
// every launch — existing installs get them without any provider action.
|
||||
syncModelProviderPersonProperties().catch((error) => {
|
||||
console.error('[Analytics] Failed to sync provider properties:', error);
|
||||
});
|
||||
|
||||
registerBrowserControlService(new ElectronBrowserControlService());
|
||||
registerNotificationService(new ElectronNotificationService(APP_LAUNCHED_AT));
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import { capture as analyticsCapture, identify as analyticsIdentify, reset as an
|
|||
import { isSignedIn } from '@x/core/dist/account/account.js';
|
||||
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
|
||||
import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
|
||||
import { applyRowboatInitialSelection, clearRowboatSelections } from '@x/core/dist/models/rowboat-selection.js';
|
||||
import { captureProviderConnected, captureProviderDisconnected } from '@x/core/dist/analytics/model-providers.js';
|
||||
|
||||
function buildRedirectUri(port: number): string {
|
||||
return `http://localhost:${port}/oauth/callback`;
|
||||
|
|
@ -339,6 +341,12 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
// multiple renderer hooks race to create the user, causing duplicates.
|
||||
let signedInUserId: string | undefined;
|
||||
if (provider === 'rowboat') {
|
||||
// Signing in connects the rowboat provider: if no assistant
|
||||
// model is saved yet, pick the initial one (recommendation if
|
||||
// the gateway lists it, else first listed). Never replaces a
|
||||
// saved choice; best-effort by design.
|
||||
await applyRowboatInitialSelection();
|
||||
captureProviderConnected('rowboat');
|
||||
try {
|
||||
const billing = await getBillingInfo();
|
||||
if (billing.userId) {
|
||||
|
|
@ -530,6 +538,11 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
|
|||
if (provider === 'rowboat') {
|
||||
analyticsCapture('user_signed_out');
|
||||
analyticsReset();
|
||||
// Signing out disconnects the rowboat provider: drop the model
|
||||
// selections that reference it (same dangling-ref cleanup as removing
|
||||
// any provider). The composer prompts for a new pick.
|
||||
await clearRowboatSelections();
|
||||
captureProviderDisconnected('rowboat');
|
||||
}
|
||||
// Notify renderer so sidebar, voice, and billing re-check state
|
||||
emitOAuthEvent({ provider, success: false });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { useEffect } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -10,12 +10,7 @@ import {
|
|||
import { Button } from "@/components/ui/button"
|
||||
import type { BillingErrorMatch } from "@/lib/billing-error"
|
||||
import * as analytics from "@/lib/analytics"
|
||||
|
||||
interface BillingRowboatAccount {
|
||||
config?: {
|
||||
appUrl?: string | null
|
||||
} | null
|
||||
}
|
||||
import { useRowboatConfig } from "@/hooks/use-rowboat-config"
|
||||
|
||||
interface BillingErrorDialogProps {
|
||||
open: boolean
|
||||
|
|
@ -24,15 +19,7 @@ interface BillingErrorDialogProps {
|
|||
}
|
||||
|
||||
export function BillingErrorDialog({ open, match, onOpenChange }: BillingErrorDialogProps) {
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
window.ipc
|
||||
.invoke('account:getRowboat', null)
|
||||
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
|
||||
.catch(() => {})
|
||||
}, [open])
|
||||
const appUrl = useRowboatConfig()?.appUrl ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (open && match) analytics.billingErrorShown(match.kind)
|
||||
|
|
|
|||
|
|
@ -549,12 +549,11 @@ function ChatInputInner({
|
|||
checkSearch()
|
||||
}, [isActive, isRowboatConnected])
|
||||
|
||||
// Selecting a model affects the *next* run created from this tab (frozen
|
||||
// once a run exists) AND persists as the app default so background agents
|
||||
// and new tabs follow the last pick. The models-config-changed dispatch
|
||||
// re-fetches the shared store too — that re-read lands on the same
|
||||
// selection (selectedModel is untouched, the live lists come from the
|
||||
// useProviderModels cache), so it's visually a no-op.
|
||||
// Selecting a model here is PER-CHAT: it affects the next run created
|
||||
// from this tab (frozen once a run exists) and nothing else. The config's
|
||||
// assistantModel is the durable default — new tabs and background work
|
||||
// always start from it, and only the settings Assistant picker (or a
|
||||
// provider connect's initial selection) writes it.
|
||||
const handleModelChange = useCallback((model: SelectedModel | null) => {
|
||||
if (lockedModel) return
|
||||
// null = the sentinel row, which the composer never renders (no
|
||||
|
|
@ -562,9 +561,6 @@ function ChatInputInner({
|
|||
if (!model) return
|
||||
setSelectedModel(model)
|
||||
onSelectedModelChange?.(model)
|
||||
void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider: model.provider, model: model.model } })
|
||||
.then(() => { window.dispatchEvent(new Event('models-config-changed')) })
|
||||
.catch(() => { toast.error('Failed to save default model') })
|
||||
}, [lockedModel, onSelectedModelChange])
|
||||
|
||||
// Effort is per-turn and unpersisted; ModelSelector reports '' when the
|
||||
|
|
|
|||
|
|
@ -24,32 +24,18 @@ let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
|
|||
}
|
||||
|
||||
function serveTwoProviders(): void {
|
||||
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } })
|
||||
handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' })
|
||||
handlers['models:list'] = async () => ({
|
||||
providers: [
|
||||
{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4' }] },
|
||||
{ id: 'anthropic', name: 'Anthropic', models: [{ id: 'claude-opus-4-8' }] },
|
||||
{ id: 'openai', flavor: 'openai', status: 'ok', models: [{ id: 'gpt-5.4' }] },
|
||||
{ id: 'anthropic', flavor: 'anthropic', status: 'ok', models: [{ id: 'claude-opus-4-8' }] },
|
||||
],
|
||||
})
|
||||
handlers['workspace:readFile'] = async () => ({
|
||||
data: JSON.stringify({
|
||||
provider: { flavor: 'openai' },
|
||||
model: 'gpt-5.4',
|
||||
providers: {
|
||||
openai: { apiKey: 'sk-a', model: 'gpt-5.4' },
|
||||
anthropic: { apiKey: 'sk-b', model: 'claude-opus-4-8' },
|
||||
},
|
||||
}),
|
||||
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
|
||||
})
|
||||
}
|
||||
|
||||
async function openMenu(): Promise<void> {
|
||||
const trigger = screen.getByRole('button')
|
||||
// Radix opens the menu from the trigger's keydown handler in jsdom
|
||||
// (pointerdown would ALSO toggle — one gesture only).
|
||||
fireEvent.keyDown(trigger, { key: 'Enter' })
|
||||
await waitFor(() => expect(document.querySelector('[role="menu"]')).not.toBeNull())
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
await waitFor(() => expect(document.querySelector('[cmdk-root]')).not.toBeNull())
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -135,7 +121,7 @@ describe('ModelSelector', () => {
|
|||
/>,
|
||||
)
|
||||
await openMenu()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models…'), {
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), {
|
||||
target: { value: 'openrouter/meituan/longcat-2.0' },
|
||||
})
|
||||
fireEvent.click(await screen.findByText('Use "openrouter/meituan/longcat-2.0"'))
|
||||
|
|
@ -155,39 +141,68 @@ describe('ModelSelector', () => {
|
|||
/>,
|
||||
)
|
||||
await openMenu()
|
||||
// Wait for the store snapshot (the default provider comes from it).
|
||||
await screen.findByText('claude-opus-4-8')
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models…'), { target: { value: 'my-local-model' } })
|
||||
// Wait for the store snapshot (split mode opens on the default
|
||||
// provider's group, so its model is what renders first).
|
||||
await screen.findByText('gpt-5.4')
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), { target: { value: 'my-local-model' } })
|
||||
fireEvent.click(await screen.findByText('Use "my-local-model"'))
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: 'openai', model: 'my-local-model' })
|
||||
})
|
||||
|
||||
it('liveCredentials live-fetches a provider that is not saved anywhere', async () => {
|
||||
it('split view: ArrowRight switches provider and Enter selects from the new group', 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} defaultOption={{ label: 'x' }} />)
|
||||
await openMenu()
|
||||
await screen.findByText('gpt-5.4')
|
||||
|
||||
// Regression: swapping the provider column used to orphan cmdk's
|
||||
// internal highlight, making Enter a no-op until ↑/↓ was pressed.
|
||||
const input = screen.getByPlaceholderText('Search models and providers…')
|
||||
fireEvent.keyDown(input, { key: 'ArrowRight' })
|
||||
await screen.findByText('claude-opus-4-8')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: 'anthropic', model: 'claude-opus-4-8' })
|
||||
})
|
||||
|
||||
it('search matches provider names and shows that provider\'s whole group', async () => {
|
||||
serveTwoProviders()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
providerFilter="openrouter"
|
||||
liveCredentials={{ flavor: 'openrouter', apiKey: 'sk-or-typed', baseURL: '' }}
|
||||
allowCustom
|
||||
defaultOption={{ label: 'Auto (recommended)' }}
|
||||
/>,
|
||||
<ModelSelector variant="field" value={null} onChange={() => {}} defaultOption={{ label: 'x' }} />,
|
||||
)
|
||||
await openMenu()
|
||||
// 600ms debounce in useProviderModels before the fetch fires.
|
||||
const row = await screen.findByText('meituan/longcat-2.0', undefined, { timeout: 3000 })
|
||||
await screen.findByText('gpt-5.4')
|
||||
// "anthropic" matches no model id — but it matches the provider, so its
|
||||
// group stays visible in full while others filter away.
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), { target: { value: 'anthropic' } })
|
||||
await screen.findByText('claude-opus-4-8')
|
||||
expect(screen.queryByText('gpt-5.4')).toBeNull()
|
||||
fireEvent.click(row)
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: 'openrouter', model: 'meituan/longcat-2.0' })
|
||||
expect(screen.queryByText('No models match')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a failed provider group as an error row with Retry (refreshing that provider)', async () => {
|
||||
handlers['models:list'] = async (args) => {
|
||||
const refreshed = (args as { refreshProvider?: string } | null)?.refreshProvider === 'ollama'
|
||||
return {
|
||||
providers: [
|
||||
{ id: 'openai', flavor: 'openai', status: 'ok', models: [{ id: 'gpt-5.4' }] },
|
||||
refreshed
|
||||
? { id: 'ollama', flavor: 'ollama', status: 'ok', models: [{ id: 'llama3' }] }
|
||||
: { id: 'ollama', flavor: 'ollama', status: 'error', error: 'connection refused', models: [] },
|
||||
],
|
||||
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
|
||||
}
|
||||
}
|
||||
render(<ModelSelector variant="field" value={null} onChange={() => {}} defaultOption={{ label: 'x' }} />)
|
||||
await openMenu()
|
||||
// Split mode opens on the default provider (openai); the failed
|
||||
// provider shows an error dot in the column — select it to see the row.
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /Ollama/ }))
|
||||
const retry = await screen.findByText('Retry')
|
||||
expect(screen.getByText('connection refused')).toBeInTheDocument()
|
||||
fireEvent.click(retry)
|
||||
// The retried fetch recovers the group's models.
|
||||
await screen.findByText('llama3')
|
||||
})
|
||||
|
||||
it('staticOptions renders only the supplied rows and round-trips ids and null', async () => {
|
||||
|
|
@ -209,7 +224,7 @@ describe('ModelSelector', () => {
|
|||
await openMenu()
|
||||
// Only the caller's rows — nothing from the shared catalog store.
|
||||
expect(screen.queryByText('gpt-5.4')).toBeNull()
|
||||
expect(screen.queryByText('claude-opus-4-8', { selector: '[role="menuitemradio"] span' })).not.toBeNull()
|
||||
expect(screen.queryByText('claude-opus-4-8', { selector: '[role="option"] span' })).not.toBeNull()
|
||||
// Colliding "Opus" labels are disambiguated by their raw id.
|
||||
expect(screen.getAllByText('Opus')).toHaveLength(2)
|
||||
|
||||
|
|
@ -217,7 +232,7 @@ describe('ModelSelector', () => {
|
|||
expect(onChange).toHaveBeenCalledWith({ provider: '', model: 'sonnet' })
|
||||
|
||||
await openMenu()
|
||||
fireEvent.click(screen.getByText('Default (recommended)', { selector: '[role="menuitemradio"] span' }))
|
||||
fireEvent.click(screen.getByText('Default (recommended)', { selector: '[role="option"] span' }))
|
||||
expect(onChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
|
|
@ -235,7 +250,7 @@ describe('ModelSelector', () => {
|
|||
/>,
|
||||
)
|
||||
await openMenu()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models…'), { target: { value: 'my-custom-model' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), { target: { value: 'my-custom-model' } })
|
||||
const custom = await screen.findByText('Use "my-custom-model"')
|
||||
fireEvent.click(custom)
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: 'anthropic', model: 'my-custom-model' })
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Brain, ChevronDown, LoaderIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Brain, Check, ChevronDown } from 'lucide-react'
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { useModels, type ModelPickerGroup, type ModelRef } from '@/hooks/use-models'
|
||||
import { useProviderModels, type ProviderModelsFlavor } from '@/hooks/use-provider-models'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type { ModelRef } from '@/hooks/use-models'
|
||||
|
|
@ -21,7 +26,7 @@ export type ReasoningEffortLevel = 'low' | 'medium' | 'high'
|
|||
|
||||
const TOOLTIP_DELAY_MS = 1000
|
||||
|
||||
const providerDisplayNames: Record<string, string> = {
|
||||
export const providerDisplayNames: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
google: 'Gemini',
|
||||
|
|
@ -47,94 +52,34 @@ function getModelDisplayName(model: string) {
|
|||
return model.split('/').pop() || model
|
||||
}
|
||||
|
||||
// Rendered inside the dropdown's radio group: each live provider fetches its
|
||||
// own list, so groups load and fail independently. Pinned models (the saved
|
||||
// default / app default) render first — the model that actually runs is
|
||||
// always pickable even while the fetch is pending or failed. Live-fetched
|
||||
// ids carry no reasoning metadata, so the effort control stays hidden for
|
||||
// them (reasoningByKey lookup misses default to off).
|
||||
//
|
||||
// The group owns its header so it can hide itself when the search filter
|
||||
// matches none of its rows. Loading/error rows are status, not models — they
|
||||
// render (with the header) regardless of the filter, and don't count toward
|
||||
// the parent's "No models match" check (which is what gets reported up).
|
||||
function LiveProviderGroupItems({ group, label, pinnedModels, filter, onModelRowsChange }: {
|
||||
group: Extract<ModelPickerGroup, { kind: 'live' }>
|
||||
label: string
|
||||
pinnedModels: string[]
|
||||
filter: string
|
||||
onModelRowsChange: (flavor: string, hasModelRows: boolean) => void
|
||||
}) {
|
||||
const { status, models, error, refetch } = useProviderModels({
|
||||
flavor: group.flavor,
|
||||
apiKey: group.apiKey,
|
||||
baseURL: group.baseURL,
|
||||
})
|
||||
const items = [...pinnedModels, ...models.filter((m) => !pinnedModels.includes(m))]
|
||||
const visible = filter ? items.filter((m) => m.toLowerCase().includes(filter)) : items
|
||||
const showStatus = status === 'loading' || status === 'error'
|
||||
const hasModelRows = visible.length > 0
|
||||
useEffect(() => {
|
||||
onModelRowsChange(group.flavor, hasModelRows)
|
||||
}, [group.flavor, hasModelRows, onModelRowsChange])
|
||||
if (!hasModelRows && !showStatus) return null
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{label}</DropdownMenuLabel>
|
||||
{visible.map((m) => {
|
||||
const key = `${group.flavor}/${m}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
<span className="truncate">{m}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
{status === 'loading' && (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
|
||||
<LoaderIcon className="h-3 w-3 animate-spin" />
|
||||
Loading models…
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
refetch()
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<span className="truncate text-destructive">{error || 'Failed to load models'}</span>
|
||||
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The standardized model picker (model-selection consolidation), mounted
|
||||
// everywhere models are chosen. One controlled value/onChange contract with
|
||||
// per-surface modes layered on as optional props: the composer's full
|
||||
// catalog picker (provider groups, live fetches, search, reasoning effort),
|
||||
// settings' default sentinel / field trigger / provider scoping / typed-id
|
||||
// escape hatch, per-task "(global default)" inheritance, and caller-supplied
|
||||
// restricted lists (coding-agent options).
|
||||
// everywhere models are chosen — the composer pill, every settings field,
|
||||
// per-task overrides, and the coding-agent restricted lists. One controlled
|
||||
// value/onChange contract with per-surface modes layered on as optional
|
||||
// props.
|
||||
//
|
||||
// The dropdown is a Popover + cmdk Command. With the search empty and more
|
||||
// than one provider connected, it browses as a SPLIT VIEW: providers on the
|
||||
// left (the assistant's provider pre-selected), the chosen provider's models
|
||||
// on the right — ←/→ switches provider, ↑/↓ navigates models. Typing
|
||||
// collapses to one flat list filtered across ALL providers (model ids and
|
||||
// provider names both match). Scoped/static pickers stay flat.
|
||||
export interface ModelSelectorProps {
|
||||
/** Current selection; null follows the app default / the sentinel. */
|
||||
value: ModelRef | null
|
||||
/** null only ever fires when defaultOption is set (sentinel picked). */
|
||||
onChange: (value: ModelRef | null) => void
|
||||
/**
|
||||
* Pinned top entry ("Rowboat default", "Same as assistant") that selects
|
||||
* null. When set, a null value renders this label instead of the app
|
||||
* default model.
|
||||
* Pinned top entry ("Same as Assistant") that selects null. When set, a
|
||||
* null value renders this label instead of the app default model.
|
||||
*/
|
||||
defaultOption?: { label: string }
|
||||
/**
|
||||
* Inheritance flavor of defaultOption for per-task overrides
|
||||
* ("(global default)"): same sentinel row and null semantics, but null
|
||||
* means "inherit the global default at runtime" and the trigger renders
|
||||
* the label muted, so an un-overridden field reads like a placeholder.
|
||||
* Mutually exclusive with defaultOption (defaultOption wins).
|
||||
* Inheritance flavor of defaultOption for per-task overrides: same
|
||||
* sentinel row and null semantics, but null means "inherit at runtime"
|
||||
* and the trigger renders the label muted, so an un-overridden field
|
||||
* reads like a placeholder. Mutually exclusive with defaultOption
|
||||
* (defaultOption wins).
|
||||
*/
|
||||
inheritDefault?: { label: string }
|
||||
/**
|
||||
|
|
@ -143,22 +88,9 @@ export interface ModelSelectorProps {
|
|||
*/
|
||||
variant?: 'pill' | 'field'
|
||||
/**
|
||||
* Restrict the picker to one provider's group (catalog or live). Providers
|
||||
* not configured in models.json fall back to their static models:list
|
||||
* catalog so a provider mid-setup still lists models.
|
||||
* Restrict the picker to one connected provider's group.
|
||||
*/
|
||||
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.
|
||||
|
|
@ -171,12 +103,11 @@ export interface ModelSelectorProps {
|
|||
/**
|
||||
* Caller-supplied restricted list (e.g. a coding agent's own model
|
||||
* options): the picker renders ONLY these rows plus the defaultOption
|
||||
* sentinel — no catalog groups, no live fetches. Entries are opaque
|
||||
* engine ids, not provider/model pairs, so the selected ref is
|
||||
* {provider: '', model: id} — the id travels in .model and provider is
|
||||
* meaningless. Search filters on label and id; rows whose label differs
|
||||
* from their id show the id as secondary text (labels can collide, e.g.
|
||||
* Claude lists both the 'opus' alias and the concrete id as "Opus").
|
||||
* sentinel — no catalog groups. Entries are opaque engine ids, not
|
||||
* provider/model pairs, so the selected ref is {provider: '', model: id}.
|
||||
* Search filters on label and id; rows whose label differs from their id
|
||||
* show the id as secondary text (labels can collide, e.g. Claude lists
|
||||
* both the 'opus' alias and the concrete id as "Opus").
|
||||
*/
|
||||
staticOptions?: Array<{ id: string; label?: string }>
|
||||
/** Optional title attribute for the trigger button (header tooltips). */
|
||||
|
|
@ -193,8 +124,8 @@ export interface ModelSelectorProps {
|
|||
onEffortChange?: (effort: '' | ReasoningEffortLevel) => void
|
||||
}
|
||||
|
||||
// Radio value for the defaultOption sentinel row. Never a valid model key
|
||||
// (real keys always contain "provider/").
|
||||
// cmdk item value for the defaultOption sentinel row. Never a valid model
|
||||
// key (real keys always contain "provider/").
|
||||
const DEFAULT_OPTION_KEY = '__default__'
|
||||
|
||||
// Un-scoped custom entries can't know their provider, so the rule is:
|
||||
|
|
@ -230,7 +161,6 @@ export function ModelSelector({
|
|||
inheritDefault,
|
||||
variant = 'pill',
|
||||
providerFilter,
|
||||
liveCredentials,
|
||||
allowCustom = false,
|
||||
staticOptions,
|
||||
triggerTitle,
|
||||
|
|
@ -238,91 +168,112 @@ export function ModelSelector({
|
|||
effort = '',
|
||||
onEffortChange,
|
||||
}: ModelSelectorProps) {
|
||||
const { groups: allGroups, reasoningByKey, defaultModel, catalogByProvider } = useModels()
|
||||
const { groups: allGroups, reasoningByKey, defaultModel, catalogByProvider, refresh } = useModels()
|
||||
|
||||
// inheritDefault is defaultOption with placeholder styling — one sentinel
|
||||
// code path, not two.
|
||||
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)
|
||||
const scoped = allGroups.filter((g) => g.id === providerFilter)
|
||||
if (scoped.length > 0) return scoped
|
||||
const catalogModels = catalogByProvider[providerFilter] || []
|
||||
return catalogModels.length > 0 ? [{ kind: 'catalog', flavor: providerFilter, models: catalogModels }] : []
|
||||
}, [allGroups, providerFilter, catalogByProvider, liveFlavor, liveApiKey, liveBaseURL])
|
||||
return catalogModels.length > 0
|
||||
? [{ id: providerFilter, flavor: providerFilter, models: catalogModels, status: 'ok' }]
|
||||
: []
|
||||
}, [allGroups, providerFilter, catalogByProvider])
|
||||
|
||||
// Search filter for the model dropdown. Reset each time the menu opens;
|
||||
// matching is a case-insensitive substring test on the model id. Live
|
||||
// groups filter themselves and report whether they still have rows, so the
|
||||
// parent can render the global "No models match" row.
|
||||
const [modelFilter, setModelFilter] = useState('')
|
||||
const modelFilterInputRef = useRef<HTMLInputElement>(null)
|
||||
const [liveGroupHasRows, setLiveGroupHasRows] = useState<Record<string, boolean>>({})
|
||||
const modelFilterValue = modelFilter.trim().toLowerCase()
|
||||
const handleLiveGroupRows = useCallback((flavor: string, hasRows: boolean) => {
|
||||
setLiveGroupHasRows((prev) => (prev[flavor] === hasRows ? prev : { ...prev, [flavor]: hasRows }))
|
||||
}, [])
|
||||
const [open, setOpen] = useState(false)
|
||||
// cmdk's highlighted-item value, controlled: when the split view swaps the
|
||||
// provider column, the previous group's items unmount and cmdk's internal
|
||||
// highlight is left pointing at a value with no item — ↵ becomes a no-op.
|
||||
// Driving the value ourselves re-anchors the highlight on the new group.
|
||||
const [commandValue, setCommandValue] = useState('')
|
||||
// Search text; case-insensitive substring test on the model id AND the
|
||||
// provider name — typing "rowboat" surfaces the whole Rowboat group.
|
||||
const [query, setQuery] = useState('')
|
||||
const queryValue = query.trim().toLowerCase()
|
||||
const groupMatchesFilter = useCallback((g: ModelPickerGroup) =>
|
||||
(providerDisplayNames[g.flavor] || g.flavor).toLowerCase().includes(queryValue)
|
||||
|| g.id.toLowerCase().includes(queryValue), [queryValue])
|
||||
|
||||
// Split view only where browsing across providers is meaningful.
|
||||
const splitMode = !staticOptions && !providerFilter && !queryValue && groups.length > 1
|
||||
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
|
||||
const activeGroup = splitMode
|
||||
? (groups.find((g) => g.id === activeProviderId) ?? groups[0])
|
||||
: null
|
||||
|
||||
const handleOpenChange = useCallback((next: boolean) => {
|
||||
setOpen(next)
|
||||
if (next) {
|
||||
// Per-opening state: fresh search, provider column on the selection's
|
||||
// (else the assistant's) provider — groups[0] is already the
|
||||
// assistant's group by store ordering. Empty commandValue lets cmdk
|
||||
// highlight the first rendered item itself.
|
||||
setQuery('')
|
||||
setCommandValue('')
|
||||
setActiveProviderId(value?.provider ?? defaultModel?.provider ?? null)
|
||||
}
|
||||
}, [value, defaultModel])
|
||||
|
||||
// Switch the split view's provider column and re-anchor the keyboard
|
||||
// highlight on the new group's first row (see commandValue above).
|
||||
const switchProvider = useCallback((g: ModelPickerGroup) => {
|
||||
setActiveProviderId(g.id)
|
||||
setCommandValue(
|
||||
g.models.length > 0
|
||||
? `${g.id}/${g.models[0]}`
|
||||
: g.status === 'error'
|
||||
? `__retry__:${g.id}`
|
||||
: sentinel ? DEFAULT_OPTION_KEY : '',
|
||||
)
|
||||
}, [sentinel])
|
||||
|
||||
// The effective default always renders even when no group carries it (the
|
||||
// gateway list failed, or its provider was removed from config) — the
|
||||
// picker must never be missing the model that actually runs. Live groups
|
||||
// pin the default themselves, so a flavor match is enough there. A
|
||||
// provider's list failed, or its provider was removed from config) — the
|
||||
// picker must never be missing the model that actually runs. A
|
||||
// provider-scoped picker only shows it when it belongs to that provider.
|
||||
const standaloneDefault = useMemo<ModelRef | null>(() => {
|
||||
if (!defaultModel) return null
|
||||
if (providerFilter && defaultModel.provider !== providerFilter) return null
|
||||
const covered = groups.some((g) =>
|
||||
g.flavor === defaultModel.provider &&
|
||||
(g.kind === 'live' || g.models.includes(defaultModel.model)))
|
||||
g.id === defaultModel.provider && g.models.includes(defaultModel.model))
|
||||
return covered ? null : defaultModel
|
||||
}, [groups, defaultModel, providerFilter])
|
||||
|
||||
const standaloneVisible = standaloneDefault !== null &&
|
||||
(!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue))
|
||||
(!queryValue || standaloneDefault.model.toLowerCase().includes(queryValue))
|
||||
// Static mode replaces all store-driven rows with the caller's list.
|
||||
const staticVisible = useMemo(() => {
|
||||
if (!staticOptions) return null
|
||||
if (!modelFilterValue) return staticOptions
|
||||
if (!queryValue) return staticOptions
|
||||
return staticOptions.filter((o) =>
|
||||
(o.label ?? o.id).toLowerCase().includes(modelFilterValue) || o.id.toLowerCase().includes(modelFilterValue))
|
||||
}, [staticOptions, modelFilterValue])
|
||||
(o.label ?? o.id).toLowerCase().includes(queryValue) || o.id.toLowerCase().includes(queryValue))
|
||||
}, [staticOptions, queryValue])
|
||||
const staticLabelFor = (id: string) => staticOptions?.find((o) => o.id === id)?.label ?? id
|
||||
// Nothing matches anywhere → "No models match". Live groups that haven't
|
||||
// reported yet (first render after opening) count as having rows so the
|
||||
// empty row never flashes.
|
||||
// Nothing matches anywhere → "No models match".
|
||||
const anyModelRowVisible = staticVisible
|
||||
? staticVisible.length > 0
|
||||
: standaloneVisible || groups.some((g) =>
|
||||
g.kind === 'catalog'
|
||||
? g.models.some((m) => m.toLowerCase().includes(modelFilterValue))
|
||||
: liveGroupHasRows[g.flavor] !== false)
|
||||
: standaloneVisible
|
||||
|| groups.some((g) =>
|
||||
groupMatchesFilter(g) ? g.models.length > 0
|
||||
: g.models.some((m) => m.toLowerCase().includes(queryValue)))
|
||||
|
||||
const handleModelChange = useCallback((key: string) => {
|
||||
// The cmdk value of the current selection, for check indicators.
|
||||
const selectedKey = value
|
||||
? (staticOptions ? value.model : `${value.provider}/${value.model}`)
|
||||
: sentinel
|
||||
? DEFAULT_OPTION_KEY
|
||||
: (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
|
||||
|
||||
const select = useCallback((ref: ModelRef | null) => {
|
||||
if (lockedModel) return
|
||||
if (key === DEFAULT_OPTION_KEY) {
|
||||
onChange(null)
|
||||
return
|
||||
}
|
||||
// Static keys are opaque engine ids — no provider/model split (ids may
|
||||
// themselves contain slashes).
|
||||
if (staticOptions) {
|
||||
onChange({ provider: '', model: key })
|
||||
return
|
||||
}
|
||||
const slash = key.indexOf('/')
|
||||
if (slash <= 0 || slash === key.length - 1) return
|
||||
onChange({ provider: key.slice(0, slash), model: key.slice(slash + 1) })
|
||||
}, [lockedModel, onChange, staticOptions])
|
||||
setOpen(false)
|
||||
onChange(ref)
|
||||
}, [lockedModel, onChange])
|
||||
|
||||
// Reasoning effort applies to the model the next message will actually use:
|
||||
// the frozen model when locked, else the picker selection, else the app
|
||||
|
|
@ -345,6 +296,42 @@ export function ModelSelector({
|
|||
}
|
||||
}, [reasoningAvailable, effort, onEffortChange])
|
||||
|
||||
const renderModelItem = (providerId: string, model: string, secondary?: string) => {
|
||||
const key = `${providerId}/${model}`
|
||||
return (
|
||||
<CommandItem
|
||||
key={key}
|
||||
value={key}
|
||||
onSelect={() => select({ provider: providerId, model })}
|
||||
>
|
||||
<Check className={cn('size-3.5 shrink-0', selectedKey === key ? 'opacity-100' : 'opacity-0')} />
|
||||
<span className="truncate">{model}</span>
|
||||
{secondary && <span className="ml-auto shrink-0 text-xs text-muted-foreground">{secondary}</span>}
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSentinelItem = () => sentinel && (
|
||||
<CommandItem value={DEFAULT_OPTION_KEY} onSelect={() => select(null)}>
|
||||
<Check className={cn('size-3.5 shrink-0', selectedKey === DEFAULT_OPTION_KEY ? 'opacity-100' : 'opacity-0')} />
|
||||
<span className="truncate">{sentinel.label}</span>
|
||||
</CommandItem>
|
||||
)
|
||||
|
||||
const renderErrorItem = (g: ModelPickerGroup) => (
|
||||
<CommandItem
|
||||
key={`__retry__:${g.id}`}
|
||||
value={`__retry__:${g.id}`}
|
||||
// Retry refreshes in place — the popover stays open and the group
|
||||
// re-renders when the store updates.
|
||||
onSelect={() => refresh(g.id)}
|
||||
className="text-xs"
|
||||
>
|
||||
<span className="truncate text-destructive">{g.error || 'Failed to load models'}</span>
|
||||
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
|
||||
</CommandItem>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{reasoningAvailable && onEffortChange && (
|
||||
|
|
@ -390,19 +377,14 @@ export function ModelSelector({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
// The filter is per-opening, never sticky. Focus the search
|
||||
// input once the content has mounted and Radix has run its own
|
||||
// open-focus (DropdownMenu.Content has no onOpenAutoFocus).
|
||||
if (open) {
|
||||
setModelFilter('')
|
||||
setLiveGroupHasRows({})
|
||||
setTimeout(() => modelFilterInputRef.current?.focus(), 0)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
// modal: the settings Dialog's scroll-lock cancels wheel events over
|
||||
// content portalled outside its subtree — a modal popover brings its
|
||||
// own lock layer that permits scrolling within (Radix's supported
|
||||
// fix for popover-inside-dialog; matches the old DropdownMenu's
|
||||
// modality). Keyboard scrolling was never affected (cmdk uses
|
||||
// programmatic scrollIntoView).
|
||||
<Popover open={open} onOpenChange={handleOpenChange} modal>
|
||||
<PopoverTrigger asChild>
|
||||
{variant === 'field' ? (
|
||||
// Styled after ui/select's SelectTrigger so it sits naturally
|
||||
// in forms next to real Select fields.
|
||||
|
|
@ -439,130 +421,154 @@ export function ModelSelector({
|
|||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align={variant === 'field' ? 'start' : 'end'}
|
||||
className={cn('p-0 overflow-hidden', variant === 'field' && 'min-w-[var(--radix-dropdown-menu-trigger-width)]')}
|
||||
className={cn(
|
||||
'p-0 overflow-hidden',
|
||||
splitMode
|
||||
? 'w-[480px]'
|
||||
: variant === 'field'
|
||||
? 'w-[var(--radix-popover-trigger-width)] min-w-[300px]'
|
||||
: 'w-[320px]',
|
||||
)}
|
||||
>
|
||||
{!staticOptions && groups.length === 0 && !standaloneDefault && !sentinel && !allowCustom ? (
|
||||
<div className="p-1">
|
||||
<DropdownMenuItem disabled>Connect a provider in Settings</DropdownMenuItem>
|
||||
</div>
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">Connect a provider in Settings</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Fixed search header — lives OUTSIDE the scroll area (the
|
||||
inner div below scrolls), so it's flush at the very top
|
||||
and always visible without any scroll. */}
|
||||
<div className="bg-popover p-1">
|
||||
<input
|
||||
ref={modelFilterInputRef}
|
||||
value={modelFilter}
|
||||
onChange={(e) => setModelFilter(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Printable keys belong to the input, not the menu's
|
||||
// typeahead; arrows and Escape stay with the menu.
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Escape') {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
placeholder="Search models…"
|
||||
className="h-7 w-full rounded-sm border border-input bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto p-1 pt-0">
|
||||
<DropdownMenuRadioGroup
|
||||
value={
|
||||
value
|
||||
? (staticOptions ? value.model : `${value.provider}/${value.model}`)
|
||||
: sentinel
|
||||
? DEFAULT_OPTION_KEY
|
||||
: (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
|
||||
}
|
||||
onValueChange={handleModelChange}
|
||||
>
|
||||
{sentinel && (
|
||||
<DropdownMenuRadioItem value={DEFAULT_OPTION_KEY}>
|
||||
<span className="truncate">{sentinel.label}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
{staticVisible?.map((o) => (
|
||||
<DropdownMenuRadioItem key={o.id} value={o.id}>
|
||||
<span className="truncate">{o.label ?? o.id}</span>
|
||||
{o.label && o.label !== o.id && (
|
||||
<span className="ml-2 shrink-0 text-xs text-muted-foreground">{o.id}</span>
|
||||
)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
{!staticOptions && standaloneDefault && standaloneVisible && (
|
||||
<DropdownMenuRadioItem value={`${standaloneDefault.provider}/${standaloneDefault.model}`}>
|
||||
<span className="truncate">{standaloneDefault.model}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
{!staticOptions && groups.map((g) => {
|
||||
const label = providerDisplayNames[g.flavor] || g.flavor
|
||||
if (g.kind === 'live') {
|
||||
// The app default leads its live group; the group's
|
||||
// own saved model follows (both stay pickable through
|
||||
// fetch loading/failure).
|
||||
const pinned: string[] = []
|
||||
if (defaultModel && defaultModel.provider === g.flavor) pinned.push(defaultModel.model)
|
||||
if (g.savedModel && !pinned.includes(g.savedModel)) pinned.push(g.savedModel)
|
||||
<Command
|
||||
// Filtering is ours (provider-name matching, the custom-id
|
||||
// escape hatch, split-mode layout) — cmdk only does keyboard
|
||||
// navigation and selection over what we render, with the
|
||||
// highlighted value controlled (see commandValue).
|
||||
shouldFilter={false}
|
||||
value={commandValue}
|
||||
onValueChange={setCommandValue}
|
||||
onKeyDown={(e) => {
|
||||
// Split mode: ←/→ cycles the provider column (tabs
|
||||
// semantics); ↑/↓ stays on the model list via cmdk.
|
||||
if (!splitMode || groups.length === 0) return
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||
e.preventDefault()
|
||||
const index = groups.findIndex((g) => g.id === (activeGroup?.id ?? ''))
|
||||
const next = e.key === 'ArrowRight'
|
||||
? (index + 1) % groups.length
|
||||
: (index - 1 + groups.length) % groups.length
|
||||
switchProvider(groups[next])
|
||||
}}
|
||||
>
|
||||
<CommandInput
|
||||
autoFocus
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Search models and providers…"
|
||||
/>
|
||||
{splitMode && activeGroup ? (
|
||||
<div className="flex">
|
||||
{/* Provider column — tab-like: click or ←/→. */}
|
||||
<div className="w-40 shrink-0 border-r max-h-80 overflow-y-auto p-1" role="tablist" aria-label="Providers">
|
||||
{groups.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={g.id === activeGroup.id}
|
||||
tabIndex={-1}
|
||||
onClick={() => switchProvider(g)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm',
|
||||
g.id === activeGroup.id ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{providerDisplayNames[g.flavor] || g.flavor}</span>
|
||||
{g.status === 'error' ? (
|
||||
<span className="size-2 shrink-0 rounded-full bg-destructive" />
|
||||
) : (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">{g.models.length}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<CommandList className="max-h-80 flex-1">
|
||||
<CommandGroup>
|
||||
{renderSentinelItem()}
|
||||
{standaloneDefault && standaloneDefault.provider === activeGroup.id &&
|
||||
renderModelItem(standaloneDefault.provider, standaloneDefault.model)}
|
||||
{activeGroup.models.map((m) => renderModelItem(activeGroup.id, m))}
|
||||
{activeGroup.status === 'error' && renderErrorItem(activeGroup)}
|
||||
{activeGroup.status === 'ok' && activeGroup.models.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models reported</div>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</div>
|
||||
) : (
|
||||
<CommandList className="max-h-80">
|
||||
{sentinel && !queryValue && (
|
||||
<CommandGroup>{renderSentinelItem()}</CommandGroup>
|
||||
)}
|
||||
{staticVisible && staticVisible.length > 0 && (
|
||||
<CommandGroup>
|
||||
{staticVisible.map((o) => (
|
||||
<CommandItem key={o.id} value={o.id} onSelect={() => select({ provider: '', model: o.id })}>
|
||||
<Check className={cn('size-3.5 shrink-0', selectedKey === o.id ? 'opacity-100' : 'opacity-0')} />
|
||||
<span className="truncate">{o.label ?? o.id}</span>
|
||||
{o.label && o.label !== o.id && (
|
||||
<span className="ml-2 shrink-0 text-xs text-muted-foreground">{o.id}</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{!staticOptions && standaloneDefault && standaloneVisible && (
|
||||
<CommandGroup>
|
||||
{renderModelItem(
|
||||
standaloneDefault.provider,
|
||||
standaloneDefault.model,
|
||||
providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider,
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{!staticOptions && groups.map((g) => {
|
||||
// A provider-name match shows the whole group.
|
||||
const visibleModels = queryValue && !groupMatchesFilter(g)
|
||||
? g.models.filter((m) => m.toLowerCase().includes(queryValue))
|
||||
: g.models
|
||||
// Error rows are status, not models: they render (with
|
||||
// the header) regardless of the filter and don't count
|
||||
// toward "No models match".
|
||||
const showError = g.status === 'error'
|
||||
if (visibleModels.length === 0 && !showError) return null
|
||||
return (
|
||||
<LiveProviderGroupItems
|
||||
key={g.flavor}
|
||||
group={g}
|
||||
label={label}
|
||||
pinnedModels={pinned}
|
||||
filter={modelFilterValue}
|
||||
onModelRowsChange={handleLiveGroupRows}
|
||||
/>
|
||||
<CommandGroup key={g.id} heading={providerDisplayNames[g.flavor] || g.flavor}>
|
||||
{visibleModels.map((m) => renderModelItem(g.id, m))}
|
||||
{showError && renderErrorItem(g)}
|
||||
</CommandGroup>
|
||||
)
|
||||
}
|
||||
const visibleModels = modelFilterValue
|
||||
? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue))
|
||||
: g.models
|
||||
if (visibleModels.length === 0) return null
|
||||
return (
|
||||
<Fragment key={g.flavor}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
{label}
|
||||
</DropdownMenuLabel>
|
||||
{visibleModels.map((m) => {
|
||||
const key = `${g.flavor}/${m}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
<span className="truncate">{m}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{modelFilterValue && !anyModelRowVisible && (
|
||||
allowCustom ? (
|
||||
// Escape hatch for ids the lists don't carry (local
|
||||
// servers, brand-new models): select exactly what was
|
||||
// typed. Scoped pickers attach it to their provider;
|
||||
// un-scoped ones split "provider/model" on the first
|
||||
// slash, else pair the text with the default provider.
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onChange(parseCustomModel(modelFilter.trim(), providerFilter, defaultModel))}
|
||||
>
|
||||
<span className="truncate">Use "{modelFilter.trim()}"</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuRadioGroup>
|
||||
</div>
|
||||
</>
|
||||
})}
|
||||
{queryValue && !anyModelRowVisible && (
|
||||
allowCustom ? (
|
||||
// Escape hatch for ids the lists don't carry (local
|
||||
// servers, brand-new models): select exactly what was
|
||||
// typed.
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__custom__"
|
||||
onSelect={() => select(parseCustomModel(query.trim(), providerFilter, defaultModel))}
|
||||
>
|
||||
<span className="truncate">Use "{query.trim()}"</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
) : (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
|
||||
)
|
||||
)}
|
||||
</CommandList>
|
||||
)}
|
||||
</Command>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -62,10 +62,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col h-full max-h-[85vh] overflow-y-auto p-8 md:p-10">
|
||||
<StepIndicator
|
||||
currentStep={state.currentStep}
|
||||
path={state.onboardingPath}
|
||||
/>
|
||||
<StepIndicator currentStep={state.currentStep} />
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={state.currentStep}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
import * as React from "react"
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Step, OnboardingPath } from "./use-onboarding-state"
|
||||
import type { Step } from "./use-onboarding-state"
|
||||
|
||||
const ROWBOAT_STEPS = [
|
||||
// Both paths share one sequence now: screen 1 handles Rowboat sign-in, and
|
||||
// screen 2 ("Providers") serves everyone — first provider for key users,
|
||||
// optional extras for signed-in users.
|
||||
const STEPS = [
|
||||
{ step: 0 as Step, label: "Welcome" },
|
||||
{ step: 2 as Step, label: "Connect" },
|
||||
{ step: 3 as Step, label: "Code" },
|
||||
{ step: 4 as Step, label: "Done" },
|
||||
]
|
||||
|
||||
const BYOK_STEPS = [
|
||||
{ step: 0 as Step, label: "Welcome" },
|
||||
{ step: 1 as Step, label: "Model" },
|
||||
{ step: 1 as Step, label: "Providers" },
|
||||
{ step: 2 as Step, label: "Connect" },
|
||||
{ step: 3 as Step, label: "Code" },
|
||||
{ step: 4 as Step, label: "Done" },
|
||||
|
|
@ -20,11 +16,10 @@ const BYOK_STEPS = [
|
|||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: Step
|
||||
path: OnboardingPath
|
||||
}
|
||||
|
||||
export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
|
||||
const steps = path === 'byok' ? BYOK_STEPS : ROWBOAT_STEPS
|
||||
export function StepIndicator({ currentStep }: StepIndicatorProps) {
|
||||
const steps = STEPS
|
||||
const currentIndex = steps.findIndex(s => s.step === currentStep)
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,268 +1,37 @@
|
|||
import { Loader2, CheckCircle2, ArrowLeft, X, Lightbulb } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
OpenAIIcon,
|
||||
AnthropicIcon,
|
||||
GoogleIcon,
|
||||
OllamaIcon,
|
||||
OpenRouterIcon,
|
||||
VercelIcon,
|
||||
GenericApiIcon,
|
||||
} from "../provider-icons"
|
||||
import type { OnboardingState, LlmProviderFlavor } from "../use-onboarding-state"
|
||||
import { ProvidersSection } from "@/components/settings/providers-section"
|
||||
import { useModels } from "@/hooks/use-models"
|
||||
import type { OnboardingState } from "../use-onboarding-state"
|
||||
|
||||
interface LlmSetupStepProps {
|
||||
state: OnboardingState
|
||||
}
|
||||
|
||||
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [
|
||||
{ id: "openai", name: "OpenAI", description: "GPT models", color: "bg-green-500/10 text-green-600 dark:text-green-400", icon: <OpenAIIcon /> },
|
||||
{ id: "anthropic", name: "Anthropic", description: "Claude models", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400", icon: <AnthropicIcon /> },
|
||||
{ id: "google", name: "Gemini", description: "Google AI Studio", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400", icon: <GoogleIcon /> },
|
||||
{ id: "ollama", name: "Ollama", description: "Local models", color: "bg-purple-500/10 text-purple-600 dark:text-purple-400", icon: <OllamaIcon /> },
|
||||
]
|
||||
|
||||
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [
|
||||
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", color: "bg-pink-500/10 text-pink-600 dark:text-pink-400", icon: <OpenRouterIcon /> },
|
||||
{ id: "aigateway", name: "AI Gateway", description: "Vercel AI Gateway", color: "bg-sky-500/10 text-sky-600 dark:text-sky-400", icon: <VercelIcon /> },
|
||||
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom endpoint", color: "bg-gray-500/10 text-gray-600 dark:text-gray-400", icon: <GenericApiIcon /> },
|
||||
]
|
||||
|
||||
// Screen 2 of onboarding: the SAME provider surface as Settings (connected
|
||||
// list + add-provider flow), reframed for first-run. Users who signed in on
|
||||
// screen 1 already have a working assistant (initial selection runs at
|
||||
// sign-in) and see "Add more providers"; users who skipped sign-in connect
|
||||
// their first provider here. Continue gates on an assistant model existing —
|
||||
// the one thing chat can't run without.
|
||||
export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
||||
const {
|
||||
llmProvider, setLlmProvider, modelsLoading, modelsError,
|
||||
activeConfig, testState, setTestState, showApiKey,
|
||||
showBaseURL, canTest, showMoreProviders, setShowMoreProviders,
|
||||
updateProviderConfig, handleTestAndSaveLlmConfig, handleTestAndAddAnother,
|
||||
connectedFlavors, handleNext, handleBack,
|
||||
upsellDismissed, setUpsellDismissed, handleSwitchToRowboat,
|
||||
chatgpt,
|
||||
} = state
|
||||
|
||||
const isMoreProvider = moreProviders.some(p => p.id === llmProvider)
|
||||
// "Sign in with ChatGPT" (below the OpenAI card) is an alternative to a key:
|
||||
// signing in counts as a connected provider alongside any saved BYOK one,
|
||||
// which is what unlocks Continue when no API key was entered.
|
||||
const hasConnectedProvider = connectedFlavors.size > 0 || chatgpt.status.signedIn
|
||||
// Connect-only, mirroring Settings: entering a key (or base URL) is enough
|
||||
// and the model is resolved silently at save. openai-compatible is the sole
|
||||
// exception with a visible Model field — its /models endpoint often doesn't
|
||||
// exist, so a typed value must be able to win.
|
||||
const showModelInput = llmProvider === "openai-compatible"
|
||||
|
||||
const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => {
|
||||
const isSelected = llmProvider === provider.id
|
||||
return (
|
||||
<motion.button
|
||||
key={provider.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
onClick={() => {
|
||||
setLlmProvider(provider.id)
|
||||
setTestState({ status: "idle" })
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-xl border-2 p-4 text-left transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-transparent bg-muted/50 hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("size-10 rounded-lg flex items-center justify-center shrink-0", provider.color)}>
|
||||
{provider.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{provider.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{provider.description}</div>
|
||||
</div>
|
||||
{connectedFlavors.has(provider.id) && (
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400 ml-auto shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
const { handleNext, handleBack } = state
|
||||
const { defaultModel, isRowboatConnected } = useModels()
|
||||
const hasAssistant = defaultModel !== null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1">
|
||||
{/* Title */}
|
||||
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
|
||||
Choose your provider
|
||||
{isRowboatConnected ? "Add more providers" : "Connect a model provider"}
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground text-center mb-6">
|
||||
Select a provider and configure your API key
|
||||
{isRowboatConnected
|
||||
? "Rowboat is ready to use. Optionally connect your own API keys or local models — their models appear alongside your Rowboat models."
|
||||
: "Connect an API key or a local model to power the Assistant."}
|
||||
</p>
|
||||
|
||||
{/* Inline Rowboat upsell callout */}
|
||||
{!upsellDismissed && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="rounded-xl bg-primary/5 border border-primary/20 p-4 mb-6 flex items-start gap-3"
|
||||
>
|
||||
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">Tip:</span> Hosted models recommended. Locally run LLMs can struggle with Rowboat's parallel background agents. Bring your own API keys below, or sign in for instant access.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSwitchToRowboat}
|
||||
className="text-sm text-primary font-medium hover:underline mt-1 inline-block"
|
||||
>
|
||||
Sign in instead
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setUpsellDismissed(true)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Provider selection */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Provider</span>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{primaryProviders.map((p, i) => renderProviderCard(p, i))}
|
||||
</div>
|
||||
{(showMoreProviders || isMoreProvider) ? (
|
||||
<div className="grid gap-2 sm:grid-cols-2 mt-2">
|
||||
{moreProviders.map((p, i) => renderProviderCard(p, i + 4))}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowMoreProviders(true)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
|
||||
>
|
||||
More providers...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="h-px bg-border my-4" />
|
||||
|
||||
{/* Provider configuration */}
|
||||
<div className="space-y-4">
|
||||
{/* Every provider resolves its model silently at save. openai-compatible
|
||||
alone keeps this field, since its /models is unreliable and a typed
|
||||
value must win; leaving it blank auto-selects from the fetched list. */}
|
||||
{showModelInput && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Model
|
||||
</label>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={activeConfig.model}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
|
||||
placeholder="Model ID (leave empty to auto-select)"
|
||||
/>
|
||||
)}
|
||||
{modelsError && (
|
||||
<div className="text-xs text-destructive">{modelsError}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showApiKey && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
API Key {!state.requiresApiKey && "(optional)"}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={activeConfig.apiKey}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { apiKey: e.target.value })}
|
||||
placeholder="Paste your API key"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ChatGPT subscription — OAuth sign-in shown under the OpenAI card,
|
||||
mirroring Settings (settings-dialog.tsx, gated on provider ===
|
||||
"openai"). Alternative to an API key: signs in via the shared
|
||||
chatgpt:* path, independent of models.json (the Codex model client
|
||||
consumes the token in core). */}
|
||||
{llmProvider === "openai" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
ChatGPT Subscription
|
||||
</label>
|
||||
{chatgpt.status.signedIn ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400 min-w-0">
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
Connected as {chatgpt.status.email ?? "your ChatGPT account"}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={chatgpt.signOut}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
) : chatgpt.isSigningIn ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Waiting for browser…
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.cancelSignIn}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Use your ChatGPT Plus/Pro subscription
|
||||
</span>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.signIn}>
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBaseURL && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Base URL
|
||||
</label>
|
||||
<Input
|
||||
value={activeConfig.baseURL}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { baseURL: e.target.value })}
|
||||
placeholder={
|
||||
llmProvider === "ollama"
|
||||
? "http://localhost:11434"
|
||||
: llmProvider === "openai-compatible"
|
||||
? "http://localhost:1234/v1"
|
||||
: "https://ai-gateway.vercel.sh/v1"
|
||||
}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ProvidersSection dialogOpen variant="onboarding" />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t">
|
||||
|
|
@ -270,44 +39,9 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{testState.status === "success" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
Connected
|
||||
</motion.div>
|
||||
)}
|
||||
{testState.status === "error" && (
|
||||
<span className="text-sm text-destructive max-w-[200px] truncate">
|
||||
{testState.error}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestAndAddAnother}
|
||||
disabled={!canTest || testState.status === "testing"}
|
||||
>
|
||||
Save & add another
|
||||
</Button>
|
||||
<Button
|
||||
onClick={canTest ? handleTestAndSaveLlmConfig : handleNext}
|
||||
disabled={testState.status === "testing" || (!canTest && !hasConnectedProvider)}
|
||||
className="min-w-[140px]"
|
||||
>
|
||||
{testState.status === "testing" ? (
|
||||
<><Loader2 className="size-4 animate-spin mr-2" />Testing...</>
|
||||
) : (canTest || !hasConnectedProvider) ? (
|
||||
"Test & Continue"
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleNext} disabled={!hasAssistant} className="min-w-[140px]">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useState, useEffect, useCallback } from "react"
|
||||
import { setGoogleCredentials } from "@/lib/google-credentials-store"
|
||||
import { useChatGPT } from "@/hooks/useChatGPT"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export interface ProviderState {
|
||||
|
|
@ -13,45 +12,10 @@ export type Step = 0 | 1 | 2 | 3 | 4
|
|||
|
||||
export type OnboardingPath = 'rowboat' | 'byok' | null
|
||||
|
||||
export type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
|
||||
|
||||
export interface LlmModelOption {
|
||||
id: string
|
||||
name?: string
|
||||
release_date?: string
|
||||
}
|
||||
|
||||
export function useOnboardingState(open: boolean, onComplete: (opts?: { startTour?: boolean }) => void) {
|
||||
const [currentStep, setCurrentStep] = useState<Step>(0)
|
||||
const [onboardingPath, setOnboardingPath] = useState<OnboardingPath>(null)
|
||||
|
||||
// LLM setup state
|
||||
const [llmProvider, setLlmProvider] = useState<LlmProviderFlavor>("openai")
|
||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [modelsError, setModelsError] = useState<string | null>(null)
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
})
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||
status: "idle",
|
||||
})
|
||||
const [connectedFlavors, setConnectedFlavors] = useState<Set<LlmProviderFlavor>>(new Set())
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
|
||||
// "Sign in with ChatGPT" (subscription OAuth) is offered below the OpenAI
|
||||
// card, mirroring Settings. It isn't a LlmProviderFlavor — no API key, no
|
||||
// models.json entry — it signs in via the dedicated chatgpt:* IPC (same
|
||||
// path Settings uses) and the Codex model client consumes the token in
|
||||
// core, leaving the BYOK config machinery untouched.
|
||||
const chatgpt = useChatGPT()
|
||||
|
||||
// OAuth provider states
|
||||
const [providers, setProviders] = useState<string[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(true)
|
||||
|
|
@ -72,9 +36,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
|
||||
// Inline upsell callout dismissed
|
||||
const [upsellDismissed, setUpsellDismissed] = useState(false)
|
||||
|
||||
// Composio Gmail/Calendar sync was removed — flags are seeded false and
|
||||
// never flipped. Kept here so legacy gating expressions still type-check.
|
||||
const [useComposioForGoogle] = useState(false)
|
||||
|
|
@ -89,27 +50,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||
|
||||
const updateProviderConfig = useCallback(
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], ...updates },
|
||||
}))
|
||||
setTestState({ status: "idle" })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const activeConfig = providerConfigs[llmProvider]
|
||||
const showApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway" || llmProvider === "openai-compatible"
|
||||
const requiresApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway"
|
||||
const requiresBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible"
|
||||
const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway"
|
||||
const isLocalProvider = llmProvider === "ollama" || llmProvider === "openai-compatible"
|
||||
const canTest =
|
||||
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
|
||||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||
|
||||
// Track connected providers for the completion step
|
||||
const connectedProviders = Object.entries(providerStates)
|
||||
.filter(([, state]) => state.isConnected)
|
||||
|
|
@ -135,56 +75,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
loadProviders()
|
||||
}, [open])
|
||||
|
||||
// Load LLM models catalog on open
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
setModelsLoading(true)
|
||||
setModelsError(null)
|
||||
const result = await window.ipc.invoke("models:list", null)
|
||||
const catalog: Record<string, LlmModelOption[]> = {}
|
||||
for (const provider of result.providers || []) {
|
||||
catalog[provider.id] = provider.models || []
|
||||
}
|
||||
setModelsCatalog(catalog)
|
||||
} catch (error) {
|
||||
console.error("Failed to load models catalog:", error)
|
||||
setModelsError("Failed to load models list")
|
||||
setModelsCatalog({})
|
||||
} finally {
|
||||
setModelsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadModels()
|
||||
}, [open])
|
||||
|
||||
// Preferred default models for each provider
|
||||
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
openai: "gpt-5.4",
|
||||
anthropic: "claude-opus-4-8",
|
||||
}
|
||||
|
||||
// Initialize default models from catalog
|
||||
useEffect(() => {
|
||||
if (Object.keys(modelsCatalog).length === 0) return
|
||||
setProviderConfigs(prev => {
|
||||
const next = { ...prev }
|
||||
const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
|
||||
for (const provider of cloudProviders) {
|
||||
const models = modelsCatalog[provider]
|
||||
if (models?.length && !next[provider].model) {
|
||||
const preferredModel = preferredDefaults[provider]
|
||||
const hasPreferred = preferredModel && models.some(m => m.id === preferredModel)
|
||||
next[provider] = { ...next[provider], model: hasPreferred ? preferredModel : (models[0]?.id || "") }
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [modelsCatalog])
|
||||
|
||||
// Load Granola config
|
||||
const refreshGranolaConfig = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -389,11 +279,7 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
// BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (code mode) → 4 (done)
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep === 0) {
|
||||
if (onboardingPath === 'byok') {
|
||||
setCurrentStep(1)
|
||||
} else {
|
||||
setCurrentStep(2)
|
||||
}
|
||||
setCurrentStep(1)
|
||||
} else if (currentStep === 1) {
|
||||
setCurrentStep(2)
|
||||
} else if (currentStep === 2) {
|
||||
|
|
@ -408,11 +294,7 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
setCurrentStep(0)
|
||||
setOnboardingPath(null)
|
||||
} else if (currentStep === 2) {
|
||||
if (onboardingPath === 'rowboat') {
|
||||
setCurrentStep(0)
|
||||
} else {
|
||||
setCurrentStep(1)
|
||||
}
|
||||
setCurrentStep(1)
|
||||
} else if (currentStep === 3) {
|
||||
setCurrentStep(2)
|
||||
}
|
||||
|
|
@ -429,99 +311,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
onComplete({ startTour: true })
|
||||
}, [onComplete])
|
||||
|
||||
// Test the active provider's credentials and persist its config. Returns
|
||||
// whether it succeeded so callers can decide whether to advance or stay.
|
||||
const testAndSaveActiveProvider = useCallback(async (): Promise<boolean> => {
|
||||
if (!canTest) return false
|
||||
setTestState({ status: "testing" })
|
||||
try {
|
||||
const apiKey = activeConfig.apiKey.trim() || undefined
|
||||
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||
const provider = { flavor: llmProvider, apiKey, baseURL }
|
||||
|
||||
// Fetch the provider's models from the key — this both validates the
|
||||
// credentials and gives us the list to populate the chat picker.
|
||||
const result = await window.ipc.invoke("models:listForProvider", { provider })
|
||||
if (!result.success) {
|
||||
setTestState({ status: "error", error: result.error })
|
||||
toast.error(result.error || "Connection test failed")
|
||||
return false
|
||||
}
|
||||
|
||||
const catalog: string[] = result.models ?? []
|
||||
const typed = activeConfig.model.trim()
|
||||
// Hosted providers hide the model field (it holds an auto-seeded
|
||||
// default), so only treat it as user intent where the field is shown —
|
||||
// mirrors showModelInput in llm-setup-step.
|
||||
const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
|
||||
const modelInputShown = !hostedProviders.includes(llmProvider)
|
||||
|
||||
if (modelInputShown && typed && llmProvider === "ollama" && catalog.length > 0 && !catalog.includes(typed)) {
|
||||
// Ollama's tag list is authoritative: an unlisted model isn't pulled,
|
||||
// so saving it would break chat at runtime with no obvious cause.
|
||||
const error = `Model '${typed}' is not available on this Ollama server. Pull it first (ollama pull ${typed}) or pick one of: ${catalog.slice(0, 5).join(", ")}${catalog.length > 5 ? ", …" : ""}`
|
||||
setTestState({ status: "error", error })
|
||||
toast.error(error)
|
||||
return false
|
||||
}
|
||||
|
||||
// Resolve the model silently — same precedence as the settings dialog's
|
||||
// resolvedModel, so onboarding no longer asks users to type one.
|
||||
// openai-compatible keeps a visible Model field (its /models is
|
||||
// unreliable), so a typed value there wins; otherwise the typed/saved
|
||||
// model if the fetched list still has it, else the flavor's preferred
|
||||
// default, else the first fetched id. An empty list falls back to
|
||||
// whatever was typed/seeded.
|
||||
let model: string
|
||||
if (llmProvider === "openai-compatible" && typed) {
|
||||
model = typed
|
||||
} else if (catalog.length > 0) {
|
||||
const preferred = preferredDefaults[llmProvider]
|
||||
model = (typed && catalog.includes(typed))
|
||||
? typed
|
||||
: ((preferred && catalog.includes(preferred)) ? preferred : catalog[0])
|
||||
} else {
|
||||
model = typed
|
||||
}
|
||||
|
||||
// `models` is the user's curated assistant-model list (shown in Settings),
|
||||
// NOT the full provider catalog. Onboarding seeds it with just the selected
|
||||
// model; users add more from Settings. Persisting the whole catalog here
|
||||
// rendered every model as a separate assistant-model row.
|
||||
await window.ipc.invoke("models:saveConfig", { provider, model, models: model ? [model] : [] })
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
setTestState({ status: "success" })
|
||||
setConnectedFlavors(prev => new Set(prev).add(llmProvider))
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Connection test failed:", error)
|
||||
setTestState({ status: "error", error: "Connection test failed" })
|
||||
toast.error("Connection test failed")
|
||||
return false
|
||||
}
|
||||
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider])
|
||||
|
||||
// Save the active provider and advance to the next step.
|
||||
const handleTestAndSaveLlmConfig = useCallback(async () => {
|
||||
const ok = await testAndSaveActiveProvider()
|
||||
if (ok) handleNext()
|
||||
}, [testAndSaveActiveProvider, handleNext])
|
||||
|
||||
// Save the active provider but stay on the step. Switch to the next provider the
|
||||
// user hasn't connected yet so the form is fresh and the buttons re-enable once
|
||||
// they enter that key. (Clearing the current field instead left the buttons
|
||||
// disabled on an empty form with no clear next step.)
|
||||
const handleTestAndAddAnother = useCallback(async () => {
|
||||
const ok = await testAndSaveActiveProvider()
|
||||
if (!ok) return
|
||||
// setConnectedFlavors is async, so include the just-saved provider here.
|
||||
const connectedNow = new Set(connectedFlavors).add(llmProvider)
|
||||
const order: LlmProviderFlavor[] = ["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]
|
||||
const next = order.find(p => !connectedNow.has(p))
|
||||
if (next) setLlmProvider(next)
|
||||
setTestState({ status: "idle" })
|
||||
}, [testAndSaveActiveProvider, connectedFlavors, llmProvider])
|
||||
|
||||
// Check connection status for all providers
|
||||
const refreshAllStatuses = useCallback(async () => {
|
||||
refreshGranolaConfig()
|
||||
|
|
@ -677,12 +466,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
startConnect('google', { clientId, clientSecret })
|
||||
}, [startConnect])
|
||||
|
||||
// Switch to rowboat path from BYOK inline callout
|
||||
const handleSwitchToRowboat = useCallback(() => {
|
||||
setOnboardingPath('rowboat')
|
||||
setCurrentStep(0)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Step state
|
||||
currentStep,
|
||||
|
|
@ -690,32 +473,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
onboardingPath,
|
||||
setOnboardingPath,
|
||||
|
||||
// LLM state
|
||||
llmProvider,
|
||||
setLlmProvider,
|
||||
modelsCatalog,
|
||||
modelsLoading,
|
||||
modelsError,
|
||||
providerConfigs,
|
||||
activeConfig,
|
||||
testState,
|
||||
setTestState,
|
||||
showApiKey,
|
||||
requiresApiKey,
|
||||
requiresBaseURL,
|
||||
showBaseURL,
|
||||
isLocalProvider,
|
||||
canTest,
|
||||
connectedFlavors,
|
||||
showMoreProviders,
|
||||
setShowMoreProviders,
|
||||
updateProviderConfig,
|
||||
handleTestAndSaveLlmConfig,
|
||||
handleTestAndAddAnother,
|
||||
|
||||
// ChatGPT subscription sign-in (shown below the OpenAI card)
|
||||
chatgpt,
|
||||
|
||||
// OAuth state
|
||||
providers,
|
||||
providersLoading,
|
||||
|
|
@ -746,10 +503,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
handleSlackSaveWorkspaces,
|
||||
handleSlackDisable,
|
||||
|
||||
// Upsell
|
||||
upsellDismissed,
|
||||
setUpsellDismissed,
|
||||
|
||||
// Composio/Gmail state
|
||||
useComposioForGoogle,
|
||||
gmailConnected,
|
||||
|
|
@ -773,7 +526,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
handleBack,
|
||||
handleComplete,
|
||||
handleCompleteWithTour,
|
||||
handleSwitchToRowboat,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { cn } from "@/lib/utils"
|
|||
import * as analytics from "@/lib/analytics"
|
||||
import { useTheme } from "@/contexts/theme-context"
|
||||
import { toast } from "sonner"
|
||||
import { AnthropicIcon, DiscordIcon, GenericApiIcon, GitHubIcon, GoogleIcon, OllamaIcon, OpenAIIcon, OpenRouterIcon, VercelIcon } from "@/components/onboarding/provider-icons"
|
||||
import { AnthropicIcon, DiscordIcon, GitHubIcon, OpenAIIcon } from "@/components/onboarding/provider-icons"
|
||||
import { AccountSettings } from "@/components/settings/account-settings"
|
||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||
import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings"
|
||||
|
|
@ -32,9 +32,8 @@ import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
|
|||
import { DEFAULT_TURN_LIMITS_SETTINGS } from "@x/shared/src/turn-limits.js"
|
||||
import type { ipc as ipcShared } from "@x/shared"
|
||||
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
|
||||
import { useProviderModels } from "@/hooks/use-provider-models"
|
||||
import { useChatGPT } from "@/hooks/useChatGPT"
|
||||
import { ModelSelector, type ModelRef } from "@/components/model-selector"
|
||||
import { ModelSelectionSection } from "@/components/settings/model-selection-section"
|
||||
import { ProvidersSection } from "@/components/settings/providers-section"
|
||||
import { useModels } from "@/hooks/use-models"
|
||||
|
||||
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "advanced" | "help"
|
||||
|
|
@ -483,786 +482,7 @@ function AppearanceSettings() {
|
|||
)
|
||||
}
|
||||
|
||||
// --- Model Settings UI ---
|
||||
|
||||
type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
|
||||
|
||||
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }> = [
|
||||
{ id: "openai", name: "OpenAI", description: "GPT models", icon: OpenAIIcon },
|
||||
{ id: "anthropic", name: "Anthropic", description: "Claude models", icon: AnthropicIcon },
|
||||
{ id: "google", name: "Gemini", description: "Google AI Studio", icon: GoogleIcon },
|
||||
{ id: "ollama", name: "Ollama (Local)", description: "Run models locally", icon: OllamaIcon },
|
||||
]
|
||||
|
||||
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }> = [
|
||||
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", icon: OpenRouterIcon },
|
||||
{ id: "aigateway", name: "AI Gateway (Vercel)", description: "Vercel's AI Gateway", icon: VercelIcon },
|
||||
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom OpenAI-compatible API", icon: GenericApiIcon },
|
||||
]
|
||||
|
||||
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
openai: "gpt-5.4",
|
||||
anthropic: "claude-opus-4-8",
|
||||
}
|
||||
|
||||
const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
ollama: "http://localhost:11434",
|
||||
"openai-compatible": "http://localhost:1234/v1",
|
||||
aigateway: "https://ai-gateway.vercel.sh/v1",
|
||||
}
|
||||
|
||||
type ProviderModelConfig = {
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
models: string[]
|
||||
knowledgeGraphModel: string
|
||||
meetingNotesModel: string
|
||||
liveNoteAgentModel: string
|
||||
autoPermissionDecisionModel: string
|
||||
}
|
||||
|
||||
function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: boolean; rowboatConnected?: boolean }) {
|
||||
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
||||
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
||||
// Flavors present in the saved providers map — drives each card's
|
||||
// "Connected" indicator, independent of which card is active.
|
||||
const [savedProviders, setSavedProviders] = useState<Set<LlmProviderFlavor>>(new Set())
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
|
||||
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "https://ai-gateway.vercel.sh/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
})
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" })
|
||||
const [configLoading, setConfigLoading] = useState(true)
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
// "Defer background tasks while a chat is running" — a top-level
|
||||
// models.json flag. deferExplicit tracks whether the user (or the Ollama
|
||||
// auto-enable) has ever set it, so we only auto-enable once.
|
||||
const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false)
|
||||
const [deferExplicit, setDeferExplicit] = useState(false)
|
||||
|
||||
const activeConfig = providerConfigs[provider]
|
||||
// Live per-key model list for the active provider — drives the primary
|
||||
// model area. The per-function fields below still use the static catalog.
|
||||
const providerModels = useProviderModels({
|
||||
flavor: provider,
|
||||
apiKey: activeConfig.apiKey,
|
||||
baseURL: activeConfig.baseURL,
|
||||
})
|
||||
// "Sign in with ChatGPT" subscription state — only rendered on the OpenAI
|
||||
// card; independent of the API-key providerConfigs state above.
|
||||
const chatgpt = useChatGPT()
|
||||
const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible"
|
||||
const requiresApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway"
|
||||
const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway"
|
||||
const requiresBaseURL = provider === "ollama" || provider === "openai-compatible"
|
||||
const isLocalProvider = provider === "ollama" || provider === "openai-compatible"
|
||||
const isMoreProvider = moreProviders.some(p => p.id === provider)
|
||||
|
||||
const primaryModel = activeConfig.models[0] || ""
|
||||
// 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
|
||||
// on demand if needed, inside handleTestAndSave).
|
||||
const canTest =
|
||||
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
|
||||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(prov: LlmProviderFlavor, updates: Partial<ProviderModelConfig>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { ...prev[prov], ...updates },
|
||||
}))
|
||||
setTestState({ status: "idle" })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// All primary-model writes go through here: the old `models: [value]` form
|
||||
// silently dropped every saved model after the first.
|
||||
const setPrimaryModel = useCallback((prov: LlmProviderFlavor, value: string) => {
|
||||
setProviderConfigs(prev => {
|
||||
const existing = prev[prov].models
|
||||
return {
|
||||
...prev,
|
||||
[prov]: { ...prev[prov], models: [value, ...existing.slice(1).filter(m => m && m !== value)] },
|
||||
}
|
||||
})
|
||||
setTestState({ status: "idle" })
|
||||
}, [])
|
||||
|
||||
|
||||
// Load current config from file
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
|
||||
const asString = (v: unknown): string => (typeof v === "string" ? v : "")
|
||||
|
||||
async function loadCurrentConfig() {
|
||||
try {
|
||||
setConfigLoading(true)
|
||||
const result = await window.ipc.invoke("workspace:readFile", {
|
||||
path: "config/models.json",
|
||||
})
|
||||
const parsed = JSON.parse(result.data)
|
||||
setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true)
|
||||
setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean")
|
||||
const knownFlavors = new Set<string>([...primaryProviders, ...moreProviders].map(p => p.id))
|
||||
setSavedProviders(new Set(
|
||||
Object.keys(parsed?.providers ?? {}).filter((k): k is LlmProviderFlavor => knownFlavors.has(k))
|
||||
))
|
||||
if (parsed?.provider?.flavor && parsed?.model) {
|
||||
const flavor = parsed.provider.flavor as LlmProviderFlavor
|
||||
setProvider(flavor)
|
||||
setDefaultProvider(flavor)
|
||||
setProviderConfigs(prev => {
|
||||
const next = { ...prev };
|
||||
// Hydrate all saved providers from the providers map
|
||||
if (parsed.providers) {
|
||||
for (const [key, entry] of Object.entries(parsed.providers)) {
|
||||
if (key in next) {
|
||||
const e = entry as any;
|
||||
const savedModels: string[] = Array.isArray(e.models) && e.models.length > 0
|
||||
? e.models
|
||||
: e.model ? [e.model] : [""];
|
||||
next[key as LlmProviderFlavor] = {
|
||||
apiKey: e.apiKey || "",
|
||||
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
|
||||
models: savedModels,
|
||||
knowledgeGraphModel: asString(e.knowledgeGraphModel),
|
||||
meetingNotesModel: asString(e.meetingNotesModel),
|
||||
liveNoteAgentModel: asString(e.liveNoteAgentModel),
|
||||
autoPermissionDecisionModel: asString(e.autoPermissionDecisionModel),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// Active provider takes precedence from top-level config,
|
||||
// but only if it exists in the providers map (wasn't deleted)
|
||||
if (parsed.providers?.[flavor]) {
|
||||
const existingModels = next[flavor].models;
|
||||
const activeModels = existingModels[0] === parsed.model
|
||||
? existingModels
|
||||
: [parsed.model, ...existingModels.filter((m: string) => m && m !== parsed.model)];
|
||||
next[flavor] = {
|
||||
apiKey: parsed.provider.apiKey || "",
|
||||
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
|
||||
models: activeModels.length > 0 ? activeModels : [""],
|
||||
knowledgeGraphModel: asString(parsed.knowledgeGraphModel),
|
||||
meetingNotesModel: asString(parsed.meetingNotesModel),
|
||||
liveNoteAgentModel: asString(parsed.liveNoteAgentModel),
|
||||
autoPermissionDecisionModel: asString(parsed.autoPermissionDecisionModel),
|
||||
};
|
||||
}
|
||||
return next;
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// No existing config or parse error - use defaults
|
||||
} finally {
|
||||
setConfigLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadCurrentConfig()
|
||||
}, [dialogOpen])
|
||||
|
||||
const handleDeferToggle = useCallback(async (value: boolean) => {
|
||||
setDeferBackgroundTasks(value)
|
||||
setDeferExplicit(true)
|
||||
try {
|
||||
await window.ipc.invoke("models:updateConfig", { deferBackgroundTasks: value })
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
} catch {
|
||||
toast.error("Failed to save setting")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTestAndSave = useCallback(async () => {
|
||||
if (!canTest) return
|
||||
setTestState({ status: "testing" })
|
||||
try {
|
||||
// Normally providerModels has loaded by click time and resolvedModel is
|
||||
// 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 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", {
|
||||
provider: {
|
||||
flavor: provider,
|
||||
apiKey: activeConfig.apiKey.trim() || undefined,
|
||||
baseURL: activeConfig.baseURL.trim() || undefined,
|
||||
},
|
||||
})
|
||||
if (listRes.success && listRes.models && listRes.models.length > 0) {
|
||||
const preferred = preferredDefaults[provider]
|
||||
model = preferred && listRes.models.includes(preferred) ? preferred : listRes.models[0]
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
setTestState({ status: "error", error: "Enter a model to connect" })
|
||||
toast.error("Enter a model to connect")
|
||||
return
|
||||
}
|
||||
// The silently resolved model takes the primary slot; the rest of the
|
||||
// saved list is preserved (same semantics setPrimaryModel had).
|
||||
const existing = activeConfig.models.map(m => m.trim())
|
||||
const allModels = [model, ...existing.slice(1).filter(m => m && m !== model)]
|
||||
const providerConfig = {
|
||||
provider: {
|
||||
flavor: provider,
|
||||
apiKey: activeConfig.apiKey.trim() || undefined,
|
||||
baseURL: activeConfig.baseURL.trim() || undefined,
|
||||
},
|
||||
model: allModels[0] || "",
|
||||
models: allModels,
|
||||
...(rowboatConnected ? {} : {
|
||||
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
|
||||
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
|
||||
autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined,
|
||||
}),
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
await window.ipc.invoke("models:saveConfig", providerConfig)
|
||||
setDefaultProvider(provider)
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], models: allModels },
|
||||
}))
|
||||
setSavedProviders(prev => new Set(prev).add(provider))
|
||||
setTestState({ status: "success" })
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
// Local models compete with background agents for the same hardware:
|
||||
// when the user connects Ollama and has never touched the defer
|
||||
// flag, enable it for them (they can switch it off below).
|
||||
if (provider === "ollama" && !deferExplicit && !deferBackgroundTasks) {
|
||||
void handleDeferToggle(true)
|
||||
}
|
||||
// Capability probe caveats (local models): saved, but the user should
|
||||
// know when the model can't do tools or has a too-small context.
|
||||
const warnings: string[] = result.warnings ?? []
|
||||
if (warnings.length > 0) {
|
||||
for (const warning of warnings) {
|
||||
toast.warning(warning, { duration: 12000 })
|
||||
}
|
||||
toast.success("Model configuration saved (with warnings)")
|
||||
} else {
|
||||
toast.success("Model configuration saved")
|
||||
}
|
||||
} else {
|
||||
setTestState({ status: "error", error: result.error })
|
||||
toast.error(result.error || "Connection test failed")
|
||||
}
|
||||
} catch {
|
||||
setTestState({ status: "error", error: "Connection test failed" })
|
||||
toast.error("Connection test failed")
|
||||
}
|
||||
}, [canTest, resolvedModel, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle])
|
||||
|
||||
const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
|
||||
const config = providerConfigs[prov]
|
||||
const allModels = config.models.map(m => m.trim()).filter(Boolean)
|
||||
if (!allModels[0]) return
|
||||
try {
|
||||
await window.ipc.invoke("models:saveConfig", {
|
||||
provider: {
|
||||
flavor: prov,
|
||||
apiKey: config.apiKey.trim() || undefined,
|
||||
baseURL: config.baseURL.trim() || undefined,
|
||||
},
|
||||
model: allModels[0],
|
||||
models: allModels,
|
||||
...(rowboatConnected ? {} : {
|
||||
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
||||
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
|
||||
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
setDefaultProvider(prov)
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
toast.success("Default provider updated")
|
||||
} catch {
|
||||
toast.error("Failed to set default provider")
|
||||
}
|
||||
}, [providerConfigs, rowboatConnected])
|
||||
|
||||
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
|
||||
const isDefaultProv = defaultProvider === prov
|
||||
try {
|
||||
const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
|
||||
let parsed = JSON.parse(result.data)
|
||||
|
||||
// Disconnecting the default provider: silently promote another
|
||||
// connected provider first — the connect-only UI has no usable
|
||||
// set-as-default step to send the user to. Prefer the provider the
|
||||
// user's explicit defaultSelection points at; promotion goes through
|
||||
// the same models:saveConfig path Set-as-default uses, so the repo
|
||||
// writes a valid top-level provider/model.
|
||||
let promoted: LlmProviderFlavor | null = null
|
||||
if (isDefaultProv) {
|
||||
const selProvider = parsed?.defaultSelection?.provider
|
||||
const candidates = Object.keys(parsed?.providers ?? {})
|
||||
.filter((k): k is LlmProviderFlavor => k !== prov && k in providerConfigs)
|
||||
.sort((a, b) => (a === selProvider ? -1 : b === selProvider ? 1 : 0))
|
||||
for (const candidate of candidates) {
|
||||
const config = providerConfigs[candidate]
|
||||
const allModels = config.models.map(m => m.trim()).filter(Boolean)
|
||||
// Same silent precedence as connect, minus the live list we don't
|
||||
// have here: the provider's saved model, else its preferred default.
|
||||
const model = allModels[0] || preferredDefaults[candidate] || ""
|
||||
if (!model) continue
|
||||
await window.ipc.invoke("models:saveConfig", {
|
||||
provider: {
|
||||
flavor: candidate,
|
||||
apiKey: config.apiKey.trim() || undefined,
|
||||
baseURL: config.baseURL.trim() || undefined,
|
||||
},
|
||||
model,
|
||||
models: allModels.length > 0 ? allModels : [model],
|
||||
...(rowboatConnected ? {} : {
|
||||
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
||||
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
|
||||
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
promoted = candidate
|
||||
break
|
||||
}
|
||||
if (promoted) {
|
||||
// saveConfig rewrote top-level and the providers map — re-read so
|
||||
// the deletion write below doesn't clobber the promotion.
|
||||
const fresh = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
|
||||
parsed = JSON.parse(fresh.data)
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed?.providers?.[prov]) {
|
||||
delete parsed.providers[prov]
|
||||
}
|
||||
// A defaultSelection pointing at the removed provider is dangling —
|
||||
// drop it so llm:getDefaultModel falls back cleanly.
|
||||
if (parsed?.defaultSelection?.provider === prov) {
|
||||
delete parsed.defaultSelection
|
||||
}
|
||||
// If the deleted provider is the current top-level active one,
|
||||
// switch top-level config to the current default provider
|
||||
if (parsed?.provider?.flavor === prov && defaultProvider && defaultProvider !== prov) {
|
||||
const defConfig = providerConfigs[defaultProvider]
|
||||
const defModels = defConfig.models.map(m => m.trim()).filter(Boolean)
|
||||
parsed.provider = {
|
||||
flavor: defaultProvider,
|
||||
apiKey: defConfig.apiKey.trim() || undefined,
|
||||
baseURL: defConfig.baseURL.trim() || undefined,
|
||||
}
|
||||
parsed.model = defModels[0] || ""
|
||||
parsed.models = defModels
|
||||
if (!rowboatConnected) {
|
||||
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
|
||||
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
|
||||
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
|
||||
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
|
||||
}
|
||||
} else if (parsed?.provider?.flavor === prov) {
|
||||
// Removing the last connected provider: drop the top-level pair
|
||||
// entirely. The schema requires it, so core's readConfig() treats
|
||||
// the file as "no config" — signed-in falls back to the curated
|
||||
// gateway default and signed-out llm:getDefaultModel rejects, which
|
||||
// the composer already handles (it shows the connect hint).
|
||||
delete parsed.provider
|
||||
delete parsed.model
|
||||
delete parsed.models
|
||||
delete parsed.knowledgeGraphModel
|
||||
delete parsed.meetingNotesModel
|
||||
delete parsed.liveNoteAgentModel
|
||||
delete parsed.autoPermissionDecisionModel
|
||||
// With no BYOK providers left, any non-gateway selection is dangling.
|
||||
if (parsed?.defaultSelection && parsed.defaultSelection.provider !== "rowboat"
|
||||
&& Object.keys(parsed?.providers ?? {}).length === 0) {
|
||||
delete parsed.defaultSelection
|
||||
}
|
||||
}
|
||||
await window.ipc.invoke("workspace:writeFile", {
|
||||
path: "config/models.json",
|
||||
data: JSON.stringify(parsed, null, 2),
|
||||
})
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
}))
|
||||
setSavedProviders(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(prov)
|
||||
return next
|
||||
})
|
||||
if (isDefaultProv) setDefaultProvider(promoted)
|
||||
setTestState({ status: "idle" })
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
if (promoted) {
|
||||
const promotedName = [...primaryProviders, ...moreProviders].find(p => p.id === promoted)?.name || promoted
|
||||
toast.success(`Disconnected · ${promotedName} is now the default`)
|
||||
} else {
|
||||
toast.success("Provider configuration removed")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to remove provider")
|
||||
}
|
||||
}, [defaultProvider, providerConfigs, rowboatConnected])
|
||||
|
||||
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }) => {
|
||||
const isDefault = defaultProvider === p.id
|
||||
const isSelected = provider === p.id
|
||||
const isConnected = savedProviders.has(p.id)
|
||||
const hasModel = providerConfigs[p.id].models[0]?.trim().length > 0
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
setProvider(p.id)
|
||||
setTestState({ status: "idle" })
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-2.5 text-left transition-colors relative",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<p.icon className="size-4 shrink-0" />
|
||||
<span className="text-sm font-medium">{p.name}</span>
|
||||
{isDefault && !rowboatConnected && (
|
||||
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
{isConnected && (
|
||||
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
|
||||
{!isDefault && hasModel && isSelected && (
|
||||
<div className="mt-1.5 flex items-center gap-3">
|
||||
{!rowboatConnected && (
|
||||
<span
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSetDefault(p.id)
|
||||
}}
|
||||
className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
Set as default
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteProvider(p.id)
|
||||
}}
|
||||
className="inline-flex text-[11px] text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
|
||||
>
|
||||
Remove
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (configLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Provider selection */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider</span>
|
||||
<div className="grid gap-2 grid-cols-2">
|
||||
{primaryProviders.map(renderProviderCard)}
|
||||
</div>
|
||||
{(showMoreProviders || isMoreProvider) ? (
|
||||
<div className="grid gap-2 grid-cols-2 mt-2">
|
||||
{moreProviders.map(renderProviderCard)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowMoreProviders(true)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
|
||||
>
|
||||
More providers...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Key — key-first: the model list is fetched from it */}
|
||||
{showApiKey && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{provider === "openai-compatible" ? "API Key (optional)" : "API Key"}
|
||||
</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={activeConfig.apiKey}
|
||||
onChange={(e) => updateConfig(provider, { apiKey: e.target.value })}
|
||||
onBlur={() => providerModels.refetch()}
|
||||
placeholder="Paste your API key"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ChatGPT subscription — OAuth sign-in, independent of the API key and
|
||||
of models.json (the Codex model client consumes the token via core) */}
|
||||
{provider === "openai" && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
ChatGPT Subscription
|
||||
</span>
|
||||
{chatgpt.status.signedIn ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600 min-w-0">
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
Connected as {chatgpt.status.email ?? "your ChatGPT account"}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={chatgpt.signOut}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
) : chatgpt.isSigningIn ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Waiting for browser…
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.cancelSignIn}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Use your ChatGPT Plus/Pro subscription
|
||||
</span>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.signIn}>
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base URL */}
|
||||
{showBaseURL && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Base URL</span>
|
||||
<Input
|
||||
value={activeConfig.baseURL}
|
||||
onChange={(e) => updateConfig(provider, { baseURL: e.target.value })}
|
||||
onBlur={() => providerModels.refetch()}
|
||||
placeholder={
|
||||
provider === "ollama"
|
||||
? "http://localhost:11434"
|
||||
: provider === "openai-compatible"
|
||||
? "http://localhost:1234/v1"
|
||||
: "https://ai-gateway.vercel.sh/v1"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection status — the model itself is resolved silently on save */}
|
||||
<div className="space-y-2">
|
||||
{providerModels.status === "idle" ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isLocalProvider
|
||||
? "Enter your base URL to connect"
|
||||
: "Enter your API key to connect"}
|
||||
</div>
|
||||
) : providerModels.status === "loading" ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Checking connection…
|
||||
</div>
|
||||
) : providerModels.status === "error" ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-destructive break-words">
|
||||
{providerModels.error || "Connection check failed"}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => providerModels.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
{provider !== "openai-compatible" && (
|
||||
<Input
|
||||
value={primaryModel}
|
||||
onChange={(e) => setPrimaryModel(provider, e.target.value)}
|
||||
placeholder="Enter a model to connect anyway"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : providerModels.models.length === 0 && provider !== "openai-compatible" ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Connected, but the provider reported no models — enter one manually
|
||||
</div>
|
||||
<Input
|
||||
value={primaryModel}
|
||||
onChange={(e) => setPrimaryModel(provider, e.target.value)}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
Connected · {providerModels.models.length} model{providerModels.models.length === 1 ? "" : "s"} available
|
||||
</div>
|
||||
)}
|
||||
{savedProviders.has(provider) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => handleDeleteProvider(provider)}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</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
|
||||
ModelRef picker value is adapted at this boundary: string ↔
|
||||
{provider, model} with the active card's flavor. allowCustom keeps
|
||||
arbitrary ids typeable (local servers, unlisted models). */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{!rowboatConnected && (<>
|
||||
{(
|
||||
[
|
||||
{ label: "Knowledge graph model", field: "knowledgeGraphModel" },
|
||||
{ label: "Meeting notes model", field: "meetingNotesModel" },
|
||||
{ label: "Track block model", field: "liveNoteAgentModel" },
|
||||
{ label: "Auto-permission model", field: "autoPermissionDecisionModel" },
|
||||
] as const
|
||||
).map(({ label, field }) => (
|
||||
<div key={field} className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
providerFilter={provider}
|
||||
allowCustom
|
||||
defaultOption={{ label: "Same as assistant" }}
|
||||
value={activeConfig[field] ? { provider, model: activeConfig[field] } : null}
|
||||
onChange={(ref) => updateConfig(provider, { [field]: ref ? ref.model : "" })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{/* Test status */}
|
||||
{testState.status === "error" && (
|
||||
<div className="text-sm text-destructive">
|
||||
{testState.error || "Connection test failed"}
|
||||
</div>
|
||||
)}
|
||||
{testState.status === "success" && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
Connected and saved
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Defer background tasks while chatting */}
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">Defer background tasks while chatting</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Background agents (knowledge sync, live notes, tasks) wait until your chat finishes. Recommended for local models.
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={deferBackgroundTasks} onCheckedChange={handleDeferToggle} />
|
||||
</div>
|
||||
|
||||
{/* Test & Save button */}
|
||||
<Button
|
||||
onClick={handleTestAndSave}
|
||||
disabled={!canTest || testState.status === "testing"}
|
||||
className="w-full"
|
||||
>
|
||||
{testState.status === "testing" ? (
|
||||
<><Loader2 className="size-4 animate-spin mr-2" />Connecting...</>
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Tools Library Settings ---
|
||||
|
||||
interface ToolkitInfo {
|
||||
slug: string
|
||||
|
|
@ -1581,128 +801,6 @@ function ToolsLibrarySettings({ dialogOpen, rowboatConnected }: { dialogOpen: bo
|
|||
)
|
||||
}
|
||||
|
||||
// --- Rowboat Model Settings (when signed in via Rowboat) ---
|
||||
//
|
||||
// Hybrid mode: every picker lists the gateway catalog PLUS any BYOK
|
||||
// providers configured below (ModelSelector renders the shared store's
|
||||
// groups, live-fetching list-less providers inside the open dropdown).
|
||||
// Saved via models:updateConfig as {provider, model} refs, so a signed-in
|
||||
// user can e.g. keep the gateway assistant while running background agents
|
||||
// on a local Ollama model. Selections stay local until Save — unlike the
|
||||
// composer, picking here must not write anything by itself.
|
||||
|
||||
function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [selectedDefault, setSelectedDefault] = useState<ModelRef | null>(null)
|
||||
const [selectedKg, setSelectedKg] = useState<ModelRef | null>(null)
|
||||
const [selectedLiveNote, setSelectedLiveNote] = useState<ModelRef | null>(null)
|
||||
const [selectedAutoPermission, setSelectedAutoPermission] = useState<ModelRef | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
let parsed: Record<string, unknown> = {}
|
||||
try {
|
||||
const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
|
||||
parsed = JSON.parse(configResult.data)
|
||||
} catch { /* no config yet */ }
|
||||
|
||||
// Current selections. Legacy string overrides pair with the BYOK
|
||||
// top-level flavor (mirrors core/models/defaults.ts).
|
||||
const legacyFlavor = (parsed.provider as Record<string, unknown> | undefined)?.flavor
|
||||
const toRef = (value: unknown): ModelRef | null => {
|
||||
if (!value) return null
|
||||
if (typeof value === "string") {
|
||||
return typeof legacyFlavor === "string" ? { provider: legacyFlavor, model: value } : null
|
||||
}
|
||||
const ref = value as { provider?: unknown; model?: unknown }
|
||||
return typeof ref.provider === "string" && typeof ref.model === "string"
|
||||
? { provider: ref.provider, model: ref.model }
|
||||
: null
|
||||
}
|
||||
setSelectedDefault(toRef(parsed.defaultSelection))
|
||||
setSelectedKg(toRef(parsed.knowledgeGraphModel))
|
||||
setSelectedLiveNote(toRef(parsed.liveNoteAgentModel))
|
||||
setSelectedAutoPermission(toRef(parsed.autoPermissionDecisionModel))
|
||||
} catch {
|
||||
toast.error("Failed to load models")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
}, [dialogOpen])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await window.ipc.invoke("models:updateConfig", {
|
||||
defaultSelection: selectedDefault,
|
||||
knowledgeGraphModel: selectedKg,
|
||||
liveNoteAgentModel: selectedLiveNote,
|
||||
autoPermissionDecisionModel: selectedAutoPermission,
|
||||
})
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
toast.success("Model configuration saved")
|
||||
} catch {
|
||||
toast.error("Failed to save model configuration")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [selectedDefault, selectedKg, selectedLiveNote, selectedAutoPermission])
|
||||
|
||||
const renderModelField = (
|
||||
label: string,
|
||||
value: ModelRef | null,
|
||||
onChange: (v: ModelRef | null) => void,
|
||||
) => (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{label}</label>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
defaultOption={{ label: "Rowboat default" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select the models Rowboat uses. Rowboat models are provided through your account; models from your own providers route through your keys or local runtimes.
|
||||
</p>
|
||||
|
||||
{renderModelField("Assistant model", selectedDefault, setSelectedDefault)}
|
||||
{renderModelField("Knowledge graph model", selectedKg, setSelectedKg)}
|
||||
{renderModelField("Background agents model", selectedLiveNote, setSelectedLiveNote)}
|
||||
{renderModelField("Permission checks model", selectedAutoPermission, setSelectedAutoPermission)}
|
||||
|
||||
{/* Save */}
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<><Loader2 className="size-4 animate-spin mr-2" />Saving...</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Note Tagging Settings ---
|
||||
|
||||
interface TagDef {
|
||||
|
|
@ -2795,8 +1893,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
<div className="px-6 pb-4 pt-5">
|
||||
<h3 className="text-lg font-semibold tracking-tight">{activeTabConfig.label}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{activeTab === "models" && rowboatConnected
|
||||
? "Select your default models"
|
||||
{activeTab === "models"
|
||||
? "Choose the models Rowboat uses for chat and background work."
|
||||
: activeTabConfig.description}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -2823,21 +1921,22 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
) : activeTab === "mobile" ? (
|
||||
<MobileChannelsSettings dialogOpen={open} />
|
||||
) : activeTab === "models" ? (
|
||||
rowboatConnected
|
||||
? (
|
||||
<div className="space-y-8">
|
||||
<RowboatModelSettings dialogOpen={open} />
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Your own providers</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect your own API keys or local runtimes (Ollama, LM Studio). Their models appear in the model pickers above and alongside your Rowboat models, and are billed to you directly.
|
||||
</p>
|
||||
<ModelSettings dialogOpen={open} rowboatConnected />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: <ModelSettings dialogOpen={open} />
|
||||
// ONE model-selection surface for signed-in and BYOK alike:
|
||||
// the Assistant model + per-task overrides, then provider
|
||||
// (credential) management below.
|
||||
<div className="space-y-8">
|
||||
<ModelSelectionSection dialogOpen={open} />
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">{rowboatConnected ? "Your own providers" : "Providers"}</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{rowboatConnected
|
||||
? "Connect your own API keys or local runtimes (Ollama, LM Studio). Their models appear in every picker alongside your Rowboat models, and are billed to you directly."
|
||||
: "Connect API keys or local runtimes (Ollama, LM Studio). Every connected provider's models appear in the pickers above."}
|
||||
</p>
|
||||
<ProvidersSection dialogOpen={open} />
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "note-tagging" ? (
|
||||
<NoteTaggingSettings dialogOpen={open} />
|
||||
) : activeTab === "appearance" ? (
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from "@/components/ui/alert-dialog"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { useRowboatConfig } from "@/hooks/use-rowboat-config"
|
||||
import { CreditRewards } from "@/components/settings/credit-rewards"
|
||||
import { toast } from "sonner"
|
||||
import { getBillingPlanData, type BillingUsageBucket } from "@x/shared/dist/billing.js"
|
||||
|
|
@ -56,7 +57,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
const [connectionLoading, setConnectionLoading] = useState(true)
|
||||
const [disconnecting, setDisconnecting] = useState(false)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
const appUrl = useRowboatConfig()?.appUrl ?? null
|
||||
const { billing, isLoading: billingLoading, refresh: refreshBilling } = useBilling(isRowboatConnected)
|
||||
const currentPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
|
||||
const hasPaidSubscription = currentPlan?.category === 'starter' || currentPlan?.category === 'pro'
|
||||
|
|
@ -80,14 +81,6 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
}
|
||||
}, [dialogOpen, checkConnection])
|
||||
|
||||
useEffect(() => {
|
||||
if (isRowboatConnected) {
|
||||
window.ipc.invoke('account:getRowboat', null)
|
||||
.then((account) => setAppUrl(account.config?.appUrl ?? null))
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [isRowboatConnected])
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
if (event.provider === 'rowboat') {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { __resetModelsForTests } from '@/hooks/use-models'
|
||||
import { ModelSelectionSection } from './model-selection-section'
|
||||
|
||||
// Same preload stub pattern as use-models.test.tsx.
|
||||
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
|
||||
let updateCalls: unknown[] = []
|
||||
|
||||
;(window as unknown as { ipc: unknown }).ipc = {
|
||||
on: () => () => undefined,
|
||||
invoke: (channel: string, args: unknown) => {
|
||||
if (channel === 'models:updateConfig') updateCalls.push(args)
|
||||
const handler = handlers[channel]
|
||||
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
|
||||
},
|
||||
}
|
||||
|
||||
const EMPTY_TASKS = {
|
||||
knowledgeGraph: null,
|
||||
meetingNotes: null,
|
||||
liveNoteAgent: null,
|
||||
autoPermissionDecision: null,
|
||||
chatTitle: null,
|
||||
backgroundTask: null,
|
||||
subagent: null,
|
||||
}
|
||||
|
||||
function serve(opts: {
|
||||
assistant?: { provider: string; model: string } | null
|
||||
taskModels?: Record<string, { provider: string; model: string } | null>
|
||||
}): void {
|
||||
handlers['models:list'] = async () => ({
|
||||
providers: [
|
||||
{ id: 'rowboat', flavor: 'rowboat', status: 'ok', models: [{ id: 'google/gemini-3.5-flash' }] },
|
||||
],
|
||||
defaultModel: opts.assistant ?? null,
|
||||
})
|
||||
handlers['models:getConfig'] = async () => ({
|
||||
assistantModel: opts.assistant ?? null,
|
||||
taskModels: { ...EMPTY_TASKS, ...(opts.taskModels ?? {}) },
|
||||
deferBackgroundTasks: false,
|
||||
})
|
||||
handlers['models:updateConfig'] = async () => ({ success: true })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetModelsForTests()
|
||||
handlers = {}
|
||||
updateCalls = []
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('ModelSelectionSection', () => {
|
||||
it('shows the effective assistant model and "Same as Assistant" for un-overridden tasks', async () => {
|
||||
serve({ assistant: { provider: 'rowboat', model: 'google/gemini-3.5-flash' } })
|
||||
render(<ModelSelectionSection dialogOpen />)
|
||||
|
||||
// Assistant trigger shows the actual model — no "Auto" anywhere.
|
||||
await waitFor(() => expect(screen.getByTitle('Assistant model')).toHaveTextContent('google/gemini-3.5-flash'))
|
||||
// The old sentinel labels are gone for good.
|
||||
expect(screen.queryByText(/Auto \(/)).toBeNull()
|
||||
expect(screen.queryByText('Rowboat default')).toBeNull()
|
||||
|
||||
// All seven tasks render, inheriting.
|
||||
for (const label of ['Background agents', 'Subagents', 'Knowledge graph', 'Meeting notes', 'Live notes', 'Permission checks', 'Chat titles']) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
expect(screen.getAllByText('Same as Assistant').length).toBe(7)
|
||||
// Inherit subtext names the resolved assistant.
|
||||
expect(screen.getAllByText('Currently uses Rowboat · google/gemini-3.5-flash').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('an overridden task shows "Use Assistant model" and clicking it clears the override', async () => {
|
||||
serve({
|
||||
assistant: { provider: 'rowboat', model: 'google/gemini-3.5-flash' },
|
||||
taskModels: { knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' } },
|
||||
})
|
||||
render(<ModelSelectionSection dialogOpen />)
|
||||
|
||||
const clear = await screen.findByText('Use Assistant model')
|
||||
fireEvent.click(clear)
|
||||
await waitFor(() => expect(updateCalls).toEqual([
|
||||
{ taskModels: { knowledgeGraph: null } },
|
||||
]))
|
||||
// Back to inheriting.
|
||||
await waitFor(() => expect(screen.queryByText('Use Assistant model')).toBeNull())
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { ModelSelector, providerDisplayNames, type ModelRef } from "@/components/model-selector"
|
||||
import { useModels } from "@/hooks/use-models"
|
||||
|
||||
// The unified model-selection surface (signed-in and BYOK alike): ONE
|
||||
// required Assistant model plus per-task overrides that default to
|
||||
// "Same as Assistant". No "Auto" rows — every choice is an explicit model;
|
||||
// recommendation logic only ever picks INITIAL models at provider-connect
|
||||
// time, never appears as a dropdown option.
|
||||
|
||||
type TaskKey =
|
||||
| "backgroundTask"
|
||||
| "subagent"
|
||||
| "knowledgeGraph"
|
||||
| "meetingNotes"
|
||||
| "liveNoteAgent"
|
||||
| "autoPermissionDecision"
|
||||
| "chatTitle"
|
||||
|
||||
const TASKS: Array<{ key: TaskKey; label: string; description: string }> = [
|
||||
{ key: "backgroundTask", label: "Background agents", description: "Scheduled and event-driven agents that run without a chat" },
|
||||
{ key: "subagent", label: "Subagents", description: "Workers the assistant spawns during a chat" },
|
||||
{ key: "knowledgeGraph", label: "Knowledge graph", description: "Note creation, email classification, knowledge sync" },
|
||||
{ key: "meetingNotes", label: "Meeting notes", description: "Meeting summaries and prep briefs" },
|
||||
{ key: "liveNoteAgent", label: "Live notes", description: "Self-updating notes and their routing" },
|
||||
{ key: "autoPermissionDecision", label: "Permission checks", description: "Auto-approval of safe tool calls" },
|
||||
{ key: "chatTitle", label: "Chat titles", description: "Naming chats from the first message" },
|
||||
]
|
||||
|
||||
function refLabel(ref: ModelRef): string {
|
||||
return `${providerDisplayNames[ref.provider] || ref.provider} · ${ref.model}`
|
||||
}
|
||||
|
||||
export function ModelSelectionSection({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
// The effective assistant model — the same value every picker shows.
|
||||
const { defaultModel, groups } = useModels()
|
||||
const [taskModels, setTaskModels] = useState<Partial<Record<TaskKey, ModelRef | null>>>({})
|
||||
|
||||
// Retired-model detection: the saved assistant no longer appears in its
|
||||
// provider's live list. Only trusted lists count — a failed fetch or an
|
||||
// openai-compatible endpoint (whose /models is often unreliable) must not
|
||||
// flag a working model.
|
||||
const assistantUnavailable = (() => {
|
||||
if (!defaultModel) return false
|
||||
const group = groups.find((g) => g.id === defaultModel.provider)
|
||||
if (!group || group.status !== "ok" || group.models.length === 0) return false
|
||||
if (group.flavor === "openai-compatible") return false
|
||||
return !group.models.includes(defaultModel.model)
|
||||
})()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const cfg = await window.ipc.invoke("models:getConfig", null)
|
||||
setTaskModels(cfg.taskModels)
|
||||
} catch {
|
||||
// Fresh install — everything inherits.
|
||||
setTaskModels({})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogOpen) void load()
|
||||
}, [dialogOpen, load])
|
||||
|
||||
const setAssistant = useCallback(async (ref: ModelRef | null) => {
|
||||
// No sentinel row on the assistant picker, so ref is never null — the
|
||||
// assistant is the one required selection.
|
||||
if (!ref) return
|
||||
try {
|
||||
await window.ipc.invoke("models:updateConfig", { assistantModel: ref })
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
} catch {
|
||||
toast.error("Failed to save the Assistant model")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setTask = useCallback(async (key: TaskKey, ref: ModelRef | null) => {
|
||||
const previous = taskModels
|
||||
setTaskModels((prev) => ({ ...prev, [key]: ref }))
|
||||
try {
|
||||
await window.ipc.invoke("models:updateConfig", { taskModels: { [key]: ref } })
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
} catch {
|
||||
toast.error("Failed to save the model")
|
||||
setTaskModels(previous)
|
||||
}
|
||||
}, [taskModels])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Assistant model — the one required primary selection. */}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
||||
Assistant model
|
||||
{assistantUnavailable && (
|
||||
<span className="rounded-full bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-destructive">
|
||||
Unavailable
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Used for chat and for any task without its own model selection.
|
||||
</p>
|
||||
</div>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={defaultModel}
|
||||
onChange={setAssistant}
|
||||
triggerTitle="Assistant model"
|
||||
/>
|
||||
{assistantUnavailable && defaultModel && (
|
||||
<p className="text-xs text-destructive">
|
||||
This model is no longer listed by {providerDisplayNames[defaultModel.provider] || defaultModel.provider}. Choose another model to continue.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Per-task overrides — inherit the assistant unless picked. */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">Models for other tasks</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
These tasks use the Assistant model unless you choose a different one.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4">
|
||||
{TASKS.map(({ key, label, description }) => {
|
||||
const override = taskModels[key] ?? null
|
||||
const inheritText = key === "subagent"
|
||||
? "Uses the spawning chat's model"
|
||||
: defaultModel
|
||||
? `Currently uses ${refLabel(defaultModel)}`
|
||||
: "Uses the Assistant model"
|
||||
return (
|
||||
<div key={key} className="space-y-1.5 min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
{override && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground shrink-0"
|
||||
onClick={() => void setTask(key, null)}
|
||||
>
|
||||
Use Assistant model
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
allowCustom
|
||||
inheritDefault={{ label: "Same as Assistant" }}
|
||||
value={override}
|
||||
onChange={(ref) => void setTask(key, ref)}
|
||||
triggerTitle={label}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground truncate" title={override ? refLabel(override) : inheritText}>
|
||||
{override ? "Uses a different model from the Assistant" : inheritText}
|
||||
</p>
|
||||
<p className="sr-only">{description}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,761 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import * as analytics from "@/lib/analytics"
|
||||
import { ArrowLeft, CheckCircle2, Loader2, Plus, RefreshCw } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { providerDisplayNames, type ModelRef } from "@/components/model-selector"
|
||||
import { selectInitialModel, selectInitialTaskModels } from "@x/shared/dist/initial-selection.js"
|
||||
import { normalizeModelRecommendation, type ModelRecommendations } from "@x/shared/dist/rowboat-account.js"
|
||||
import { useModels } from "@/hooks/use-models"
|
||||
import { useRowboatConfig } from "@/hooks/use-rowboat-config"
|
||||
import { useChatGPT } from "@/hooks/useChatGPT"
|
||||
import {
|
||||
AnthropicIcon,
|
||||
GenericApiIcon,
|
||||
GoogleIcon,
|
||||
OllamaIcon,
|
||||
OpenAIIcon,
|
||||
OpenRouterIcon,
|
||||
VercelIcon,
|
||||
} from "@/components/onboarding/provider-icons"
|
||||
|
||||
// Provider lifecycle: connected-provider cards (status + model counts), the
|
||||
// add-provider flow, per-provider manage (replace key / endpoint / refresh
|
||||
// models / used-by), and disconnect with its consequences spelled out.
|
||||
// Providers manage CREDENTIALS only — model choices live in
|
||||
// ModelSelectionSection above this section.
|
||||
|
||||
type ByokFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
|
||||
|
||||
interface ProviderMeta {
|
||||
id: string
|
||||
flavor: string
|
||||
baseURL?: string
|
||||
hasApiKey: boolean
|
||||
}
|
||||
|
||||
interface Selections {
|
||||
assistantModel: ModelRef | null
|
||||
taskModels: Record<string, ModelRef | null>
|
||||
}
|
||||
|
||||
const TASK_LABELS: Record<string, string> = {
|
||||
backgroundTask: "Background agents",
|
||||
subagent: "Subagents",
|
||||
knowledgeGraph: "Knowledge graph",
|
||||
meetingNotes: "Meeting notes",
|
||||
liveNoteAgent: "Live notes",
|
||||
autoPermissionDecision: "Permission checks",
|
||||
chatTitle: "Chat titles",
|
||||
}
|
||||
|
||||
const BYOK_CATALOG: Array<{ flavor: ByokFlavor; name: string; tagline: string; icon: React.ElementType; needsKey: boolean; needsEndpoint: boolean; optionalKey?: boolean; manualModel?: boolean }> = [
|
||||
{ flavor: "openai", name: "OpenAI", tagline: "GPT models", icon: OpenAIIcon, needsKey: true, needsEndpoint: false },
|
||||
{ flavor: "anthropic", name: "Anthropic", tagline: "Claude models", icon: AnthropicIcon, needsKey: true, needsEndpoint: false },
|
||||
{ flavor: "google", name: "Gemini", tagline: "Google AI Studio", icon: GoogleIcon, needsKey: true, needsEndpoint: false },
|
||||
{ flavor: "ollama", name: "Ollama", tagline: "Run models locally", icon: OllamaIcon, needsKey: false, needsEndpoint: true },
|
||||
{ flavor: "openrouter", name: "OpenRouter", tagline: "One key, many models", icon: OpenRouterIcon, needsKey: true, needsEndpoint: false },
|
||||
{ flavor: "aigateway", name: "AI Gateway (Vercel)", tagline: "Vercel's AI Gateway", icon: VercelIcon, needsKey: true, needsEndpoint: false },
|
||||
{ flavor: "openai-compatible", name: "OpenAI-Compatible", tagline: "Custom OpenAI-compatible endpoint", icon: GenericApiIcon, needsKey: true, optionalKey: true, needsEndpoint: true, manualModel: true },
|
||||
]
|
||||
|
||||
const DEFAULT_BASE_URLS: Partial<Record<ByokFlavor, string>> = {
|
||||
ollama: "http://localhost:11434",
|
||||
"openai-compatible": "http://localhost:1234/v1",
|
||||
aigateway: "https://ai-gateway.vercel.sh/v1",
|
||||
}
|
||||
|
||||
function flavorMeta(flavor: string) {
|
||||
return BYOK_CATALOG.find((c) => c.flavor === flavor)
|
||||
}
|
||||
|
||||
export function ProvidersSection({ dialogOpen, variant = "settings" }: {
|
||||
dialogOpen: boolean
|
||||
/**
|
||||
* "onboarding" renders the same connected-provider list + add flow but
|
||||
* without the settings-only chrome (Manage buttons, defer toggle) — the
|
||||
* onboarding step supplies its own framing and navigation.
|
||||
*/
|
||||
variant?: "settings" | "onboarding"
|
||||
}) {
|
||||
const { groups, isRowboatConnected, refresh } = useModels()
|
||||
const chatgpt = useChatGPT()
|
||||
const modelRecommendations = useRowboatConfig()?.modelRecommendations
|
||||
|
||||
const [providersMeta, setProvidersMeta] = useState<ProviderMeta[]>([])
|
||||
const [selections, setSelections] = useState<Selections>({ assistantModel: null, taskModels: {} })
|
||||
const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [manageId, setManageId] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const cfg = await window.ipc.invoke("models:getConfig", null)
|
||||
setProvidersMeta(cfg.providers)
|
||||
setSelections({ assistantModel: cfg.assistantModel, taskModels: cfg.taskModels })
|
||||
setDeferBackgroundTasks(cfg.deferBackgroundTasks)
|
||||
} catch { /* fresh install */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogOpen) void load()
|
||||
}, [dialogOpen, load])
|
||||
useEffect(() => {
|
||||
const handler = () => void load()
|
||||
window.addEventListener("models-config-changed", handler)
|
||||
// Main-side config writes (Rowboat sign-in seeding the assistant,
|
||||
// sign-out clearing selections, ChatGPT state) announce themselves on
|
||||
// the auth broadcasts, not the window event — reload on those too.
|
||||
const cleanups = [
|
||||
window.ipc.on("oauth:didConnect", handler),
|
||||
window.ipc.on("chatgpt:statusChanged", handler),
|
||||
]
|
||||
return () => {
|
||||
window.removeEventListener("models-config-changed", handler)
|
||||
for (const cleanup of cleanups) cleanup()
|
||||
}
|
||||
}, [load])
|
||||
|
||||
// One card per connected provider, in catalog order (assistant's first).
|
||||
const cards = useMemo(() => {
|
||||
return groups.map((g) => {
|
||||
const meta = providersMeta.find((p) => p.id === g.id)
|
||||
return {
|
||||
id: g.id,
|
||||
flavor: g.flavor,
|
||||
name: providerDisplayNames[g.flavor] || g.flavor,
|
||||
status: g.status,
|
||||
error: g.error,
|
||||
modelCount: g.models.length,
|
||||
meta,
|
||||
}
|
||||
})
|
||||
}, [groups, providersMeta])
|
||||
|
||||
const usedBy = useCallback((providerId: string): Array<{ label: string; model: string }> => {
|
||||
const rows: Array<{ label: string; model: string }> = []
|
||||
if (selections.assistantModel?.provider === providerId) {
|
||||
rows.push({ label: "Assistant model", model: selections.assistantModel.model })
|
||||
}
|
||||
for (const [key, ref] of Object.entries(selections.taskModels)) {
|
||||
if (ref?.provider === providerId) {
|
||||
rows.push({ label: TASK_LABELS[key] ?? key, model: ref.model })
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}, [selections])
|
||||
|
||||
const handleDeferToggle = useCallback(async (value: boolean) => {
|
||||
setDeferBackgroundTasks(value)
|
||||
try {
|
||||
await window.ipc.invoke("models:updateConfig", { deferBackgroundTasks: value })
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
} catch {
|
||||
toast.error("Failed to save setting")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const manageCard = manageId ? cards.find((c) => c.id === manageId) ?? null : null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
{cards.length === 0 && (
|
||||
<div className="rounded-md border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Connect Rowboat, use your own API key, or choose a local provider to start using the Assistant.
|
||||
</div>
|
||||
)}
|
||||
{cards.map((c) => (
|
||||
<div key={c.id} className="flex items-center gap-3 rounded-md border px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{c.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full shrink-0",
|
||||
c.status === "ok" ? "bg-green-500" : "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
{c.status === "ok"
|
||||
? `Connected · ${c.modelCount} model${c.modelCount === 1 ? "" : "s"} available`
|
||||
: (c.error || "Could not load models")}
|
||||
</div>
|
||||
</div>
|
||||
{c.status === "error" && (
|
||||
<Button variant="ghost" size="sm" onClick={() => refresh(c.id)}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
{variant === "settings" && (
|
||||
<Button variant="outline" size="sm" onClick={() => setManageId(c.id)}>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="size-4 mr-1" />
|
||||
Add provider
|
||||
</Button>
|
||||
|
||||
{/* Defer background tasks while chatting */}
|
||||
{variant === "settings" && (
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">Defer background tasks while chatting</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Background agents wait until no chat is running. Recommended for local models.
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={deferBackgroundTasks} onCheckedChange={handleDeferToggle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddProviderDialog
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
connectedIds={cards.map((c) => c.id)}
|
||||
isRowboatConnected={isRowboatConnected}
|
||||
chatgptSignedIn={chatgpt.status.signedIn}
|
||||
onChatGPTSignIn={chatgpt.signIn}
|
||||
hadAssistant={selections.assistantModel !== null}
|
||||
modelRecommendations={modelRecommendations}
|
||||
analyticsSource={variant === "onboarding" ? "onboarding" : "connect"}
|
||||
/>
|
||||
|
||||
{manageCard && (
|
||||
<ManageProviderDialog
|
||||
card={manageCard}
|
||||
usedBy={usedBy(manageCard.id)}
|
||||
onClose={() => setManageId(null)}
|
||||
onRefreshModels={async () => { await refresh(manageCard.id) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Add provider ----------
|
||||
|
||||
type AddStep =
|
||||
| { kind: "choose" }
|
||||
| { kind: "creds"; flavor: ByokFlavor }
|
||||
| { kind: "authwait"; which: "rowboat" | "chatgpt" }
|
||||
| { kind: "loading"; flavor: ByokFlavor }
|
||||
| { kind: "result"; name: string; first: boolean; pickedModel: string | null; modelCount: number | null }
|
||||
| { kind: "error"; flavor: ByokFlavor; message: string }
|
||||
|
||||
function AddProviderDialog({ open, onOpenChange, connectedIds, isRowboatConnected, chatgptSignedIn, onChatGPTSignIn, hadAssistant, modelRecommendations, analyticsSource }: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
connectedIds: string[]
|
||||
isRowboatConnected: boolean
|
||||
chatgptSignedIn: boolean
|
||||
onChatGPTSignIn: () => Promise<unknown> | void
|
||||
hadAssistant: boolean
|
||||
modelRecommendations: ModelRecommendations | undefined
|
||||
analyticsSource: 'connect' | 'onboarding'
|
||||
}) {
|
||||
const [step, setStep] = useState<AddStep>({ kind: "choose" })
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [baseURL, setBaseURL] = useState("")
|
||||
const [manualModel, setManualModel] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep({ kind: "choose" })
|
||||
setApiKey("")
|
||||
setBaseURL("")
|
||||
setManualModel("")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Rowboat / ChatGPT sign-in completes out-of-band (browser); the shared
|
||||
// store refreshes on the auth broadcasts, so the provider appearing in
|
||||
// connectedIds is the completion signal.
|
||||
useEffect(() => {
|
||||
if (step.kind !== "authwait") return
|
||||
const id = step.which === "rowboat" ? "rowboat" : "codex"
|
||||
if (connectedIds.includes(id)) {
|
||||
setStep({
|
||||
kind: "result",
|
||||
name: providerDisplayNames[id] || id,
|
||||
// Both sign-ins seed the assistant when none was set (main-side
|
||||
// initial selection) — first-provider copy applies to either.
|
||||
first: !hadAssistant,
|
||||
pickedModel: null,
|
||||
modelCount: null,
|
||||
})
|
||||
}
|
||||
}, [step, connectedIds, hadAssistant])
|
||||
|
||||
const chooseEntries = useMemo(() => {
|
||||
const entries: Array<{ id: string; name: string; tagline: string; icon: React.ElementType | null; onChoose: () => void }> = []
|
||||
if (!isRowboatConnected) {
|
||||
entries.push({
|
||||
id: "rowboat",
|
||||
name: "Rowboat",
|
||||
tagline: "Included with your plan",
|
||||
icon: null,
|
||||
onChoose: () => {
|
||||
setStep({ kind: "authwait", which: "rowboat" })
|
||||
void window.ipc.invoke("oauth:connect", { provider: "rowboat" }).catch(() => {
|
||||
setStep({ kind: "choose" })
|
||||
toast.error("Sign-in failed to start")
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
if (!chatgptSignedIn) {
|
||||
entries.push({
|
||||
id: "codex",
|
||||
name: "ChatGPT",
|
||||
tagline: "Use your Plus/Pro subscription",
|
||||
icon: OpenAIIcon,
|
||||
onChoose: () => {
|
||||
setStep({ kind: "authwait", which: "chatgpt" })
|
||||
void Promise.resolve(onChatGPTSignIn()).catch(() => {
|
||||
setStep({ kind: "choose" })
|
||||
toast.error("Sign-in failed to start")
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
for (const c of BYOK_CATALOG) {
|
||||
if (connectedIds.includes(c.flavor)) continue
|
||||
entries.push({
|
||||
id: c.flavor,
|
||||
name: c.name,
|
||||
tagline: c.tagline,
|
||||
icon: c.icon,
|
||||
onChoose: () => {
|
||||
setApiKey("")
|
||||
setBaseURL(DEFAULT_BASE_URLS[c.flavor] ?? "")
|
||||
setManualModel("")
|
||||
setStep({ kind: "creds", flavor: c.flavor })
|
||||
},
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}, [isRowboatConnected, chatgptSignedIn, connectedIds, onChatGPTSignIn])
|
||||
|
||||
const connect = useCallback(async (flavor: ByokFlavor) => {
|
||||
const meta = flavorMeta(flavor)
|
||||
if (!meta) return
|
||||
const key = apiKey.trim()
|
||||
const url = baseURL.trim()
|
||||
if (meta.needsKey && !meta.optionalKey && !key) {
|
||||
toast.error("Enter an API key")
|
||||
return
|
||||
}
|
||||
if (meta.needsEndpoint && !url) {
|
||||
toast.error("Enter the endpoint URL")
|
||||
return
|
||||
}
|
||||
setStep({ kind: "loading", flavor })
|
||||
const providerEntry = {
|
||||
flavor,
|
||||
apiKey: key || undefined,
|
||||
baseURL: url || undefined,
|
||||
}
|
||||
try {
|
||||
const listRes = await window.ipc.invoke("models:listForProvider", { provider: providerEntry })
|
||||
const list = listRes.success ? listRes.models ?? [] : []
|
||||
const typed = manualModel.trim()
|
||||
const model = typed || (selectInitialModel(flavor, list, modelRecommendations) ?? "")
|
||||
if (!listRes.success && !model) {
|
||||
setStep({ kind: "error", flavor, message: listRes.error || "Could not load the provider's model list." })
|
||||
return
|
||||
}
|
||||
if (!model) {
|
||||
setStep({ kind: "error", flavor, message: "The provider reported no models. Enter a model id manually and retry." })
|
||||
return
|
||||
}
|
||||
const testRes = await window.ipc.invoke("models:test", { provider: providerEntry, model })
|
||||
if (!testRes.success) {
|
||||
setStep({ kind: "error", flavor, message: testRes.error || "Connection test failed" })
|
||||
return
|
||||
}
|
||||
await window.ipc.invoke("models:setProvider", { id: flavor, provider: providerEntry })
|
||||
// Initial selection only — a saved assistant is never replaced. The
|
||||
// prop can be stale (a Rowboat sign-in moments ago seeds the
|
||||
// assistant MAIN-side), so re-read the authoritative config at the
|
||||
// moment of decision instead of trusting render-time state.
|
||||
const cfgNow = await window.ipc.invoke("models:getConfig", null).catch(() => null)
|
||||
const hasAssistantNow = cfgNow ? cfgNow.assistantModel !== null : hadAssistant
|
||||
if (!hasAssistantNow) {
|
||||
// Task recommendations ride along the seeding moment as visible
|
||||
// overrides (validated against the live list; only differences).
|
||||
const taskModels = selectInitialTaskModels(flavor, flavor, list, modelRecommendations, model)
|
||||
await window.ipc.invoke("models:updateConfig", {
|
||||
assistantModel: { provider: flavor, model },
|
||||
...(Object.keys(taskModels).length > 0 ? { taskModels } : {}),
|
||||
})
|
||||
analytics.llmInitialModelSelected({
|
||||
flavor,
|
||||
model,
|
||||
recommended: model === normalizeModelRecommendation(modelRecommendations, flavor)?.assistantModel,
|
||||
taskOverridesSeeded: Object.keys(taskModels).length,
|
||||
source: analyticsSource,
|
||||
})
|
||||
}
|
||||
for (const warning of testRes.warnings ?? []) {
|
||||
toast.warning(warning, { duration: 12000 })
|
||||
}
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
setStep({
|
||||
kind: "result",
|
||||
name: meta.name,
|
||||
first: !hasAssistantNow,
|
||||
pickedModel: !hasAssistantNow ? model : null,
|
||||
modelCount: list.length > 0 ? list.length : null,
|
||||
})
|
||||
} catch {
|
||||
setStep({ kind: "error", flavor, message: "Connection failed" })
|
||||
}
|
||||
}, [apiKey, baseURL, manualModel, modelRecommendations, hadAssistant])
|
||||
|
||||
const credsMeta = step.kind === "creds" || step.kind === "error" ? flavorMeta(step.flavor) : null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step.kind === "creds" && credsMeta ? `Connect ${credsMeta.name}` : "Add provider"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{step.kind === "choose" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect Rowboat, add your own API key, or run models locally. Each provider's models appear alongside the others in every picker.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{chooseEntries.map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
onClick={e.onChoose}
|
||||
className="rounded-md border px-3 py-2.5 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{e.icon && <e.icon className="size-4 shrink-0" />}
|
||||
<span className="text-sm font-medium">{e.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{e.tagline}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === "creds" && credsMeta && (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep({ kind: "choose" })}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
All providers
|
||||
</button>
|
||||
{credsMeta.needsKey && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
API key{credsMeta.optionalKey ? " (optional)" : ""}
|
||||
</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="Paste your API key"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{credsMeta.needsEndpoint && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Endpoint URL</span>
|
||||
<Input
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
placeholder={DEFAULT_BASE_URLS[credsMeta.flavor] ?? "https://api.example.com/v1"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{credsMeta.manualModel && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model id (optional)</span>
|
||||
<Input
|
||||
value={manualModel}
|
||||
onChange={(e) => setManualModel(e.target.value)}
|
||||
placeholder="Leave empty to auto-select"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full" onClick={() => void connect(credsMeta.flavor)}>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === "authwait" && (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
<div className="text-sm font-medium">Complete sign-in in your browser…</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep({ kind: "choose" })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === "loading" && (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
<div className="text-sm font-medium">Loading available models…</div>
|
||||
<div className="text-xs text-muted-foreground">Validating the connection and fetching the model list.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === "result" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-green-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
{step.name} connected
|
||||
</div>
|
||||
{step.first && step.pickedModel && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
We selected <span className="font-medium text-foreground">{step.pickedModel}</span> as your Assistant model to get you started. You can change it any time above.
|
||||
</p>
|
||||
)}
|
||||
{step.first && !step.pickedModel && (
|
||||
<p className="text-xs text-muted-foreground">Your Assistant model has been set. You can change it any time above.</p>
|
||||
)}
|
||||
{!step.first && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{step.modelCount ? `${step.modelCount} models are now available. ` : ""}Your Assistant model is unchanged — pick a model from {step.name} for any task whenever you like.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => onOpenChange(false)}>Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === "error" && credsMeta && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-destructive">Couldn't connect</div>
|
||||
<p className="text-xs text-muted-foreground break-words">{step.message}</p>
|
||||
{credsMeta.manualModel && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model id</span>
|
||||
<Input
|
||||
value={manualModel}
|
||||
onChange={(e) => setManualModel(e.target.value)}
|
||||
placeholder="Enter a model id to connect anyway"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setStep({ kind: "creds", flavor: step.flavor })}>
|
||||
Review connection
|
||||
</Button>
|
||||
<Button onClick={() => void connect(step.flavor)}>Retry</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Manage provider ----------
|
||||
|
||||
function ManageProviderDialog({ card, usedBy, onClose, onRefreshModels }: {
|
||||
card: { id: string; flavor: string; name: string; status: "ok" | "error"; error?: string; modelCount: number; meta?: ProviderMeta }
|
||||
usedBy: Array<{ label: string; model: string }>
|
||||
onClose: () => void
|
||||
onRefreshModels: () => Promise<void>
|
||||
}) {
|
||||
const isAuthDerived = card.flavor === "rowboat" || card.flavor === "codex"
|
||||
const meta = flavorMeta(card.flavor)
|
||||
const chatgpt = useChatGPT()
|
||||
const [replacingKey, setReplacingKey] = useState(false)
|
||||
const [newKey, setNewKey] = useState("")
|
||||
const [endpoint, setEndpoint] = useState(card.meta?.baseURL ?? "")
|
||||
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const handleRefreshModels = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await onRefreshModels()
|
||||
toast.success("Models refreshed")
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [onRefreshModels])
|
||||
|
||||
const saveCredentials = useCallback(async (updates: { apiKey?: string; baseURL?: string }) => {
|
||||
try {
|
||||
await window.ipc.invoke("models:setProvider", {
|
||||
id: card.id,
|
||||
provider: {
|
||||
flavor: card.flavor as ByokFlavor,
|
||||
...(updates.apiKey !== undefined ? { apiKey: updates.apiKey } : {}),
|
||||
...(updates.baseURL !== undefined ? { baseURL: updates.baseURL } : {}),
|
||||
},
|
||||
})
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
toast.success("Provider updated")
|
||||
setReplacingKey(false)
|
||||
setNewKey("")
|
||||
} catch {
|
||||
toast.error("Failed to update provider")
|
||||
}
|
||||
}, [card.id, card.flavor])
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
try {
|
||||
if (card.flavor === "rowboat") {
|
||||
await window.ipc.invoke("oauth:disconnect", { provider: "rowboat" })
|
||||
} else if (card.flavor === "codex") {
|
||||
await chatgpt.signOut()
|
||||
} else {
|
||||
await window.ipc.invoke("models:removeProvider", { id: card.id })
|
||||
}
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
toast.success(`${card.name} disconnected`)
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error("Failed to disconnect")
|
||||
}
|
||||
}, [card, chatgpt, onClose])
|
||||
|
||||
const assistantAffected = usedBy.some((u) => u.label === "Assistant model")
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose() }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{card.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className={cn("size-2 rounded-full shrink-0", card.status === "ok" ? "bg-green-500" : "bg-destructive")} />
|
||||
{card.status === "ok"
|
||||
? `Connected · ${card.modelCount} model${card.modelCount === 1 ? "" : "s"} available`
|
||||
: (card.error || "Could not load models")}
|
||||
</div>
|
||||
|
||||
{!isAuthDerived && meta?.needsKey && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">API key</span>
|
||||
{replacingKey ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
placeholder="Paste the new API key"
|
||||
/>
|
||||
<Button size="sm" disabled={!newKey.trim()} onClick={() => void saveCredentials({ apiKey: newKey.trim() })}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input readOnly value={card.meta?.hasApiKey ? "••••••••••••••••" : "No key saved"} className="font-mono text-xs" />
|
||||
<Button variant="outline" size="sm" onClick={() => setReplacingKey(true)}>
|
||||
Replace
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthDerived && meta?.needsEndpoint && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Endpoint URL</span>
|
||||
<div className="flex gap-2">
|
||||
<Input value={endpoint} onChange={(e) => setEndpoint(e.target.value)} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!endpoint.trim() || endpoint.trim() === (card.meta?.baseURL ?? "")}
|
||||
onClick={() => void saveCredentials({ baseURL: endpoint.trim() })}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2.5">
|
||||
<div className="text-xs text-muted-foreground">Refresh to pull the latest models from {card.name}.</div>
|
||||
<Button variant="outline" size="sm" disabled={refreshing} onClick={() => void handleRefreshModels()}>
|
||||
<RefreshCw className={cn("size-3.5 mr-1", refreshing && "animate-spin")} />
|
||||
{refreshing ? "Refreshing…" : "Refresh models"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Used by</span>
|
||||
{usedBy.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">No model selections currently use this provider.</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{usedBy.map((u) => (
|
||||
<div key={u.label} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">{u.label}</span>
|
||||
<span className="text-muted-foreground truncate ml-3">{u.model}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-3">
|
||||
{!confirmDisconnect ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{card.flavor === "rowboat"
|
||||
? "Sign out of your Rowboat account."
|
||||
: `Remove ${card.name} and its models from Rowboat.`}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" className="text-destructive" onClick={() => setConfirmDisconnect(true)}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Disconnect {card.name}?</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{usedBy.length > 0
|
||||
? `${usedBy.length} model selection${usedBy.length === 1 ? "" : "s"} use${usedBy.length === 1 ? "s" : ""} this provider. Task overrides will reset to the Assistant model${assistantAffected ? ", and you'll need to pick a new Assistant model" : ""}.`
|
||||
: "Its models will no longer be available in Rowboat."}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmDisconnect(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => void disconnect()}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ import { SidebarCreditRewards } from "@/components/sidebar-credit-rewards"
|
|||
import { MascotFaceIcon } from "@/components/talking-head"
|
||||
import { extractConferenceLink } from "@/lib/calendar-event"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { useRowboatConfig } from "@/hooks/use-rowboat-config"
|
||||
import { toast } from "@/lib/toast"
|
||||
import { getBillingPlanData } from "@x/shared/dist/billing.js"
|
||||
import { ServiceEvent } from "@x/shared/src/service-events.js"
|
||||
|
|
@ -477,7 +478,7 @@ export function SidebarContentPanel({
|
|||
const outOfCreditsRef = useRef(false)
|
||||
const creditPopoverAutoShownRef = useRef(false)
|
||||
const [loggingIn, setLoggingIn] = useState(false)
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
const appUrl = useRowboatConfig()?.appUrl ?? null
|
||||
const { billing, refresh: refreshBilling } = useBilling(isRowboatConnected)
|
||||
const currentBillingPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
|
||||
|
||||
|
|
@ -734,12 +735,6 @@ export function SidebarContentPanel({
|
|||
setShowOauthAlert(true)
|
||||
}
|
||||
}
|
||||
if (connected && mounted) {
|
||||
try {
|
||||
const account = await window.ipc.invoke('account:getRowboat', null)
|
||||
if (mounted) setAppUrl(account.config?.appUrl ?? null)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch OAuth state:', error)
|
||||
if (mounted) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { __resetModelsForTests, useModels } from './use-models'
|
|||
// captures listeners so tests can fire main-process broadcasts.
|
||||
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
|
||||
let invokeCounts: Record<string, number> = {}
|
||||
let invokeArgs: Record<string, unknown[]> = {}
|
||||
let ipcListeners: Record<string, Array<(payload: unknown) => void>> = {}
|
||||
|
||||
;(window as unknown as { ipc: unknown }).ipc = {
|
||||
|
|
@ -19,21 +20,31 @@ let ipcListeners: Record<string, Array<(payload: unknown) => void>> = {}
|
|||
},
|
||||
invoke: (channel: string, args: unknown) => {
|
||||
invokeCounts[channel] = (invokeCounts[channel] ?? 0) + 1
|
||||
;(invokeArgs[channel] ??= []).push(args)
|
||||
const handler = handlers[channel]
|
||||
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
|
||||
},
|
||||
}
|
||||
|
||||
function serveConfig(providers: Record<string, unknown>): void {
|
||||
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } })
|
||||
handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' })
|
||||
// One catalog response serves the whole snapshot — the unified pipeline's
|
||||
// single IPC call.
|
||||
function serveCatalog(catalog: {
|
||||
providers: Array<{
|
||||
id: string
|
||||
flavor?: string
|
||||
status?: 'ok' | 'error'
|
||||
error?: string
|
||||
models: Array<{ id: string; reasoning?: boolean }>
|
||||
}>
|
||||
defaultModel: { provider: string; model: string } | null
|
||||
}): void {
|
||||
handlers['models:list'] = async () => ({
|
||||
providers: [
|
||||
{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4', reasoning: true }, { id: 'gpt-5.4-mini' }] },
|
||||
],
|
||||
})
|
||||
handlers['workspace:readFile'] = async () => ({
|
||||
data: JSON.stringify({ provider: { flavor: 'openai' }, model: 'gpt-5.4', providers }),
|
||||
providers: catalog.providers.map((p) => ({
|
||||
flavor: p.id, // one instance per flavor today: id === flavor key
|
||||
status: 'ok' as const,
|
||||
...p,
|
||||
})),
|
||||
defaultModel: catalog.defaultModel,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -41,12 +52,18 @@ beforeEach(() => {
|
|||
__resetModelsForTests()
|
||||
handlers = {}
|
||||
invokeCounts = {}
|
||||
invokeArgs = {}
|
||||
ipcListeners = {}
|
||||
})
|
||||
|
||||
describe('useModels', () => {
|
||||
it('shares one fetch across concurrently mounted consumers', async () => {
|
||||
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })
|
||||
serveCatalog({
|
||||
providers: [
|
||||
{ id: 'openai', models: [{ id: 'gpt-5.4', reasoning: true }, { id: 'gpt-5.4-mini' }] },
|
||||
],
|
||||
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
|
||||
})
|
||||
|
||||
const first = renderHook(() => useModels())
|
||||
const second = renderHook(() => useModels())
|
||||
|
|
@ -55,21 +72,22 @@ describe('useModels', () => {
|
|||
await waitFor(() => expect(second.result.current.groups.length).toBeGreaterThan(0))
|
||||
|
||||
expect(invokeCounts['models:list']).toBe(1)
|
||||
expect(invokeCounts['workspace:readFile']).toBe(1)
|
||||
expect(first.result.current.groups).toEqual([
|
||||
{ kind: 'catalog', flavor: 'openai', models: ['gpt-5.4', 'gpt-5.4-mini'] },
|
||||
{ id: 'openai', flavor: 'openai', models: ['gpt-5.4', 'gpt-5.4-mini'], status: 'ok' },
|
||||
])
|
||||
expect(first.result.current.reasoningByKey).toEqual({ 'openai/gpt-5.4': true })
|
||||
expect(first.result.current.defaultModel).toEqual({ provider: 'openai', model: 'gpt-5.4' })
|
||||
// Raw catalog is exposed for provider-scoped pickers (unconfigured
|
||||
// providers have no group but may have a catalog).
|
||||
// Raw catalog is exposed for provider-scoped pickers.
|
||||
expect(first.result.current.catalogByProvider).toEqual({ openai: ['gpt-5.4', 'gpt-5.4-mini'] })
|
||||
// Both consumers see the same store snapshot, not copies.
|
||||
expect(second.result.current.groups).toBe(first.result.current.groups)
|
||||
})
|
||||
|
||||
it('serves the cache to late mounts without refetching', async () => {
|
||||
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })
|
||||
serveCatalog({
|
||||
providers: [{ id: 'openai', models: [{ id: 'gpt-5.4' }] }],
|
||||
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
|
||||
})
|
||||
|
||||
const first = renderHook(() => useModels())
|
||||
await waitFor(() => expect(first.result.current.groups.length).toBeGreaterThan(0))
|
||||
|
|
@ -80,27 +98,47 @@ describe('useModels', () => {
|
|||
expect(invokeCounts['models:list']).toBe(1)
|
||||
})
|
||||
|
||||
it('sign-out via the oauth:didConnect broadcast flips isRowboatConnected and drops the rowboat group', async () => {
|
||||
// Signed in: gateway catalog present.
|
||||
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: true } } })
|
||||
handlers['llm:getDefaultModel'] = async () => ({ provider: 'rowboat', model: 'claude-opus-4-8' })
|
||||
handlers['models:list'] = async () => ({
|
||||
providers: [{ id: 'rowboat', name: 'Rowboat', models: [{ id: 'claude-opus-4-8' }] }],
|
||||
it('orders the default group and model first and passes error status through', async () => {
|
||||
serveCatalog({
|
||||
providers: [
|
||||
{ id: 'ollama', status: 'error', error: 'connection refused', models: [] },
|
||||
{ id: 'openai', models: [{ id: 'gpt-4.1' }, { id: 'gpt-5.4' }] },
|
||||
],
|
||||
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
|
||||
})
|
||||
handlers['workspace:readFile'] = async () => ({
|
||||
data: JSON.stringify({ provider: { flavor: 'openai' }, model: 'gpt-5.4', providers: {} }),
|
||||
|
||||
const { result } = renderHook(() => useModels())
|
||||
await waitFor(() => expect(result.current.groups.length).toBe(2))
|
||||
|
||||
// The default's group leads (despite arriving second) and the default
|
||||
// model leads within it.
|
||||
expect(result.current.groups[0]).toEqual({
|
||||
id: 'openai', flavor: 'openai', models: ['gpt-5.4', 'gpt-4.1'], status: 'ok',
|
||||
})
|
||||
// A failed provider keeps its group, with the error travelling along
|
||||
// (ModelSelector renders it as an inline error row + Retry).
|
||||
expect(result.current.groups[1]).toEqual({
|
||||
id: 'ollama', flavor: 'ollama', models: [], status: 'error', error: 'connection refused',
|
||||
})
|
||||
})
|
||||
|
||||
it('sign-out via the oauth:didConnect broadcast flips isRowboatConnected and drops the rowboat group', async () => {
|
||||
serveCatalog({
|
||||
providers: [{ id: 'rowboat', models: [{ id: 'claude-opus-4-8' }] }],
|
||||
defaultModel: { provider: 'rowboat', model: 'claude-opus-4-8' },
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useModels())
|
||||
await waitFor(() => expect(result.current.isRowboatConnected).toBe(true))
|
||||
expect(result.current.groups).toEqual([{ kind: 'catalog', flavor: 'rowboat', models: ['claude-opus-4-8'] }])
|
||||
expect(result.current.groups).toEqual([
|
||||
{ id: 'rowboat', flavor: 'rowboat', models: ['claude-opus-4-8'], status: 'ok' },
|
||||
])
|
||||
|
||||
// Sign out: main broadcasts oauth:didConnect with success:false
|
||||
// (disconnectProvider's emitOAuthEvent) — same channel as connect.
|
||||
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } })
|
||||
handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' })
|
||||
handlers['models:list'] = async () => ({
|
||||
providers: [{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4' }] }],
|
||||
serveCatalog({
|
||||
providers: [{ id: 'openai', models: [{ id: 'gpt-5.4' }] }],
|
||||
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
|
||||
})
|
||||
act(() => {
|
||||
for (const listener of ipcListeners['oauth:didConnect'] ?? []) {
|
||||
|
|
@ -109,32 +147,55 @@ describe('useModels', () => {
|
|||
})
|
||||
|
||||
await waitFor(() => expect(result.current.isRowboatConnected).toBe(false))
|
||||
expect(result.current.groups.some((g) => g.flavor === 'rowboat')).toBe(false)
|
||||
expect(result.current.groups.some((g) => g.id === 'rowboat')).toBe(false)
|
||||
})
|
||||
|
||||
it('refetches on models-config-changed and updates every consumer', async () => {
|
||||
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })
|
||||
serveCatalog({
|
||||
providers: [{ id: 'openai', models: [{ id: 'gpt-5.4' }] }],
|
||||
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useModels())
|
||||
await waitFor(() => expect(result.current.groups.length).toBe(1))
|
||||
|
||||
serveConfig({
|
||||
openai: { apiKey: 'sk-test', model: 'gpt-5.4' },
|
||||
ollama: { baseURL: 'http://localhost:11434' },
|
||||
// The settings Save path: the config write lands first, then the event
|
||||
// fires — the refetch must see the new provider set and default (this is
|
||||
// what moves a fresh composer tab's trigger label without a restart).
|
||||
serveCatalog({
|
||||
providers: [
|
||||
{ id: 'openai', models: [{ id: 'gpt-5.4' }] },
|
||||
{ id: 'ollama', models: [{ id: 'llama3' }] },
|
||||
],
|
||||
defaultModel: { provider: 'ollama', model: 'llama3' },
|
||||
})
|
||||
// The settings Save path: models:updateConfig lands first, then the
|
||||
// event fires — the refetch must see the new default (this is what
|
||||
// moves a fresh composer tab's trigger label without a restart).
|
||||
handlers['llm:getDefaultModel'] = async () => ({ provider: 'ollama', model: 'llama3' })
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.groups.length).toBe(2))
|
||||
expect(result.current.groups[1]).toEqual({
|
||||
kind: 'live', flavor: 'ollama', apiKey: '', baseURL: 'http://localhost:11434', savedModel: '',
|
||||
})
|
||||
expect(result.current.defaultModel).toEqual({ provider: 'ollama', model: 'llama3' })
|
||||
expect(invokeCounts['models:list']).toBe(2)
|
||||
// Event-driven refetches are plain rebuilds, not forced provider
|
||||
// refreshes — the Event object must never leak in as a provider id.
|
||||
expect(invokeArgs['models:list']).toEqual([null, null])
|
||||
})
|
||||
|
||||
it('refresh(providerId) asks main to drop that provider\'s cached list', async () => {
|
||||
serveCatalog({
|
||||
providers: [{ id: 'ollama', status: 'error', error: 'down', models: [] }],
|
||||
defaultModel: null,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useModels())
|
||||
await waitFor(() => expect(result.current.groups.length).toBe(1))
|
||||
|
||||
// Promise-returning so callers (Manage's "Refresh models") can render
|
||||
// progress and confirm completion.
|
||||
await act(async () => {
|
||||
await result.current.refresh('ollama')
|
||||
})
|
||||
expect(invokeCounts['models:list']).toBe(2)
|
||||
expect(invokeArgs['models:list'][1]).toEqual({ refreshProvider: 'ollama' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,45 +1,49 @@
|
|||
import { useMemo, useSyncExternalStore } from 'react'
|
||||
import type { ProviderModelsFlavor } from './use-provider-models'
|
||||
|
||||
export interface ModelRef {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
// One picker group per connected provider. Catalog groups carry a resolved
|
||||
// model list (models:list / saved config); live groups carry credentials and
|
||||
// fetch their list from the provider inside the dropdown via
|
||||
// useProviderModels (models:listForProvider).
|
||||
export type ModelPickerGroup =
|
||||
| { kind: 'catalog'; flavor: string; models: string[] }
|
||||
| { kind: 'live'; flavor: ProviderModelsFlavor; apiKey: string; baseURL: string; savedModel: string }
|
||||
|
||||
const LIVE_PICKER_FLAVORS = new Set<string>(['openrouter', 'aigateway', 'ollama', 'openai-compatible'])
|
||||
// Catalog-preferred flavors that degrade to a live fetch when models:list has
|
||||
// no catalog for them (signed-in mode returns only the rowboat provider, or
|
||||
// the models.dev cache is empty).
|
||||
const LIVE_FALLBACK_FLAVORS = new Set<string>(['openai', 'anthropic', 'google'])
|
||||
// One picker group per connected provider, straight from the unified model
|
||||
// catalog (models:list → core/models/catalog.ts). Every provider — Rowboat
|
||||
// gateway, ChatGPT subscription (codex), BYOK keys, local endpoints — comes
|
||||
// through the same pipeline with a resolved list and a status; there is no
|
||||
// renderer-side fetching or per-flavor special casing.
|
||||
export interface ModelPickerGroup {
|
||||
/** Provider instance id — what ModelRef.provider joins on. */
|
||||
id: string
|
||||
/** Provider type ("openai", "ollama", "rowboat", …) — display naming. */
|
||||
flavor: string
|
||||
models: string[]
|
||||
/** 'error' = provider is connected but its model list failed to load. */
|
||||
status: 'ok' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ModelsSnapshot {
|
||||
groups: ModelPickerGroup[]
|
||||
// Per-model reasoning capability ("provider/model" → flag) from models:list.
|
||||
// Live-fetched ids carry no reasoning metadata, so lookups miss → treated
|
||||
// as non-reasoning.
|
||||
// Per-model reasoning capability ("provider/model" → flag) from the
|
||||
// catalog. Ids without metadata miss → treated as non-reasoning.
|
||||
reasoningByKey: Record<string, boolean>
|
||||
// The effective runtime default (what a run actually uses when the user
|
||||
// hasn't picked a model) — shown in pickers instead of guessing from list
|
||||
// order, which can disagree with the real default.
|
||||
defaultModel: ModelRef | null
|
||||
isRowboatConnected: boolean
|
||||
// Raw models:list catalog per provider id. Groups only cover providers
|
||||
// configured in models.json; provider-scoped pickers fall back to this so
|
||||
// a provider mid-setup (key typed, not saved) still lists its catalog.
|
||||
// Raw catalog model ids per provider id, unpinned — for provider-scoped
|
||||
// pickers that need a provider's list without group ordering applied.
|
||||
catalogByProvider: Record<string, string[]>
|
||||
}
|
||||
|
||||
export interface UseModelsResult extends ModelsSnapshot {
|
||||
/** Force a refetch now (e.g. a composer tab becoming active). */
|
||||
refresh: () => void
|
||||
/**
|
||||
* Force a refetch now (e.g. a composer tab becoming active). With a
|
||||
* provider id, that provider's cached list is dropped and refetched
|
||||
* (the error-row Retry, Manage's "Refresh models"). Resolves once the
|
||||
* snapshot has been updated, so callers can render progress.
|
||||
*/
|
||||
refresh: (providerId?: string) => Promise<void>
|
||||
}
|
||||
|
||||
const EMPTY_SNAPSHOT: ModelsSnapshot = {
|
||||
|
|
@ -61,122 +65,65 @@ let wired = false
|
|||
let wiredCleanups: Array<() => void> = []
|
||||
const subscribers = new Set<() => void>()
|
||||
|
||||
// Hybrid mode: signed-in users get the gateway list AND every BYOK provider
|
||||
// configured in models.json (selecting a BYOK model routes that message
|
||||
// through the user's own key / local server). Signed-out users get BYOK only.
|
||||
async function buildSnapshot(): Promise<ModelsSnapshot> {
|
||||
let isRowboatConnected = false
|
||||
try {
|
||||
const state = await window.ipc.invoke('oauth:getState', null)
|
||||
isRowboatConnected = state.config?.rowboat?.connected ?? false
|
||||
} catch { /* treat as signed out */ }
|
||||
async function buildSnapshot(refreshProvider?: string): Promise<ModelsSnapshot> {
|
||||
const catalog = await window.ipc.invoke(
|
||||
'models:list',
|
||||
refreshProvider ? { refreshProvider } : null,
|
||||
)
|
||||
|
||||
let defaultModel: ModelRef | null = null
|
||||
try {
|
||||
const def = await window.ipc.invoke('llm:getDefaultModel', null)
|
||||
defaultModel = { provider: def.provider, model: def.model }
|
||||
} catch { /* no default resolvable */ }
|
||||
|
||||
const groups: ModelPickerGroup[] = []
|
||||
const defaultModel: ModelRef | null = catalog.defaultModel
|
||||
const reasoningByKey: Record<string, boolean> = {}
|
||||
const catalogByProvider: Record<string, string[]> = {}
|
||||
const groups: ModelPickerGroup[] = []
|
||||
|
||||
// Full catalog per provider (gateway + models.dev cloud providers).
|
||||
const catalog: Record<string, string[]> = {}
|
||||
try {
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
for (const p of listResult.providers || []) {
|
||||
catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id)
|
||||
for (const m of p.models || []) {
|
||||
if (typeof m.reasoning === 'boolean') {
|
||||
reasoningByKey[`${p.id}/${m.id}`] = m.reasoning
|
||||
}
|
||||
for (const p of catalog.providers) {
|
||||
const ids = p.models.map((m) => m.id)
|
||||
catalogByProvider[p.id] = ids
|
||||
for (const m of p.models) {
|
||||
if (typeof m.reasoning === 'boolean') {
|
||||
reasoningByKey[`${p.id}/${m.id}`] = m.reasoning
|
||||
}
|
||||
}
|
||||
} catch { /* offline / no catalog — groups fall back to saved config below */ }
|
||||
|
||||
if (isRowboatConnected && (catalog['rowboat'] || []).length > 0) {
|
||||
groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] })
|
||||
groups.push({
|
||||
id: p.id,
|
||||
flavor: p.flavor,
|
||||
models: ids,
|
||||
status: p.status,
|
||||
...(p.error ? { error: p.error } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
// ChatGPT subscription (codex): models:list only carries this catalog
|
||||
// while signed in with ChatGPT, so presence is the gate.
|
||||
if ((catalog['codex'] || []).length > 0) {
|
||||
groups.push({ kind: 'catalog', flavor: 'codex', models: catalog['codex'] })
|
||||
// The effective default leads the picker: its group first and, within the
|
||||
// group, the model itself first.
|
||||
if (defaultModel) {
|
||||
const index = groups.findIndex((g) => g.id === defaultModel.provider)
|
||||
if (index >= 0) {
|
||||
const [group] = groups.splice(index, 1)
|
||||
groups.unshift(group)
|
||||
const mi = group.models.indexOf(defaultModel.model)
|
||||
if (mi > 0) {
|
||||
group.models.splice(mi, 1)
|
||||
group.models.unshift(defaultModel.model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
|
||||
// List the default provider's group first.
|
||||
const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : ''
|
||||
const flavors = Object.keys(parsed?.providers || {})
|
||||
.sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0))
|
||||
|
||||
for (const flavor of flavors) {
|
||||
const e = (parsed.providers[flavor] || {}) as Record<string, unknown>
|
||||
const apiKey = typeof e.apiKey === 'string' ? e.apiKey.trim() : ''
|
||||
const baseURL = typeof e.baseURL === 'string' ? e.baseURL.trim() : ''
|
||||
if (!apiKey && !baseURL) continue // provider not configured
|
||||
const savedModel = typeof e.model === 'string' ? e.model : ''
|
||||
|
||||
// Live flavors fetch their list from the provider inside the
|
||||
// dropdown, with the credentials saved in config. Catalog flavors
|
||||
// degrade to the same live fetch when models:list carried no
|
||||
// catalog for them (signed in, or empty models.dev cache).
|
||||
const catalogModels = catalog[flavor] || []
|
||||
if (LIVE_PICKER_FLAVORS.has(flavor) || (catalogModels.length === 0 && LIVE_FALLBACK_FLAVORS.has(flavor))) {
|
||||
groups.push({ kind: 'live', flavor: flavor as ProviderModelsFlavor, apiKey, baseURL, savedModel })
|
||||
continue
|
||||
}
|
||||
|
||||
// Catalog group: the saved default model leads, then the catalog.
|
||||
// Saved models[] survives as the fallback for unknown flavors the
|
||||
// live fetch doesn't support.
|
||||
const models: string[] = []
|
||||
const push = (model: string) => {
|
||||
if (model && !models.includes(model)) models.push(model)
|
||||
}
|
||||
push(savedModel)
|
||||
if (catalogModels.length > 0) {
|
||||
for (const m of catalogModels) push(m)
|
||||
} else {
|
||||
const saved = Array.isArray(e.models) ? e.models as string[] : []
|
||||
for (const m of saved) push(m)
|
||||
}
|
||||
groups.push({ kind: 'catalog', flavor, models })
|
||||
}
|
||||
|
||||
// The user's explicit default selection leads the picker: its group
|
||||
// first and, within a catalog group, the model itself first. (Live
|
||||
// groups pin the default at the top themselves.)
|
||||
const sel = parsed?.defaultSelection
|
||||
if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') {
|
||||
const index = groups.findIndex((g) => g.flavor === sel.provider)
|
||||
if (index >= 0) {
|
||||
const [group] = groups.splice(index, 1)
|
||||
groups.unshift(group)
|
||||
if (group.kind === 'catalog') {
|
||||
const mi = group.models.indexOf(sel.model)
|
||||
if (mi > 0) {
|
||||
group.models.splice(mi, 1)
|
||||
group.models.unshift(sel.model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* no BYOK config yet */ }
|
||||
|
||||
return { groups, reasoningByKey, defaultModel, isRowboatConnected, catalogByProvider: catalog }
|
||||
return {
|
||||
groups,
|
||||
reasoningByKey,
|
||||
defaultModel,
|
||||
isRowboatConnected: catalog.providers.some((p) => p.id === 'rowboat'),
|
||||
catalogByProvider,
|
||||
}
|
||||
}
|
||||
|
||||
function startFetch(): void {
|
||||
function startFetch(refreshProvider?: string): Promise<void> {
|
||||
// Concurrent fetches race (an event can fire while one is in flight) —
|
||||
// only the newest run may write the snapshot, else a slow stale run can
|
||||
// clobber the fresh list.
|
||||
const seq = ++fetchSeq
|
||||
fetching = true
|
||||
void buildSnapshot()
|
||||
return buildSnapshot(refreshProvider)
|
||||
.then((next) => {
|
||||
if (seq !== fetchSeq) return
|
||||
snapshot = next
|
||||
|
|
@ -192,28 +139,30 @@ function startFetch(): void {
|
|||
})
|
||||
}
|
||||
|
||||
function refreshModels(): void {
|
||||
startFetch()
|
||||
function refreshModels(providerId?: string): Promise<void> {
|
||||
return startFetch(typeof providerId === 'string' ? providerId : undefined)
|
||||
}
|
||||
|
||||
function ensureLoaded(): void {
|
||||
if (!loaded && !fetching) startFetch()
|
||||
if (!loaded && !fetching) void startFetch()
|
||||
}
|
||||
|
||||
function wireGlobalEvents(): void {
|
||||
if (wired) return
|
||||
wired = true
|
||||
// Event payloads must not leak into startFetch's refreshProvider arg.
|
||||
const refetch = () => void startFetch()
|
||||
// Config edits anywhere in the app (settings dialog, composer pick,
|
||||
// onboarding) announce themselves on this window event.
|
||||
window.addEventListener('models-config-changed', refreshModels)
|
||||
window.addEventListener('models-config-changed', refetch)
|
||||
wiredCleanups = [
|
||||
() => window.removeEventListener('models-config-changed', refreshModels),
|
||||
// Rowboat sign-in/out swaps the whole hybrid list. Despite the name,
|
||||
// main broadcasts this channel on every OAuth state change — including
|
||||
() => window.removeEventListener('models-config-changed', refetch),
|
||||
// Rowboat sign-in/out swaps the provider set. Despite the name, main
|
||||
// broadcasts this channel on every OAuth state change — including
|
||||
// disconnect (disconnectProvider emits { provider, success: false }).
|
||||
window.ipc.on('oauth:didConnect', refreshModels),
|
||||
window.ipc.on('oauth:didConnect', refetch),
|
||||
// ChatGPT subscription models appear/disappear with the ChatGPT session.
|
||||
window.ipc.on('chatgpt:statusChanged', refreshModels),
|
||||
window.ipc.on('chatgpt:statusChanged', refetch),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
// Flavors the live model-list fetch (models:listForProvider) supports.
|
||||
// "rowboat" (the signed-in gateway) is deliberately absent — its catalog
|
||||
// comes from models:list, and core throws on the flavor.
|
||||
export type ProviderModelsFlavor =
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "google"
|
||||
| "openrouter"
|
||||
| "aigateway"
|
||||
| "ollama"
|
||||
| "openai-compatible"
|
||||
|
||||
export type ProviderModelsStatus = "idle" | "loading" | "loaded" | "error"
|
||||
|
||||
export interface UseProviderModelsResult {
|
||||
/** idle = credentials are insufficient to attempt a fetch. */
|
||||
status: ProviderModelsStatus
|
||||
models: string[]
|
||||
error: string | null
|
||||
/** Bypass the cache and fetch now (key-field blur / Retry). No-op while idle. */
|
||||
refetch: () => void
|
||||
}
|
||||
|
||||
const AIGATEWAY_DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v1"
|
||||
// The automatic fetch fires only once the credential inputs stop changing —
|
||||
// never per keystroke, which would spray partial API keys at the provider.
|
||||
const FETCH_DEBOUNCE_MS = 600
|
||||
|
||||
// Module-level so provider switches and dialog reopens don't refetch.
|
||||
// Successful results only, keyed on `${flavor}|${apiKey}|${baseURL}`.
|
||||
const listCache = new Map<string, string[]>()
|
||||
// De-dupes concurrent requests for the same key (debounce firing + field blur).
|
||||
const inFlight = new Map<string, Promise<string[]>>()
|
||||
|
||||
function credentialsSufficient(flavor: ProviderModelsFlavor, apiKey: string, baseURL: string): boolean {
|
||||
if (flavor === "ollama" || flavor === "openai-compatible") return baseURL.length > 0
|
||||
return apiKey.length > 0
|
||||
}
|
||||
|
||||
function fetchProviderModels(
|
||||
cacheKey: string,
|
||||
provider: { flavor: ProviderModelsFlavor; apiKey?: string; baseURL?: string },
|
||||
): Promise<string[]> {
|
||||
const pending = inFlight.get(cacheKey)
|
||||
if (pending) return pending
|
||||
const request = window.ipc
|
||||
.invoke("models:listForProvider", { provider })
|
||||
.then((result) => {
|
||||
if (!result.success) throw new Error(result.error || "Failed to list models")
|
||||
const models = result.models ?? []
|
||||
listCache.set(cacheKey, models)
|
||||
return models
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight.delete(cacheKey)
|
||||
})
|
||||
inFlight.set(cacheKey, request)
|
||||
return request
|
||||
}
|
||||
|
||||
export function useProviderModels(input: {
|
||||
flavor: ProviderModelsFlavor
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
}): UseProviderModelsResult {
|
||||
const { flavor } = input
|
||||
const apiKey = input.apiKey.trim()
|
||||
const baseURL = input.baseURL.trim() || (flavor === "aigateway" ? AIGATEWAY_DEFAULT_BASE_URL : "")
|
||||
const cacheKey = `${flavor}|${apiKey}|${baseURL}`
|
||||
const sufficient = credentialsSufficient(flavor, apiKey, baseURL)
|
||||
|
||||
const [state, setState] = useState<{
|
||||
key: string
|
||||
status: ProviderModelsStatus
|
||||
models: string[]
|
||||
error: string | null
|
||||
}>({ key: "", status: "idle", models: [], error: null })
|
||||
// Bumped whenever the inputs change (and on unmount) so completions of
|
||||
// superseded fetches never write state.
|
||||
const epochRef = useRef(0)
|
||||
|
||||
const startFetch = useCallback(() => {
|
||||
const epoch = ++epochRef.current
|
||||
setState({ key: cacheKey, status: "loading", models: [], error: null })
|
||||
fetchProviderModels(cacheKey, {
|
||||
flavor,
|
||||
apiKey: apiKey || undefined,
|
||||
baseURL: baseURL || undefined,
|
||||
})
|
||||
.then((models) => {
|
||||
if (epochRef.current !== epoch) return
|
||||
setState({ key: cacheKey, status: "loaded", models, error: null })
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (epochRef.current !== epoch) return
|
||||
const message = err instanceof Error ? err.message : "Failed to list models"
|
||||
setState({ key: cacheKey, status: "error", models: [], error: message })
|
||||
})
|
||||
}, [cacheKey, flavor, apiKey, baseURL])
|
||||
|
||||
useEffect(() => {
|
||||
epochRef.current++
|
||||
if (!sufficient) {
|
||||
setState({ key: cacheKey, status: "idle", models: [], error: null })
|
||||
return
|
||||
}
|
||||
const cached = listCache.get(cacheKey)
|
||||
if (cached) {
|
||||
setState({ key: cacheKey, status: "loaded", models: cached, error: null })
|
||||
return
|
||||
}
|
||||
setState({ key: cacheKey, status: "loading", models: [], error: null })
|
||||
const timer = setTimeout(() => {
|
||||
// A blur-triggered refetch may have already filled the cache while the
|
||||
// debounce was pending — don't fetch the same key twice.
|
||||
const nowCached = listCache.get(cacheKey)
|
||||
if (nowCached) {
|
||||
setState({ key: cacheKey, status: "loaded", models: nowCached, error: null })
|
||||
return
|
||||
}
|
||||
startFetch()
|
||||
}, FETCH_DEBOUNCE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [cacheKey, sufficient, startFetch])
|
||||
|
||||
useEffect(() => () => {
|
||||
epochRef.current++
|
||||
}, [])
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
if (!sufficient) return
|
||||
listCache.delete(cacheKey)
|
||||
startFetch()
|
||||
}, [sufficient, cacheKey, startFetch])
|
||||
|
||||
// State lags the inputs by one render (the effect above reconciles), so
|
||||
// derive the answer for the *current* inputs — a provider switch must never
|
||||
// flash the previous provider's list.
|
||||
if (state.key !== cacheKey) {
|
||||
const cached = sufficient ? listCache.get(cacheKey) : undefined
|
||||
if (cached) return { status: "loaded", models: cached, error: null, refetch }
|
||||
return { status: sufficient ? "loading" : "idle", models: [], error: null, refetch }
|
||||
}
|
||||
return { status: state.status, models: state.models, error: state.error, refetch }
|
||||
}
|
||||
68
apps/x/apps/renderer/src/hooks/use-rowboat-config.test.tsx
Normal file
68
apps/x/apps/renderer/src/hooks/use-rowboat-config.test.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { __resetRowboatConfigForTests, fetchRowboatConfig, useRowboatConfig } from './use-rowboat-config'
|
||||
|
||||
// Same preload stub pattern as use-models.test.tsx: invoke routes by channel
|
||||
// through a per-test handler map, with per-channel call counts to observe the
|
||||
// store's fetch dedupe.
|
||||
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
|
||||
let invokeCounts: Record<string, number> = {}
|
||||
|
||||
;(window as unknown as { ipc: unknown }).ipc = {
|
||||
on: () => () => undefined,
|
||||
invoke: (channel: string, args: unknown) => {
|
||||
invokeCounts[channel] = (invokeCounts[channel] ?? 0) + 1
|
||||
const handler = handlers[channel]
|
||||
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
|
||||
},
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
appUrl: 'https://app.example.com',
|
||||
websocketApiUrl: 'https://ws.example.com',
|
||||
supabaseUrl: 'https://supabase.example.com',
|
||||
billing: { plans: [] },
|
||||
modelRecommendations: { openai: 'gpt-5.4' },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetRowboatConfigForTests()
|
||||
handlers = {}
|
||||
invokeCounts = {}
|
||||
})
|
||||
|
||||
describe('useRowboatConfig', () => {
|
||||
it('serves every consumer from one shared fetch', async () => {
|
||||
handlers['rowboat:getConfig'] = async () => CONFIG
|
||||
|
||||
const first = renderHook(() => useRowboatConfig())
|
||||
const second = renderHook(() => useRowboatConfig())
|
||||
|
||||
await waitFor(() => expect(first.result.current?.appUrl).toBe('https://app.example.com'))
|
||||
await waitFor(() => expect(second.result.current?.modelRecommendations).toEqual({ openai: 'gpt-5.4' }))
|
||||
expect(invokeCounts['rowboat:getConfig']).toBe(1)
|
||||
|
||||
// Imperative reads share the same cache — no extra IPC.
|
||||
await expect(fetchRowboatConfig()).resolves.toEqual(CONFIG)
|
||||
expect(invokeCounts['rowboat:getConfig']).toBe(1)
|
||||
})
|
||||
|
||||
it('retries after an unreachable API instead of caching null forever', async () => {
|
||||
handlers['rowboat:getConfig'] = async () => null
|
||||
|
||||
await expect(fetchRowboatConfig()).resolves.toBeNull()
|
||||
handlers['rowboat:getConfig'] = async () => CONFIG
|
||||
await expect(fetchRowboatConfig()).resolves.toEqual(CONFIG)
|
||||
expect(invokeCounts['rowboat:getConfig']).toBe(2)
|
||||
})
|
||||
|
||||
it('returns null while loading and to consumers when the fetch rejects', async () => {
|
||||
handlers['rowboat:getConfig'] = async () => {
|
||||
throw new Error('ipc down')
|
||||
}
|
||||
const { result } = renderHook(() => useRowboatConfig())
|
||||
expect(result.current).toBeNull()
|
||||
await expect(fetchRowboatConfig()).resolves.toBeNull()
|
||||
expect(result.current).toBeNull()
|
||||
})
|
||||
})
|
||||
67
apps/x/apps/renderer/src/hooks/use-rowboat-config.ts
Normal file
67
apps/x/apps/renderer/src/hooks/use-rowboat-config.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { z } from 'zod'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { RowboatApiConfig } from '@x/shared/dist/rowboat-account.js'
|
||||
|
||||
export type RowboatConfig = z.infer<typeof RowboatApiConfig>
|
||||
|
||||
// The single renderer-side reader of the /v1/config bootstrap (service URLs,
|
||||
// billing catalog, model recommendations) via rowboat:getConfig. The config
|
||||
// is static for the lifetime of the app run (main caches the fetch), so one
|
||||
// module-level store serves every consumer: the first subscriber triggers
|
||||
// the fetch, everyone shares the result, and a failed fetch retries on the
|
||||
// next consumer instead of caching null forever.
|
||||
let cached: RowboatConfig | null = null
|
||||
let pending: Promise<RowboatConfig | null> | null = null
|
||||
const subscribers = new Set<() => void>()
|
||||
|
||||
/**
|
||||
* Imperative access for non-hook call paths (e.g. useRowboatAccount's
|
||||
* refresh) — same shared cache as the hook.
|
||||
*/
|
||||
export function fetchRowboatConfig(): Promise<RowboatConfig | null> {
|
||||
if (cached) return Promise.resolve(cached)
|
||||
if (!pending) {
|
||||
pending = window.ipc
|
||||
.invoke('rowboat:getConfig', null)
|
||||
.then((config) => {
|
||||
if (config) {
|
||||
cached = config
|
||||
for (const notify of subscribers) notify()
|
||||
} else {
|
||||
// Main returned null (API unreachable) — leave the cache empty so
|
||||
// a later consumer retries.
|
||||
pending = null
|
||||
}
|
||||
return config
|
||||
})
|
||||
.catch(() => {
|
||||
pending = null
|
||||
return null
|
||||
})
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
subscribers.add(onStoreChange)
|
||||
void fetchRowboatConfig()
|
||||
return () => {
|
||||
subscribers.delete(onStoreChange)
|
||||
}
|
||||
}
|
||||
|
||||
function getSnapshot(): RowboatConfig | null {
|
||||
return cached
|
||||
}
|
||||
|
||||
/** The Rowboat bootstrap config, or null while loading / when unreachable. */
|
||||
export function useRowboatConfig(): RowboatConfig | null {
|
||||
return useSyncExternalStore(subscribe, getSnapshot)
|
||||
}
|
||||
|
||||
// Test-only: drop the shared cache so each test starts cold.
|
||||
export function __resetRowboatConfigForTests(): void {
|
||||
cached = null
|
||||
pending = null
|
||||
subscribers.clear()
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { toast } from 'sonner';
|
|||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||
import { finalizeDeepgramStream } from '@/lib/deepgram-finalize';
|
||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||
import { fetchRowboatConfig } from '@/hooks/use-rowboat-config';
|
||||
|
||||
export type MeetingTranscriptionState = 'idle' | 'connecting' | 'recording' | 'stopping';
|
||||
|
||||
|
|
@ -258,14 +259,19 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
detectHeadphones(),
|
||||
// 2. Set up Deepgram WebSocket (account refresh + connect + wait for open)
|
||||
(async () => {
|
||||
const account = await refreshRowboatAccount();
|
||||
// Token from account refresh; websocket URL from the
|
||||
// sign-in-independent bootstrap config store.
|
||||
const [account, rowboatConfig] = await Promise.all([
|
||||
refreshRowboatAccount(),
|
||||
fetchRowboatConfig(),
|
||||
]);
|
||||
let ws: WebSocket;
|
||||
if (
|
||||
account?.signedIn &&
|
||||
account.accessToken &&
|
||||
account.config?.websocketApiUrl
|
||||
rowboatConfig?.websocketApiUrl
|
||||
) {
|
||||
const listenUrl = buildDeepgramListenUrl(account.config.websocketApiUrl, DEEPGRAM_PARAMS);
|
||||
const listenUrl = buildDeepgramListenUrl(rowboatConfig.websocketApiUrl, DEEPGRAM_PARAMS);
|
||||
console.log('[meeting] Using Rowboat WebSocket');
|
||||
ws = new WebSocket(listenUrl, ['bearer', account.accessToken]);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { z } from 'zod';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { RowboatApiConfig } from '@x/shared/dist/rowboat-account.js';
|
||||
|
||||
|
||||
// Account state only — sign-in status and the access token. The bootstrap
|
||||
// config (/v1/config: service URLs, billing catalog, model recommendations)
|
||||
// is deliberately NOT part of this snapshot: it's unauthenticated and
|
||||
// sign-in independent, so consumers read it from use-rowboat-config instead.
|
||||
interface RowboatAccountState {
|
||||
signedIn: boolean;
|
||||
accessToken: string | null;
|
||||
config: z.infer<typeof RowboatApiConfig> | null;
|
||||
}
|
||||
|
||||
export type RowboatAccountSnapshot = RowboatAccountState;
|
||||
|
|
@ -14,7 +14,6 @@ export type RowboatAccountSnapshot = RowboatAccountState;
|
|||
const DEFAULT_STATE: RowboatAccountState = {
|
||||
signedIn: false,
|
||||
accessToken: null,
|
||||
config: null,
|
||||
};
|
||||
|
||||
export function useRowboatAccount() {
|
||||
|
|
@ -28,7 +27,6 @@ export function useRowboatAccount() {
|
|||
const next: RowboatAccountSnapshot = {
|
||||
signedIn: result.signedIn,
|
||||
accessToken: result.accessToken,
|
||||
config: result.config,
|
||||
};
|
||||
setState(next);
|
||||
return next;
|
||||
|
|
@ -58,7 +56,6 @@ export function useRowboatAccount() {
|
|||
return {
|
||||
signedIn: state.signedIn,
|
||||
accessToken: state.accessToken,
|
||||
config: state.config,
|
||||
isLoading,
|
||||
refresh,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from 'react';
|
|||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||
import { finalizeDeepgramStream } from '@/lib/deepgram-finalize';
|
||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||
import { fetchRowboatConfig } from '@/hooks/use-rowboat-config';
|
||||
import posthog from 'posthog-js';
|
||||
import * as analytics from '@/lib/analytics';
|
||||
|
||||
|
|
@ -91,13 +92,19 @@ export function useVoiceMode() {
|
|||
|
||||
// Refresh cached auth details (called on warmup, not on mic click)
|
||||
const refreshAuth = useCallback(async () => {
|
||||
const account = await refreshRowboatAccount();
|
||||
// Auth (account) and the websocket URL (bootstrap config) are
|
||||
// separate concerns: the token comes from account refresh, the URL
|
||||
// from the sign-in-independent config store.
|
||||
const [account, rowboatConfig] = await Promise.all([
|
||||
refreshRowboatAccount(),
|
||||
fetchRowboatConfig(),
|
||||
]);
|
||||
if (
|
||||
account?.signedIn &&
|
||||
account.accessToken &&
|
||||
account.config?.websocketApiUrl
|
||||
rowboatConfig?.websocketApiUrl
|
||||
) {
|
||||
cachedAuth = { type: 'rowboat', url: account.config.websocketApiUrl, token: account.accessToken };
|
||||
cachedAuth = { type: 'rowboat', url: rowboatConfig.websocketApiUrl, token: account.accessToken };
|
||||
} else {
|
||||
const config = await window.ipc.invoke('voice:getConfig', null);
|
||||
if (config?.deepgram) {
|
||||
|
|
|
|||
|
|
@ -336,3 +336,18 @@ export function settingsTabChanged(tab: string) {
|
|||
export function onboardingCompleted() {
|
||||
posthog.capture('onboarding_completed')
|
||||
}
|
||||
|
||||
// A provider connect seeded the assistant model (only happens when none was
|
||||
// configured). `recommended` = the backend's flavor recommendation was in
|
||||
// the provider's live list; false = first-listed fallback. Flavor only —
|
||||
// never provider instance ids, keys, or endpoints.
|
||||
export function llmInitialModelSelected(props: {
|
||||
flavor: string
|
||||
model: string
|
||||
recommended: boolean
|
||||
taskOverridesSeeded: number
|
||||
source: 'connect' | 'onboarding'
|
||||
}) {
|
||||
const { taskOverridesSeeded, ...rest } = props
|
||||
posthog.capture('llm_initial_model_selected', { ...rest, task_overrides_seeded: taskOverridesSeeded })
|
||||
}
|
||||
|
|
|
|||
105
apps/x/packages/core/src/analytics/model-providers.ts
Normal file
105
apps/x/packages/core/src/analytics/model-providers.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { capture, setPersonProperties } from "./posthog.js";
|
||||
import type { IModelConfigRepo } from "../models/repo.js";
|
||||
|
||||
/**
|
||||
* Provider-level analytics for model selection.
|
||||
*
|
||||
* Privacy rules, encoded here so call sites can't get them wrong:
|
||||
* - Only provider FLAVORS ever leave the app. Instance ids equal flavor keys
|
||||
* today, but a future multi-key setup makes ids user-named — so every
|
||||
* surface maps id → flavor before capturing.
|
||||
* - Never credentials: no apiKey, no headers, and no baseURL (local
|
||||
* endpoints can carry internal hostnames).
|
||||
* - Model ids are fine (they already ride on llm_usage).
|
||||
*
|
||||
* All I/O is lazy (dynamic imports, container resolution at call time) so
|
||||
* this module stays import-cycle-free — models/repo.ts imports it.
|
||||
*/
|
||||
|
||||
const FLAVOR_CACHE_TTL_MS = 10_000;
|
||||
let flavorCache: { at: number; byId: Map<string, string> } | null = null;
|
||||
|
||||
async function resolveRepo(): Promise<IModelConfigRepo> {
|
||||
const { default: container } = await import("../di/container.js");
|
||||
return container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
}
|
||||
|
||||
async function providerFlavorsById(): Promise<Map<string, string>> {
|
||||
if (flavorCache && Date.now() - flavorCache.at < FLAVOR_CACHE_TTL_MS) {
|
||||
return flavorCache.byId;
|
||||
}
|
||||
const byId = new Map<string, string>();
|
||||
try {
|
||||
const cfg = await (await resolveRepo()).getConfig();
|
||||
for (const [id, entry] of Object.entries(cfg.providers)) {
|
||||
byId.set(id, entry.flavor);
|
||||
}
|
||||
} catch {
|
||||
// No config yet — empty map; ids fall through unchanged.
|
||||
}
|
||||
flavorCache = { at: Date.now(), byId };
|
||||
return byId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a provider instance id to its flavor for analytics. Unknown ids fall
|
||||
* back to the raw value — which today always equals the flavor key.
|
||||
*/
|
||||
export async function flavorForProviderId(id: string): Promise<string> {
|
||||
if (id === "rowboat" || id === "codex") return id;
|
||||
return (await providerFlavorsById()).get(id) ?? id;
|
||||
}
|
||||
|
||||
export function invalidateFlavorCache(): void {
|
||||
flavorCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the person properties describing the user's provider setup:
|
||||
* `llm_provider_flavors` (sorted, includes rowboat/codex from auth state),
|
||||
* `llm_provider_count`, and the configured assistant model. Call after any
|
||||
* provider or assistant change; also called on every app launch so existing
|
||||
* installs get baselined without waiting for an action.
|
||||
*/
|
||||
export async function syncModelProviderPersonProperties(): Promise<void> {
|
||||
try {
|
||||
const cfg = await (await resolveRepo()).getConfig().catch(() => null);
|
||||
const { isSignedIn } = await import("../account/account.js");
|
||||
const { getChatGPTStatus } = await import("../auth/chatgpt-auth.js");
|
||||
const flavors = new Set<string>();
|
||||
for (const entry of Object.values(cfg?.providers ?? {})) {
|
||||
flavors.add(entry.flavor);
|
||||
}
|
||||
if (await isSignedIn().catch(() => false)) flavors.add("rowboat");
|
||||
const chatgpt = await getChatGPTStatus().catch(() => ({ signedIn: false }));
|
||||
if (chatgpt.signedIn) flavors.add("codex");
|
||||
|
||||
const assistant = cfg?.assistantModel ?? null;
|
||||
setPersonProperties({
|
||||
llm_provider_flavors: [...flavors].sort(),
|
||||
llm_provider_count: flavors.size,
|
||||
...(assistant
|
||||
? {
|
||||
assistant_model: assistant.model,
|
||||
assistant_model_flavor: await flavorForProviderId(assistant.provider),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[Analytics] provider person-props sync failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** One provider became connected (any surface: settings, onboarding, sign-in). */
|
||||
export function captureProviderConnected(flavor: string): void {
|
||||
capture("llm_provider_connected", { flavor });
|
||||
invalidateFlavorCache();
|
||||
void syncModelProviderPersonProperties();
|
||||
}
|
||||
|
||||
/** One provider was disconnected / signed out. */
|
||||
export function captureProviderDisconnected(flavor: string): void {
|
||||
capture("llm_provider_disconnected", { flavor });
|
||||
invalidateFlavorCache();
|
||||
void syncModelProviderPersonProperties();
|
||||
}
|
||||
|
|
@ -91,6 +91,21 @@ export function reset(): void {
|
|||
identifiedUserId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge person properties onto the CURRENT identity — the rowboat user once
|
||||
* identified, else the anonymous installation id (identify on the same
|
||||
* distinctId merges properties without changing identity).
|
||||
*/
|
||||
export function setPersonProperties(properties: Record<string, unknown>): void {
|
||||
const ph = getClient();
|
||||
if (!ph) return;
|
||||
try {
|
||||
ph.identify({ distinctId: activeDistinctId(), properties });
|
||||
} catch (err) {
|
||||
console.error('[Analytics] setPersonProperties failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a PostHog feature flag for the current identity (rowboat user id
|
||||
* once identified, installation id before that). `defaultValue` is returned
|
||||
|
|
|
|||
|
|
@ -21,18 +21,29 @@ export interface CaptureLlmUsageArgs {
|
|||
}
|
||||
|
||||
export function captureLlmUsage(args: CaptureLlmUsageArgs): void {
|
||||
const usage = args.usage ?? {};
|
||||
const properties: Record<string, unknown> = {
|
||||
use_case: args.useCase,
|
||||
model: args.model,
|
||||
provider: args.provider,
|
||||
input_tokens: usage.inputTokens ?? 0,
|
||||
output_tokens: usage.outputTokens ?? 0,
|
||||
total_tokens: usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
};
|
||||
if (args.subUseCase) properties.sub_use_case = args.subUseCase;
|
||||
if (args.agentName) properties.agent_name = args.agentName;
|
||||
if (usage.cachedInputTokens != null) properties.cached_input_tokens = usage.cachedInputTokens;
|
||||
if (usage.reasoningTokens != null) properties.reasoning_tokens = usage.reasoningTokens;
|
||||
capture('llm_usage', properties);
|
||||
// Fire-and-forget: callers pass the provider INSTANCE id (ModelRef
|
||||
// .provider); analytics reports the FLAVOR — ids may one day be
|
||||
// user-named, and charts must not fracture when "openai-work" appears.
|
||||
// Today ids equal flavor keys, so the fallback is lossless.
|
||||
void (async () => {
|
||||
let provider = args.provider;
|
||||
try {
|
||||
const { flavorForProviderId } = await import('./model-providers.js');
|
||||
provider = await flavorForProviderId(args.provider);
|
||||
} catch { /* keep the raw value */ }
|
||||
const usage = args.usage ?? {};
|
||||
const properties: Record<string, unknown> = {
|
||||
use_case: args.useCase,
|
||||
model: args.model,
|
||||
provider,
|
||||
input_tokens: usage.inputTokens ?? 0,
|
||||
output_tokens: usage.outputTokens ?? 0,
|
||||
total_tokens: usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
};
|
||||
if (args.subUseCase) properties.sub_use_case = args.subUseCase;
|
||||
if (args.agentName) properties.agent_name = args.agentName;
|
||||
if (usage.cachedInputTokens != null) properties.cached_input_tokens = usage.cachedInputTokens;
|
||||
if (usage.reasoningTokens != null) properties.reasoning_tokens = usage.reasoningTokens;
|
||||
capture('llm_usage', properties);
|
||||
})();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -385,5 +385,10 @@ export async function signOutChatGPT(): Promise<void> {
|
|||
}
|
||||
}
|
||||
await clearStore();
|
||||
// Signing out disconnects the codex provider: drop the model selections
|
||||
// that reference it (same dangling-ref cleanup as removing any
|
||||
// provider). Lazy import — models/catalog imports this module.
|
||||
const { clearCodexSelections } = await import('../models/chatgpt-selection.js');
|
||||
await clearCodexSelections();
|
||||
console.log('[ChatGPTAuth] Signed out');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ import container from "../di/container.js";
|
|||
import { WorkDir } from "../config/config.js";
|
||||
import type { ISessions } from "../runtime/sessions/api.js";
|
||||
import type { ITurnEventBus } from "../runtime/turns/event-hub.js";
|
||||
import { isSignedIn } from "../account/account.js";
|
||||
import { listGatewayModels } from "../models/gateway.js";
|
||||
import { listOnboardingModels } from "../models/models-dev.js";
|
||||
import { getModelCatalog, providerDisplayName } from "../models/catalog.js";
|
||||
import { ChannelBridge, type ModelChoice } from "./bridge.js";
|
||||
import type { IChannelsConfigRepo } from "./repo.js";
|
||||
import { TelegramTransport } from "./transports/telegram.js";
|
||||
|
|
@ -82,16 +80,16 @@ export function subscribeChannelsStatus(listener: (status: Status) => void): ()
|
|||
|
||||
// Same catalog the desktop model picker uses (models:list IPC).
|
||||
async function listBridgeModels(): Promise<ModelChoice[]> {
|
||||
const catalog = (await isSignedIn())
|
||||
? await listGatewayModels()
|
||||
: await listOnboardingModels();
|
||||
return catalog.providers.flatMap((provider) =>
|
||||
provider.models.map((m) => ({
|
||||
provider: provider.id,
|
||||
model: m.id,
|
||||
label: `${m.name ?? m.id} — ${provider.name}`,
|
||||
})),
|
||||
);
|
||||
const catalog = await getModelCatalog();
|
||||
return catalog.providers
|
||||
.filter((provider) => provider.status === "ok")
|
||||
.flatMap((provider) =>
|
||||
provider.models.map((m) => ({
|
||||
provider: provider.id,
|
||||
model: m.id,
|
||||
label: `${m.name ?? m.id} — ${providerDisplayName(provider.flavor)}`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function ensureBridge(): ChannelBridge {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@ import { CronExpressionParser } from 'cron-parser';
|
|||
import { generateText } from 'ai';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { runWhenPossible } from '../runtime/assembly/headless-app.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import container from '../di/container.js';
|
||||
import type { IModelConfigRepo } from '../models/repo.js';
|
||||
import { getKgModel, resolveProviderConfig } from '../models/defaults.js';
|
||||
import { createLanguageModel } from '../models/models.js';
|
||||
import { inlineTask } from '@x/shared';
|
||||
import { captureLlmUsage } from '../analytics/usage.js';
|
||||
|
|
@ -611,9 +609,9 @@ export async function processRowboatInstruction(
|
|||
* Returns a schedule object or null for one-time tasks.
|
||||
*/
|
||||
export async function classifySchedule(instruction: string): Promise<InlineTaskSchedule | null> {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
const model = createLanguageModel(config.provider, config.model);
|
||||
const selection = await getKgModel();
|
||||
const providerConfig = await resolveProviderConfig(selection.provider);
|
||||
const model = createLanguageModel(providerConfig, selection.model);
|
||||
|
||||
const now = new Date();
|
||||
const defaultEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
|
@ -660,8 +658,8 @@ Respond with ONLY valid JSON: either a schedule object or null. No other text.`;
|
|||
captureLlmUsage({
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'inline_task_classify',
|
||||
model: config.model,
|
||||
provider: config.provider.flavor,
|
||||
model: selection.model,
|
||||
provider: selection.provider,
|
||||
usage: result.usage,
|
||||
});
|
||||
|
||||
|
|
|
|||
173
apps/x/packages/core/src/models/catalog.test.ts
Normal file
173
apps/x/packages/core/src/models/catalog.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* The unified model catalog: every provider (rowboat gateway, codex, BYOK,
|
||||
* local) flows through one function with per-provider status and a
|
||||
* credential-fingerprinted list cache. These tests pin the policy: who gets
|
||||
* discovered, which lister serves which flavor, how failures surface, and
|
||||
* when the cache is (in)validated.
|
||||
*/
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isSignedIn: vi.fn(async () => false),
|
||||
getChatGPTStatus: vi.fn(async () => ({ signedIn: false })),
|
||||
listGatewayModels: vi.fn(async () => ({
|
||||
providers: [{ id: 'rowboat', name: 'Rowboat', models: [{ id: 'google/gemini-3.5-flash', reasoning: true }] }],
|
||||
})),
|
||||
listCodexModels: vi.fn(async () => ({
|
||||
providers: [{ id: 'codex', name: 'OpenAI Codex', models: [{ id: 'gpt-5.6-sol', reasoning: true }] }],
|
||||
})),
|
||||
listModelsForProvider: vi.fn(async (_config: unknown) => ['live-model-1']),
|
||||
listOnboardingModels: vi.fn(async () => ({ providers: [] as Array<{ id: string; name: string; models: Array<{ id: string; name?: string; reasoning?: boolean }> }> })),
|
||||
getDefaultModelAndProvider: vi.fn(async () => ({ provider: 'openai', model: 'gpt-5.4' })),
|
||||
getConfig: vi.fn(async (): Promise<unknown> => {
|
||||
throw new Error('no models.json');
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../account/account.js', () => ({ isSignedIn: mocks.isSignedIn }));
|
||||
vi.mock('../auth/chatgpt-auth.js', () => ({ getChatGPTStatus: mocks.getChatGPTStatus }));
|
||||
vi.mock('./gateway.js', () => ({ listGatewayModels: mocks.listGatewayModels }));
|
||||
vi.mock('./codex.js', () => ({ listCodexModels: mocks.listCodexModels }));
|
||||
vi.mock('./models.js', () => ({ listModelsForProvider: mocks.listModelsForProvider }));
|
||||
vi.mock('./models-dev.js', () => ({ listOnboardingModels: mocks.listOnboardingModels }));
|
||||
vi.mock('./defaults.js', () => ({ getDefaultModelAndProvider: mocks.getDefaultModelAndProvider }));
|
||||
vi.mock('../di/container.js', () => ({
|
||||
default: { resolve: () => ({ getConfig: mocks.getConfig }) },
|
||||
}));
|
||||
|
||||
import { getModelCatalog, __resetModelCatalogForTests } from './catalog.js';
|
||||
|
||||
// v2 config: providers keyed by instance id, flavor explicit inside (the
|
||||
// helper defaults flavor to the key — one instance per flavor today).
|
||||
function serveConfig(
|
||||
providers: Record<string, Record<string, unknown>>,
|
||||
assistantModel?: { provider: string; model: string },
|
||||
): void {
|
||||
mocks.getConfig.mockImplementation(async () => ({
|
||||
version: 2,
|
||||
providers: Object.fromEntries(
|
||||
Object.entries(providers).map(([id, entry]) => [id, { flavor: id, ...entry }]),
|
||||
),
|
||||
...(assistantModel ? { assistantModel } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__resetModelCatalogForTests();
|
||||
mocks.isSignedIn.mockResolvedValue(false);
|
||||
mocks.getChatGPTStatus.mockResolvedValue({ signedIn: false });
|
||||
mocks.listOnboardingModels.mockResolvedValue({ providers: [] });
|
||||
mocks.getDefaultModelAndProvider.mockResolvedValue({ provider: 'openai', model: 'gpt-5.4' });
|
||||
mocks.getConfig.mockRejectedValue(new Error('no models.json'));
|
||||
});
|
||||
|
||||
describe('getModelCatalog', () => {
|
||||
it('treats rowboat, codex, and BYOK providers as one uniform provider list', async () => {
|
||||
mocks.isSignedIn.mockResolvedValue(true);
|
||||
mocks.getChatGPTStatus.mockResolvedValue({ signedIn: true });
|
||||
serveConfig({
|
||||
ollama: { baseURL: 'http://localhost:11434' },
|
||||
});
|
||||
mocks.listModelsForProvider.mockResolvedValue(['llama3', 'qwen3']);
|
||||
|
||||
const catalog = await getModelCatalog();
|
||||
|
||||
expect(catalog.providers.map((p) => p.id)).toEqual(['rowboat', 'codex', 'ollama']);
|
||||
expect(catalog.providers.every((p) => p.status === 'ok')).toBe(true);
|
||||
expect(catalog.providers[0].models).toEqual([{ id: 'google/gemini-3.5-flash', reasoning: true }]);
|
||||
expect(catalog.providers[2]).toMatchObject({ flavor: 'ollama', models: [{ id: 'llama3' }, { id: 'qwen3' }] });
|
||||
expect(catalog.defaultModel).toEqual({ provider: 'openai', model: 'gpt-5.4' });
|
||||
});
|
||||
|
||||
it('orders the assistant model provider first among configured providers', async () => {
|
||||
serveConfig(
|
||||
{
|
||||
openrouter: { apiKey: 'sk-1' },
|
||||
ollama: { baseURL: 'http://localhost:11434' },
|
||||
},
|
||||
{ provider: 'ollama', model: 'llama3' },
|
||||
);
|
||||
mocks.listModelsForProvider.mockResolvedValue(['m']);
|
||||
|
||||
const catalog = await getModelCatalog();
|
||||
expect(catalog.providers.map((p) => p.id)).toEqual(['ollama', 'openrouter']);
|
||||
});
|
||||
|
||||
it('serves cloud flavors from the models.dev catalog and only lists live when it is empty', async () => {
|
||||
serveConfig({ openai: { apiKey: 'sk-a' } });
|
||||
mocks.listOnboardingModels.mockResolvedValue({
|
||||
providers: [{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4', reasoning: true }] }],
|
||||
});
|
||||
|
||||
const catalog = await getModelCatalog();
|
||||
expect(catalog.providers[0].models).toEqual([{ id: 'gpt-5.4', reasoning: true }]);
|
||||
expect(mocks.listModelsForProvider).not.toHaveBeenCalled();
|
||||
|
||||
// Empty models.dev cache (fresh offline install) → live listing fallback.
|
||||
__resetModelCatalogForTests();
|
||||
mocks.listOnboardingModels.mockResolvedValue({ providers: [] });
|
||||
mocks.listModelsForProvider.mockResolvedValue(['gpt-5.4-live']);
|
||||
const fallback = await getModelCatalog();
|
||||
expect(fallback.providers[0].models).toEqual([{ id: 'gpt-5.4-live' }]);
|
||||
expect(mocks.listModelsForProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a failed provider as status error instead of dropping it', async () => {
|
||||
serveConfig({ ollama: { baseURL: 'http://localhost:11434' } });
|
||||
mocks.listModelsForProvider.mockRejectedValue(new Error('connection refused'));
|
||||
|
||||
const catalog = await getModelCatalog();
|
||||
expect(catalog.providers[0]).toMatchObject({
|
||||
id: 'ollama',
|
||||
status: 'error',
|
||||
error: 'connection refused',
|
||||
models: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('caches successful lists per credential fingerprint and refetches when credentials change', async () => {
|
||||
serveConfig({ openrouter: { apiKey: 'sk-1' } });
|
||||
mocks.listModelsForProvider.mockResolvedValue(['a/b']);
|
||||
|
||||
await getModelCatalog();
|
||||
await getModelCatalog();
|
||||
expect(mocks.listModelsForProvider).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Same provider, new key → the fingerprint changes → refetch.
|
||||
serveConfig({ openrouter: { apiKey: 'sk-2' } });
|
||||
await getModelCatalog();
|
||||
expect(mocks.listModelsForProvider).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('refreshProvider bypasses the cache for that provider only', async () => {
|
||||
serveConfig({
|
||||
openrouter: { apiKey: 'sk-1' },
|
||||
ollama: { baseURL: 'http://localhost:11434' },
|
||||
});
|
||||
mocks.listModelsForProvider.mockResolvedValue(['m']);
|
||||
|
||||
await getModelCatalog();
|
||||
expect(mocks.listModelsForProvider).toHaveBeenCalledTimes(2);
|
||||
|
||||
await getModelCatalog({ refreshProvider: 'ollama' });
|
||||
// Only ollama refetched; openrouter served from cache.
|
||||
expect(mocks.listModelsForProvider).toHaveBeenCalledTimes(3);
|
||||
const lastCall = mocks.listModelsForProvider.mock.calls.at(-1)?.[0] as { flavor: string };
|
||||
expect(lastCall.flavor).toBe('ollama');
|
||||
});
|
||||
|
||||
it('caches failures briefly so every catalog build does not re-pay the fetch timeout', async () => {
|
||||
serveConfig({ ollama: { baseURL: 'http://localhost:11434' } });
|
||||
mocks.listModelsForProvider.mockRejectedValue(new Error('down'));
|
||||
|
||||
await getModelCatalog();
|
||||
await getModelCatalog();
|
||||
expect(mocks.listModelsForProvider).toHaveBeenCalledTimes(1);
|
||||
|
||||
// …but an explicit refresh always retries.
|
||||
await getModelCatalog({ refreshProvider: 'ollama' });
|
||||
expect(mocks.listModelsForProvider).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
283
apps/x/packages/core/src/models/catalog.ts
Normal file
283
apps/x/packages/core/src/models/catalog.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import z from "zod";
|
||||
import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js";
|
||||
import { isSignedIn } from "../account/account.js";
|
||||
import { getChatGPTStatus } from "../auth/chatgpt-auth.js";
|
||||
import container from "../di/container.js";
|
||||
import { IModelConfigRepo } from "./repo.js";
|
||||
import { listGatewayModels } from "./gateway.js";
|
||||
import { listCodexModels } from "./codex.js";
|
||||
import { listModelsForProvider } from "./models.js";
|
||||
import { listOnboardingModels } from "./models-dev.js";
|
||||
import { getDefaultModelAndProvider } from "./defaults.js";
|
||||
|
||||
/**
|
||||
* The unified model catalog: one function that answers "which providers are
|
||||
* connected and what models does each offer", treating every provider the
|
||||
* same way — the Rowboat gateway, the ChatGPT subscription (codex), BYOK
|
||||
* cloud keys, and local/custom endpoints are all just providers. The
|
||||
* per-provider listing mechanics (which endpoint, which fallback) live here
|
||||
* and nowhere else; the renderer consumes this through the single models:list
|
||||
* IPC call.
|
||||
*/
|
||||
|
||||
export interface CatalogModelEntry {
|
||||
id: string;
|
||||
name?: string;
|
||||
/** models.dev "supports reasoning" flag; absent = unknown. */
|
||||
reasoning?: boolean;
|
||||
}
|
||||
|
||||
export interface CatalogProviderEntry {
|
||||
/**
|
||||
* Provider INSTANCE identifier — what ModelRef.provider, assistantModel,
|
||||
* task overrides, and refreshProvider all reference. Today one instance
|
||||
* exists per flavor, so id always equals the flavor key ("openai",
|
||||
* "ollama", "rowboat", …); a future multi-key setup ("openai-work" /
|
||||
* "openai-personal") would yield two entries with distinct ids sharing
|
||||
* one flavor, without changing what an id means anywhere.
|
||||
*/
|
||||
id: string;
|
||||
/** Provider TYPE ("openai", "ollama", …, "rowboat", "codex") — drives
|
||||
* display naming, listing mechanics, and credential-field UI. */
|
||||
flavor: string;
|
||||
/** "error" = the provider is connected but its model list failed to load. */
|
||||
status: "ok" | "error";
|
||||
error?: string;
|
||||
models: CatalogModelEntry[];
|
||||
}
|
||||
|
||||
export interface ModelCatalogResult {
|
||||
providers: CatalogProviderEntry[];
|
||||
/** The effective runtime default (what runs when nothing is picked). */
|
||||
defaultModel: { provider: string; model: string } | null;
|
||||
}
|
||||
|
||||
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
rowboat: "Rowboat",
|
||||
codex: "OpenAI Codex",
|
||||
openai: "OpenAI",
|
||||
anthropic: "Anthropic",
|
||||
google: "Gemini",
|
||||
openrouter: "OpenRouter",
|
||||
aigateway: "AI Gateway",
|
||||
ollama: "Ollama",
|
||||
"openai-compatible": "OpenAI-Compatible",
|
||||
};
|
||||
|
||||
/**
|
||||
* Display name for a provider flavor. Presentation only — nothing keys on
|
||||
* it. (When multi-instance providers arrive, a user-chosen instance label
|
||||
* would take precedence over this.)
|
||||
*/
|
||||
export function providerDisplayName(flavor: string): string {
|
||||
return PROVIDER_DISPLAY_NAMES[flavor] ?? flavor;
|
||||
}
|
||||
|
||||
// Flavors whose lists come from the models.dev catalog cache (stable ids,
|
||||
// no per-account variation); the live provider API is only a fallback when
|
||||
// the cache is empty. Everything else always lists live.
|
||||
const MODELS_DEV_FLAVORS = new Set(["openai", "anthropic", "google"]);
|
||||
|
||||
// listModelsForProvider builds aigateway's URL from baseURL; apply the
|
||||
// service default here so a keyed-but-URL-less config still lists.
|
||||
const AIGATEWAY_DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v1";
|
||||
|
||||
// Successful lists are cached until the provider's credentials change or an
|
||||
// explicit refresh; failures retry after a short TTL so a temporarily-down
|
||||
// local server doesn't stay dark, without re-paying the fetch timeout on
|
||||
// every catalog build in between.
|
||||
const ERROR_RETRY_MS = 30_000;
|
||||
|
||||
interface CacheEntry {
|
||||
fingerprint: string;
|
||||
fetchedAt: number;
|
||||
status: "ok" | "error";
|
||||
error?: string;
|
||||
models: CatalogModelEntry[];
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const inFlight = new Map<string, Promise<CacheEntry>>();
|
||||
|
||||
type ProviderConfig = z.infer<typeof LlmProvider>;
|
||||
|
||||
interface DiscoveredProvider {
|
||||
id: string;
|
||||
flavor: string;
|
||||
/** Absent for rowboat/codex — their auth lives outside models.json. */
|
||||
config?: ProviderConfig;
|
||||
}
|
||||
|
||||
async function readModelConfig(): Promise<z.infer<typeof LlmModelConfig> | null> {
|
||||
try {
|
||||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
return await repo.getConfig();
|
||||
} catch {
|
||||
// Signed-in users may have no models.json at all.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which providers are connected right now. Rowboat and ChatGPT come from
|
||||
* their auth state; everything else from the models.json providers map
|
||||
* (entries carry credentials by construction in v2). The assistant model's
|
||||
* provider leads, matching picker ordering.
|
||||
*/
|
||||
async function discoverProviders(): Promise<DiscoveredProvider[]> {
|
||||
const discovered: DiscoveredProvider[] = [];
|
||||
|
||||
if (await isSignedIn().catch(() => false)) {
|
||||
discovered.push({ id: "rowboat", flavor: "rowboat" });
|
||||
}
|
||||
try {
|
||||
const chatgpt = await getChatGPTStatus();
|
||||
if (chatgpt.signedIn) discovered.push({ id: "codex", flavor: "codex" });
|
||||
} catch {
|
||||
// ChatGPT status failures must never break the main list.
|
||||
}
|
||||
|
||||
const cfg = await readModelConfig();
|
||||
const providersMap = cfg?.providers ?? {};
|
||||
const assistantProvider = cfg?.assistantModel?.provider ?? "";
|
||||
const ids = Object.keys(providersMap)
|
||||
.sort((a, b) => (a === assistantProvider ? -1 : b === assistantProvider ? 1 : 0));
|
||||
|
||||
for (const id of ids) {
|
||||
const entry = providersMap[id];
|
||||
if (!entry) continue;
|
||||
const config = { ...entry };
|
||||
if (config.flavor === "aigateway" && !config.baseURL) {
|
||||
config.baseURL = AIGATEWAY_DEFAULT_BASE_URL;
|
||||
}
|
||||
discovered.push({ id, flavor: entry.flavor, config });
|
||||
}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
|
||||
/** Cache key input: listing output depends only on flavor + credentials. */
|
||||
function fingerprintOf(provider: DiscoveredProvider): string {
|
||||
if (!provider.config) return provider.id;
|
||||
const { flavor, apiKey, baseURL, headers } = provider.config;
|
||||
return JSON.stringify({ flavor, apiKey, baseURL, headers });
|
||||
}
|
||||
|
||||
async function fetchProviderEntry(
|
||||
provider: DiscoveredProvider,
|
||||
fingerprint: string,
|
||||
modelsDevByFlavor: Map<string, CatalogModelEntry[]>,
|
||||
): Promise<CacheEntry> {
|
||||
try {
|
||||
let models: CatalogModelEntry[];
|
||||
if (provider.id === "rowboat") {
|
||||
const result = await listGatewayModels();
|
||||
models = result.providers[0]?.models ?? [];
|
||||
} else if (provider.id === "codex") {
|
||||
const result = await listCodexModels();
|
||||
models = result.providers[0]?.models ?? [];
|
||||
} else if (MODELS_DEV_FLAVORS.has(provider.flavor) && (modelsDevByFlavor.get(provider.flavor)?.length ?? 0) > 0) {
|
||||
models = modelsDevByFlavor.get(provider.flavor) ?? [];
|
||||
} else if (!provider.config) {
|
||||
throw new Error(`Provider '${provider.id}' has no configuration to list models with`);
|
||||
} else {
|
||||
// Live listing: local/custom flavors always, cloud flavors only
|
||||
// when the models.dev cache is empty (offline fresh install).
|
||||
const ids = await listModelsForProvider(provider.config);
|
||||
models = ids.map((id) => ({ id }));
|
||||
}
|
||||
return { fingerprint, fetchedAt: Date.now(), status: "ok", models };
|
||||
} catch (err) {
|
||||
return {
|
||||
fingerprint,
|
||||
fetchedAt: Date.now(),
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to list models",
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveProviderEntry(
|
||||
provider: DiscoveredProvider,
|
||||
modelsDevByFlavor: Map<string, CatalogModelEntry[]>,
|
||||
forceRefresh: boolean,
|
||||
): Promise<CacheEntry> {
|
||||
const fingerprint = fingerprintOf(provider);
|
||||
const cached = cache.get(provider.id);
|
||||
if (!forceRefresh && cached && cached.fingerprint === fingerprint) {
|
||||
const fresh = cached.status === "ok" || Date.now() - cached.fetchedAt < ERROR_RETRY_MS;
|
||||
if (fresh) return cached;
|
||||
}
|
||||
const pending = inFlight.get(provider.id);
|
||||
if (pending && !forceRefresh) return pending;
|
||||
|
||||
const request = fetchProviderEntry(provider, fingerprint, modelsDevByFlavor)
|
||||
.then((entry) => {
|
||||
cache.set(provider.id, entry);
|
||||
return entry;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlight.get(provider.id) === request) inFlight.delete(provider.id);
|
||||
});
|
||||
inFlight.set(provider.id, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export interface GetModelCatalogOptions {
|
||||
/** Drop this provider's cached list and refetch it (Retry / Refresh models). */
|
||||
refreshProvider?: string;
|
||||
}
|
||||
|
||||
export async function getModelCatalog(options?: GetModelCatalogOptions): Promise<ModelCatalogResult> {
|
||||
const discovered = await discoverProviders();
|
||||
|
||||
// One models.dev read serves every cloud flavor in the build (disk cache,
|
||||
// no network — refreshed by its own background loop).
|
||||
const modelsDevByFlavor = new Map<string, CatalogModelEntry[]>();
|
||||
if (discovered.some((p) => MODELS_DEV_FLAVORS.has(p.flavor))) {
|
||||
try {
|
||||
const catalog = await listOnboardingModels();
|
||||
for (const p of catalog.providers) {
|
||||
modelsDevByFlavor.set(p.id, p.models.map(({ id, name, reasoning }) => ({
|
||||
id,
|
||||
...(name ? { name } : {}),
|
||||
...(reasoning !== undefined ? { reasoning } : {}),
|
||||
})));
|
||||
}
|
||||
} catch {
|
||||
// Empty map → cloud flavors fall through to live listing.
|
||||
}
|
||||
}
|
||||
|
||||
const entries = await Promise.all(discovered.map(async (provider) => {
|
||||
const entry = await resolveProviderEntry(
|
||||
provider,
|
||||
modelsDevByFlavor,
|
||||
options?.refreshProvider === provider.id,
|
||||
);
|
||||
const result: CatalogProviderEntry = {
|
||||
id: provider.id,
|
||||
flavor: provider.flavor,
|
||||
status: entry.status,
|
||||
...(entry.error ? { error: entry.error } : {}),
|
||||
models: entry.models,
|
||||
};
|
||||
return result;
|
||||
}));
|
||||
|
||||
let defaultModel: ModelCatalogResult["defaultModel"] = null;
|
||||
try {
|
||||
defaultModel = await getDefaultModelAndProvider();
|
||||
} catch {
|
||||
// No default resolvable (no config, signed out) — the picker copes.
|
||||
}
|
||||
|
||||
return { providers: entries, defaultModel };
|
||||
}
|
||||
|
||||
/** Test-only: reset the per-provider list cache. */
|
||||
export function __resetModelCatalogForTests(): void {
|
||||
cache.clear();
|
||||
inFlight.clear();
|
||||
}
|
||||
61
apps/x/packages/core/src/models/chatgpt-selection.ts
Normal file
61
apps/x/packages/core/src/models/chatgpt-selection.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import container from "../di/container.js";
|
||||
import { IModelConfigRepo } from "./repo.js";
|
||||
import { listCodexModels } from "./codex.js";
|
||||
import { getRowboatConfig } from "../config/rowboat.js";
|
||||
import { selectInitialModel, selectInitialTaskModels } from "./initial-selection.js";
|
||||
import { normalizeModelRecommendation } from "@x/shared/dist/rowboat-account.js";
|
||||
import { capture } from "../analytics/posthog.js";
|
||||
|
||||
/**
|
||||
* Model-selection hooks for the ChatGPT-subscription (codex) sign-in
|
||||
* lifecycle. ChatGPT is a provider like any other: signing in connects it,
|
||||
* so it follows the same rules —
|
||||
*
|
||||
* - Connect with no saved assistant → pick an initial model (backend
|
||||
* recommendation if the subscription lists it, else the first listed
|
||||
* model) and save it. A saved assistant is NEVER replaced.
|
||||
* - Disconnect → drop the selections that reference the provider (same
|
||||
* dangling-ref cleanup as removing any provider).
|
||||
*/
|
||||
|
||||
export async function applyCodexInitialSelection(): Promise<void> {
|
||||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
try {
|
||||
const cfg = await repo.getConfig().catch(() => null);
|
||||
if (cfg?.assistantModel) return; // saved choice — never replaced
|
||||
const catalog = await listCodexModels();
|
||||
const ids = catalog.providers[0]?.models.map((m) => m.id) ?? [];
|
||||
const recommendations = (await getRowboatConfig().catch(() => null))?.modelRecommendations;
|
||||
const model = selectInitialModel("codex", ids, recommendations);
|
||||
if (model) {
|
||||
// Task recommendations ride along the seeding moment (codex has
|
||||
// none today; the path is uniform across providers).
|
||||
const taskModels = selectInitialTaskModels("codex", "codex", ids, recommendations, model);
|
||||
await repo.updateConfig({
|
||||
assistantModel: { provider: "codex", model },
|
||||
...(Object.keys(taskModels).length > 0 ? { taskModels } : {}),
|
||||
});
|
||||
capture("llm_initial_model_selected", {
|
||||
flavor: "codex",
|
||||
model,
|
||||
recommended: model === normalizeModelRecommendation(recommendations, "codex")?.assistantModel,
|
||||
task_overrides_seeded: Object.keys(taskModels).length,
|
||||
source: "sign_in",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Best-effort: a failed initial selection must never break sign-in.
|
||||
console.warn("[models] Initial selection after ChatGPT sign-in failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearCodexSelections(): Promise<void> {
|
||||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
try {
|
||||
// "codex" has no providers-map entry; removeProvider still clears
|
||||
// the assistantModel / task overrides that reference it.
|
||||
await repo.removeProvider("codex");
|
||||
} catch (error) {
|
||||
console.warn("[models] Clearing codex selections after sign-out failed:", error);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,8 @@
|
|||
import z from "zod";
|
||||
import { LlmModelConfig, LlmProvider, ModelRef } from "@x/shared/dist/models.js";
|
||||
import { LlmModelConfig, LlmProvider, ModelRef, type TaskModelKey } from "@x/shared/dist/models.js";
|
||||
import { IModelConfigRepo } from "./repo.js";
|
||||
import { isSignedIn } from "../account/account.js";
|
||||
import container from "../di/container.js";
|
||||
|
||||
const SIGNED_IN_DEFAULT_MODEL = "google/gemini-3.5-flash";
|
||||
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
|
||||
// KG note-creation historically failed on identity (self-notes, perspective
|
||||
// flips, misread outbound email) — root cause was the owner block never being
|
||||
// injected, not the model tier. With identity injected + the NON-NEGOTIABLE
|
||||
// RULES checklist + the end-of-message owner reminder, the lite tier is
|
||||
// serviceable and 6x cheaper than full flash for this always-on service.
|
||||
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite";
|
||||
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite";
|
||||
const SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL = "google/gemini-3.1-flash-lite";
|
||||
// Must be on the gateway's server-side allowlist or title calls 403
|
||||
// "Model not allowed" (and silently keep the placeholder title).
|
||||
const SIGNED_IN_CHAT_TITLE_MODEL = "google/gemini-3.5-flash-lite";
|
||||
|
||||
export type ModelSelection = z.infer<typeof ModelRef>;
|
||||
|
||||
async function readConfig(): Promise<z.infer<typeof LlmModelConfig> | null> {
|
||||
|
|
@ -25,36 +10,25 @@ async function readConfig(): Promise<z.infer<typeof LlmModelConfig> | null> {
|
|||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
return await repo.getConfig();
|
||||
} catch {
|
||||
// Signed-in users may have no models.json at all.
|
||||
// Fresh install before ensureConfig ran, or an unreadable file.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single source of truth for "what model+provider should we use when
|
||||
* the caller didn't specify and the agent didn't declare".
|
||||
*
|
||||
* Resolution order (hybrid mode):
|
||||
* 1. `defaultSelection` — the user's explicit choice; may point at the
|
||||
* gateway ("rowboat") or any BYOK provider, and is honored in both modes
|
||||
* (a "rowboat" selection is skipped while signed out — it needs auth).
|
||||
* 2. Signed in → the curated gateway default.
|
||||
* 3. BYOK → the legacy top-level provider/model pair.
|
||||
* the caller didn't specify and the agent didn't declare": the config's
|
||||
* assistantModel, period. It is written by onboarding / provider connect
|
||||
* (via initial selection) and by every model pick in the UI; hidden
|
||||
* fallback defaults were removed with the v2 config migration.
|
||||
*/
|
||||
export async function getDefaultModelAndProvider(): Promise<{ model: string; provider: string }> {
|
||||
const signedIn = await isSignedIn();
|
||||
const cfg = await readConfig();
|
||||
const selection = cfg?.defaultSelection;
|
||||
if (selection && (selection.provider !== "rowboat" || signedIn)) {
|
||||
return { model: selection.model, provider: selection.provider };
|
||||
const assistant = cfg?.assistantModel;
|
||||
if (!assistant) {
|
||||
throw new Error("No assistant model configured (connect a provider or sign in)");
|
||||
}
|
||||
if (signedIn) {
|
||||
return { model: SIGNED_IN_DEFAULT_MODEL, provider: SIGNED_IN_DEFAULT_PROVIDER };
|
||||
}
|
||||
if (!cfg) {
|
||||
throw new Error("No model configuration found (models.json missing and not signed in)");
|
||||
}
|
||||
return { model: cfg.model, provider: cfg.provider.flavor };
|
||||
return { model: assistant.model, provider: assistant.provider };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -68,69 +42,39 @@ export async function shouldDeferBackgroundTasks(): Promise<boolean> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve a provider name (as stored on a run, an agent, or returned by
|
||||
* getDefaultModelAndProvider) into the full LlmProvider config that
|
||||
* createProvider expects (apiKey/baseURL/headers).
|
||||
* Resolve a provider instance id (as stored on a run, an agent, or returned
|
||||
* by getDefaultModelAndProvider) into the LlmProvider entry that
|
||||
* createProvider expects.
|
||||
*
|
||||
* - "rowboat" → gateway provider (auth via OAuth bearer; no creds field).
|
||||
* - other names → look up models.json's `providers[name]` map.
|
||||
* - fallback: if the name matches the active default's flavor (legacy
|
||||
* single-provider configs that didn't write to the providers map yet).
|
||||
* - "codex" → ChatGPT subscription (auth in chatgpt-auth.json).
|
||||
* - other ids → the models.json providers map.
|
||||
*/
|
||||
export async function resolveProviderConfig(name: string): Promise<z.infer<typeof LlmProvider>> {
|
||||
if (name === "rowboat") {
|
||||
return { flavor: "rowboat" };
|
||||
}
|
||||
if (name === "codex") {
|
||||
// ChatGPT subscription: auth lives in chatgpt-auth.json (core auth
|
||||
// layer), never in models.json — which may not exist at all.
|
||||
return { flavor: "codex" };
|
||||
}
|
||||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
const cfg = await repo.getConfig();
|
||||
const entry = cfg.providers?.[name];
|
||||
if (entry) {
|
||||
return LlmProvider.parse({
|
||||
flavor: name,
|
||||
apiKey: entry.apiKey,
|
||||
baseURL: entry.baseURL,
|
||||
headers: entry.headers,
|
||||
contextLength: entry.contextLength,
|
||||
reasoningEffort: entry.reasoningEffort,
|
||||
});
|
||||
const cfg = await readConfig();
|
||||
const entry = cfg?.providers[name];
|
||||
if (!entry) {
|
||||
throw new Error(`Provider '${name}' is referenced but not configured`);
|
||||
}
|
||||
if (cfg.provider.flavor === name) {
|
||||
return cfg.provider;
|
||||
}
|
||||
throw new Error(`Provider '${name}' is referenced but not configured`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Per-category model resolution (hybrid mode):
|
||||
// 1. An explicit override wins in BOTH modes. Provider-qualified refs are
|
||||
// used as-is (a "rowboat" ref is skipped while signed out); legacy string
|
||||
// overrides pair with the BYOK provider they were configured against
|
||||
// (the top-level flavor), NOT the dynamic default — so a signed-in user's
|
||||
// local-model overrides keep routing to their local server.
|
||||
// 2. No override, signed in → the curated gateway model.
|
||||
// 3. No override, BYOK → the assistant default.
|
||||
async function getCategoryModel(
|
||||
category: "knowledgeGraphModel" | "meetingNotesModel" | "liveNoteAgentModel" | "autoPermissionDecisionModel",
|
||||
curatedModel: string,
|
||||
): Promise<ModelSelection> {
|
||||
const signedIn = await isSignedIn();
|
||||
/**
|
||||
* Per-task model resolution: the explicit taskModels override wins, else the
|
||||
* assistant model. No hidden per-task defaults — the v2 migration
|
||||
* materialized the historical curated models as visible overrides.
|
||||
*/
|
||||
async function getCategoryModel(category: TaskModelKey): Promise<ModelSelection> {
|
||||
const cfg = await readConfig();
|
||||
const override = cfg?.[category];
|
||||
const override = cfg?.taskModels?.[category];
|
||||
if (override) {
|
||||
if (typeof override === "string") {
|
||||
if (cfg) {
|
||||
return { model: override, provider: cfg.provider.flavor };
|
||||
}
|
||||
} else if (override.provider !== "rowboat" || signedIn) {
|
||||
return { model: override.model, provider: override.provider };
|
||||
}
|
||||
}
|
||||
if (signedIn) {
|
||||
return { model: curatedModel, provider: SIGNED_IN_DEFAULT_PROVIDER };
|
||||
return { model: override.model, provider: override.provider };
|
||||
}
|
||||
return getDefaultModelAndProvider();
|
||||
}
|
||||
|
|
@ -140,63 +84,41 @@ async function getCategoryModel(
|
|||
* etc.) when they're the top-level of a run.
|
||||
*/
|
||||
export async function getKgModel(): Promise<ModelSelection> {
|
||||
return getCategoryModel("knowledgeGraphModel", SIGNED_IN_KG_MODEL);
|
||||
return getCategoryModel("knowledgeGraph");
|
||||
}
|
||||
|
||||
/** Model used by the live-note agent + routing classifier. */
|
||||
export async function getLiveNoteAgentModel(): Promise<ModelSelection> {
|
||||
return getCategoryModel("liveNoteAgentModel", SIGNED_IN_LIVE_NOTE_AGENT_MODEL);
|
||||
return getCategoryModel("liveNoteAgent");
|
||||
}
|
||||
|
||||
/** Model used by the auto-permission classifier. */
|
||||
export async function getAutoPermissionDecisionModel(): Promise<ModelSelection> {
|
||||
return getCategoryModel("autoPermissionDecisionModel", SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL);
|
||||
return getCategoryModel("autoPermissionDecision");
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used by the meeting-notes summarizer. No special signed-in curated
|
||||
* model — historically meetings used the assistant model.
|
||||
*/
|
||||
/** Model used by the meeting-notes summarizer. */
|
||||
export async function getMeetingNotesModel(): Promise<ModelSelection> {
|
||||
return getCategoryModel("meetingNotesModel", SIGNED_IN_DEFAULT_MODEL);
|
||||
return getCategoryModel("meetingNotes");
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used to auto-name chat sessions from the first user message.
|
||||
*
|
||||
* Deliberately NOT getCategoryModel: that helper routes every signed-in user
|
||||
* to the gateway, even one whose assistant default is a BYOK provider (the
|
||||
* common "gateway limits exhausted, switched to own key" case) — and a title
|
||||
* call against an exhausted gateway fails silently forever. Instead the
|
||||
* curated model applies only when the assistant default itself routes
|
||||
* through the gateway; otherwise titles follow the assistant provider.
|
||||
*/
|
||||
/** Model used to auto-name chat sessions from the first user message. */
|
||||
export async function getChatTitleModel(): Promise<ModelSelection> {
|
||||
const signedIn = await isSignedIn();
|
||||
const cfg = await readConfig();
|
||||
const override = cfg?.chatTitleModel;
|
||||
if (override) {
|
||||
if (typeof override === "string") {
|
||||
if (cfg) {
|
||||
return { model: override, provider: cfg.provider.flavor };
|
||||
}
|
||||
} else if (override.provider !== "rowboat" || signedIn) {
|
||||
return { model: override.model, provider: override.provider };
|
||||
}
|
||||
}
|
||||
const dflt = await getDefaultModelAndProvider();
|
||||
if (dflt.provider === SIGNED_IN_DEFAULT_PROVIDER) {
|
||||
return { model: SIGNED_IN_CHAT_TITLE_MODEL, provider: SIGNED_IN_DEFAULT_PROVIDER };
|
||||
}
|
||||
return dflt;
|
||||
return getCategoryModel("chatTitle");
|
||||
}
|
||||
|
||||
/** Model used by the background-task agent + routing classifier. */
|
||||
export async function getBackgroundTaskAgentModel(): Promise<ModelSelection> {
|
||||
return getCategoryModel("backgroundTask");
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used by the background-task agent + routing classifier. Currently
|
||||
* mirrors `getLiveNoteAgentModel()` — both surfaces want a fast, reliable
|
||||
* agent model. Split into its own getter so a future per-feature override
|
||||
* doesn't require touching all call sites.
|
||||
* Explicit subagent model override, or null to inherit the PARENT turn's
|
||||
* model (spawn-agent's default — which is the assistant for a top-level
|
||||
* chat). Not getCategoryModel: the no-override fallback is the parent, not
|
||||
* the assistant, and the caller owns that resolution.
|
||||
*/
|
||||
export async function getBackgroundTaskAgentModel(): Promise<ModelSelection> {
|
||||
return getLiveNoteAgentModel();
|
||||
export async function getSubagentModelOverride(): Promise<ModelSelection | null> {
|
||||
const cfg = await readConfig();
|
||||
return cfg?.taskModels?.subagent ?? null;
|
||||
}
|
||||
|
|
|
|||
75
apps/x/packages/core/src/models/initial-selection.test.ts
Normal file
75
apps/x/packages/core/src/models/initial-selection.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { selectInitialModel, selectInitialTaskModels } from './initial-selection.js';
|
||||
|
||||
describe('selectInitialModel', () => {
|
||||
const recommendations = {
|
||||
openai: 'gpt-5.4',
|
||||
openrouter: 'anthropic/claude-opus-4.8',
|
||||
};
|
||||
|
||||
it('picks the recommended model when the provider lists it', () => {
|
||||
expect(selectInitialModel('openai', ['gpt-4.1', 'gpt-5.4', 'gpt-5.4-mini'], recommendations))
|
||||
.toBe('gpt-5.4');
|
||||
});
|
||||
|
||||
it('falls back to the first listed model when the recommendation is not in the list', () => {
|
||||
expect(selectInitialModel('openai', ['gpt-4.1', 'gpt-4o'], recommendations))
|
||||
.toBe('gpt-4.1');
|
||||
});
|
||||
|
||||
it('falls back to the first listed model for flavors with no recommendation', () => {
|
||||
expect(selectInitialModel('ollama', ['llama3', 'qwen3'], recommendations))
|
||||
.toBe('llama3');
|
||||
});
|
||||
|
||||
it('falls back to the first listed model when no recommendations map is available', () => {
|
||||
expect(selectInitialModel('openai', ['gpt-4.1'], undefined)).toBe('gpt-4.1');
|
||||
});
|
||||
|
||||
it('returns null when the provider listed nothing', () => {
|
||||
expect(selectInitialModel('openai', [], recommendations)).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts the nested { assistantModel, taskModels } wire shape', () => {
|
||||
const nested = { rowboat: { assistantModel: 'google/gemini-3.5-flash', taskModels: {} } };
|
||||
expect(selectInitialModel('rowboat', ['a', 'google/gemini-3.5-flash'], nested))
|
||||
.toBe('google/gemini-3.5-flash');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectInitialTaskModels', () => {
|
||||
const gatewayList = [
|
||||
'google/gemini-3.5-flash',
|
||||
'google/gemini-3.1-flash-lite',
|
||||
'google/gemini-3.5-flash-lite',
|
||||
];
|
||||
const nested = {
|
||||
rowboat: {
|
||||
assistantModel: 'google/gemini-3.5-flash',
|
||||
taskModels: {
|
||||
knowledgeGraph: 'google/gemini-3.1-flash-lite',
|
||||
chatTitle: 'google/gemini-3.5-flash-lite',
|
||||
// Equal to the assistant → redundant, inherit produces it.
|
||||
meetingNotes: 'google/gemini-3.5-flash',
|
||||
// Not in the provider's list → stale hint, skipped.
|
||||
liveNoteAgent: 'google/gemini-9-experimental',
|
||||
// Unknown key → ignored.
|
||||
somethingNew: 'google/gemini-3.1-flash-lite',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('writes overrides only for listed recs that differ from the assistant', () => {
|
||||
expect(selectInitialTaskModels('rowboat', 'rowboat', gatewayList, nested, 'google/gemini-3.5-flash'))
|
||||
.toEqual({
|
||||
knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
chatTitle: { provider: 'rowboat', model: 'google/gemini-3.5-flash-lite' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns nothing for legacy flat recommendations or absent maps', () => {
|
||||
expect(selectInitialTaskModels('rowboat', 'rowboat', gatewayList, { rowboat: 'google/gemini-3.5-flash' }, 'x'))
|
||||
.toEqual({});
|
||||
expect(selectInitialTaskModels('rowboat', 'rowboat', gatewayList, undefined, 'x')).toEqual({});
|
||||
});
|
||||
});
|
||||
3
apps/x/packages/core/src/models/initial-selection.ts
Normal file
3
apps/x/packages/core/src/models/initial-selection.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Pure selection logic lives in @x/shared (the renderer's connect flow uses
|
||||
// the same implementation); re-exported here for core call sites.
|
||||
export { selectInitialModel, selectInitialTaskModels } from "@x/shared/dist/initial-selection.js";
|
||||
145
apps/x/packages/core/src/models/migrate.test.ts
Normal file
145
apps/x/packages/core/src/models/migrate.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { migrateModelsConfig } from './migrate.js';
|
||||
|
||||
/**
|
||||
* The migration contract: evaluate the v1 resolution rules (including the
|
||||
* curated signed-in defaults that lived only in code) one last time and
|
||||
* write their answers explicitly, so v2's simple "override else assistant"
|
||||
* rules produce identical effective models. Overrides are written ONLY
|
||||
* where the old effective model differs from inherit-from-assistant.
|
||||
*/
|
||||
|
||||
describe('migrateModelsConfig', () => {
|
||||
it('returns null for a config that is already v2', () => {
|
||||
expect(migrateModelsConfig({ version: 2, providers: {} }, false)).toBeNull();
|
||||
});
|
||||
|
||||
it('signed-out BYOK: adopts the top-level pair as assistant, writes no task overrides', () => {
|
||||
const v1 = {
|
||||
provider: { flavor: 'openai', apiKey: 'sk-a' },
|
||||
model: 'gpt-5.4',
|
||||
providers: { openai: { apiKey: 'sk-a', model: 'gpt-5.4', models: ['gpt-5.4'] } },
|
||||
};
|
||||
expect(migrateModelsConfig(v1, false)).toEqual({
|
||||
version: 2,
|
||||
providers: { openai: { flavor: 'openai', apiKey: 'sk-a' } },
|
||||
assistantModel: { provider: 'openai', model: 'gpt-5.4' },
|
||||
});
|
||||
});
|
||||
|
||||
it('signed-in with untouched bootstrap config: materializes the curated defaults', () => {
|
||||
// The classic signed-in models.json — the bootstrap file that nothing
|
||||
// ever wrote to; every effective model lived in code branches.
|
||||
const v1 = { provider: { flavor: 'openai' }, model: 'gpt-5.4' };
|
||||
expect(migrateModelsConfig(v1, true)).toEqual({
|
||||
version: 2,
|
||||
providers: {}, // bootstrap top-level pair had no credentials
|
||||
assistantModel: { provider: 'rowboat', model: 'google/gemini-3.5-flash' },
|
||||
taskModels: {
|
||||
knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
// v1 background tasks mirrored the live-note model.
|
||||
backgroundTask: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
// chat titles used flash-lite because the assistant routes
|
||||
// through the gateway; meeting notes used the curated default
|
||||
// which EQUALS the assistant → no override written for it.
|
||||
chatTitle: { provider: 'rowboat', model: 'google/gemini-3.5-flash-lite' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('signed-in user with a BYOK defaultSelection: keeps it, materializes the differing task models', () => {
|
||||
const v1 = {
|
||||
provider: { flavor: 'ollama', baseURL: 'http://localhost:11434' },
|
||||
model: 'llama3',
|
||||
providers: { ollama: { baseURL: 'http://localhost:11434', model: 'llama3' } },
|
||||
defaultSelection: { provider: 'ollama', model: 'llama3' },
|
||||
};
|
||||
const v2 = migrateModelsConfig(v1, true);
|
||||
expect(v2?.assistantModel).toEqual({ provider: 'ollama', model: 'llama3' });
|
||||
expect(v2?.taskModels).toEqual({
|
||||
knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
backgroundTask: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
// Meeting notes used the curated gateway default, which now
|
||||
// differs from the (BYOK) assistant — preserved explicitly.
|
||||
meetingNotes: { provider: 'rowboat', model: 'google/gemini-3.5-flash' },
|
||||
// Chat titles followed the assistant whenever it was NOT the
|
||||
// gateway — inherit reproduces that, so no override.
|
||||
});
|
||||
});
|
||||
|
||||
it('explicit v1 overrides survive: legacy strings pair with the top-level flavor, refs pass through', () => {
|
||||
const v1 = {
|
||||
provider: { flavor: 'openai', apiKey: 'sk-a' },
|
||||
model: 'gpt-5.4',
|
||||
providers: { openai: { apiKey: 'sk-a' } },
|
||||
knowledgeGraphModel: 'gpt-5.4-mini', // legacy string form
|
||||
meetingNotesModel: { provider: 'ollama', model: 'qwen3' }, // ref form
|
||||
};
|
||||
const v2 = migrateModelsConfig(v1, false);
|
||||
expect(v2?.taskModels).toEqual({
|
||||
knowledgeGraph: { provider: 'openai', model: 'gpt-5.4-mini' },
|
||||
meetingNotes: { provider: 'ollama', model: 'qwen3' },
|
||||
});
|
||||
});
|
||||
|
||||
it('a v1 live-note override propagates to backgroundTask (v1 bg tasks mirrored live-note)', () => {
|
||||
const v1 = {
|
||||
provider: { flavor: 'openai', apiKey: 'sk-a' },
|
||||
model: 'gpt-5.4',
|
||||
liveNoteAgentModel: { provider: 'ollama', model: 'qwen3' },
|
||||
};
|
||||
expect(migrateModelsConfig(v1, false)?.taskModels).toEqual({
|
||||
liveNoteAgent: { provider: 'ollama', model: 'qwen3' },
|
||||
backgroundTask: { provider: 'ollama', model: 'qwen3' },
|
||||
});
|
||||
});
|
||||
|
||||
it('an override equal to the assistant is dropped (inherit produces the same model)', () => {
|
||||
const v1 = {
|
||||
provider: { flavor: 'openai', apiKey: 'sk-a' },
|
||||
model: 'gpt-5.4',
|
||||
knowledgeGraphModel: 'gpt-5.4',
|
||||
};
|
||||
expect(migrateModelsConfig(v1, false)?.taskModels).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a rowboat defaultSelection is skipped while signed out (needs auth), like v1 resolution did', () => {
|
||||
const v1 = {
|
||||
provider: { flavor: 'openai', apiKey: 'sk-a' },
|
||||
model: 'gpt-5.4',
|
||||
defaultSelection: { provider: 'rowboat', model: 'google/gemini-3.5-flash' },
|
||||
};
|
||||
expect(migrateModelsConfig(v1, false)?.assistantModel)
|
||||
.toEqual({ provider: 'openai', model: 'gpt-5.4' });
|
||||
});
|
||||
|
||||
it('providers without credentials are dropped; connection prefs survive', () => {
|
||||
const v1 = {
|
||||
provider: { flavor: 'openai', apiKey: 'sk-a' },
|
||||
model: 'gpt-5.4',
|
||||
providers: {
|
||||
openai: { apiKey: 'sk-a', models: ['gpt-5.4'] },
|
||||
anthropic: { model: 'claude-opus-4-8' }, // no key: never connected
|
||||
ollama: { baseURL: 'http://localhost:11434', contextLength: 32768, reasoningEffort: 'low' },
|
||||
},
|
||||
};
|
||||
expect(migrateModelsConfig(v1, false)?.providers).toEqual({
|
||||
openai: { flavor: 'openai', apiKey: 'sk-a' },
|
||||
ollama: { flavor: 'ollama', baseURL: 'http://localhost:11434', contextLength: 32768, reasoningEffort: 'low' },
|
||||
});
|
||||
});
|
||||
|
||||
it('degrades gracefully on garbage input: empty v2 config', () => {
|
||||
expect(migrateModelsConfig('not an object', false)).toEqual({ version: 2, providers: {} });
|
||||
expect(migrateModelsConfig({}, false)).toEqual({ version: 2, providers: {} });
|
||||
});
|
||||
|
||||
it('deferBackgroundTasks is carried over', () => {
|
||||
const v1 = { provider: { flavor: 'openai', apiKey: 'sk-a' }, model: 'gpt-5.4', deferBackgroundTasks: true };
|
||||
expect(migrateModelsConfig(v1, false)?.deferBackgroundTasks).toBe(true);
|
||||
});
|
||||
});
|
||||
171
apps/x/packages/core/src/models/migrate.ts
Normal file
171
apps/x/packages/core/src/models/migrate.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { z } from "zod";
|
||||
import { LlmModelConfig, LlmProvider, ModelRef } from "@x/shared/dist/models.js";
|
||||
|
||||
/**
|
||||
* One-time migration of models.json from version 1 to version 2.
|
||||
*
|
||||
* v1 accreted three generations of schema: a top-level provider/model pair
|
||||
* (the original single-provider config), a providers map whose entries
|
||||
* duplicated credentials AND carried saved model lists (pre-dynamic-listing
|
||||
* picker caches), plus `defaultSelection` and flat category overrides bolted
|
||||
* on for hybrid mode. On top of that, several effective models existed only
|
||||
* as hidden branches in code (the signed-in curated defaults).
|
||||
*
|
||||
* v2 stores providers as credentials-only entries and model choices in
|
||||
* exactly two places: `assistantModel` and `taskModels`. This migration
|
||||
* evaluates the OLD resolution rules one last time and writes their answers
|
||||
* down explicitly, so the simplified v2 resolvers produce identical
|
||||
* effective models for every existing user. Task overrides are written ONLY
|
||||
* where the old effective model differs from plain inherit-from-assistant.
|
||||
*
|
||||
* The curated model ids below are FROZEN COPIES of the v1 constants that
|
||||
* lived in defaults.ts (deleted with this migration). They are historical
|
||||
* data, not live configuration — do not update them when recommendations
|
||||
* change.
|
||||
*/
|
||||
|
||||
const V1_SIGNED_IN_ASSISTANT: z.infer<typeof ModelRef> = { provider: "rowboat", model: "google/gemini-3.5-flash" };
|
||||
const V1_CURATED_LITE = "google/gemini-3.1-flash-lite";
|
||||
const V1_CURATED_CHAT_TITLE = "google/gemini-3.5-flash-lite";
|
||||
|
||||
// v1 schema — kept here, and only here, for the migration reader.
|
||||
const ModelOverrideV1 = z.union([z.string(), ModelRef]);
|
||||
export const LlmModelConfigV1 = z.object({
|
||||
provider: LlmProvider,
|
||||
model: z.string(),
|
||||
models: z.array(z.string()).optional(),
|
||||
defaultSelection: ModelRef.optional(),
|
||||
deferBackgroundTasks: z.boolean().optional(),
|
||||
providers: z.record(z.string(), z.object({
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
contextLength: z.number().int().positive().optional(),
|
||||
reasoningEffort: z.enum(["low", "medium", "high"]).optional(),
|
||||
model: z.string().optional(),
|
||||
models: z.array(z.string()).optional(),
|
||||
knowledgeGraphModel: z.string().optional(),
|
||||
meetingNotesModel: z.string().optional(),
|
||||
liveNoteAgentModel: z.string().optional(),
|
||||
autoPermissionDecisionModel: z.string().optional(),
|
||||
})).optional(),
|
||||
knowledgeGraphModel: ModelOverrideV1.optional(),
|
||||
meetingNotesModel: ModelOverrideV1.optional(),
|
||||
liveNoteAgentModel: ModelOverrideV1.optional(),
|
||||
autoPermissionDecisionModel: ModelOverrideV1.optional(),
|
||||
chatTitleModel: ModelOverrideV1.optional(),
|
||||
});
|
||||
|
||||
type V2 = z.infer<typeof LlmModelConfig>;
|
||||
type Ref = z.infer<typeof ModelRef>;
|
||||
|
||||
function sameRef(a: Ref | undefined, b: Ref | undefined): boolean {
|
||||
return !!a && !!b && a.provider === b.provider && a.model === b.model;
|
||||
}
|
||||
|
||||
function asRef(value: unknown): Ref | undefined {
|
||||
const parsed = ModelRef.safeParse(value);
|
||||
return parsed.success && parsed.data.model ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a v1 category override the way defaults.ts used to: a bare string
|
||||
* pairs with the top-level provider flavor; a ref is used as-is except a
|
||||
* "rowboat" ref while signed out (needs auth → was skipped).
|
||||
*/
|
||||
function v1Override(raw: Record<string, unknown>, key: string, signedIn: boolean): Ref | undefined {
|
||||
const value = raw[key];
|
||||
if (typeof value === "string" && value) {
|
||||
const flavor = (raw.provider as Record<string, unknown> | undefined)?.flavor;
|
||||
return typeof flavor === "string" && flavor ? { provider: flavor, model: value } : undefined;
|
||||
}
|
||||
const ref = asRef(value);
|
||||
if (ref && (ref.provider !== "rowboat" || signedIn)) return ref;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The old effective assistant model (defaults.ts resolution order). */
|
||||
function v1EffectiveAssistant(raw: Record<string, unknown>, signedIn: boolean): Ref | undefined {
|
||||
const selection = asRef(raw.defaultSelection);
|
||||
if (selection && (selection.provider !== "rowboat" || signedIn)) return selection;
|
||||
if (signedIn) return V1_SIGNED_IN_ASSISTANT;
|
||||
const flavor = (raw.provider as Record<string, unknown> | undefined)?.flavor;
|
||||
const model = raw.model;
|
||||
if (typeof flavor === "string" && flavor && typeof model === "string" && model) {
|
||||
return { provider: flavor, model };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a raw parsed models.json (any shape) to v2. Returns null when the
|
||||
* input is already v2 — nothing to do.
|
||||
*
|
||||
* Pure: sign-in state is an input; no I/O.
|
||||
*/
|
||||
export function migrateModelsConfig(rawInput: unknown, signedIn: boolean): V2 | null {
|
||||
const raw = (rawInput && typeof rawInput === "object" ? rawInput : {}) as Record<string, unknown>;
|
||||
if (raw.version === 2) return null;
|
||||
|
||||
// Providers: map entries with some credential survive, stripped to
|
||||
// credentials + connection prefs. The top-level pair is merged in for
|
||||
// very old configs that predate the providers map.
|
||||
const providers: V2["providers"] = {};
|
||||
const candidateEntries: Array<[string, unknown]> = Object.entries(
|
||||
(raw.providers && typeof raw.providers === "object" ? raw.providers : {}) as Record<string, unknown>,
|
||||
);
|
||||
const topLevel = raw.provider as Record<string, unknown> | undefined;
|
||||
if (topLevel && typeof topLevel.flavor === "string" && !candidateEntries.some(([k]) => k === topLevel.flavor)) {
|
||||
candidateEntries.push([topLevel.flavor, topLevel]);
|
||||
}
|
||||
for (const [id, value] of candidateEntries) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const entry = value as Record<string, unknown>;
|
||||
const apiKey = typeof entry.apiKey === "string" ? entry.apiKey.trim() : "";
|
||||
const baseURL = typeof entry.baseURL === "string" ? entry.baseURL.trim() : "";
|
||||
if (!apiKey && !baseURL) continue; // never connected
|
||||
const parsed = LlmProvider.safeParse({ ...entry, flavor: id });
|
||||
if (parsed.success) providers[id] = parsed.data;
|
||||
}
|
||||
|
||||
const assistantModel = v1EffectiveAssistant(raw, signedIn);
|
||||
|
||||
// Old effective model per task, via the deleted v1 rules.
|
||||
const liveNoteEffective = v1Override(raw, "liveNoteAgentModel", signedIn)
|
||||
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel);
|
||||
const oldTaskModels: Record<string, Ref | undefined> = {
|
||||
knowledgeGraph: v1Override(raw, "knowledgeGraphModel", signedIn)
|
||||
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
|
||||
liveNoteAgent: liveNoteEffective,
|
||||
// v1 had no backgroundTask key — getBackgroundTaskAgentModel mirrored
|
||||
// the live-note model (override included). v2 gives it its own slot.
|
||||
backgroundTask: liveNoteEffective,
|
||||
autoPermissionDecision: v1Override(raw, "autoPermissionDecisionModel", signedIn)
|
||||
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
|
||||
meetingNotes: v1Override(raw, "meetingNotesModel", signedIn)
|
||||
?? (signedIn ? V1_SIGNED_IN_ASSISTANT : assistantModel),
|
||||
// Chat titles used the curated lite model ONLY when the assistant itself
|
||||
// routed through the gateway (the exhausted-gateway safeguard).
|
||||
chatTitle: v1Override(raw, "chatTitleModel", signedIn)
|
||||
?? (assistantModel?.provider === "rowboat"
|
||||
? { provider: "rowboat", model: V1_CURATED_CHAT_TITLE }
|
||||
: assistantModel),
|
||||
};
|
||||
|
||||
// Write an override only where the old effective model differs from what
|
||||
// v2 inheritance (assistant) would produce.
|
||||
const taskModels: NonNullable<V2["taskModels"]> = {};
|
||||
for (const [key, ref] of Object.entries(oldTaskModels)) {
|
||||
if (ref && !sameRef(ref, assistantModel)) {
|
||||
taskModels[key as keyof typeof taskModels] = ref;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
providers,
|
||||
...(assistantModel ? { assistantModel } : {}),
|
||||
...(Object.keys(taskModels).length > 0 ? { taskModels } : {}),
|
||||
...(raw.deferBackgroundTasks === true ? { deferBackgroundTasks: true } : {}),
|
||||
};
|
||||
}
|
||||
92
apps/x/packages/core/src/models/repo.test.ts
Normal file
92
apps/x/packages/core/src/models/repo.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { rmSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The repo guards the only copy of the user's API keys. These tests pin the
|
||||
// data-loss behaviors: corrupt or invalid files must never be silently
|
||||
// overwritten by boot or by an unrelated write.
|
||||
|
||||
// vi.mock factories are hoisted above module code — the temp path must be
|
||||
// computable inside vi.hoisted without imports (created in beforeEach).
|
||||
const workDir = vi.hoisted(() =>
|
||||
`${process.env.TMPDIR?.replace(/\/$/, '') ?? '/tmp'}/models-repo-test-${process.pid}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
|
||||
vi.mock('../config/config.js', () => ({ WorkDir: workDir }));
|
||||
vi.mock('../account/account.js', () => ({ isSignedIn: async () => false }));
|
||||
vi.mock('../analytics/posthog.js', () => ({ capture: () => {} }));
|
||||
vi.mock('../analytics/model-providers.js', () => ({
|
||||
captureProviderConnected: () => {},
|
||||
captureProviderDisconnected: () => {},
|
||||
syncModelProviderPersonProperties: async () => {},
|
||||
}));
|
||||
|
||||
import { FSModelConfigRepo } from './repo.js';
|
||||
|
||||
const configDir = path.join(workDir, 'config');
|
||||
const configPath = path.join(configDir, 'models.json');
|
||||
|
||||
beforeEach(async () => {
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
process.on('exit', () => {
|
||||
try { rmSync(workDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
||||
});
|
||||
|
||||
describe('FSModelConfigRepo data safety', () => {
|
||||
it('creates an empty v2 config when the file is missing', async () => {
|
||||
await new FSModelConfigRepo().ensureConfig();
|
||||
expect(JSON.parse(await fs.readFile(configPath, 'utf8'))).toEqual({ version: 2, providers: {} });
|
||||
});
|
||||
|
||||
it('quarantines corrupt JSON instead of overwriting the only copy of the keys', async () => {
|
||||
await fs.writeFile(configPath, '{"version":2,"providers":{"openai":{"flavor":"op'); // truncated
|
||||
await new FSModelConfigRepo().ensureConfig();
|
||||
|
||||
const entries = await fs.readdir(configDir);
|
||||
const quarantined = entries.find((f) => f.startsWith('models.json.corrupt-'));
|
||||
expect(quarantined).toBeDefined();
|
||||
expect(await fs.readFile(path.join(configDir, quarantined as string), 'utf8')).toContain('"op');
|
||||
expect(JSON.parse(await fs.readFile(configPath, 'utf8'))).toEqual({ version: 2, providers: {} });
|
||||
});
|
||||
|
||||
it('migration keeps the v1 original as models.json.v1.bak', async () => {
|
||||
const v1 = { provider: { flavor: 'openai', apiKey: 'sk-a' }, model: 'gpt-5.4' };
|
||||
await fs.writeFile(configPath, JSON.stringify(v1));
|
||||
await new FSModelConfigRepo().ensureConfig();
|
||||
|
||||
expect(JSON.parse(await fs.readFile(`${configPath}.v1.bak`, 'utf8'))).toEqual(v1);
|
||||
const migrated = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
||||
expect(migrated.version).toBe(2);
|
||||
expect(migrated.providers.openai.apiKey).toBe('sk-a');
|
||||
});
|
||||
|
||||
it('a schema-invalid file makes writes FAIL instead of clobbering stored credentials', async () => {
|
||||
// Parses as JSON, fails zod (bad flavor) — the pre-fix behavior was
|
||||
// to fall back to an empty config and overwrite everything on the
|
||||
// next unrelated write.
|
||||
await fs.writeFile(configPath, JSON.stringify({
|
||||
version: 2,
|
||||
providers: { weird: { flavor: 'not-a-flavor', apiKey: 'sk-precious' } },
|
||||
}));
|
||||
const repo = new FSModelConfigRepo();
|
||||
await expect(repo.updateConfig({ deferBackgroundTasks: true })).rejects.toThrow();
|
||||
// The file is untouched — the key survives.
|
||||
expect((await fs.readFile(configPath, 'utf8'))).toContain('sk-precious');
|
||||
});
|
||||
|
||||
it('writes land atomically via temp + rename (no lingering temp file)', async () => {
|
||||
const repo = new FSModelConfigRepo();
|
||||
await repo.ensureConfig();
|
||||
await repo.setProvider('openai', { flavor: 'openai', apiKey: 'sk-a' });
|
||||
const entries = await fs.readdir(configDir);
|
||||
expect(entries).not.toContain('models.json.tmp');
|
||||
expect(JSON.parse(await fs.readFile(configPath, 'utf8')).providers.openai.apiKey).toBe('sk-a');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,114 +1,195 @@
|
|||
import { ModelConfig } from "./models.js";
|
||||
import { LlmModelConfig, LlmProvider, ModelRef, TaskModels } from "@x/shared/dist/models.js";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import { isSignedIn } from "../account/account.js";
|
||||
import { capture } from "../analytics/posthog.js";
|
||||
import {
|
||||
captureProviderConnected,
|
||||
captureProviderDisconnected,
|
||||
syncModelProviderPersonProperties,
|
||||
} from "../analytics/model-providers.js";
|
||||
import { migrateModelsConfig } from "./migrate.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
|
||||
type Config = z.infer<typeof LlmModelConfig>;
|
||||
type Ref = z.infer<typeof ModelRef>;
|
||||
type TaskModelPatch = { [K in keyof z.infer<typeof TaskModels>]?: Ref | null };
|
||||
|
||||
// Top-level merge patch: omitted keys are untouched; an explicit null clears
|
||||
// a key. taskModels merges per-key (null clears that task's override).
|
||||
export type ModelConfigPatch = {
|
||||
[K in
|
||||
| "defaultSelection"
|
||||
| "knowledgeGraphModel"
|
||||
| "meetingNotesModel"
|
||||
| "liveNoteAgentModel"
|
||||
| "autoPermissionDecisionModel"
|
||||
| "deferBackgroundTasks"]?: z.infer<typeof ModelConfig>[K] | null;
|
||||
assistantModel?: Ref | null;
|
||||
taskModels?: TaskModelPatch;
|
||||
deferBackgroundTasks?: boolean | null;
|
||||
};
|
||||
|
||||
export interface IModelConfigRepo {
|
||||
/** Create the file if missing; migrate v1 → v2 in place if needed. */
|
||||
ensureConfig(): Promise<void>;
|
||||
getConfig(): Promise<z.infer<typeof ModelConfig>>;
|
||||
setConfig(config: z.infer<typeof ModelConfig>): Promise<void>;
|
||||
// Merge the given top-level keys into the existing file without touching
|
||||
// provider credentials — hybrid settings (default selection, category
|
||||
// overrides) save through this. Omitted keys are untouched; an explicit
|
||||
// null clears the key back to its default.
|
||||
getConfig(): Promise<Config>;
|
||||
/** Upsert one provider entry (credentials + connection prefs). */
|
||||
setProvider(id: string, provider: z.infer<typeof LlmProvider>): Promise<void>;
|
||||
/**
|
||||
* Remove a provider entry and every model selection that references it
|
||||
* (a dangling assistantModel / task override would just error at run
|
||||
* time — dropping them lets resolution fall back cleanly).
|
||||
*/
|
||||
removeProvider(id: string): Promise<void>;
|
||||
updateConfig(patch: ModelConfigPatch): Promise<void>;
|
||||
}
|
||||
|
||||
const defaultConfig: z.infer<typeof ModelConfig> = {
|
||||
provider: {
|
||||
flavor: "openai",
|
||||
},
|
||||
model: "gpt-5.4",
|
||||
const emptyConfig: Config = {
|
||||
version: 2,
|
||||
providers: {},
|
||||
};
|
||||
|
||||
function isEnoent(err: unknown): boolean {
|
||||
return (err as NodeJS.ErrnoException | null)?.code === "ENOENT";
|
||||
}
|
||||
|
||||
export class FSModelConfigRepo implements IModelConfigRepo {
|
||||
private readonly configPath = path.join(WorkDir, "config", "models.json");
|
||||
|
||||
async ensureConfig(): Promise<void> {
|
||||
let rawText: string;
|
||||
try {
|
||||
await fs.access(this.configPath);
|
||||
} catch {
|
||||
await fs.writeFile(this.configPath, JSON.stringify(defaultConfig, null, 2));
|
||||
rawText = await fs.readFile(this.configPath, "utf8");
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) {
|
||||
await this.write(emptyConfig);
|
||||
} else {
|
||||
// Transient read failure (permissions, I/O): NEVER overwrite
|
||||
// the existing file — it holds the user's API keys. Leave it
|
||||
// for the next boot; per-call getConfig errors degrade
|
||||
// features without destroying data.
|
||||
console.error("[models] Could not read models.json; leaving it untouched:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(rawText);
|
||||
} catch (err) {
|
||||
// Corrupt JSON (e.g. a crash mid-write): quarantine the file for
|
||||
// manual recovery instead of overwriting the only copy of the
|
||||
// user's credentials.
|
||||
const quarantinePath = `${this.configPath}.corrupt-${Date.now()}`;
|
||||
await fs.rename(this.configPath, quarantinePath).catch(() => {});
|
||||
console.error(`[models] models.json is corrupt; preserved at ${quarantinePath}:`, err);
|
||||
await this.write(emptyConfig);
|
||||
return;
|
||||
}
|
||||
const signedIn = await isSignedIn().catch(() => false);
|
||||
const migrated = migrateModelsConfig(raw, signedIn);
|
||||
if (migrated) {
|
||||
// Keep the v1 original recoverable — the migration is the only
|
||||
// record of the old selections once it runs.
|
||||
await fs.writeFile(`${this.configPath}.v1.bak`, rawText).catch(() => {});
|
||||
await this.write(migrated);
|
||||
// One-shot rollout signal for the v1 → v2 schema migration.
|
||||
capture("models_config_migrated", {
|
||||
had_assistant: Boolean(migrated.assistantModel),
|
||||
materialized_overrides: Object.keys(migrated.taskModels ?? {}).length,
|
||||
provider_count: Object.keys(migrated.providers).length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getConfig(): Promise<z.infer<typeof ModelConfig>> {
|
||||
async getConfig(): Promise<Config> {
|
||||
const config = await fs.readFile(this.configPath, "utf8");
|
||||
return ModelConfig.parse(JSON.parse(config));
|
||||
return LlmModelConfig.parse(JSON.parse(config));
|
||||
}
|
||||
|
||||
async setConfig(config: z.infer<typeof ModelConfig>): Promise<void> {
|
||||
let existingProviders: Record<string, Record<string, unknown>> = {};
|
||||
try {
|
||||
const raw = await fs.readFile(this.configPath, "utf8");
|
||||
const existing = JSON.parse(raw);
|
||||
existingProviders = existing.providers || {};
|
||||
} catch {
|
||||
// No existing config
|
||||
async setProvider(id: string, provider: z.infer<typeof LlmProvider>): Promise<void> {
|
||||
// The credential-less flavors are never stored: their connection IS
|
||||
// their auth token store (oauth.json / chatgpt-auth.json), and the
|
||||
// catalog derives their presence from auth state — a providers-map
|
||||
// entry would double-list them.
|
||||
if (provider.flavor === "rowboat" || provider.flavor === "codex") {
|
||||
throw new Error(`Provider flavor '${provider.flavor}' is auth-derived and cannot be stored in models.json`);
|
||||
}
|
||||
const config = await this.read();
|
||||
const isNew = !config.providers[id];
|
||||
// Merge over an existing entry: replacing a key must not wipe
|
||||
// hand-tuned connection prefs (contextLength, reasoningEffort).
|
||||
config.providers[id] = LlmProvider.parse({
|
||||
...config.providers[id],
|
||||
...provider,
|
||||
});
|
||||
await this.write(config);
|
||||
// A brand-new entry is a connect; a key rotation is not.
|
||||
if (isNew) captureProviderConnected(provider.flavor);
|
||||
}
|
||||
|
||||
existingProviders[config.provider.flavor] = {
|
||||
...existingProviders[config.provider.flavor],
|
||||
apiKey: config.provider.apiKey,
|
||||
baseURL: config.provider.baseURL,
|
||||
headers: config.provider.headers,
|
||||
// Preserve hand-edited local-model tuning unless the caller sets it.
|
||||
...(config.provider.contextLength !== undefined
|
||||
? { contextLength: config.provider.contextLength }
|
||||
: {}),
|
||||
...(config.provider.reasoningEffort !== undefined
|
||||
? { reasoningEffort: config.provider.reasoningEffort }
|
||||
: {}),
|
||||
model: config.model,
|
||||
models: config.models,
|
||||
knowledgeGraphModel: config.knowledgeGraphModel,
|
||||
meetingNotesModel: config.meetingNotesModel,
|
||||
liveNoteAgentModel: config.liveNoteAgentModel,
|
||||
autoPermissionDecisionModel: config.autoPermissionDecisionModel,
|
||||
};
|
||||
|
||||
// saveConfig owns provider credentials/model lists; the hybrid-mode
|
||||
// selections are owned by updateConfig — carry them over when the
|
||||
// incoming config doesn't set them.
|
||||
let existingSelections: Record<string, unknown> = {};
|
||||
try {
|
||||
const raw = await fs.readFile(this.configPath, "utf8");
|
||||
const existing = JSON.parse(raw);
|
||||
existingSelections = Object.fromEntries(
|
||||
["defaultSelection", "knowledgeGraphModel", "meetingNotesModel", "liveNoteAgentModel", "autoPermissionDecisionModel", "deferBackgroundTasks"]
|
||||
.filter((key) => existing[key] !== undefined && (config as Record<string, unknown>)[key] === undefined)
|
||||
.map((key) => [key, existing[key]]),
|
||||
);
|
||||
} catch {
|
||||
// No existing config
|
||||
async removeProvider(id: string): Promise<void> {
|
||||
const config = await this.read();
|
||||
const removed = config.providers[id];
|
||||
delete config.providers[id];
|
||||
if (config.assistantModel?.provider === id) {
|
||||
delete config.assistantModel;
|
||||
}
|
||||
|
||||
const toWrite = { ...existingSelections, ...config, providers: existingProviders };
|
||||
await fs.writeFile(this.configPath, JSON.stringify(toWrite, null, 2));
|
||||
if (config.taskModels) {
|
||||
for (const key of Object.keys(config.taskModels) as Array<keyof NonNullable<Config["taskModels"]>>) {
|
||||
if (config.taskModels[key]?.provider === id) {
|
||||
delete config.taskModels[key];
|
||||
}
|
||||
}
|
||||
if (Object.keys(config.taskModels).length === 0) delete config.taskModels;
|
||||
}
|
||||
await this.write(config);
|
||||
if (removed) captureProviderDisconnected(removed.flavor);
|
||||
}
|
||||
|
||||
async updateConfig(patch: ModelConfigPatch): Promise<void> {
|
||||
const raw = await fs.readFile(this.configPath, "utf8");
|
||||
const existing = JSON.parse(raw) as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined || value === null) {
|
||||
delete existing[key];
|
||||
} else {
|
||||
existing[key] = value;
|
||||
}
|
||||
const config = await this.read();
|
||||
if (patch.assistantModel !== undefined) {
|
||||
if (patch.assistantModel === null) delete config.assistantModel;
|
||||
else config.assistantModel = patch.assistantModel;
|
||||
}
|
||||
ModelConfig.parse(existing);
|
||||
await fs.writeFile(this.configPath, JSON.stringify(existing, null, 2));
|
||||
if (patch.taskModels !== undefined) {
|
||||
const merged = { ...(config.taskModels ?? {}) };
|
||||
for (const [key, value] of Object.entries(patch.taskModels)) {
|
||||
if (value === undefined) continue;
|
||||
if (value === null) delete merged[key as keyof typeof merged];
|
||||
else merged[key as keyof typeof merged] = value;
|
||||
}
|
||||
if (Object.keys(merged).length > 0) config.taskModels = merged;
|
||||
else delete config.taskModels;
|
||||
}
|
||||
if (patch.deferBackgroundTasks !== undefined) {
|
||||
if (patch.deferBackgroundTasks === null) delete config.deferBackgroundTasks;
|
||||
else config.deferBackgroundTasks = patch.deferBackgroundTasks;
|
||||
}
|
||||
await this.write(config);
|
||||
// The assistant person properties track the config.
|
||||
if (patch.assistantModel !== undefined) {
|
||||
void syncModelProviderPersonProperties();
|
||||
}
|
||||
}
|
||||
|
||||
private async read(): Promise<Config> {
|
||||
try {
|
||||
return await this.getConfig();
|
||||
} catch (err) {
|
||||
// ONLY a missing file falls back to empty (writes can arrive
|
||||
// before ensureConfig on a fresh install). Any other failure —
|
||||
// unreadable file, schema-invalid content — must propagate:
|
||||
// read-modify-write on an empty fallback would clobber the
|
||||
// user's stored credentials.
|
||||
if (isEnoent(err)) {
|
||||
return structuredClone(emptyConfig);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic write (temp + rename): a crash mid-write must never leave a
|
||||
// truncated models.json — that file is the only copy of the user's keys.
|
||||
private async write(config: Config): Promise<void> {
|
||||
const data = JSON.stringify(LlmModelConfig.parse(config), null, 2);
|
||||
const tmpPath = `${this.configPath}.tmp`;
|
||||
await fs.writeFile(tmpPath, data);
|
||||
await fs.rename(tmpPath, this.configPath);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
apps/x/packages/core/src/models/rowboat-selection.ts
Normal file
67
apps/x/packages/core/src/models/rowboat-selection.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import container from "../di/container.js";
|
||||
import { IModelConfigRepo } from "./repo.js";
|
||||
import { listGatewayModels } from "./gateway.js";
|
||||
import { getRowboatConfig } from "../config/rowboat.js";
|
||||
import { selectInitialModel, selectInitialTaskModels } from "./initial-selection.js";
|
||||
import { normalizeModelRecommendation } from "@x/shared/dist/rowboat-account.js";
|
||||
import { capture } from "../analytics/posthog.js";
|
||||
|
||||
/**
|
||||
* Model-selection hooks for the Rowboat sign-in lifecycle. Signing in is
|
||||
* "connecting the rowboat provider", so it follows the same rules as any
|
||||
* provider connect:
|
||||
*
|
||||
* - Connect with no saved assistant → pick an initial model (backend
|
||||
* recommendation if the gateway lists it, else the first listed model)
|
||||
* and save it. A saved assistant is NEVER replaced — recommendations only
|
||||
* ever choose the initial model.
|
||||
* - Disconnect → drop the selections that reference the provider (same
|
||||
* dangling-reference cleanup as removing any provider).
|
||||
*/
|
||||
|
||||
export async function applyRowboatInitialSelection(): Promise<void> {
|
||||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
try {
|
||||
const cfg = await repo.getConfig().catch(() => null);
|
||||
if (cfg?.assistantModel) return; // saved choice — never replaced
|
||||
const catalog = await listGatewayModels();
|
||||
const ids = catalog.providers[0]?.models.map((m) => m.id) ?? [];
|
||||
const recommendations = (await getRowboatConfig().catch(() => null))?.modelRecommendations;
|
||||
const model = selectInitialModel("rowboat", ids, recommendations);
|
||||
if (model) {
|
||||
// Task recommendations ride along the seeding moment: the
|
||||
// gateway's lite-tier task models become visible overrides so
|
||||
// always-on background work doesn't run on assistant-class
|
||||
// models (plan-credit economics).
|
||||
const taskModels = selectInitialTaskModels("rowboat", "rowboat", ids, recommendations, model);
|
||||
await repo.updateConfig({
|
||||
assistantModel: { provider: "rowboat", model },
|
||||
...(Object.keys(taskModels).length > 0 ? { taskModels } : {}),
|
||||
});
|
||||
// Measures recommendation quality: hit = the backend's pick was
|
||||
// in the gateway list; miss = first-listed fallback.
|
||||
capture("llm_initial_model_selected", {
|
||||
flavor: "rowboat",
|
||||
model,
|
||||
recommended: model === normalizeModelRecommendation(recommendations, "rowboat")?.assistantModel,
|
||||
task_overrides_seeded: Object.keys(taskModels).length,
|
||||
source: "sign_in",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Best-effort: a failed initial selection must never break sign-in.
|
||||
// The picker copes with an unset assistant (shows the connect hint).
|
||||
console.warn("[models] Initial selection after Rowboat sign-in failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearRowboatSelections(): Promise<void> {
|
||||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
try {
|
||||
// "rowboat" has no providers-map entry; removeProvider still clears
|
||||
// the assistantModel / task overrides that reference it.
|
||||
await repo.removeProvider("rowboat");
|
||||
} catch (error) {
|
||||
console.warn("[models] Clearing Rowboat selections after sign-out failed:", error);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import type { TurnEvent, TurnState } from "@x/shared/dist/turns.js";
|
||||
import type { ITurnRuntime } from "../turns/api.js";
|
||||
|
|
@ -10,6 +10,16 @@ import type {
|
|||
} from "./headless.js";
|
||||
import { runSpawnedAgent } from "./spawn-agent.js";
|
||||
|
||||
// spawn-agent reads the configured subagent override lazily from
|
||||
// models/defaults.js; stub it so tests control the whole precedence chain
|
||||
// (explicit args > configured override > parent model).
|
||||
const subagentOverride = vi.hoisted(() => ({
|
||||
value: null as { provider: string; model: string } | null,
|
||||
}));
|
||||
vi.mock("../../models/defaults.js", () => ({
|
||||
getSubagentModelOverride: async () => subagentOverride.value,
|
||||
}));
|
||||
|
||||
const TS = "2026-07-07T10:00:00Z";
|
||||
|
||||
function parentCreated(
|
||||
|
|
@ -95,6 +105,34 @@ function fakeServices(opts: {
|
|||
const signal = new AbortController().signal;
|
||||
|
||||
describe("runSpawnedAgent", () => {
|
||||
beforeEach(() => {
|
||||
subagentOverride.value = null;
|
||||
});
|
||||
|
||||
it("uses the configured subagent override when no explicit model is passed", async () => {
|
||||
subagentOverride.value = { provider: "ollama", model: "qwen3" };
|
||||
const { services, started } = fakeServices({});
|
||||
await runSpawnedAgent(
|
||||
{ task: "t", instructions: "x" },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(started[0].agent).toMatchObject({
|
||||
inline: { model: { provider: "ollama", model: "qwen3" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("explicit per-spawn model args beat the configured override", async () => {
|
||||
subagentOverride.value = { provider: "ollama", model: "qwen3" };
|
||||
const { services, started } = fakeServices({});
|
||||
await runSpawnedAgent(
|
||||
{ task: "t", instructions: "x", model: "gpt-5.4", provider: "openai" },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(started[0].agent).toMatchObject({
|
||||
inline: { model: { provider: "openai", model: "gpt-5.4" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("runs an inline child on the parent's model and returns the result envelope", async () => {
|
||||
const { services, started } = fakeServices({});
|
||||
const progress: unknown[] = [];
|
||||
|
|
|
|||
|
|
@ -135,12 +135,15 @@ export async function runSpawnedAgent(
|
|||
parentModel = undefined;
|
||||
}
|
||||
|
||||
// Model precedence: explicit per-spawn args > the user's configured
|
||||
// subagent override (taskModels.subagent) > the parent turn's model.
|
||||
const subagentOverride = input.model ? null : await readSubagentOverride();
|
||||
const model: z.infer<typeof ModelDescriptor> | undefined = input.model
|
||||
? {
|
||||
provider: input.provider ?? parentModel?.provider ?? "",
|
||||
model: input.model,
|
||||
}
|
||||
: parentModel;
|
||||
: subagentOverride ?? parentModel;
|
||||
if (model && !model.provider) {
|
||||
return spawnError(
|
||||
"`model` was set but no provider could be determined; pass `provider` too",
|
||||
|
|
@ -232,6 +235,21 @@ export async function runSpawnedAgent(
|
|||
};
|
||||
}
|
||||
|
||||
// Lazy for the same import-cycle reason as resolveServices; best-effort —
|
||||
// an unreadable config means "no override", never a failed spawn.
|
||||
async function readSubagentOverride(): Promise<
|
||||
z.infer<typeof ModelDescriptor> | null
|
||||
> {
|
||||
try {
|
||||
const { getSubagentModelOverride } = await import(
|
||||
"../../models/defaults.js"
|
||||
);
|
||||
return await getSubagentModelOverride();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveServices(): Promise<
|
||||
NonNullable<SpawnedAgentCallbacks["services"]>
|
||||
> {
|
||||
|
|
|
|||
67
apps/x/packages/shared/src/initial-selection.ts
Normal file
67
apps/x/packages/shared/src/initial-selection.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { z } from "zod";
|
||||
import { ModelRef, TaskModels } from "./models.js";
|
||||
import { normalizeModelRecommendation, type ModelRecommendations } from "./rowboat-account.js";
|
||||
|
||||
/**
|
||||
* Initial model selection for a provider being connected for the first time.
|
||||
*
|
||||
* Implements the selection order from the provider/model-selection spec:
|
||||
* 1. If Rowboat's recommended model for this flavor appears in the
|
||||
* provider's available list, pick it.
|
||||
* 2. Otherwise pick the first model the provider returned.
|
||||
* 3. With no list at all, return null — the caller offers retry or manual
|
||||
* entry.
|
||||
*
|
||||
* Task-model recommendations ride along the same moment: when (and only
|
||||
* when) a connect seeds the assistant, the provider's per-task
|
||||
* recommendations become visible taskModels overrides — each validated
|
||||
* against the live list and skipped when it equals the chosen assistant
|
||||
* (inheritance already produces it; only differences are written).
|
||||
*
|
||||
* This runs ONLY when a provider is first connected and has no saved
|
||||
* selection. It must never run over an existing choice: after initial setup
|
||||
* the saved model configuration is the source of truth, and changes to the
|
||||
* recommendations or to the provider's list order must not silently replace
|
||||
* what the user picked.
|
||||
*
|
||||
* Pure functions by design: callers supply the provider's available models
|
||||
* (from the unified catalog / a live probe) and the recommendations map
|
||||
* (from /v1/config via rowboat:getConfig, keyed by provider FLAVOR in each
|
||||
* provider's native id format). Everything is best-effort — an absent map,
|
||||
* an unknown flavor, or a recommendation the provider doesn't serve all
|
||||
* degrade gracefully.
|
||||
*/
|
||||
|
||||
const TASK_MODEL_KEYS = Object.keys(TaskModels.shape) as Array<keyof z.infer<typeof TaskModels>>;
|
||||
|
||||
export function selectInitialModel(
|
||||
flavor: string,
|
||||
availableModelIds: string[],
|
||||
recommendations: ModelRecommendations | undefined,
|
||||
): string | null {
|
||||
const recommended = normalizeModelRecommendation(recommendations, flavor)?.assistantModel;
|
||||
if (recommended && availableModelIds.includes(recommended)) {
|
||||
return recommended;
|
||||
}
|
||||
return availableModelIds[0] ?? null;
|
||||
}
|
||||
|
||||
export function selectInitialTaskModels(
|
||||
providerId: string,
|
||||
flavor: string,
|
||||
availableModelIds: string[],
|
||||
recommendations: ModelRecommendations | undefined,
|
||||
assistantModel: string,
|
||||
): Partial<Record<keyof z.infer<typeof TaskModels>, z.infer<typeof ModelRef>>> {
|
||||
const taskRecommendations = normalizeModelRecommendation(recommendations, flavor)?.taskModels;
|
||||
if (!taskRecommendations) return {};
|
||||
const overrides: Partial<Record<keyof z.infer<typeof TaskModels>, z.infer<typeof ModelRef>>> = {};
|
||||
for (const key of TASK_MODEL_KEYS) {
|
||||
const model = taskRecommendations[key];
|
||||
// Unknown keys are ignored; a rec equal to the assistant is redundant
|
||||
// (inherit produces it); an unlisted rec is a stale hint.
|
||||
if (!model || model === assistantModel || !availableModelIds.includes(model)) continue;
|
||||
overrides[key] = { provider: providerId, model };
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { z } from 'zod';
|
|||
import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js';
|
||||
import { ListToolsResponse } from './mcp.js';
|
||||
import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js';
|
||||
import { LlmModelConfig, LlmProvider, ModelOverride, ModelRef, ReasoningEffort } from './models.js';
|
||||
import { LlmProvider, ModelRef, ReasoningEffort } from './models.js';
|
||||
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
||||
import { AgentScheduleState } from './agent-schedule-state.js';
|
||||
import { ServiceEvent } from './service-events.js';
|
||||
|
|
@ -646,26 +646,44 @@ const ipcSchemas = {
|
|||
req: BackgroundTaskAgentEvent,
|
||||
res: z.null(),
|
||||
},
|
||||
// The unified model catalog (core/models/catalog.ts): every connected
|
||||
// provider — Rowboat gateway, ChatGPT subscription (codex), BYOK keys,
|
||||
// local/custom endpoints — listed the same way, with per-provider status.
|
||||
'models:list': {
|
||||
req: z.null(),
|
||||
req: z.object({
|
||||
// Drop this provider's cached list and refetch (Retry / Refresh).
|
||||
refreshProvider: z.string().optional(),
|
||||
}).nullable(),
|
||||
res: z.object({
|
||||
providers: z.array(z.object({
|
||||
// Provider INSTANCE id — what ModelRef.provider / assistantModel /
|
||||
// refreshProvider reference. One instance per flavor today, so it
|
||||
// always equals the flavor key; kept distinct so a future
|
||||
// multi-instance setup ("openai-work") is additive.
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
// Provider TYPE ("openai", "ollama", "rowboat", "codex", …) —
|
||||
// drives display naming and credential-field UI.
|
||||
flavor: z.string(),
|
||||
// 'error' = provider is connected but its model list failed to load.
|
||||
status: z.enum(['ok', 'error']),
|
||||
error: z.string().optional(),
|
||||
models: z.array(z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
release_date: z.string().optional(),
|
||||
// models.dev "supports reasoning/extended thinking" flag; absent =
|
||||
// unknown. Gates the composer's reasoning-effort control.
|
||||
reasoning: z.boolean().optional(),
|
||||
})),
|
||||
})),
|
||||
lastUpdated: z.string().optional(),
|
||||
// The effective runtime default (what runs when nothing is picked).
|
||||
defaultModel: ModelRef.nullable(),
|
||||
}),
|
||||
},
|
||||
'models:test': {
|
||||
req: LlmModelConfig,
|
||||
req: z.object({
|
||||
provider: LlmProvider,
|
||||
model: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
|
|
@ -709,23 +727,69 @@ const ipcSchemas = {
|
|||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'models:saveConfig': {
|
||||
req: LlmModelConfig,
|
||||
// Upsert one provider entry (credentials + connection prefs). Model
|
||||
// choices are NOT part of a provider — set them via models:updateConfig.
|
||||
'models:setProvider': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
provider: LlmProvider,
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
// Partial top-level merge into models.json — used by hybrid (signed-in +
|
||||
// BYOK) settings to set the default selection / category overrides without
|
||||
// clobbering the BYOK provider config that saveConfig owns. Omitted keys
|
||||
// are untouched; null clears a key back to its default.
|
||||
// Remove a provider entry plus any assistantModel / task override that
|
||||
// references it (dangling selections would just error at run time).
|
||||
'models:removeProvider': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
// Current model selections plus credential-FREE provider metadata (the
|
||||
// renderer never needs keys to render pickers or provider cards). Null
|
||||
// assistantModel = not configured yet.
|
||||
'models:getConfig': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
// Configured BYOK provider entries, secrets stripped: enough to
|
||||
// render manage/edit surfaces (masked key indicator, endpoint).
|
||||
providers: z.array(z.object({
|
||||
id: z.string(),
|
||||
flavor: z.string(),
|
||||
baseURL: z.string().optional(),
|
||||
hasApiKey: z.boolean(),
|
||||
})),
|
||||
assistantModel: ModelRef.nullable(),
|
||||
taskModels: z.object({
|
||||
knowledgeGraph: ModelRef.nullable(),
|
||||
meetingNotes: ModelRef.nullable(),
|
||||
liveNoteAgent: ModelRef.nullable(),
|
||||
autoPermissionDecision: ModelRef.nullable(),
|
||||
chatTitle: ModelRef.nullable(),
|
||||
backgroundTask: ModelRef.nullable(),
|
||||
subagent: ModelRef.nullable(),
|
||||
}),
|
||||
deferBackgroundTasks: z.boolean(),
|
||||
}),
|
||||
},
|
||||
// Partial merge of model selections into models.json. Omitted keys are
|
||||
// untouched; null clears a key (a cleared task override inherits the
|
||||
// assistant model again). taskModels merges per-key.
|
||||
'models:updateConfig': {
|
||||
req: z.object({
|
||||
defaultSelection: ModelRef.nullable().optional(),
|
||||
knowledgeGraphModel: ModelOverride.nullable().optional(),
|
||||
meetingNotesModel: ModelOverride.nullable().optional(),
|
||||
liveNoteAgentModel: ModelOverride.nullable().optional(),
|
||||
autoPermissionDecisionModel: ModelOverride.nullable().optional(),
|
||||
assistantModel: ModelRef.nullable().optional(),
|
||||
taskModels: z.object({
|
||||
knowledgeGraph: ModelRef.nullable().optional(),
|
||||
meetingNotes: ModelRef.nullable().optional(),
|
||||
liveNoteAgent: ModelRef.nullable().optional(),
|
||||
autoPermissionDecision: ModelRef.nullable().optional(),
|
||||
chatTitle: ModelRef.nullable().optional(),
|
||||
backgroundTask: ModelRef.nullable().optional(),
|
||||
subagent: ModelRef.nullable().optional(),
|
||||
}).optional(),
|
||||
deferBackgroundTasks: z.boolean().nullable().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
|
|
@ -773,9 +837,16 @@ const ipcSchemas = {
|
|||
res: z.object({
|
||||
signedIn: z.boolean(),
|
||||
accessToken: z.string().nullable(),
|
||||
config: RowboatApiConfig.nullable(),
|
||||
}),
|
||||
},
|
||||
// The unauthenticated /v1/config bootstrap (service URLs, billing catalog,
|
||||
// model recommendations). Independent of sign-in state — main caches the
|
||||
// fetch once per app run; null when the API is unreachable. Renderer
|
||||
// consumers go through the useRowboatConfig() hook.
|
||||
'rowboat:getConfig': {
|
||||
req: z.null(),
|
||||
res: RowboatApiConfig.nullable(),
|
||||
},
|
||||
'oauth:didConnect': {
|
||||
req: z.object({
|
||||
provider: z.string(),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import { z } from "zod";
|
|||
// thinkingLevel, OpenRouter reasoning.effort) is mapped at invoke time.
|
||||
export const ReasoningEffort = z.enum(["low", "medium", "high"]);
|
||||
|
||||
// A provider entry: its TYPE (flavor) plus credentials and connection
|
||||
// preferences. Deliberately carries NO model fields — model lists are always
|
||||
// fetched from the provider (core/models/catalog.ts), and model choices live
|
||||
// in assistantModel / taskModels.
|
||||
export const LlmProvider = z.object({
|
||||
// "rowboat" (signed-in gateway) and "codex" (ChatGPT subscription via
|
||||
// "Sign in with ChatGPT") are credential-less flavors: they never appear
|
||||
|
|
@ -28,51 +32,52 @@ export const LlmProvider = z.object({
|
|||
reasoningEffort: ReasoningEffort.optional(),
|
||||
});
|
||||
|
||||
// A provider-qualified model reference. `provider` is a provider name as
|
||||
// understood by resolveProviderConfig — a BYOK flavor ("ollama", "openai",
|
||||
// …) or "rowboat" for the signed-in gateway.
|
||||
// A provider-qualified model reference. `provider` is a provider INSTANCE id
|
||||
// as understood by resolveProviderConfig — a key of the providers map, or
|
||||
// "rowboat" / "codex" for the credential-less providers. Today one instance
|
||||
// exists per flavor, so instance ids equal flavor keys.
|
||||
export const ModelRef = z.object({
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
// Category overrides accept either a bare model id (legacy: paired with the
|
||||
// active default provider) or a provider-qualified ref (hybrid mode: e.g.
|
||||
// gateway assistant + local Ollama background agents).
|
||||
export const ModelOverride = z.union([z.string(), ModelRef]);
|
||||
// The per-task model override slots. Absence = inherit the assistant model
|
||||
// (except `subagent`, whose default is the PARENT turn's model — which is
|
||||
// the assistant for a top-level chat).
|
||||
export const TaskModels = z.object({
|
||||
knowledgeGraph: ModelRef.optional(),
|
||||
meetingNotes: ModelRef.optional(),
|
||||
liveNoteAgent: ModelRef.optional(),
|
||||
autoPermissionDecision: ModelRef.optional(),
|
||||
chatTitle: ModelRef.optional(),
|
||||
backgroundTask: ModelRef.optional(),
|
||||
subagent: ModelRef.optional(),
|
||||
});
|
||||
export type TaskModelKey = keyof z.infer<typeof TaskModels>;
|
||||
|
||||
/**
|
||||
* models.json, version 2.
|
||||
*
|
||||
* The design: providers carry credentials only (keyed by instance id, with
|
||||
* the flavor explicit inside each entry); model choices live in exactly two
|
||||
* places — the required-once-configured `assistantModel`, and optional
|
||||
* per-task overrides that otherwise inherit from it. Model LISTS are never
|
||||
* stored: they are fetched live per provider by the unified catalog.
|
||||
*
|
||||
* Version 1 (top-level provider/model pair + per-provider model lists +
|
||||
* defaultSelection + flat category overrides) is migrated on boot by
|
||||
* core/models/migrate.ts and its schema lives there.
|
||||
*/
|
||||
export const LlmModelConfig = z.object({
|
||||
provider: LlmProvider,
|
||||
model: z.string(),
|
||||
models: z.array(z.string()).optional(),
|
||||
// The user's explicit default assistant model. When set it wins over both
|
||||
// the signed-in curated default and the legacy top-level provider/model
|
||||
// pair — this is what lets signed-in users default to a BYOK model.
|
||||
defaultSelection: ModelRef.optional(),
|
||||
version: z.literal(2),
|
||||
providers: z.record(z.string(), LlmProvider),
|
||||
// The one primary model choice: what runs when nothing more specific was
|
||||
// picked. Absent only before onboarding / first provider connect.
|
||||
assistantModel: ModelRef.optional(),
|
||||
taskModels: TaskModels.optional(),
|
||||
// When true, background agent runs (knowledge pipeline, live notes,
|
||||
// background tasks) wait until no chat turn is running before starting.
|
||||
// Surfaced as a settings checkbox; recommended for local models, where a
|
||||
// background run competes with the chat for the same hardware.
|
||||
deferBackgroundTasks: z.boolean().optional(),
|
||||
providers: z.record(z.string(), z.object({
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
contextLength: z.number().int().positive().optional(),
|
||||
reasoningEffort: ReasoningEffort.optional(),
|
||||
model: z.string().optional(),
|
||||
models: z.array(z.string()).optional(),
|
||||
knowledgeGraphModel: z.string().optional(),
|
||||
meetingNotesModel: z.string().optional(),
|
||||
liveNoteAgentModel: z.string().optional(),
|
||||
autoPermissionDecisionModel: z.string().optional(),
|
||||
})).optional(),
|
||||
// Per-category model overrides. Honored in both modes: when unset,
|
||||
// signed-in users get the curated gateway defaults and BYOK users get the
|
||||
// assistant model. Read by helpers in core/models/defaults.ts.
|
||||
knowledgeGraphModel: ModelOverride.optional(),
|
||||
meetingNotesModel: ModelOverride.optional(),
|
||||
liveNoteAgentModel: ModelOverride.optional(),
|
||||
autoPermissionDecisionModel: ModelOverride.optional(),
|
||||
chatTitleModel: ModelOverride.optional(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,4 +11,43 @@ export const RowboatApiConfig = z.object({
|
|||
// app keeps working against API deployments that predate it — the rewards
|
||||
// UI just stays empty until the backend serves the catalog
|
||||
creditActivations: z.array(CreditActivationCatalogEntrySchema).optional(),
|
||||
// Recommended models per provider FLAVOR, in each provider's native id
|
||||
// format: one assistantModel (the primary) plus optional per-task
|
||||
// taskModels overrides mirroring models.json v2 vocabulary (a missing
|
||||
// task key = inherit the assistant — task recs exist only where the
|
||||
// intended model differs; for rowboat they reproduce the pre-v2 curated
|
||||
// lite-tier task models so plan credits aren't burned by background
|
||||
// services). Hints for the INITIAL selection when a provider is first
|
||||
// connected — never a catalog, and never applied over a saved choice
|
||||
// (see shared/initial-selection.ts). The bare-string form is the legacy
|
||||
// wire shape, accepted so backend deploy order and rollback are
|
||||
// non-events. Local/custom flavors are intentionally absent: the API
|
||||
// can't know which models exist in a user's environment. Optional so
|
||||
// older API deployments and failed fetches never break parsing —
|
||||
// recommendations are best-effort by design.
|
||||
modelRecommendations: z.record(z.string(), z.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
assistantModel: z.string(),
|
||||
taskModels: z.record(z.string(), z.string()).optional(),
|
||||
}),
|
||||
])).optional(),
|
||||
});
|
||||
|
||||
export type ModelRecommendations = NonNullable<z.infer<typeof RowboatApiConfig>['modelRecommendations']>;
|
||||
|
||||
export interface NormalizedModelRecommendation {
|
||||
assistantModel: string;
|
||||
taskModels: Record<string, string>;
|
||||
}
|
||||
|
||||
/** One provider's recommendation in canonical form; null when absent. */
|
||||
export function normalizeModelRecommendation(
|
||||
recommendations: ModelRecommendations | undefined,
|
||||
flavor: string,
|
||||
): NormalizedModelRecommendation | null {
|
||||
const raw = recommendations?.[flavor];
|
||||
if (!raw) return null;
|
||||
if (typeof raw === 'string') return { assistantModel: raw, taskModels: {} };
|
||||
return { assistantModel: raw.assistantModel, taskModels: raw.taskModels ?? {} };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue