Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/browseros-agent/apps/app/lib/llm-providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ export interface LlmProviderConfig {
// so it is stored as a free string validated against the model at selection time.
reasoningEffort?: string
reasoningSummary?: 'auto' | 'concise' | 'detailed'

// Whether a credential is stored on the server, for providers read back from
// it. The values themselves never leave the server, so a form editing an
// existing provider sees these rather than the secret, and leaving a field
// blank keeps what is stored.
hasApiKey?: boolean
hasAccessKeyId?: boolean
hasSecretAccessKey?: boolean
hasSessionToken?: boolean
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,51 @@ function createSelectionStore(
watch: () => () => {},
}
}

// Each extension surface holds its own cache of a list that lives on the
// server, so one can be a refetch behind another. Repairing against a list
// that has not caught up destroys a choice the user just made, which is what
// made selecting a new provider appear to revert to BrowserOS.
describe('resolveRepairedSelection with an incomplete list', () => {
const browserosTarget = {
kind: 'llm' as const,
id: 'browseros',
name: 'BrowserOS',
type: 'browseros' as const,
provider: {} as never,
}

it('leaves a selection this surface has not seen yet alone', () => {
expect(
resolveRepairedSelection({
selection: { kind: 'llm', id: 'just-created' },
resolvedTarget: browserosTarget,
ready: true,
knownIds: new Set(['browseros']),
}).repair,
).toBe(false)
})

// The case repair exists for: the provider is gone from a list that does
// know about it, so the selection genuinely dangles.
it('still repairs a selection the list can account for', () => {
expect(
resolveRepairedSelection({
selection: { kind: 'llm', id: 'deleted-but-known' },
resolvedTarget: browserosTarget,
ready: true,
knownIds: new Set(['browseros', 'deleted-but-known']),
}),
).toEqual({ repair: true, selection: { kind: 'llm', id: 'browseros' } })
})

it('repairs as before when no list is given', () => {
expect(
resolveRepairedSelection({
selection: { kind: 'llm', id: 'gone' },
resolvedTarget: browserosTarget,
ready: true,
}).repair,
).toBe(true)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -121,24 +121,39 @@ export type RepairSelectionDecision =
| { repair: true; selection: SidepanelChatTargetSelection | null }

/**
* Decides whether a persisted sidebar selection needs repair. It never repairs
* an ACP selection: the agents list is fetch-backed and can be stale (a
* persisted react-query cache, or a different extension context that has not
* refetched a newly-created agent), so repairing here would wipe a valid ACP
* default and silently downgrade it to the LLM fallback. Stale ACP selections
* are cleaned by `clearSidepanelChatTargetSelectionForAgent` on delete, and
* `resolveSidepanelChatTarget` already falls back non-destructively at render.
* Only LLM selections are repaired, since providers load reliably from local
* storage, and only once loads are settled.
* Decides whether a persisted sidebar selection needs repair.
*
* Repair exists for one case: the selection names something that has been
* deleted, so the sidebar would otherwise point at nothing. Absence from the
* list in hand is not evidence of that. Each extension surface holds its own
* query cache of a list that lives on the server, so a provider added in
* another surface is missing here until this one refetches, and rewriting the
* selection then destroys a choice the user just made. That is what made
* picking a new provider appear to revert to BrowserOS while picking an agent
* worked, since ACP selections were already exempt.
*
* So a selection is only repaired when the list is known to be complete, and a
* selection this list cannot confirm is left alone for the next render, when
* the revision signal will have brought the list up to date.
* `resolveSidepanelChatTarget` already falls back non-destructively for
* display, so nothing is broken while that happens. Stale entries are still
* cleaned on delete by `clearSidepanelChatTargetSelectionForAgent`.
*/
export function resolveRepairedSelection({
selection,
resolvedTarget,
ready,
knownIds,
}: {
selection: SidepanelChatTargetSelection | null
resolvedTarget: SidepanelChatTarget | undefined
ready: boolean
/**
* Every id this surface currently knows about, of either kind. A selection
* naming something absent from it is treated as not yet loaded rather than
* as deleted.
*/
knownIds?: ReadonlySet<string>
}): RepairSelectionDecision {
if (!ready || !selection) return { repair: false }
if (selection.kind === 'acp') return { repair: false }
Expand All @@ -149,6 +164,8 @@ export function resolveRepairedSelection({
) {
return { repair: false }
}
// Inconclusive rather than deleted: this surface has not seen it yet.
if (knownIds && !knownIds.has(selection.id)) return { repair: false }
return {
repair: true,
selection: resolvedTarget
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,18 @@ export function useChatTargetSelection() {
selection: targetSelection,
resolvedTarget: selectedChatTarget,
ready,
knownIds: new Set(chatTargets.map((target) => target.id)),
})
if (!decision.repair) return
setTargetSelection(decision.selection)
void persistSidepanelChatTargetSelection(selectedChatTarget)
}, [agentsSettled, isLoadingProviders, selectedChatTarget, targetSelection])
}, [
agentsSettled,
chatTargets,
isLoadingProviders,
selectedChatTarget,
targetSelection,
])

const selectedLlmProviderRef = useRef<LlmProviderConfig | null>(
selectedLlmProvider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@ import { hc } from 'hono/client'
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { resolveAgentServerUrlWithRetry } from '@/modules/browseros/agent-server-url.helpers'
import {
type ProviderRow,
toProviderConfigs,
toProviderPayload,
} from './llm-providers.helpers'
import { toProviderConfigs, toProviderPayload } from './llm-providers.helpers'
import { bumpProviderRevision } from './llm-providers.revision'

async function providersClient() {
const baseUrl = await resolveAgentServerUrlWithRetry()
Expand All @@ -23,6 +20,7 @@ export async function putProvider(config: LlmProviderConfig): Promise<void> {
if (!response.ok) {
throw new Error(`Failed to save provider (${response.status})`)
}
await bumpProviderRevision()
}

export async function deleteProvider(providerId: string): Promise<void> {
Expand All @@ -33,6 +31,7 @@ export async function deleteProvider(providerId: string): Promise<void> {
if (!response.ok && response.status !== 404) {
throw new Error(`Failed to delete provider (${response.status})`)
}
await bumpProviderRevision()
}

/**
Expand All @@ -58,6 +57,7 @@ export async function putDefaultProvider(providerId: string): Promise<void> {
if (!response.ok) {
throw new Error(`Failed to set the default provider (${response.status})`)
}
await bumpProviderRevision()
}

export async function listProviders(): Promise<LlmProviderConfig[]> {
Expand All @@ -67,7 +67,7 @@ export async function listProviders(): Promise<LlmProviderConfig[]> {
throw new Error(`Failed to load providers (${response.status})`)
}
const { providers } = await response.json()
return toProviderConfigs(providers as ProviderRow[])
return toProviderConfigs(providers)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@ function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
id: 'provider-1',
type: 'openai',
name: 'My OpenAI',
kind: 'llm',
baseUrl: null,
modelId: 'gpt-5.5',
supportsImages: true,
contextWindow: 200000,
temperature: 0.2,
apiKey: null,
accessKeyId: null,
secretAccessKey: null,
sessionToken: null,
hasApiKey: false,
hasAccessKeyId: false,
hasSecretAccessKey: false,
hasSessionToken: false,
resourceName: null,
region: null,
reasoningEffort: null,
Expand Down Expand Up @@ -54,20 +55,26 @@ describe('toProviderConfig', () => {
const converted = toProviderConfig(row())

expect(converted?.baseUrl).toBeUndefined()
expect(converted?.apiKey).toBeUndefined()
expect(converted?.reasoningSummary).toBeUndefined()
})

it('carries the credentials across', () => {
// Credentials never leave the server now, so the row reports only whether
// one is set and the config carries that instead.
it('carries the credential flags, not the credentials', () => {
const converted = toProviderConfig(
row({ apiKey: 'sk-test', accessKeyId: 'AKIA', region: 'us-east-1' }),
row({ hasApiKey: true, region: 'us-east-1' }),
)

expect(converted).toMatchObject({
apiKey: 'sk-test',
accessKeyId: 'AKIA',
region: 'us-east-1',
})
expect(converted).toMatchObject({ hasApiKey: true, region: 'us-east-1' })
expect(converted?.apiKey).toBeUndefined()
})

// Coding agents share this table and this endpoint. Filtering on kind is
// what excludes them; the unknown-type guard below used to do it by accident.
it('rejects a coding agent', () => {
expect(
toProviderConfig(row({ kind: 'acp', type: 'claude', modelId: null })),
).toBeNull()
})

// The row survives in the database and comes back on upgrade. Showing it
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
import { isProviderType } from '@/lib/llm-providers/providerTemplates'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'

/** A provider row as the server returns it: absent values are null, not undefined. */
/**
* A provider row as the server returns it.
*
* Absent values are null rather than undefined, and credentials are not here
* at all: the server reports only whether each is set, so a key cannot reach
* a surface that has no use for it.
*/
export interface ProviderRow {
id: string
kind: 'llm' | 'acp'
type: string
name: string
baseUrl: string | null
modelId: string
// Nullable since the table holds coding agents too, and those carry neither.
modelId: string | null
supportsImages: boolean
contextWindow: number
contextWindow: number | null
temperature: number
apiKey: string | null
accessKeyId: string | null
secretAccessKey: string | null
sessionToken: string | null
hasApiKey: boolean
hasAccessKeyId: boolean
hasSecretAccessKey: boolean
hasSessionToken: boolean
resourceName: string | null
region: string | null
reasoningEffort: string | null
Expand Down Expand Up @@ -45,7 +53,13 @@ function toReasoningSummary(
* map, the template lookup and the default base URLs, all keyed by the union.
*/
export function toProviderConfig(row: ProviderRow): LlmProviderConfig | null {
// Coding agents share this table and this endpoint, and are served to the
// surfaces that want them through their own hook. Filtering on kind says
// that; leaning on the unknown-type guard below to drop them happened to
// work and said something else entirely.
if (row.kind !== 'llm') return null
if (!isProviderType(row.type)) return null
if (row.modelId === null || row.contextWindow === null) return null

return {
id: row.id,
Expand All @@ -56,10 +70,10 @@ export function toProviderConfig(row: ProviderRow): LlmProviderConfig | null {
supportsImages: row.supportsImages,
contextWindow: row.contextWindow,
temperature: row.temperature,
apiKey: orUndefined(row.apiKey),
accessKeyId: orUndefined(row.accessKeyId),
secretAccessKey: orUndefined(row.secretAccessKey),
sessionToken: orUndefined(row.sessionToken),
hasApiKey: row.hasApiKey,
hasAccessKeyId: row.hasAccessKeyId,
hasSecretAccessKey: row.hasSecretAccessKey,
hasSessionToken: row.hasSessionToken,
resourceName: orUndefined(row.resourceName),
region: orUndefined(row.region),
reasoningEffort: orUndefined(row.reasoningEffort),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { createQuery } from 'react-query-kit'
import {
resolveDefaultProviderId,
Expand All @@ -14,6 +15,7 @@ import {
putProvider,
} from './llm-providers.api'
import { planProviderSave } from './llm-providers.helpers'
import { watchProviderRevision } from './llm-providers.revision'

export interface UseLlmProvidersReturn {
providers: LlmProviderConfig[]
Expand Down Expand Up @@ -60,11 +62,36 @@ export async function persistDefaultProviderId(
await putDefaultProvider(providerId)
}

/**
* Keeps this surface current with provider writes made in another one.
*
* Each extension surface has its own query cache, and the rows live on the
* server where nothing can watch them. Writers bump a revision in extension
* storage instead, which does broadcast to every context, and each mounted
* view refetches. This is what `providersStorage.watch` used to do before the
* list moved off extension storage.
*/
function useProviderRevision(): void {
const queryClient = useQueryClient()

useEffect(
() =>
watchProviderRevision(() => {
queryClient.invalidateQueries({ queryKey: useProvidersQuery.getKey() })
queryClient.invalidateQueries({
queryKey: useDefaultProviderIdQuery.getKey(),
})
}),
[queryClient],
)
}

/** Hook for managing LLM provider configurations. */
export function useLlmProviders(): UseLlmProvidersReturn {
const queryClient = useQueryClient()
const providersQuery = useProvidersQuery()
const defaultQuery = useDefaultProviderIdQuery()
useProviderRevision()
const storedDefaultId = defaultQuery.data ?? DEFAULT_PROVIDER_ID

const providers = providersQuery.data ?? []
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { storage } from '@wxt-dev/storage'

/**
* A change signal, not data.
*
* The provider list and the selected provider live on the server, and every
* extension surface holds its own query cache of them. Extension storage used
* to carry the data itself, so `watch` kept the side panel, the new tab, the
* settings page and the scheduled tasks page in step for free. Moving the data
* to the server kept the cache per surface and dropped the broadcast, so a
* provider added in one place stayed invisible to the others until they
* happened to refetch. This keeps the broadcast half.
*
* The value is a timestamp rather than a counter so two surfaces writing at
* once cannot lose a bump to a read-modify-write race.
*/
export const providerRevisionStorage = storage.defineItem<number>(
'local:provider-revision',
{ fallback: 0 },
)

export async function bumpProviderRevision(): Promise<void> {
await providerRevisionStorage.setValue(Date.now())
}

export function watchProviderRevision(onChange: () => void): () => void {
return providerRevisionStorage.watch(() => onChange())
}
Loading
Loading