Skip to content
Merged
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
208 changes: 184 additions & 24 deletions backend/src/services/opencode-models.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ModelInfo, ModelRef } from '@opencode-manager/shared/opencode'
import { formatOpenCodeModelRef, openCodeLocation, parseOpenCodeModelRef } from '@opencode-manager/shared/opencode'
import { formatOpenCodeModelRef, openCodeLocation, parseOpenCodeModelRef, selectConfiguredModelRef } from '@opencode-manager/shared/opencode'
import type { OpenCodeClient } from './opencode/client'

export interface ResolvedOpenCodeModel {
Expand All @@ -9,6 +9,14 @@ export interface ResolvedOpenCodeModel {
model: string
}

export interface ResolveOpenCodeModelOptions {
preferredModel?: string | null
signal?: AbortSignal
}

export const MODEL_LOAD_TIMEOUT_MS = 15_000
export const MODEL_LOAD_POLL_MS = 500

function normalizeModelCandidate(model: string | null | undefined): string | null {
if (!model) {
return null
Expand All @@ -31,29 +39,24 @@ function findAvailable(models: ModelInfo[], ref: ModelRef): ModelInfo | undefine
return models.find((model) => model.providerID === ref.providerID && model.id === ref.id)
}

export async function resolveOpenCodeModel(
client: OpenCodeClient,
directory: string,
options?: {
preferredModel?: string | null
},
): Promise<ResolvedOpenCodeModel> {
const location = openCodeLocation(directory)
const [modelsResponse, defaultResponse] = await Promise.all([
client.api.model.list(location),
client.api.model.default(location),
])
const models = modelsResponse.data
function isTargetLoaded(models: ModelInfo[], targetRef: ModelRef | undefined): boolean {
return !targetRef || findAvailable(models, targetRef) !== undefined
}

const preferred = normalizeModelCandidate(options?.preferredModel)
if (preferred) {
const parsedPreferred = parseOpenCodeModelRef(preferred)
if (parsedPreferred && findAvailable(models, parsedPreferred)) {
return toResolvedModel(parsedPreferred)
}
function resolveFromLoadedModels(
models: ModelInfo[],
defaultModel: ModelInfo | null,
configuredRef: ModelRef | undefined,
preferredRef: ModelRef | undefined,
): ResolvedOpenCodeModel | null {
if (preferredRef && findAvailable(models, preferredRef)) {
return toResolvedModel(preferredRef)
}

if (configuredRef && findAvailable(models, configuredRef)) {
return toResolvedModel(configuredRef)
}

const defaultModel = defaultResponse.data
if (defaultModel) {
const defaultRef: ModelRef = { providerID: defaultModel.providerID, id: defaultModel.id }
if (findAvailable(models, defaultRef)) {
Expand All @@ -62,9 +65,166 @@ export async function resolveOpenCodeModel(
}

const fallback = models.find((model) => model.enabled)
if (fallback) {
return toResolvedModel({ providerID: fallback.providerID, id: fallback.id })
return fallback ? toResolvedModel({ providerID: fallback.providerID, id: fallback.id }) : null
}

function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) {
resolve()
return
}

const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)

function onAbort(): void {
clearTimeout(timer)
resolve()
}

signal.addEventListener('abort', onAbort, { once: true })

if (signal.aborted) {
onAbort()
}
})
}

type PollingResult<T> = { ok: true; value: T } | { ok: false }

function isAbortLike(error: unknown, signal: AbortSignal): boolean {
if (error === signal.reason) {
return true
}

if (typeof error !== 'object' || error === null) {
return false
}

if ((error as { name?: unknown }).name === 'AbortError') {
return true
}

const cause = (error as { cause?: unknown }).cause
return cause !== undefined && cause !== error && isAbortLike(cause, signal)
}

async function runPollingRequest<T>(
request: () => Promise<T>,
external: AbortSignal | undefined,
deadline: AbortSignal,
): Promise<PollingResult<T>> {
try {
return { ok: true, value: await request() }
} catch (error) {
external?.throwIfAborted()

if (deadline.aborted && isAbortLike(error, deadline)) {
return { ok: false }
}

throw error
}
}

async function readConfiguredRef(
client: OpenCodeClient,
location: ReturnType<typeof openCodeLocation>,
signal: AbortSignal,
): Promise<ModelRef | undefined> {
const entries = await client.api.config.get(location, { signal })
return selectConfiguredModelRef(entries)
}

export async function resolveOpenCodeModel(
client: OpenCodeClient,
directory: string,
options?: ResolveOpenCodeModelOptions,
): Promise<ResolvedOpenCodeModel> {
const location = openCodeLocation(directory)
const preferred = normalizeModelCandidate(options?.preferredModel)
const preferredRef = preferred ? parseOpenCodeModelRef(preferred) : undefined
const external = options?.signal

const timeoutController = new AbortController()
const timeoutId = setTimeout(() => {
timeoutController.abort(new Error('Timed out waiting for the OpenCode model catalog to load'))
}, MODEL_LOAD_TIMEOUT_MS)
const signal = external ? AbortSignal.any([external, timeoutController.signal]) : timeoutController.signal

let models: ModelInfo[] = []
let defaultModel: ModelInfo | null = null

try {
external?.throwIfAborted()

const configuredRef = await readConfiguredRef(client, location, signal)
const targetRef = preferredRef ?? configuredRef

throw new Error('No configured OpenCode models are available')
for (;;) {
external?.throwIfAborted()

if (timeoutController.signal.aborted) {
break
}

const [listResult, defaultResult] = await Promise.all([
runPollingRequest(
() => client.api.model.list(location, { signal }),
external,
timeoutController.signal,
),
runPollingRequest(
() => client.api.model.default(location, { signal }),
external,
timeoutController.signal,
),
])

if (listResult.ok) {
models = listResult.value.data
}

if (defaultResult.ok) {
defaultModel = defaultResult.value.data
}

if (!listResult.ok || !defaultResult.ok) {
break
}

external?.throwIfAborted()

if (timeoutController.signal.aborted) {
break
}

const resolved = resolveFromLoadedModels(models, defaultModel, configuredRef, preferredRef)
if (resolved && isTargetLoaded(models, targetRef)) {
return resolved
}

await sleep(MODEL_LOAD_POLL_MS, signal)
}

external?.throwIfAborted()

const resolved = resolveFromLoadedModels(models, defaultModel, configuredRef, preferredRef)
if (resolved) {
return resolved
}

if (models.length === 0) {
throw timeoutController.signal.reason instanceof Error
? timeoutController.signal.reason
: new Error('Timed out waiting for the OpenCode model catalog to load')
}

throw new Error('No configured OpenCode models are available')
} finally {
clearTimeout(timeoutId)
}
}
Loading