Skip to content
Open
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
4 changes: 4 additions & 0 deletions backend/src/routes/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ function credentialConnections(integration: IntegrationInfo) {
export function createProvidersRoutes(openCodeClient: OpenCodeClient) {
const app = new Hono()

app.get('/catalog-plugins', async (c) => {
return c.json(await openCodeClient.managedCatalogPlugins?.() ?? [])
})

app.get('/model-state', async (c) => {
try {
const state = await readOpenCodeModelState()
Expand Down
16 changes: 15 additions & 1 deletion 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 { findModelInfo, formatOpenCodeModelRef, openCodeLocation, parseOpenCodeModelRef, selectConfiguredModelRef, selectEffectiveModelRef } from '@opencode-manager/shared/opencode'
import { applyMiniMaxCatalogPolicy, findModelInfo, formatOpenCodeModelRef, openCodeLocation, parseOpenCodeModelRef, selectConfiguredModelRef, selectEffectiveModelRef } from '@opencode-manager/shared/opencode'
import type { OpenCodeClient } from './opencode/client'

export interface ResolvedOpenCodeModel {
Expand Down Expand Up @@ -176,6 +176,20 @@ export async function resolveOpenCodeModel(

if (listResult.ok) {
models = listResult.value.data
try {
const [entries, providers, plugins, managed] = await Promise.all([
client.api.config.get(location, { signal }),
client.api.provider.list(location, { signal }),
client.api.plugin.list(location, { signal }),
client.managedCatalogPlugins?.() ?? Promise.resolve([]),
])
if (providers.location.directory === directory && plugins.location.directory === directory &&
listResult.value.location.directory === directory) {
models = applyMiniMaxCatalogPolicy(models, providers.data, entries, plugins.data, managed)
}
} catch {
external?.throwIfAborted()
}
}

if (defaultResult.ok) {
Expand Down
8 changes: 6 additions & 2 deletions backend/src/services/opencode/client.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { logger } from '../../utils/logger'
import { ENV } from '@opencode-manager/shared/config/env'
import { ENV, getOpenCodeConfigHome } from '@opencode-manager/shared/config/env'
import { verifiedManagedCatalogPlugins } from './plugin-registry'
import { createOpenCodeApi, type OpenCodeApi } from '@opencode-manager/shared/opencode'
import { filterProxyHeaders, NULL_BODY_STATUSES } from '../../utils/proxy-headers'
import { getOpenCodeBasicAuthHeader, type OpenCodePasswordResolver } from './auth'
import { getOpenCodeUpstreamBaseUrl, withDefaultOpenCodeDirectory } from './upstream'

export interface OpenCodeClient {
readonly api: OpenCodeApi
managedCatalogPlugins?: () => Promise<Array<{ id: string; path: string }>>
forwardRaw(request: Request): Promise<Response>
}

Expand Down Expand Up @@ -143,5 +145,7 @@ export function createOpenCodeClient(
const password = typeof passwordOverride === 'string' ? passwordOverride : ENV.OPENCODE.SERVER_PASSWORD
const basicAuth = password ? getOpenCodeBasicAuthHeader(password) : null

return new FetchOpenCodeClient({ baseUrl, basicAuth, passwordResolver })
const client: OpenCodeClient = new FetchOpenCodeClient({ baseUrl, basicAuth, passwordResolver })
client.managedCatalogPlugins = () => verifiedManagedCatalogPlugins(getOpenCodeConfigHome())
return client
}
19 changes: 18 additions & 1 deletion backend/src/services/opencode/plugin-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { buildGhEnvPluginSource } from '../opencode-gh-env-plugin'
import { buildManagerToolPluginSource } from '../opencode-manager-tool-plugin'
import { buildSandboxPluginSource } from '../opencode-sandbox-plugin'
import { getOpenCodeServiceSettingsPath } from '../opencode-service-mode'
import { ensureSandboxShellShim } from '../sandbox/shell-shim'
import { ensureSandboxShellShim, sandboxShellShimPath } from '../sandbox/shell-shim'

const MANAGED_OPENCODE_PLUGIN_IDS = {
ghEnv: 'ocm.gh-env',
Expand Down Expand Up @@ -41,6 +41,23 @@ export function getOpenCodePluginDir(configHome: string): string {
return join(configHome, 'opencode', 'plugins')
}

export async function verifiedManagedCatalogPlugins(configHome: string): Promise<Array<{ id: string; path: string }>> {
const verified = await Promise.all(MANAGED_OPENCODE_PLUGINS.map(async (plugin) => {
const path = join(getOpenCodePluginDir(configHome), plugin.filename)
try {
const source = plugin.buildSource({
id: plugin.id,
shellShimPath: sandboxShellShimPath(configHome),
serviceSettingsPath: getOpenCodeServiceSettingsPath(),
})
return await fs.readFile(path, 'utf8') === source ? { id: plugin.id, path } : undefined
} catch {
return undefined
}
}))
return verified.filter((plugin): plugin is { id: string; path: string } => plugin !== undefined)
}

export async function installManagedPlugins(configHome: string): Promise<void> {
const shellShimPath = await ensureSandboxShellShim(configHome)
const serviceSettingsPath = getOpenCodeServiceSettingsPath()
Expand Down
36 changes: 35 additions & 1 deletion backend/test/services/opencode-plugin-registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,43 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { promises as fs } from 'node:fs'
import { verifiedManagedCatalogPlugins } from '../../src/services/opencode/plugin-registry'
import { sandboxShellShimPath } from '../../src/services/sandbox/shell-shim'
import { getOpenCodeServiceSettingsPath } from '../../src/services/opencode-service-mode'
import { buildGhEnvPluginSource } from '../../src/services/opencode-gh-env-plugin'
import { buildManagerToolPluginSource } from '../../src/services/opencode-manager-tool-plugin'
import { buildSandboxPluginSource } from '../../src/services/opencode-sandbox-plugin'

describe('managed OpenCode plugin id wiring', () => {
it('attests exact generated plugin content and installation paths', async () => {
const configHome = '/managed-config'
const read = vi.spyOn(fs, 'readFile')
.mockResolvedValueOnce(buildGhEnvPluginSource('ocm.gh-env'))
.mockResolvedValueOnce(buildManagerToolPluginSource('ocm.manager'))
.mockResolvedValueOnce(buildSandboxPluginSource('ocm.sandbox', sandboxShellShimPath(configHome), getOpenCodeServiceSettingsPath()))
try {
expect(await verifiedManagedCatalogPlugins(configHome)).toEqual([
{ id: 'ocm.gh-env', path: '/managed-config/opencode/plugins/ocm-gh-env.js' },
{ id: 'ocm.manager', path: '/managed-config/opencode/plugins/ocm-manager.js' },
{ id: 'ocm.sandbox', path: '/managed-config/opencode/plugins/ocm-sandbox.js' },
])
expect(read).toHaveBeenCalledWith('/managed-config/opencode/plugins/ocm-manager.js', 'utf8')
} finally {
read.mockRestore()
}
})

it('does not exempt changed, missing or substituted plugins', async () => {
const read = vi.spyOn(fs, 'readFile')
.mockResolvedValueOnce(buildGhEnvPluginSource('different.id'))
.mockResolvedValueOnce(buildManagerToolPluginSource('ocm.manager') + '\nchanged')
.mockRejectedValueOnce(new Error('missing'))
try {
expect(await verifiedManagedCatalogPlugins('/managed-config')).toEqual([])
} finally {
read.mockRestore()
}
})

it('bakes the id each source builder receives instead of hard-coding it', () => {
expect(buildGhEnvPluginSource('custom.gh-env')).toContain("id: 'custom.gh-env'")
expect(buildManagerToolPluginSource('custom.manager')).toContain("id: 'custom.manager'")
Expand Down
82 changes: 82 additions & 0 deletions backend/test/shared/minimax-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import type { ConfigEntry, ModelInfo, PluginInfo, ProviderInfo } from '@opencode/client'
import { applyMiniMaxCatalogPolicy } from '../../../shared/src/opencode/minimaxCatalog'
import { isSelectableCatalogModel, selectEffectiveModelRef, selectInteractiveModel, selectPreferredVariant } from '../../../shared/src/opencode/modelPreference'

function model(id: string, providerID = 'minimax'): ModelInfo {
return {
id, modelID: id, providerID, name: id, enabled: true, status: 'active',
capabilities: { input: ['text', 'image'], output: ['text'], tools: true },
variants: [{ id: 'high', settings: { reasoning_effort: 'high' } }],
time: { released: 0 }, limit: { context: 1000000, output: 524288 }, cost: [],
} as ModelInfo
}

const providers = ['minimax', 'minimax-cn', 'opencode', 'openrouter'].map((id) => ({ id })) as ProviderInfo[]
const raw = [model('MiniMax-M2.7'), model('MiniMax-M3'), model('MiniMax-M3.1-Flash')]
const policy = (entries: ConfigEntry[] = [], plugins: PluginInfo[] = []) => applyMiniMaxCatalogPolicy(raw, providers, entries, plugins)

describe('MiniMax ordinary catalog', () => {
it('chooses Flash in both fallbacks and keeps the complete runtime catalog', () => {
const models = policy()
expect(models).toHaveLength(3)
expect(models.filter(isSelectableCatalogModel).map((item) => item.id)).toEqual(['MiniMax-M3', 'MiniMax-M3.1-Flash'])
expect(selectInteractiveModel({ models })).toEqual({ providerID: 'minimax', modelID: 'MiniMax-M3.1-Flash' })
expect(selectEffectiveModelRef({ models })).toEqual({ providerID: 'minimax', id: 'MiniMax-M3.1-Flash' })
expect(selectEffectiveModelRef({ models, defaultModel: raw[0] })).toEqual({ providerID: 'minimax', id: 'MiniMax-M3.1-Flash' })
})

it('preserves current, configured, recent, explicit and variant selections', () => {
const models = policy()
const ref = { providerID: 'minimax', id: 'MiniMax-M2.7', variant: 'high' }
const selection = { providerID: ref.providerID, modelID: ref.id }
for (const options of [{ current: selection }, { configured: ref }, { agentModel: ref }, { recent: [selection] }]) {
expect(selectInteractiveModel({ models, ...options })).toEqual(selection)
}
expect(selectEffectiveModelRef({ models, candidates: [ref] })).toBe(ref)
expect(selectEffectiveModelRef({ models, defaultModel: ref })).toBe(ref)
expect(selectPreferredVariant({ model: selection, info: models[0], configured: ref })).toBe('high')
})

it('preserves native same-ID config and unknown plugins including failed plugins', () => {
expect(policy([{ type: 'document', info: { providers: { minimax: { models: { 'MiniMax-M2.7': {} } } } } }])).toEqual(raw)
expect(policy([{ type: 'document', info: { plugins: ['custom-plugin'] } }])).toEqual(raw)
for (const source of [{ type: 'sdk' }, { type: 'package', target: 'custom' }, { type: 'local', path: '/custom.js' }] as PluginInfo['source'][]) {
expect(policy([], [{ source, features: {}, state: { status: 'active' } }])).toEqual(raw)
expect(policy([], [{ source, features: {}, state: { status: 'failed', error: 'partial setup' } }])).toEqual(raw)
}
})

it('exempts only verified manager IDs and exact local paths', () => {
const plugin: PluginInfo = { id: 'ocm.manager', source: { type: 'local', path: '/managed/ocm-manager.js' }, features: {}, state: { status: 'active' } }
const verified = [{ id: 'ocm.manager', path: '/managed/ocm-manager.js' }]
expect(applyMiniMaxCatalogPolicy(raw, providers, [], [plugin], verified)[0].miniMaxCatalogRank).toBe(-1)
expect(policy([], [plugin])).toEqual(raw)
expect(applyMiniMaxCatalogPolicy(raw, providers, [], [{ ...plugin, id: 'custom' }], verified)).toEqual(raw)
expect(applyMiniMaxCatalogPolicy(raw, providers, [], [{ ...plugin, source: { type: 'local', path: '/other/ocm-manager.js' } }], verified)).toEqual(raw)
})

it('uses exact regional and gateway IDs without inventing catalog entries or metadata', () => {
for (const [providerID, legacy, m3, flash] of [
['minimax-cn', 'MiniMax-M2.7', 'MiniMax-M3', 'MiniMax-M3.1-Flash'],
['opencode', 'minimax-m2.7-free', 'minimax-m3', 'minimax-m3.1-flash-free'],
['openrouter', 'minimax/minimax-m2.7', 'minimax/minimax-m3', 'minimax/minimax-m3.1-flash:free'],
]) {
const models = applyMiniMaxCatalogPolicy([model(legacy, providerID), model(m3, providerID), model(flash, providerID)], providers, [], [])
expect(selectInteractiveModel({ models })?.modelID).toBe(flash)
expect(selectInteractiveModel({ models: models.slice(0, 2) })?.modelID).toBe(m3)
expect(models[2].limit).toEqual(raw[2].limit)
expect(models[2].cost).toEqual([])
}
})

it('preserves non-MiniMax, media, custom aliases and non-MiniMax default order', () => {
const other = model('gpt-test', 'openai')
const media = { ...model('MiniMax-Media'), capabilities: { input: ['text'], output: ['audio'], tools: false } } as ModelInfo
const alias = { ...raw[0], id: 'custom-model' }
const mismatched = { ...raw[0], canonical: 'custom' }
const models = applyMiniMaxCatalogPolicy([other, media, alias, mismatched, ...raw], providers, [], [])
expect(models.slice(0, 4)).toEqual([other, media, alias, mismatched])
expect(selectInteractiveModel({ models })?.modelID).toBe(other.id)
})
})
118 changes: 118 additions & 0 deletions frontend/src/api/minimax-http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { getProviders } from './providers'
import { createSession, getSession, listSessionMessages, sendPrompt, switchSessionModel } from './opencode'
import { selectInteractiveModel, selectEffectiveModelRef, type SessionMessageInfo } from '@opencode-manager/shared/opencode'
import { resolveOpenCodeModel } from '../../../backend/src/services/opencode-models'
import { createOpenCodeApi } from '@opencode-manager/shared/opencode'

vi.mock('@/config', () => ({ API_BASE_URL: '', OPENCODE_API_ENDPOINT: '/api/opencode' }))
vi.mock('@/hooks/useMobile', () => ({ isMobileViewport: () => false }))

const directory = '/catalog-test'
const requests: Array<{ path: string; query: Record<string, string>; body: unknown }> = []
let config: unknown[] = []
let plugins: unknown[] = []
let failConfig = false
let selected: unknown
const providers = ['minimax', 'minimax-cn', 'opencode', 'openrouter'].map((id) => ({ id, name: id, activation: 'enabled' }))
const routes = [
['minimax', 'MiniMax-M2.7', 'MiniMax-M3', 'MiniMax-M3.1-Flash'],
['minimax-cn', 'MiniMax-M2.7', 'MiniMax-M3', 'MiniMax-M3.1-Flash'],
['opencode', 'minimax-m2.7', 'minimax-m3', 'minimax-m3.1-flash-free'],
['openrouter', 'minimax/minimax-m2.7', 'minimax/minimax-m3', 'minimax/minimax-m3.1-flash:free'],
]
const models = routes.flatMap(([providerID, ...ids]) => ids.map((id) => ({
providerID, id, modelID: id, name: id, enabled: true, status: 'active',
capabilities: { tools: true, input: ['text', 'image'], output: ['text'] },
variants: [{ id: 'high', settings: { reasoning_effort: 'high' } }],
time: { released: 0 }, limit: { context: 1000000, output: 524288 }, cost: [],
})))
const history: SessionMessageInfo[] = [{ id: 'saved', type: 'assistant', time: { created: 1 },
agent: 'build', model: { providerID: 'minimax', id: 'MiniMax-M2.7' }, content: [
{ type: 'reasoning', text: 'retained reasoning', state: { signature: 'opaque' } },
{ type: 'tool', id: 'call-1', name: 'inspect', time: { created: 1 },
state: { status: 'completed', input: { file: 'a' }, content: [{ type: 'text', text: 'ok' }] } },
{ type: 'text', text: 'saved answer' },
] }]
const server = createServer(async (req, res) => {
const url = new URL(req.url!, 'http://localhost')
let text = ''
for await (const chunk of req) text += chunk
const body = text ? JSON.parse(text) : undefined
const path = url.pathname.replace(/^\/api\/opencode/, '')
requests.push({ path, query: Object.fromEntries(url.searchParams), body })
const send = (value: unknown) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(value)) }
if (path === '/api/config') {
if (failConfig) { res.statusCode = 500; res.end(); return }
return send(config)
}
if (path === '/api/plugin') return send({ location: { directory }, data: plugins })
if (path === '/api/model') return send({ location: { directory }, data: models })
if (path === '/api/model/default') return send({ location: { directory }, data: models[0] })
if (path === '/api/provider') return send({ location: { directory }, data: providers })
if (path === '/api/session' && req.method === 'POST') { selected = body.model; return send({ data: { id: 'test', model: selected } }) }
if (path === '/api/session/test') return send({ data: { id: 'test', model: selected } })
if (path === '/api/session/test/model') { selected = body.model; res.statusCode = 204; res.end(); return }
if (path === '/api/session/test/prompt') return send({ data: { id: 'prompt' } })
if (path === '/api/session/test/message') return send({ data: history, cursor: {} })
res.statusCode = 404; res.end()
})
let origin: string

beforeAll(async () => {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
vi.stubGlobal('window', { location: { origin }, innerWidth: 1200 })
})
afterAll(async () => {
vi.unstubAllGlobals()
server.closeAllConnections()
await new Promise<void>((resolve) => server.close(() => resolve()))
})

describe('MiniMax selection through the real OpenCode SDK HTTP boundary', () => {
it('uses Flash defaults and preserves explicit legacy variants, image prompts and stored history', async () => {
const catalog = await getProviders(directory)
for (const [providerID, legacy, m3, flash] of routes) {
expect(catalog.providers.find((provider) => provider.id === providerID)?.models.map((model) => model.id)).toEqual([flash, m3])
const available = catalog.models.filter((model) => model.providerID === providerID)
const choice = selectInteractiveModel({ models: available })!
expect(choice.modelID).toBe(flash)
expect(selectEffectiveModelRef({ models: available })?.id).toBe(flash)
await createSession({ directory, model: `${choice.providerID}/${choice.modelID}` })
expect(requests.at(-1)?.body).toEqual({ location: { directory }, model: { providerID, id: flash } })
for (const id of [m3, legacy]) {
const explicit = { providerID, id, variant: 'high' }
expect(selectEffectiveModelRef({ models: available, candidates: [explicit] })).toEqual(explicit)
await switchSessionModel('test', explicit)
expect(requests.at(-1)?.body).toEqual({ model: explicit })
expect((await getSession('test')).model).toEqual(explicit)
}
const files = [{ uri: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jN2kAAAAASUVORK5CYII=' }]
await sendPrompt({ sessionID: 'test', text: 'inspect', files })
expect(requests.at(-1)?.body).toMatchObject({ text: 'inspect', files })
expect((await listSessionMessages('test')).messages).toEqual(history)
}
for (const request of requests.filter((request) => ['/api/config', '/api/plugin', '/api/provider', '/api/model'].includes(request.path))) {
expect(request.query['location[directory]']).toBe(directory)
}
})

it('preserves unknown/configured catalogs and exercises backend default resolution', async () => {
const client = { api: createOpenCodeApi({ baseUrl: `${origin}/api/opencode` }), forwardRaw: async () => new Response() }
expect((await resolveOpenCodeModel(client, directory)).id).toBe('MiniMax-M3.1-Flash')
expect((await resolveOpenCodeModel(client, directory, { preferredModel: 'minimax/MiniMax-M2.7#high' })).id).toBe('MiniMax-M2.7')
config = [{ type: 'document', info: { providers: { minimax: { models: { 'MiniMax-M2.7': {} } } } } }]
expect((await getProviders(directory)).providers.find((provider) => provider.id === 'minimax')?.models).toHaveLength(3)
expect((await resolveOpenCodeModel(client, directory)).id).toBe('MiniMax-M2.7')
config = []
plugins = [{ source: { type: 'sdk' }, features: {}, state: { status: 'active' } }]
expect((await getProviders(directory)).models).toEqual(models)
plugins = []
failConfig = true
expect((await getProviders(directory)).models).toEqual(models)
failConfig = false
})
})
Loading