Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
28 changes: 25 additions & 3 deletions console/web/src/hooks/use-llm-router-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,30 @@ describe('llm-router presence probe wiring', () => {
})

it('gates on both presence and the initial probe settling', () => {
expect(isLlmRouterAvailable({ present: true, loading: false })).toBe(true)
expect(isLlmRouterAvailable({ present: true, loading: true })).toBe(false)
expect(isLlmRouterAvailable({ present: false, loading: false })).toBe(false)
const refresh = async () => true
expect(
isLlmRouterAvailable({
present: true,
loading: false,
revision: 1,
refresh,
}),
).toBe(true)
expect(
isLlmRouterAvailable({
present: true,
loading: true,
revision: 1,
refresh,
}),
).toBe(false)
expect(
isLlmRouterAvailable({
present: false,
loading: false,
revision: 1,
refresh,
}),
).toBe(false)
})
})
101 changes: 50 additions & 51 deletions console/web/src/hooks/use-model-picker-source.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { onHarnessConfigSaved } from '@/lib/harness-config-events'
import { getIiiClient } from '@/lib/iii-client'
import {
catalogRowsToModelOptions,
fetchModelsCatalog,
Expand Down Expand Up @@ -33,18 +32,23 @@ import type { ModelOption } from '@/types/chat'
export function useModelPickerSource(
backendId: string,
routerAvailable = true,
routerRevision = 0,
): {
modelOptions: ModelOption[]
catalogKeys: string[]
catalogLoading: boolean
presentProviders: ProviderListEntry[]
refresh: () => Promise<void>
refresh: (force?: boolean) => Promise<void>
} {
const [modelOptions, setModelOptions] = useState<ModelOption[]>([])
const [presentProviders, setPresentProviders] = useState<ProviderListEntry[]>(
[],
)
const providerEventVersion = useRef(0)
const catalogRequestVersion = useRef(0)
const providerRequestVersion = useRef(0)
const routerRevisionRef = useRef(routerRevision)
routerRevisionRef.current = routerRevision
// Mirror of `presentProviders` for event handlers: React state updaters may
// run deferred, so membership checks must not live inside them.
const providersRef = useRef<ProviderListEntry[]>([])
Expand All @@ -55,27 +59,41 @@ export function useModelPickerSource(
backendId === 'real' && routerAvailable,
)

const refresh = useCallback(async () => {
if (backendId !== 'real') {
setModelOptions([])
setCatalogLoading(false)
return
}
if (!routerAvailable) {
setModelOptions([])
setCatalogLoading(false)
return
}
setCatalogLoading(true)
try {
const rows = await fetchModelsCatalog()
setModelOptions(catalogRowsToModelOptions(rows))
} catch {
setModelOptions([])
} finally {
setCatalogLoading(false)
}
}, [backendId, routerAvailable])
const refresh = useCallback(
async (force = false) => {
const requestVersion = ++catalogRequestVersion.current
const revisionAtStart = routerRevision
if (backendId !== 'real') {
setModelOptions([])
setCatalogLoading(false)
return
}
if (!routerAvailable && !force) {
setModelOptions([])
setCatalogLoading(false)
return
}
setCatalogLoading(true)
try {
const rows = await fetchModelsCatalog()
if (
catalogRequestVersion.current === requestVersion &&
routerRevisionRef.current === revisionAtStart
) {
setModelOptions(catalogRowsToModelOptions(rows))
}
} catch {
// A timeout or a reconnect race is not evidence that the configured
// catalogue became empty. Preserve the last good snapshot; a successful
// empty response above still clears it authoritatively.
} finally {
if (catalogRequestVersion.current === requestVersion) {
setCatalogLoading(false)
}
}
},
[backendId, routerAvailable, routerRevision],
)

useEffect(() => {
void refresh()
Expand All @@ -84,22 +102,26 @@ export function useModelPickerSource(
// Re-read `router::provider::list`, dropping the result if a newer provider
// event (or a newer snapshot) has advanced the version since we started.
const refreshProviders = useCallback(async () => {
const requestVersion = ++providerRequestVersion.current
const revisionAtStart = routerRevision
if (backendId !== 'real' || !routerAvailable) {
setPresentProviders([])
return
}
const snapshotVersion = providerEventVersion.current
try {
const providers = await fetchProviderList()
if (providerEventVersion.current === snapshotVersion) {
if (
providerEventVersion.current === snapshotVersion &&
providerRequestVersion.current === requestVersion &&
routerRevisionRef.current === revisionAtStart
) {
setPresentProviders(providers)
}
} catch {
if (providerEventVersion.current === snapshotVersion) {
setPresentProviders([])
}
// Preserve the last authoritative provider snapshot on transport errors.
}
}, [backendId, routerAvailable])
}, [backendId, routerAvailable, routerRevision])

// Initial snapshot (re-run when the router (re)appears). Availability flips
// are applied from `router::provider::changed`; an event for a provider the
Expand Down Expand Up @@ -173,29 +195,6 @@ export function useModelPickerSource(
}
}, [backendId, routerAvailable, refresh, refreshProviders])

// A WebSocket drop loses any change events fired while disconnected;
// re-pull both reads when the connection comes back.
useEffect(() => {
if (backendId !== 'real' || !routerAvailable) return
let disposed = false
let offConn: (() => void) | null = null
getIiiClient()
.then((client) => {
if (disposed) return
offConn = client.addConnectionStateListener((state) => {
if (state === 'connected') {
void refresh()
void refreshProviders()
}
})
})
.catch(() => {})
return () => {
disposed = true
offConn?.()
}
}, [backendId, routerAvailable, refresh, refreshProviders])

useEffect(() => {
if (backendId !== 'real' || !routerAvailable) return
return onHarnessConfigSaved(() => {
Expand Down
Loading
Loading