Files

231 lines
9.0 KiB
JavaScript

import { isIP } from 'node:net'
import { readFile } from 'node:fs/promises'
import { durableReplace } from '../../../platform/lib/atomic.mjs'
import { canonicalJson } from '../../../platform/lib/canonical-json.mjs'
import { validProviderId } from '../outbound-proxy/lib/contracts.mjs'
const CACHE_SCHEMA = 1
const DEFAULT_ENDPOINT = 'http://127.0.0.1:3079'
const RPC_TIMEOUT_MS = 5_000
function object(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function rpcValue(value, method) {
if (!object(value) || value.type !== 'server-response' || !object(value.result)) {
throw new Error(`DSH ${method} response is invalid`)
}
if (value.result.ok !== true || !Object.hasOwn(value.result, 'value')) {
throw new Error(`DSH ${method} request failed`)
}
return value.result.value
}
async function rpc(fetchImpl, endpoint, method, signal, headers) {
const rpcId = `dsh-proxy-${method}`
const response = await fetchImpl(`${endpoint}/api/${method}`, {
method: 'POST',
headers: { ...headers, 'content-type': 'application/json' },
body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: {} } }),
signal,
})
if (!response.ok) throw new Error(`DSH ${method} returned HTTP ${String(response.status)}`)
return rpcValue(await response.json(), method)
}
function nestedValue(value, path) {
let current = value
for (const part of path) {
if (!object(current) || !(part in current)) return undefined
current = current[part]
}
return current
}
function baseUrlFor(provider, namespaces) {
const namespace = namespaces.get(provider.settingsNs)
if (namespace === undefined) return undefined
const path = Array.isArray(provider.settingsPath) ? provider.settingsPath : []
const configured = nestedValue(namespace.value, path)
const defaults = nestedValue(namespace.base, path)
for (const candidate of [configured, defaults]) {
if (object(candidate) && typeof candidate.baseURL === 'string' && candidate.baseURL.trim() !== '') {
return candidate.baseURL.trim()
}
}
return undefined
}
function configuredProvider(provider, namespaces) {
const namespace = namespaces.get(provider.settingsNs)
if (namespace === undefined) return false
const path = Array.isArray(provider.settingsPath) ? provider.settingsPath : []
return path.length === 0 || nestedValue(namespace.value, path) !== undefined
}
function loopbackUrl(value) {
if (value === undefined) return false
let parsed
try { parsed = new URL(value) } catch { return false }
const host = parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']')
? parsed.hostname.slice(1, -1)
: parsed.hostname.toLowerCase()
if (host === 'localhost' || host.endsWith('.localhost')) return true
if (isIP(host) === 4) return host.startsWith('127.')
if (isIP(host) === 6) return host === '::1' || host === '0:0:0:0:0:0:0:1'
return false
}
function sanitizeProvider(provider, namespaces) {
if (!object(provider) || !validProviderId(provider.provider)) return undefined
if (!configuredProvider(provider, namespaces)) return undefined
const local = loopbackUrl(baseUrlFor(provider, namespaces))
const routingCapability = local
? 'forced-direct'
: provider.routingCapability === 'shared-dsh' || provider.supportsIndependentRouting === false
? 'shared-dsh'
: 'provider'
return Object.freeze({
id: provider.provider,
displayName: typeof provider.displayName === 'string' && provider.displayName.trim() !== ''
? provider.displayName.trim()
: provider.provider,
type: provider.provider,
active: provider.active === true,
declared: provider.declared !== false,
configured: true,
routingCapability,
reason: routingCapability === 'forced-direct'
? 'local-provider'
: routingCapability === 'shared-dsh' ? 'client-uses-shared-dsh-route' : null,
})
}
function decorate(provider, snapshot) {
if (provider.routingCapability !== 'forced-direct') {
const requestedPolicy = snapshot.configuration.modelApi.providers[provider.id]
?? snapshot.configuration.modelApi.default
return Object.freeze({
...provider,
requestedPolicy,
effectivePolicy: requestedPolicy.proxyEnabled
? provider.routingCapability === 'shared-dsh' ? 'shared-dsh' : 'proxy'
: 'direct',
})
}
return Object.freeze({
...provider,
requestedPolicy: null,
effectivePolicy: 'direct',
})
}
function validateCache(value) {
if (!object(value) || value.schema !== CACHE_SCHEMA || !Array.isArray(value.providers) || typeof value.updatedAt !== 'string') {
throw new Error('cached Provider inventory is invalid')
}
const providers = value.providers.map(provider => {
if (!object(provider) || !validProviderId(provider.id) || typeof provider.displayName !== 'string'
|| typeof provider.type !== 'string' || typeof provider.active !== 'boolean' || typeof provider.declared !== 'boolean'
|| provider.configured !== true
|| !['provider', 'shared-dsh', 'forced-direct'].includes(provider.routingCapability)
|| !(provider.reason === null || typeof provider.reason === 'string')) {
throw new Error('cached Provider inventory entry is invalid')
}
return Object.freeze({ ...provider })
})
return Object.freeze({ schema: CACHE_SCHEMA, updatedAt: value.updatedAt, providers: Object.freeze(providers) })
}
export class ProviderInventory {
constructor({
cachePath,
endpoint = DEFAULT_ENDPOINT,
fetchImpl = fetch,
authentication = Object.freeze({ cookie: async () => null }),
timeoutMs = RPC_TIMEOUT_MS,
now = () => new Date(),
}) {
if (typeof cachePath !== 'string' || cachePath === '') throw new TypeError('Provider inventory cache path is required')
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new TypeError('Provider inventory timeout is invalid')
if (!object(authentication) || typeof authentication.cookie !== 'function') {
throw new TypeError('Provider inventory authentication is invalid')
}
this.cachePath = cachePath
this.endpoint = endpoint.replace(/\/$/, '')
this.fetchImpl = fetchImpl
this.authentication = authentication
this.timeoutMs = timeoutMs
this.now = now
}
async refresh() {
const signal = AbortSignal.timeout(this.timeoutMs)
const cookie = await this.authentication.cookie()
if (!(cookie === null || typeof cookie === 'string')) throw new Error('DSH authentication cookie is invalid')
const headers = cookie === null ? {} : { cookie }
const [registeredValue, directoryValue, settingsValue] = await Promise.all([
rpc(this.fetchImpl, this.endpoint, 'llm/listProviders', signal, headers),
rpc(this.fetchImpl, this.endpoint, 'llm/listConfigurableProviders', signal, headers),
rpc(this.fetchImpl, this.endpoint, 'settings/describe', signal, headers),
])
if (!Array.isArray(registeredValue) || !Array.isArray(directoryValue) || !Array.isArray(settingsValue.namespaces)) {
throw new Error('DSH Provider inventory response is invalid')
}
const namespaces = new Map(settingsValue.namespaces
.filter(namespace => object(namespace) && typeof namespace.ns === 'string')
.map(namespace => [namespace.ns, namespace]))
const active = new Set(registeredValue
.filter(provider => object(provider) && validProviderId(provider.id))
.map(provider => provider.id))
const providers = directoryValue
.map(provider => sanitizeProvider({ ...provider, active: active.has(provider?.provider) }, namespaces))
.filter(provider => provider !== undefined)
.sort((left, right) => left.displayName.localeCompare(right.displayName) || left.id.localeCompare(right.id))
const cache = Object.freeze({
schema: CACHE_SCHEMA,
updatedAt: this.now().toISOString(),
providers: Object.freeze(providers),
})
await durableReplace(this.cachePath, canonicalJson(cache))
return cache
}
async cached() {
return validateCache(JSON.parse(await readFile(this.cachePath, 'utf8')))
}
async list(snapshot, { refresh = true } = {}) {
let cache
let source = 'live'
let error = null
if (refresh) {
try { cache = await this.refresh() } catch (failure) {
source = 'cache'
error = failure instanceof Error ? failure.message : String(failure)
}
}
if (cache === undefined) {
try { cache = await this.cached() } catch (failure) {
return Object.freeze({
schema: CACHE_SCHEMA,
source: 'unavailable',
updatedAt: null,
error: error ?? (failure instanceof Error ? failure.message : String(failure)),
providers: Object.freeze([]),
})
}
}
return Object.freeze({
schema: CACHE_SCHEMA,
source,
updatedAt: cache.updatedAt,
error,
providers: Object.freeze(cache.providers.map(provider => decorate(provider, snapshot))),
})
}
}
export const providerInventoryInternals = Object.freeze({ baseUrlFor, configuredProvider, loopbackUrl, validateCache })