From 88248e4bb0a02772110b2130582f0bebfa7045dc Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:10:31 +0800 Subject: [PATCH] feat(models): prefer current MiniMax text choices --- src/app/services/minimax-model-choices.ts | 49 ++++ src/app/services/model-selection-service.ts | 26 +- .../services/minimax-model-selection.test.ts | 270 ++++++++++++++++++ 3 files changed, 337 insertions(+), 8 deletions(-) create mode 100644 src/app/services/minimax-model-choices.ts create mode 100644 tests/app/services/minimax-model-selection.test.ts diff --git a/src/app/services/minimax-model-choices.ts b/src/app/services/minimax-model-choices.ts new file mode 100644 index 000000000..6c1867dfa --- /dev/null +++ b/src/app/services/minimax-model-choices.ts @@ -0,0 +1,49 @@ +import type { FavoriteModel } from "../types/model.js"; + +// Only recognize standard text IDs on known routes. Custom provider/model aliases and +// non-text models remain owned by the user's OpenCode configuration. +function miniMaxModelID(model: FavoriteModel): string | null { + if (model.providerID === "minimax" || model.providerID === "minimax-cn") { + return model.modelID; + } + if (model.providerID === "opencode") { + return model.modelID; + } + if (model.providerID === "openrouter" && model.modelID.startsWith("minimax/")) { + return model.modelID.slice("minimax/".length); + } + return null; +} + +function miniMaxChoiceRank(model: FavoriteModel): number { + const id = miniMaxModelID(model); + if (id === null) { + return 2; + } + const flashID = + model.providerID === "opencode" + ? "minimax-m3.1-flash-free" + : model.providerID === "openrouter" + ? "minimax-m3.1-flash:free" + : "MiniMax-M3.1-Flash"; + const m3ID = + model.providerID === "opencode" || model.providerID === "openrouter" + ? "minimax-m3" + : "MiniMax-M3"; + return id === flashID ? 0 : id === m3ID ? 1 : 2; +} + +export function isOrdinaryMiniMaxChoice(model: FavoriteModel): boolean { + const id = miniMaxModelID(model); + if (id === null || miniMaxChoiceRank(model) < 2) { + return true; + } + // Match standard text families, not arbitrary custom names or media model IDs. + return !/^minimax-(?:m\d+(?:\.\d+)?(?:-(?:highspeed|lightning|preview|free|80k|flash(?:-free)?))?(?::free)?|text-01)$/i.test( + id, + ); +} + +export function compareMiniMaxChoices(a: FavoriteModel, b: FavoriteModel): number { + return a.providerID === b.providerID ? miniMaxChoiceRank(a) - miniMaxChoiceRank(b) : 0; +} diff --git a/src/app/services/model-selection-service.ts b/src/app/services/model-selection-service.ts index e488d1880..11bb82255 100644 --- a/src/app/services/model-selection-service.ts +++ b/src/app/services/model-selection-service.ts @@ -10,6 +10,7 @@ import type { ProviderInfo, } from "../types/model.js"; import path from "node:path"; +import { compareMiniMaxChoices, isOrdinaryMiniMaxChoice } from "./minimax-model-choices.js"; interface OpenCodeModelState { favorite?: Array<{ providerID?: string; modelID?: string }>; @@ -301,13 +302,24 @@ async function readModelCatalog(options?: { for (const provider of response.data.providers) { const providerModels: FavoriteModel[] = []; - for (const modelID of Object.keys(provider.models)) { + for (const [modelID, model] of Object.entries(provider.models)) { + // Availability still includes legacy IDs for saved selections and explicit requests. validModelKeys.add(getModelKey(provider.id, modelID)); - allModels.push({ providerID: provider.id, modelID }); - providerModels.push({ providerID: provider.id, modelID }); + const choice = { providerID: provider.id, modelID }; + if ( + provider.source !== "custom" && + model.capabilities?.output?.text !== false && + !isOrdinaryMiniMaxChoice(choice) + ) { + continue; + } + allModels.push(choice); + providerModels.push(choice); } - providerModels.sort((a, b) => a.modelID.localeCompare(b.modelID)); + providerModels.sort( + (a, b) => compareMiniMaxChoices(a, b) || a.modelID.localeCompare(b.modelID), + ); modelsByProvider.set(provider.id, providerModels); if (providerModels.length === 0) { continue; @@ -780,13 +792,11 @@ export async function searchModels(query: string): Promise { .sort((a, b) => { const keyA = getModelKey(a.providerID, a.modelID).toLowerCase(); const keyB = getModelKey(b.providerID, b.modelID).toLowerCase(); - return keyA.localeCompare(keyB); + return compareMiniMaxChoices(a, b) || keyA.localeCompare(keyB); }) .slice(0, SEARCH_RESULTS_LIMIT); - logger.debug( - `[ModelManager] Model search: query="${query}", results=${results.length}`, - ); + logger.debug(`[ModelManager] Model search: query="${query}", results=${results.length}`); return results; } diff --git a/tests/app/services/minimax-model-selection.test.ts b/tests/app/services/minimax-model-selection.test.ts new file mode 100644 index 000000000..a55b38677 --- /dev/null +++ b/tests/app/services/minimax-model-selection.test.ts @@ -0,0 +1,270 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const state = vi.hoisted(() => ({ + http: vi.fn(), + version: "v1" as "v1" | "v2", + selected: undefined as { providerID: string; modelID: string; variant?: string } | undefined, +})); + +vi.mock("../../../src/opencode/client.js", async () => { + const { createOpencodeClient } = await import("@opencode-ai/sdk/v2"); + const { createV2OpencodeClient } = await import("../../../src/opencode/v2/client.js"); + const v1 = createOpencodeClient({ baseUrl: "http://opencode.test", fetch: state.http }); + return { + get opencodeServerVersion() { + return state.version; + }, + get opencodeClient() { + return state.version === "v1" + ? v1 + : createV2OpencodeClient({ baseUrl: "http://opencode.test" }); + }, + }; +}); +vi.mock("../../../src/app/stores/settings-store.js", async (importOriginal) => ({ + ...(await importOriginal()), + getCurrentModel: () => state.selected, + setCurrentModel: (model: typeof state.selected) => { + state.selected = model; + }, +})); + +import { config } from "../../../src/config.js"; +import { opencodeClient } from "../../../src/opencode/client.js"; +import { + getModelAvailability, + getModelSelectionLists, + getProviderModels, + getProviders, + getStoredModel, + reconcileStoredModelSelection, + searchModels, + selectModel, +} from "../../../src/app/services/model-selection-service.js"; +import { prepareHandedOverPrompt } from "../../../src/bot/handlers/prompt.js"; +import type { Context } from "grammy"; + +const routes = [ + ["minimax", "MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7"], + ["minimax-cn", "MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.5"], + ["opencode", "minimax-m3.1-flash-free", "minimax-m3", "minimax-m2.5"], + ["openrouter", "minimax/minimax-m3.1-flash:free", "minimax/minimax-m3", "minimax/minimax-m2.7"], +] as const; + +const catalog: { + providers: Array<{ + id: string; + name: string; + models: Record; + }>; +} = { + providers: [ + ...routes.map(([id, flash, m3, legacy]) => ({ + id, + name: id, + models: Object.fromEntries([legacy, m3, flash].map((modelID) => [modelID, { id: modelID }])), + })), + { id: "custom", name: "Custom", models: { "MiniMax-M2.7": { id: "MiniMax-M2.7" } } }, + { id: "test-provider", name: "Test", models: { "test-model": { id: "test-model" } } }, + ], +}; + +function catalogResponse(path: string, value = catalog): Response { + if (state.version === "v1") return Response.json(value); + if (path === "/api/provider") return Response.json({ data: value.providers }); + if (path === "/api/model/default") return Response.json({ data: null }); + return Response.json({ + data: value.providers.flatMap((provider) => + Object.values(provider.models).map((model) => ({ + id: model.id, + enabled: true, + time: { released: 0 }, + modelID: model.id, + providerID: provider.id, + name: model.id, + capabilities: { + input: ["text", "image"], + output: model.capabilities?.output.text === false ? ["audio"] : ["text"], + tools: true, + }, + cost: [], + variants: [], + limit: { context: 100, output: 10 }, + })), + ), + }); +} + +beforeEach(() => { + state.selected = undefined; + vi.stubGlobal("fetch", state.http); + state.http.mockImplementation(async (input, init) => { + const request = input instanceof Request ? input.clone() : new Request(input, init); + expect(new URL(request.url).origin).toBe("http://opencode.test"); + if ( + request.method === "GET" && + ["/config/providers", "/api/provider", "/api/model", "/api/model/default"].includes( + new URL(request.url).pathname, + ) + ) { + return catalogResponse(new URL(request.url).pathname); + } + if ( + request.method === "POST" && + new URL(request.url).pathname === "/api/session/ses-test/prompt" + ) { + return Response.json("inbox-test"); + } + if ( + request.method === "POST" && + new URL(request.url).pathname === "/session/ses-test/prompt_async" + ) { + return new Response(null, { status: 204 }); + } + if (request.method === "GET" && new URL(request.url).pathname === "/api/session/ses-test") + return Response.json({ data: { agent: "build" } }); + if ( + request.method === "POST" && + new URL(request.url).pathname === "/api/session/ses-test/model" + ) + return new Response(null, { status: 204 }); + throw new Error(`Unexpected SDK request: ${request.method} ${request.url}`); + }); +}); + +describe.each(["v1", "v2"] as const)( + "MiniMax choices through the real OpenCode %s SDK", + (version) => { + beforeEach(() => { + state.version = version; + }); + it.each(routes)( + "orders current %s choices while retaining legacy availability", + async (providerID, flash, m3, legacy) => { + expect((await opencodeClient.config.providers()).error).toBeUndefined(); + expect(await getProviderModels(providerID)).toEqual([ + { providerID, modelID: flash }, + { providerID, modelID: m3 }, + ]); + expect( + (await searchModels(`${providerID}/`)).filter((model) => model.providerID === providerID), + ).toEqual([ + { providerID, modelID: flash }, + { providerID, modelID: m3 }, + ]); + expect( + (await getProviders()).find((provider) => provider.id === providerID)?.modelCount, + ).toBe(2); + selectModel({ providerID, modelID: legacy, variant: "high" }); + expect(await getModelAvailability(providerID, legacy)).toBe("offered"); + expect(await reconcileStoredModelSelection()).toMatchObject({ + storedModelReplaced: false, + selectedModelListed: true, + }); + expect(getStoredModel()).toEqual({ providerID, modelID: legacy, variant: "high" }); + }, + ); + + it("preserves configured defaults, custom aliases, non-text entries and other providers", async () => { + const response = structuredClone(catalog); + response.providers = response.providers.filter((provider) => provider.id !== "minimax"); + response.providers.push({ + id: "minimax", + name: "MiniMax", + models: { + "MiniMax-M3": { id: "MiniMax-M3" }, + "my-text-model": { id: "my-text-model" }, + "speech-02-hd": { id: "speech-02-hd" }, + "MiniMax-M2.7": { id: "MiniMax-M2.7", capabilities: { output: { text: false } } }, + }, + }); + state.http.mockImplementation(async (input) => + catalogResponse( + new URL(input instanceof Request ? input.url : String(input)).pathname, + response, + ), + ); + expect(await getProviderModels("custom")).toEqual([ + { providerID: "custom", modelID: "MiniMax-M2.7" }, + ]); + expect(await getProviderModels("minimax")).toHaveLength(4); + expect(getStoredModel()).toMatchObject({ + providerID: config.opencode.model.provider, + modelID: config.opencode.model.modelId, + }); + expect((await getModelSelectionLists()).favorites).toContainEqual({ + providerID: config.opencode.model.provider, + modelID: config.opencode.model.modelId, + }); + }); + + it("retains saved legacy favorites and recent entries", async () => { + const stateHome = path.join(process.cwd(), ".tmp", "minimax-state", version); + vi.stubEnv("XDG_STATE_HOME", stateHome); + await mkdir(path.join(stateHome, "opencode"), { recursive: true }); + const legacy = { providerID: "minimax", modelID: "MiniMax-M2.7" }; + const custom = { providerID: "custom", modelID: "MiniMax-M2.7" }; + await writeFile( + path.join(stateHome, "opencode", "model.json"), + JSON.stringify({ favorite: [legacy], recent: [custom] }), + ); + const lists = await getModelSelectionLists(); + expect(lists.favorites).toContainEqual(legacy); + expect(lists.recent).toContainEqual(custom); + }); + + it.each(routes)( + "serializes picked and explicit %s IDs unchanged with files and variants", + async (providerID, flash, m3, legacy) => { + const choices = await getProviderModels(providerID); + expect(choices[0]?.modelID).toBe(flash); + for (const modelID of [flash, m3, legacy, "my-custom-model"]) { + selectModel({ providerID, modelID, variant: "high" }); + const selection = getStoredModel(); + const file = { + type: "file" as const, + mime: "image/png", + url: "data:image/png;base64,aGVsbG8=", + }; + const options = await prepareHandedOverPrompt( + {} as Context["api"], + { text: "Review this file", photos: [], fileParts: [file] }, + { id: "ses-test", directory: "/project" }, + { ...selection, agent: "build" }, + {}, + ); + expect(options).not.toBeNull(); + const response = await opencodeClient.session.promptAsync(options!); + expect(response.error).toBeUndefined(); + const [input, init] = state.http.mock.calls.at(-1)!; + const request = input instanceof Request ? input : new Request(input, init); + expect(await request.json()).toEqual( + version === "v1" + ? { + model: { providerID, modelID }, + variant: "high", + agent: "build", + parts: [{ type: "text", text: "Review this file" }, file], + } + : { + text: "Review this file", + files: [{ uri: file.url }], + delivery: "queue", + }, + ); + if (version === "v1") { + expect(new URL(request.url).searchParams.get("directory")).toBe("/project"); + } else { + const [modelURL, modelInit] = state.http.mock.calls.at(-2)!; + expect(new URL(String(modelURL)).pathname).toBe("/api/session/ses-test/model"); + expect(JSON.parse(String(modelInit?.body))).toEqual({ + model: { providerID, id: modelID, variant: "high" }, + }); + } + } + }, + ); + }, +);