mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
feat(x): rowboat config IPC + model recommendations plumbing
- RowboatApiConfig gains optional modelRecommendations (one model per provider FLAVOR, native id format) matching the merged backend payload; optional so older API deployments and failed fetches never break parsing - new rowboat:getConfig IPC serves the unauthenticated /v1/config bootstrap independent of sign-in (signed-out BYOK users need recommendations when connecting a provider); account:getRowboat is back to pure account state (signedIn/accessToken) — config is no longer a property of the account - renderer useRowboatConfig() hook: one shared store for all config consumers (appUrl dialogs/sidebar/settings), plus imperative fetchRowboatConfig() for event-time consumers (voice, meeting) that must await the value when starting a session - selectInitialModel(flavor, models, recommendations): pure spec-ordered initial pick (recommendation if listed, else first, else null) — runs only when a provider is first connected, never over a saved choice Inert plumbing: nothing calls selectInitialModel yet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5729e21d77
commit
b0000a10d9
13 changed files with 257 additions and 52 deletions
|
|
@ -1327,18 +1327,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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
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) {
|
||||
|
|
|
|||
32
apps/x/packages/core/src/models/initial-selection.test.ts
Normal file
32
apps/x/packages/core/src/models/initial-selection.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { selectInitialModel } 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();
|
||||
});
|
||||
});
|
||||
34
apps/x/packages/core/src/models/initial-selection.ts
Normal file
34
apps/x/packages/core/src/models/initial-selection.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 function by design: the caller supplies 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). Recommendations are best-effort — an
|
||||
* absent map, an unknown flavor, or a recommendation the provider doesn't
|
||||
* serve all degrade to "first available model".
|
||||
*/
|
||||
export function selectInitialModel(
|
||||
flavor: string,
|
||||
availableModelIds: string[],
|
||||
recommendations: Record<string, string> | undefined,
|
||||
): string | null {
|
||||
const recommended = recommendations?.[flavor];
|
||||
if (recommended && availableModelIds.includes(recommended)) {
|
||||
return recommended;
|
||||
}
|
||||
return availableModelIds[0] ?? null;
|
||||
}
|
||||
|
|
@ -790,9 +790,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(),
|
||||
|
|
|
|||
|
|
@ -11,4 +11,13 @@ 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(),
|
||||
// One recommended model id per provider FLAVOR (e.g. { openai: "gpt-5.4",
|
||||
// openrouter: "anthropic/claude-opus-4.8" }), in each provider's native id
|
||||
// format. A hint for the INITIAL selection when a provider is first
|
||||
// connected — never a catalog, and never applied over a saved choice
|
||||
// (see core/models/initial-selection.ts). 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.string()).optional(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue