diff --git a/backend/src/routes/providers.ts b/backend/src/routes/providers.ts index 55839233f..ab26bc253 100644 --- a/backend/src/routes/providers.ts +++ b/backend/src/routes/providers.ts @@ -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() diff --git a/backend/src/services/opencode-models.ts b/backend/src/services/opencode-models.ts index 82b3c1191..09a19ad4d 100644 --- a/backend/src/services/opencode-models.ts +++ b/backend/src/services/opencode-models.ts @@ -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 { @@ -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) { diff --git a/backend/src/services/opencode/client.ts b/backend/src/services/opencode/client.ts index 8dc63166a..b89278345 100644 --- a/backend/src/services/opencode/client.ts +++ b/backend/src/services/opencode/client.ts @@ -1,5 +1,6 @@ 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' @@ -7,6 +8,7 @@ import { getOpenCodeUpstreamBaseUrl, withDefaultOpenCodeDirectory } from './upst export interface OpenCodeClient { readonly api: OpenCodeApi + managedCatalogPlugins?: () => Promise> forwardRaw(request: Request): Promise } @@ -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 } diff --git a/backend/src/services/opencode/plugin-registry.ts b/backend/src/services/opencode/plugin-registry.ts index 71f92a339..03ed0574c 100644 --- a/backend/src/services/opencode/plugin-registry.ts +++ b/backend/src/services/opencode/plugin-registry.ts @@ -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', @@ -41,6 +41,23 @@ export function getOpenCodePluginDir(configHome: string): string { return join(configHome, 'opencode', 'plugins') } +export async function verifiedManagedCatalogPlugins(configHome: string): Promise> { + 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 { const shellShimPath = await ensureSandboxShellShim(configHome) const serviceSettingsPath = getOpenCodeServiceSettingsPath() diff --git a/backend/test/services/opencode-plugin-registry.test.ts b/backend/test/services/opencode-plugin-registry.test.ts index 5e8c1f407..3f2a30463 100644 --- a/backend/test/services/opencode-plugin-registry.test.ts +++ b/backend/test/services/opencode-plugin-registry.test.ts @@ -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'") diff --git a/backend/test/shared/minimax-catalog.test.ts b/backend/test/shared/minimax-catalog.test.ts new file mode 100644 index 000000000..9280c6b26 --- /dev/null +++ b/backend/test/shared/minimax-catalog.test.ts @@ -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) + }) +}) diff --git a/frontend/src/api/minimax-http.test.ts b/frontend/src/api/minimax-http.test.ts new file mode 100644 index 000000000..f90508c47 --- /dev/null +++ b/frontend/src/api/minimax-http.test.ts @@ -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; 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((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((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 + }) +}) diff --git a/frontend/src/api/providers.test.ts b/frontend/src/api/providers.test.ts index 97c817fe2..f431a8b1c 100644 --- a/frontend/src/api/providers.test.ts +++ b/frontend/src/api/providers.test.ts @@ -10,12 +10,13 @@ import { } from './providers' import { API_BASE_URL } from '@/config' -const { mockFetchWrapper, mockProviderList, mockModelList, mockModelDefault, mockConfigGet } = vi.hoisted(() => ({ +const { mockFetchWrapper, mockProviderList, mockModelList, mockModelDefault, mockConfigGet, mockPluginList } = vi.hoisted(() => ({ mockFetchWrapper: vi.fn(), mockProviderList: vi.fn(), mockModelList: vi.fn(), mockModelDefault: vi.fn(), mockConfigGet: vi.fn(), + mockPluginList: vi.fn(), })) vi.mock('./fetchWrapper', () => ({ @@ -28,6 +29,7 @@ vi.mock('./opencodeApi', () => ({ provider: { list: mockProviderList }, model: { list: mockModelList, default: mockModelDefault }, config: { get: mockConfigGet }, + plugin: { list: mockPluginList }, }), })) @@ -53,6 +55,38 @@ describe('getProviders', () => { vi.clearAllMocks() mockProviderList.mockResolvedValue({ location: { directory: '/repo' }, data: [] }) mockModelList.mockResolvedValue({ location: { directory: '/repo' }, data: [] }) + mockConfigGet.mockResolvedValue([]) + mockPluginList.mockResolvedValue({ location: { directory: '/repo' }, data: [] }) + mockFetchWrapper.mockResolvedValue([]) + }) + + it('filters ordinary MiniMax choices while preserving full runtime models and fails open on unknown provenance', async () => { + const models = ['MiniMax-M2.7', 'MiniMax-M3', 'MiniMax-M3.1-Flash'].map((id) => + makeAnthropicModel({ id, modelID: id, name: id, providerID: 'minimax' }), + ) + mockProviderList.mockResolvedValue({ location: { directory: '/repo' }, data: [{ id: 'minimax', name: 'MiniMax' }] }) + mockModelList.mockResolvedValue({ location: { directory: '/repo' }, data: models }) + const result = await getProviders('/repo') + expect(result.providers[0].models.map((model) => model.id)).toEqual(['MiniMax-M3.1-Flash', 'MiniMax-M3']) + expect(result.models.map((model) => model.id)).toEqual(models.map((model) => model.id)) + expect(mockConfigGet).toHaveBeenCalledWith({ location: { directory: '/repo' } }) + expect(mockPluginList).toHaveBeenCalledWith({ location: { directory: '/repo' } }) + mockConfigGet.mockRejectedValueOnce(new Error('unavailable')) + expect((await getProviders('/repo')).providers[0].models).toHaveLength(3) + mockPluginList.mockResolvedValueOnce({ location: { directory: '/other' }, data: [] }) + expect((await getProviders('/repo')).providers[0].models).toHaveLength(3) + mockConfigGet.mockResolvedValueOnce([{ type: 'document', info: { providers: { minimax: { models: { 'MiniMax-M2.7': {} } } } } }]) + expect((await getProviders('/repo')).providers[0].models).toHaveLength(3) + mockPluginList.mockResolvedValueOnce({ location: { directory: '/repo' }, data: [{ source: { type: 'sdk' } }] }) + expect((await getProviders('/repo')).providers[0].models).toHaveLength(3) + }) + + it('does not exempt local-looking plugins on remote servers', async () => { + mockProviderList.mockResolvedValue({ location: { directory: '/repo' }, data: [{ id: 'minimax', name: 'MiniMax' }] }) + mockModelList.mockResolvedValue({ location: { directory: '/repo' }, data: [makeAnthropicModel({ id: 'MiniMax-M2.7', modelID: 'MiniMax-M2.7', providerID: 'minimax' })] }) + mockPluginList.mockResolvedValue({ location: { directory: '/repo' }, data: [{ id: 'ocm.manager', source: { type: 'local', path: '/managed/ocm-manager.js' } }] }) + expect((await getProviders('/repo', 'remote')).providers[0].models).toHaveLength(1) + expect(mockFetchWrapper).not.toHaveBeenCalled() }) it('maps V2 providers and models into the UI shape', async () => { diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index e1906dfdc..b41dc4e45 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -2,6 +2,9 @@ import { API_BASE_URL } from "@/config"; import { fetchWrapper } from "./fetchWrapper"; import { callOpenCode } from "./opencodeApi"; import { + applyMiniMaxCatalogPolicy, + miniMaxCatalogRank, + type ManagedCatalogPlugin, compareCatalogModels, formatOpenCodeModelRef, isActiveCatalogProvider, @@ -16,9 +19,10 @@ import { type ModelPreferenceModel, type ModelRef, } from "@opencode-manager/shared/opencode"; -import type { CredentialListResponse, CredentialStatusResponse } from "@opencode-manager/shared/schemas"; +import { LOCAL_OPENCODE_SERVER_ID, type CredentialListResponse, type CredentialStatusResponse } from "@opencode-manager/shared/schemas"; export interface Model { + miniMaxCatalogRank?: number; id: string; key?: string; name: string; @@ -45,8 +49,9 @@ export type ModelSelection = ModelPreferenceModel export type OpenCodeModelState = ModelPreference -function mapModelInfo(model: ModelInfo): Model { +export function mapModelInfo(model: ModelInfo): Model { return { + miniMaxCatalogRank: miniMaxCatalogRank(model), id: model.modelID, key: model.id, name: model.name, @@ -67,10 +72,27 @@ export async function getProviders(directory?: string, serverId?: string): Promi serverId, ); + let catalogModels = modelResult.data; + try { + const [entries, plugins] = await callOpenCode( + (api) => Promise.all([api.config.get(location), api.plugin.list(location)]), + serverId, + ); + if (providerResult.location.directory === modelResult.location.directory && + plugins.location.directory === modelResult.location.directory && + (!directory || directory === modelResult.location.directory)) { + const managed = (!serverId || serverId === LOCAL_OPENCODE_SERVER_ID) + ? await fetchWrapper(`${API_BASE_URL}/api/providers/catalog-plugins`).catch(() => []) + : []; + catalogModels = applyMiniMaxCatalogPolicy(modelResult.data, providerResult.data, entries, plugins.data, managed); + } + } catch { + catalogModels = modelResult.data; + } const activeProviders = providerResult.data.filter(isActiveCatalogProvider); const providers = activeProviders.map((provider): Provider => { - const models = modelResult.data + const models = catalogModels .filter((model) => model.providerID === provider.id && isSelectableCatalogModel(model)) .map(mapModelInfo); @@ -95,7 +117,7 @@ export async function getProviders(directory?: string, serverId?: string): Promi ), ); - return { providers, models: modelResult.data }; + return { providers, models: catalogModels }; } catch { return { providers: [], models: [] }; } diff --git a/frontend/src/components/settings/OpenCodeModelDialog.tsx b/frontend/src/components/settings/OpenCodeModelDialog.tsx index 7583e622a..acae996fa 100644 --- a/frontend/src/components/settings/OpenCodeModelDialog.tsx +++ b/frontend/src/components/settings/OpenCodeModelDialog.tsx @@ -581,7 +581,7 @@ export function OpenCodeModelDialog({ } }} options={discoveredModelOptions} - placeholder="e.g., MiniMax-M2.7" + placeholder="e.g., MiniMax-M3.1-Flash" disabled={isLoadingModels} allowCustomValue={true} /> diff --git a/frontend/src/components/settings/OpenCodeModelsEditor.test.tsx b/frontend/src/components/settings/OpenCodeModelsEditor.test.tsx index f96fe1f2f..f8f3946ea 100644 --- a/frontend/src/components/settings/OpenCodeModelsEditor.test.tsx +++ b/frontend/src/components/settings/OpenCodeModelsEditor.test.tsx @@ -429,7 +429,7 @@ describe('OpenCodeModelDialog — model discovery', () => { expect(screen.getByText(/1 model found/i)).toBeInTheDocument() }) - const providerModelInput = screen.getByPlaceholderText('e.g., MiniMax-M2.7') + const providerModelInput = screen.getByPlaceholderText('e.g., MiniMax-M3.1-Flash') fireEvent.focus(providerModelInput) const option = await screen.findByRole('option', { name: 'gpt-4o' }) diff --git a/frontend/src/hooks/useModelSections.ts b/frontend/src/hooks/useModelSections.ts index 1af594b64..a57593ac5 100644 --- a/frontend/src/hooks/useModelSections.ts +++ b/frontend/src/hooks/useModelSections.ts @@ -26,8 +26,8 @@ export function useModelSections( const providers = catalog?.providers ?? EMPTY_PROVIDERS const sections = useMemo( - () => buildModelSections(providers, modelState, defaultModel), - [providers, modelState, defaultModel], + () => buildModelSections(providers, modelState, defaultModel, catalog?.models), + [providers, modelState, defaultModel, catalog?.models], ) return { diff --git a/frontend/src/lib/modelSections.test.ts b/frontend/src/lib/modelSections.test.ts index 056955f1e..123d39856 100644 --- a/frontend/src/lib/modelSections.test.ts +++ b/frontend/src/lib/modelSections.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { buildModelSections, filterModelSections, toModelComboboxOptions } from './modelSections' import type { Model, Provider } from '@/api/providers' +import type { ModelInfo } from '@opencode-manager/shared/opencode' function makeModel(id: string, overrides: Partial = {}): Model { return { id, key: id, name: id, released: 0, free: false, ...overrides } @@ -28,6 +29,21 @@ function valuesOf(sections: ReturnType, key: string): } describe('buildModelSections', () => { + it('retains saved legacy entries without returning them to ordinary provider sections', () => { + const provider = makeProvider('minimax', 'MiniMax', [makeModel('MiniMax-M3.1-Flash'), makeModel('MiniMax-M3')]) + const runtime = ['MiniMax-M2.7', 'MiniMax-M2.5'].map((id) => ({ + providerID: 'minimax', id, modelID: id, name: id, time: { released: 0 }, cost: [], limit: { context: 1, output: 1 }, + })) as ModelInfo[] + const sections = buildModelSections([provider], { + favorite: [{ providerID: 'minimax', modelID: 'MiniMax-M2.7' }], + recent: [{ providerID: 'minimax', modelID: 'MiniMax-M2.5' }], variant: {}, + }, 'minimax/MiniMax-M2.7#high', runtime) + expect(valuesOf(sections, 'default')).toEqual(['minimax/MiniMax-M2.7#high']) + expect(valuesOf(sections, 'favorites')).toEqual(['minimax/MiniMax-M2.7']) + expect(valuesOf(sections, 'recent')).toEqual(['minimax/MiniMax-M2.5']) + expect(valuesOf(sections, 'provider:minimax')).toEqual(['minimax/MiniMax-M3.1-Flash', 'minimax/MiniMax-M3']) + }) + it('orders default, favorites, recent, then providers', () => { const sections = buildModelSections( providers, diff --git a/frontend/src/lib/modelSections.ts b/frontend/src/lib/modelSections.ts index fb7a10a38..3a7b47c52 100644 --- a/frontend/src/lib/modelSections.ts +++ b/frontend/src/lib/modelSections.ts @@ -1,6 +1,7 @@ import { Clock, Star, type LucideIcon } from 'lucide-react' import type { ComboboxOption } from '@/components/ui/combobox' import { + mapModelInfo, formatModelName, formatProviderName, providerModelRef, @@ -9,7 +10,7 @@ import { type OpenCodeModelState, type Provider, } from '@/api/providers' -import { compareCatalogModels, parseOpenCodeModelRef } from '@opencode-manager/shared/opencode' +import { compareCatalogModels, parseOpenCodeModelRef, type ModelInfo } from '@opencode-manager/shared/opencode' export interface ModelOption { value: string @@ -47,6 +48,7 @@ export function buildModelSections( providers: Provider[], modelState: OpenCodeModelState | undefined, defaultModel?: string | null, + runtimeModels: ModelInfo[] = [], ): ModelSection[] { const optionsByValue = new Map() const providerSections = providers.map((provider): ModelSection => { @@ -58,6 +60,14 @@ export function buildModelSections( return { key: `provider:${provider.id}`, providerID: provider.id, title: formatProviderName(provider), options } }) + for (const info of runtimeModels) { + const provider = providers.find((entry) => entry.id === info.providerID) + if (!provider) continue + const model = mapModelInfo(info) + const value = providerModelRef(provider, model) + if (!optionsByValue.has(value)) optionsByValue.set(value, createOption(provider, model, value)) + } + const pinnedValues = new Set() const pinOptions = (selections: ModelSelection[] = []) => @@ -75,7 +85,7 @@ export function buildModelSections( const parsed = parseOpenCodeModelRef(defaultModel) if (!parsed) return undefined const provider = providers.find((entry) => entry.id === parsed.providerID) - const model = provider?.models.find((entry) => (entry.key ?? entry.id) === parsed.id) + const model = optionsByValue.get(providerModelRef({ id: parsed.providerID }, { id: parsed.id }))?.model if (!provider || !model) return undefined if (!parsed.variant) pinnedValues.add(providerModelRef(provider, model)) return createOption(provider, model, defaultModel) @@ -111,8 +121,8 @@ function uniqueModelSections(sections: ModelSection[]): ModelSection[] { function compareSearchMatches(a: ModelOption, b: ModelOption): number { return compareCatalogModels( - { name: a.label, free: a.model.free, released: a.model.released }, - { name: b.label, free: b.model.free, released: b.model.released }, + { ...a.model, name: a.label }, + { ...b.model, name: b.label }, ) } diff --git a/shared/src/opencode/index.ts b/shared/src/opencode/index.ts index 14c3b09ae..f349c31d3 100644 --- a/shared/src/opencode/index.ts +++ b/shared/src/opencode/index.ts @@ -1,4 +1,6 @@ import { OpenCode } from '@opencode/client' +export { applyMiniMaxCatalogPolicy, miniMaxCatalogRank } from './minimaxCatalog' +export type { ManagedCatalogPlugin } from './minimaxCatalog' import { OPENCODE_SERVER_USERNAME } from '../schemas/opencode-servers' export { OPENCODE_SERVER_USERNAME } diff --git a/shared/src/opencode/minimaxCatalog.ts b/shared/src/opencode/minimaxCatalog.ts new file mode 100644 index 000000000..76b8c59a6 --- /dev/null +++ b/shared/src/opencode/minimaxCatalog.ts @@ -0,0 +1,57 @@ +import type { ConfigEntry, ModelInfo, PluginInfo, ProviderInfo } from '@opencode/client' + +export type CatalogModelInfo = ModelInfo & { miniMaxCatalogRank?: number } +export type ManagedCatalogPlugin = { id: string; path: string } + +const routes: Record = { + minimax: { prefix: /^MiniMax-M/i, flash: 'MiniMax-M3.1-Flash', m3: 'MiniMax-M3' }, + 'minimax-cn': { prefix: /^MiniMax-M/i, flash: 'MiniMax-M3.1-Flash', m3: 'MiniMax-M3' }, + openrouter: { prefix: /^minimax\/minimax-m/i, flash: 'minimax/minimax-m3.1-flash:free', m3: 'minimax/minimax-m3' }, + opencode: { prefix: /^minimax-m/i, flash: 'minimax-m3.1-flash-free', m3: 'minimax-m3' }, +} + +export function applyMiniMaxCatalogPolicy( + models: ModelInfo[], + providers: ProviderInfo[], + entries: ConfigEntry[], + plugins: PluginInfo[], + managedPlugins: ManagedCatalogPlugin[] = [], +): CatalogModelInfo[] { + const unknownPlugin = plugins.some((plugin) => + plugin.source.type !== 'builtin' && !managedPlugins.some((managed) => + plugin.id === managed.id && plugin.source.type === 'local' && plugin.source.path === managed.path, + ), + ) + if (unknownPlugin) return models + const documents = entries.filter((entry) => entry.type === 'document') + if (documents.some((entry) => (entry.info.plugins?.length ?? 0) > 0)) return models + const configuredProviders = new Set(documents.flatMap((entry) => Object.keys(entry.info.providers ?? {}))) + return models.map((model) => { + const route = routes[model.providerID] + const provider = providers.find((entry) => entry.id === model.providerID) + if (!route || !provider || configuredProviders.has(model.providerID) || + (provider.canonical !== undefined && provider.canonical !== model.providerID) || + (model.canonical !== undefined && model.canonical !== model.providerID) || + model.id !== model.modelID || !route.prefix.test(model.modelID) || + !model.capabilities.input.includes('text') || + model.capabilities.output.length !== 1 || model.capabilities.output[0] !== 'text') return model + return { + ...model, + miniMaxCatalogRank: model.modelID === route.flash ? 0 : model.modelID === route.m3 ? 1 : -1, + } + }) +} + +export function miniMaxCatalogRank(model: ModelInfo | { miniMaxCatalogRank?: number }): number | undefined { + return 'miniMaxCatalogRank' in model && typeof model.miniMaxCatalogRank === 'number' ? model.miniMaxCatalogRank : undefined +} + +export function ordinaryModelFallback(models: CatalogModelInfo[]): ModelInfo | undefined { + const enabled = models.filter((model) => model.enabled && + (model.miniMaxCatalogRank === undefined || model.status !== 'deprecated')) + const first = enabled[0] + if (first?.miniMaxCatalogRank === undefined) return first + const selectable = enabled.filter((model) => model.miniMaxCatalogRank !== -1) + return selectable.find((model) => model.providerID === first.providerID && model.miniMaxCatalogRank === 0) ?? + selectable.find((model) => model.providerID === first.providerID && model.miniMaxCatalogRank === 1) ?? selectable[0] +} diff --git a/shared/src/opencode/modelPreference.ts b/shared/src/opencode/modelPreference.ts index 5b63848c6..7ef3f7362 100644 --- a/shared/src/opencode/modelPreference.ts +++ b/shared/src/opencode/modelPreference.ts @@ -1,5 +1,6 @@ import type { ModelInfo, ModelRef } from '@opencode/client' import { isRecord } from '../utils/record' +import { miniMaxCatalogRank, ordinaryModelFallback } from './minimaxCatalog' export type ModelPreferenceModel = { providerID: string @@ -159,7 +160,7 @@ export function selectInteractiveModel(options: SelectInteractiveModelOptions): ) if (selected) return selected - const first = options.models[0] + const first = ordinaryModelFallback(options.models) return first ? { providerID: first.providerID, modelID: first.id } : undefined } @@ -185,6 +186,7 @@ export function selectPreferredVariant(options: SelectPreferredVariantOptions): } interface CatalogSortEntry { + miniMaxCatalogRank?: number providerID?: string providerName?: string name?: string @@ -216,6 +218,9 @@ export function compareCatalogModels(a: CatalogSortEntry, b: CatalogSortEntry): const providerName = (a.providerName ?? a.providerID ?? '').localeCompare(b.providerName ?? b.providerID ?? '') if (providerName !== 0) return providerName + const miniMax = (miniMaxCatalogRank(a) ?? 2) - (miniMaxCatalogRank(b) ?? 2) + if (miniMax !== 0) return miniMax + const free = Number(catalogEntryFree(b)) - Number(catalogEntryFree(a)) if (free !== 0) return free @@ -229,8 +234,8 @@ export function isActiveCatalogProvider(provider: { activation?: string }): bool return provider.activation !== 'disabled' } -export function isSelectableCatalogModel(model: { enabled: boolean; status: string }): boolean { - return model.enabled && model.status !== 'deprecated' +export function isSelectableCatalogModel(model: { enabled: boolean; status: string; miniMaxCatalogRank?: number }): boolean { + return model.enabled && model.status !== 'deprecated' && miniMaxCatalogRank(model) !== -1 } interface SelectEffectiveModelRefOptions { @@ -246,8 +251,16 @@ export function selectEffectiveModelRef(options: SelectEffectiveModelRefOptions) if (candidate && findModelInfo(models, refSelection(candidate)) !== undefined) return candidate } - if (defaultModel && findModelInfo(models, refSelection(defaultModel)) !== undefined) return defaultModel + if (defaultModel) { + const info = findModelInfo(models, refSelection(defaultModel)) + if (info) { + if (!defaultModel.variant && miniMaxCatalogRank(info) !== undefined) { + const preferred = ordinaryModelFallback(models.filter((model) => model.providerID === info.providerID)) + if (preferred) return { providerID: preferred.providerID, id: preferred.id } + } else return defaultModel + } + } - const fallback = models.find((model) => model.enabled) + const fallback = ordinaryModelFallback(models) return fallback ? { providerID: fallback.providerID, id: fallback.id } : undefined }