mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Clarify MiMo API and Token Plan access and upgrade models / 明确 MiMo 接入方式并升级模型 (#10655)
Problem: region-first filtering hid MiMo API versus Token Plan access and new connections retained the V2.5 model catalog. Root cause: product selection depended on region, MiMo lacked a one-time model migration, and floating CI Node versions made frontend artifact producers and consumers disagree. Fix: select access type before cluster and protocol, clarify independent credentials and usage, default new connections to V2.6, and atomically append models to eligible official connections without changing existing defaults or custom data. Preserve previous-writer TOML compatibility, share repeated frontend defaults within existing budgets, and pin artifact workflows to one exact Node runtime. Verification: root and Desktop suites, previous-reader migration round trips, frontend and browser regressions, unchanged production bundle budgets, cross-platform CI, race checks, Windows packaging, memory screening, CodeQL, and release-control contracts passed. All review threads were clear before merge.
This commit is contained in:
1 parent
4fad310c93
commit
2fb74e47d4
36 files changed
+1050
-315
No files matched your search
@@ -34,7 +34,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
- id: paths
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||
- name: Install memory dependencies
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||
- name: Install memory dependencies
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
- uses: actions/setup-node@v7
|
||||
if: needs.changes.outputs.memory == 'true'
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
- uses: actions/download-artifact@v8
|
||||
if: needs.changes.outputs.memory == 'true'
|
||||
with:
|
||||
|
||||
+12
-12
@@ -48,7 +48,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
- id: filter
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
@@ -574,7 +574,7 @@ jobs:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||
- name: Install frontend dependencies
|
||||
@@ -623,7 +623,7 @@ jobs:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||
- name: Install frontend dependencies
|
||||
@@ -760,7 +760,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||
|
||||
@@ -875,7 +875,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||
|
||||
@@ -952,7 +952,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||
|
||||
@@ -1012,7 +1012,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||
|
||||
@@ -1183,7 +1183,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||
|
||||
@@ -1320,7 +1320,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
|
||||
# Same Defender hazard as the Electron leg: exclusions keep Go test
|
||||
# binaries from crashing mid-syscall and freshly written files readable.
|
||||
@@ -1432,7 +1432,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||
|
||||
@@ -1624,7 +1624,7 @@ jobs:
|
||||
cache: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
- name: Validate release workflows and executable contracts
|
||||
run: |
|
||||
go run github.com/rhysd/actionlint/cmd/[email protected] \
|
||||
@@ -1746,7 +1746,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: test
|
||||
run: npm test
|
||||
|
||||
@@ -397,7 +397,7 @@ jobs:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||
|
||||
@@ -585,7 +585,7 @@ jobs:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||
- name: Install NSIS
|
||||
@@ -724,7 +724,7 @@ jobs:
|
||||
cache-dependency-path: desktop/go.sum
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
- name: Reuse completed signed architectures from an earlier attempt
|
||||
id: reuse
|
||||
shell: bash
|
||||
@@ -940,7 +940,7 @@ jobs:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
- uses: pnpm/[email protected]
|
||||
with:
|
||||
version: 10
|
||||
@@ -1031,7 +1031,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: .node-version
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
24.20.0
|
||||
+6
-4
@@ -1908,7 +1908,7 @@ func TestAddProviderPresetAccessSavesEditableProviderAndKey(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("mimo-api provider not saved")
|
||||
}
|
||||
if p.Kind != "openai" || p.BaseURL != "https://api.xiaomimimo.com/v1" || p.Default != "mimo-v2.5-pro" {
|
||||
if p.Kind != "openai" || p.BaseURL != "https://api.xiaomimimo.com/v1" || p.Default != "mimo-v2.6-pro" {
|
||||
t.Fatalf("mimo-api provider after preset add = %+v", p)
|
||||
}
|
||||
if p.PresetID != "mimo-api" || p.PresetVersion != config.ProviderPresetVersion {
|
||||
@@ -2036,7 +2036,7 @@ func TestResetProviderPresetAccessOverwritesSameNameProvider(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("mimo-api provider missing after reset")
|
||||
}
|
||||
if got.BaseURL != "https://api.xiaomimimo.com/v1" || got.DefaultModel() != "mimo-v2.5-pro" || got.PresetID != "mimo-api" || got.PresetVersion != config.ProviderPresetVersion {
|
||||
if got.BaseURL != "https://api.xiaomimimo.com/v1" || got.DefaultModel() != "mimo-v2.6-pro" || got.PresetID != "mimo-api" || got.PresetVersion != config.ProviderPresetVersion {
|
||||
t.Fatalf("mimo-api provider after reset = %+v, want preset template", got)
|
||||
}
|
||||
if len(got.Headers) != 0 {
|
||||
@@ -3185,6 +3185,8 @@ func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {
|
||||
models := NewApp().Models()
|
||||
refs := modelRefsFromView(models)
|
||||
for _, want := range []string{
|
||||
"mimo-api/mimo-v2.6-pro",
|
||||
"mimo-api/mimo-v2.6-flash",
|
||||
"mimo-api/mimo-v2.5-pro",
|
||||
"mimo-api/mimo-v2.5",
|
||||
} {
|
||||
@@ -3192,8 +3194,8 @@ func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {
|
||||
t.Fatalf("Models() refs = %+v, missing %s", models, want)
|
||||
}
|
||||
}
|
||||
if len(models) != 2 {
|
||||
t.Fatalf("Models() len = %d, want 2: %+v", len(models), models)
|
||||
if len(models) != 4 {
|
||||
t.Fatalf("Models() len = %d, want 4: %+v", len(models), models)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ window.matchMedia = (() => ({matches: true, addEventListener(){}, removeEventLis
|
||||
const rootEl = document.getElementById("root")!;
|
||||
const root = createRoot(rootEl);
|
||||
const choices: CatalogChoice[] = Object.entries(data.catalogs).map(([id, catalog]) => ({
|
||||
id, catalog, label: id, keyEnv: `${catalog.region}_KEY`, keySet: false, models: [`${id}-model`], status: "available", statusLabel: "", actionLabel: "Add", canAdd: true,
|
||||
id, catalog, label: id, keyEnv: catalog.brandId === "mimo" ? (catalog.product === "token" ? "MIMO_TOKEN_PLAN_API_KEY" : "MIMO_API_KEY") : `${catalog.region}_KEY`, keySet: false, models: [`${id}-model`], status: "available", statusLabel: "", actionLabel: "Add", canAdd: true,
|
||||
}));
|
||||
let installed = "";
|
||||
let submittedFormat: string | undefined;
|
||||
@@ -24,7 +24,7 @@ async function change(dimension: string, value: string) {
|
||||
await selectSettingsValue(select(dimension), value);
|
||||
}
|
||||
async function brand(name: string) {
|
||||
await act(async () => {Array.from(rootEl.querySelectorAll<HTMLButtonElement>(".provider-catalog__brand")).find(el=>el.textContent===name)!.click();});
|
||||
await act(async () => {Array.from(rootEl.querySelectorAll<HTMLButtonElement>(".provider-catalog__brand")).find(el=>el.textContent?.endsWith(name))!.click();});
|
||||
}
|
||||
assert.equal(rootEl.querySelectorAll(".provider-catalog__brand").length, new Set(choices.map(c=>c.catalog.brandId)).size);
|
||||
await brand("智谱 / Z.AI");
|
||||
@@ -45,6 +45,25 @@ assert.equal(submittedFormat,"responses","custom protocol reaches connection cre
|
||||
await change("format","openai");
|
||||
await change("region","global");
|
||||
assert.equal(rootEl.querySelector<HTMLInputElement>('input[id$="-url"]')!.value,"https://api.z.ai/api/paas/v4");
|
||||
await brand("MiMo");
|
||||
assert.deepEqual(await settingsOptionValues(select("product")), ["api", "token"], "MiMo exposes pay-as-you-go and Token Plan explicitly");
|
||||
assert.equal(rootEl.querySelector('button.settings-select[id$="-region"]'), null, "MiMo pay-as-you-go API does not pretend to be a regional account platform");
|
||||
await change("product", "token");
|
||||
assert.deepEqual(await settingsOptionValues(select("region")), ["ams", "cn", "sgp"], "Token Plan exposes its three service clusters");
|
||||
await change("region", "cn");
|
||||
assert.equal(rootEl.querySelector<HTMLInputElement>('input[id$="-url"]')!.value, "https://token-plan-cn.xiaomimimo.com/v1");
|
||||
assert.match(rootEl.querySelector<HTMLInputElement>('input[id$="-key"]')!.placeholder, /MIMO_TOKEN_PLAN_API_KEY/);
|
||||
assert.match(rootEl.textContent!, /AI coding tools/);
|
||||
await change("format", "anthropic");
|
||||
await change("region", "sgp");
|
||||
assert.equal(select("format").value, "anthropic", "cluster changes retain an explicitly selected protocol");
|
||||
assert.equal(rootEl.querySelector<HTMLInputElement>('input[id$="-url"]')!.value, "https://token-plan-sgp.xiaomimimo.com/anthropic");
|
||||
await change("product", "api");
|
||||
assert.equal(select("format").value, "anthropic", "product changes retain a supported protocol");
|
||||
assert.equal(rootEl.querySelector<HTMLInputElement>('input[id$="-url"]')!.value, "https://api.xiaomimimo.com/anthropic");
|
||||
assert.match(rootEl.querySelector<HTMLInputElement>('input[id$="-key"]')!.placeholder, /MIMO_API_KEY/);
|
||||
assert.equal(select("region"), null);
|
||||
assert.doesNotMatch(rootEl.textContent!, /AI coding tools/);
|
||||
await brand("OpenAI");
|
||||
await change("format","responses");
|
||||
await act(async () => {render(true);});
|
||||
|
||||
@@ -32,6 +32,9 @@ const productKeys: Record<string, DictKey> = {
|
||||
api: "settings.catalog.productAPI", coding: "settings.catalog.productCoding", token: "settings.catalog.productToken",
|
||||
go: "settings.catalog.productGo", zen: "settings.catalog.productZen", local: "settings.catalog.productLocal",
|
||||
};
|
||||
const clusterKeys: Record<string, DictKey> = {
|
||||
cn: "settings.catalog.clusterChina", sgp: "settings.catalog.clusterSingapore", ams: "settings.catalog.clusterEurope",
|
||||
};
|
||||
|
||||
export function ProviderCatalogPicker({ choices, busy, onConnect, onView, onReset }: {
|
||||
choices: CatalogChoice[];
|
||||
@@ -53,9 +56,9 @@ export function ProviderCatalogPicker({ choices, busy, onConnect, onView, onRese
|
||||
c.catalog.brandId === "token-rhythm" ? t("settings.addProvider.preset.tokenRhythmLabel") : c.catalog.brandLabel])).entries()], [choices, t]);
|
||||
if (!selected) return null;
|
||||
const genericFormat = (value: string) => value === "dashscope-responses" ? "responses" : value;
|
||||
// The official DeepSeek connection starts with Chat Completions regardless
|
||||
// Official DeepSeek and MiMo connections start with Chat Completions regardless
|
||||
// of which protocol-specific preset supplied the brand's first catalog row.
|
||||
const defaultFormat = selected.catalog.brandId === "deepseek" && selected.catalog.product === "api"
|
||||
const defaultFormat = (selected.catalog.brandId === "deepseek" && selected.catalog.product === "api") || selected.catalog.brandId === "mimo"
|
||||
? "openai" : genericFormat(selected.catalog.format);
|
||||
const format = formatDrafts[selected.id] ?? defaultFormat;
|
||||
const protocols = protocolsForCatalog(selected.catalog);
|
||||
@@ -65,33 +68,38 @@ export function ProviderCatalogPicker({ choices, busy, onConnect, onView, onRese
|
||||
try { const u = new URL(baseURL); validURL = (u.protocol === "https:" || u.protocol === "http:") && Boolean(u.hostname) && !u.username && !u.password; } catch { /* invalid draft */ }
|
||||
const key = keyDraft.id === selected.id && keyDraft.keyEnv === selected.keyEnv ? keyDraft.value : "";
|
||||
const siblings = choices.filter(c => c.catalog.brandId === selected.catalog.brandId);
|
||||
const regionChoices = siblings.filter(c => c.catalog.region === selected.catalog.region);
|
||||
const formatChoices = regionChoices.filter(c => c.catalog.product === selected.catalog.product);
|
||||
const productChoices = siblings.filter(c => c.catalog.product === selected.catalog.product);
|
||||
const formatChoices = productChoices.filter(c => c.catalog.region === selected.catalog.region);
|
||||
const pick = (choice: CatalogChoice | undefined) => {
|
||||
if (!choice) return;
|
||||
setSelectedID(choice.id);
|
||||
setKeyDraft({id: "", keyEnv: "", value: ""});
|
||||
setConfirmReset(false);
|
||||
};
|
||||
const chooseDimension = (dimension: "region" | "product" | "format", value: string) => {
|
||||
const candidates = (dimension === "region" ? siblings : dimension === "product" ? regionChoices : formatChoices)
|
||||
const chooseDimension = (dimension: "region" | "product", value: string) => {
|
||||
const candidates = (dimension === "product" ? siblings : productChoices)
|
||||
.filter(c => c.catalog[dimension] === value);
|
||||
pick(candidates.find(c => c.catalog.product === selected.catalog.product && c.catalog.format === selected.catalog.format)
|
||||
?? candidates.find(c => c.catalog.format === selected.catalog.format) ?? candidates[0]);
|
||||
const choice = candidates.find(c => c.catalog.region === selected.catalog.region && genericFormat(c.catalog.format) === format)
|
||||
?? candidates.find(c => genericFormat(c.catalog.format) === format)
|
||||
?? candidates.find(c => c.catalog.region === selected.catalog.region) ?? candidates[0];
|
||||
pick(choice);
|
||||
if (choice && genericFormat(choice.catalog.format) === format) {
|
||||
setFormatDrafts(prev => ({...prev, [choice.id]: format}));
|
||||
}
|
||||
};
|
||||
const field = (dimension: "region" | "product" | "format", label: DictKey, candidates: CatalogChoice[]) => {
|
||||
const field = (dimension: "region" | "product", label: DictKey, candidates: CatalogChoice[]) => {
|
||||
const values = [...new Set(candidates.map(c => c.catalog[dimension]))];
|
||||
const labels = dimension === "region" ? (label === "settings.catalog.cluster" ? clusterKeys : regionKeys) : productKeys;
|
||||
return <div className="provider-catalog__field">
|
||||
<label className="set-label" htmlFor={`${uid}-${dimension}`}>{t(label)}</label>
|
||||
<SettingsSelect id={`${uid}-${dimension}`} className="mem-select" disabled={busy || values.length < 2}
|
||||
value={selected.catalog[dimension]} onValueChange={value => chooseDimension(dimension, value)}>
|
||||
{values.map(value => <option key={value} value={value}>{dimension === "format"
|
||||
? value === "bundle" ? t("settings.catalog.formatBundle") : apiFormatLabel(value)
|
||||
: (dimension === "region" ? regionKeys[value] : productKeys[value])
|
||||
? t((dimension === "region" ? regionKeys[value] : productKeys[value])) : value}</option>)}
|
||||
{values.map(value => <option key={value} value={value}>{labels[value] ? t(labels[value]) : value}</option>)}
|
||||
</SettingsSelect>
|
||||
</div>;
|
||||
};
|
||||
const hasProducts = new Set(siblings.map(c => c.catalog.product)).size > 1;
|
||||
const hasRegions = new Set(productChoices.map(c => c.catalog.region)).size > 1;
|
||||
return <div className="provider-catalog">
|
||||
<aside className="provider-catalog__sidebar">
|
||||
<input className="mem-input" aria-label={t("settings.catalog.search")} placeholder={t("settings.catalog.search")}
|
||||
@@ -108,10 +116,11 @@ export function ProviderCatalogPicker({ choices, busy, onConnect, onView, onRese
|
||||
</aside>
|
||||
<section className="provider-catalog__detail" aria-label={selected.catalog.brandLabel}>
|
||||
<strong className="provider-catalog__title">{selected.catalog.brandLabel}</strong>
|
||||
{(new Set(siblings.map(c => c.catalog.region)).size > 1 || new Set(regionChoices.map(c => c.catalog.product)).size > 1) && <div className="provider-catalog__options">
|
||||
{new Set(siblings.map(c => c.catalog.region)).size > 1 && field("region", "settings.catalog.region", siblings)}
|
||||
{new Set(regionChoices.map(c => c.catalog.product)).size > 1 && field("product", "settings.catalog.product", regionChoices)}
|
||||
{(hasProducts || hasRegions) && <div className="provider-catalog__options">
|
||||
{hasProducts && field("product", "settings.catalog.product", siblings)}
|
||||
{hasRegions && field("region", selected.catalog.brandId === "mimo" && selected.catalog.product === "token" ? "settings.catalog.cluster" : "settings.catalog.region", productChoices)}
|
||||
</div>}
|
||||
{selected.catalog.brandId === "mimo" && selected.catalog.product === "token" && <div className="mem-hint">{t("settings.catalog.mimoTokenPlanHint")}</div>}
|
||||
<div className="provider-catalog__field">
|
||||
<label className="set-label" htmlFor={`${uid}-format`}>{t("settings.providerProtocol")}</label>
|
||||
<SettingsSelect id={`${uid}-format`} className="mem-select" disabled={busy} value={format}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { makeMockSessionExportBindings, type SessionExportBindings } from "./ses
|
||||
import type { AttachmentBindings } from "./attachmentBindings";
|
||||
import { makeMockSessionLifecycleBindings, type SessionLifecycleBindings } from "./sessionLifecycleBindings";
|
||||
import { makeMockModelSettingsBindings, type ModelSettingsBindings } from "./modelSettingsBridge";
|
||||
import { mockProviderTemplate, mockPreset, mockBundlePreset, mockKimiAPIModels, mockLongCatModels, mockTokenRhythmModels, mockTokenRhythmModelOverrides, mockMiMoV25Models, mockMiniMaxModels, mockGLMAPIModels, mockGLMCodingModels, mockGLMAnthropicModels, mockQwenAPIModels, mockQwenPlanModels, mockQwenPlanVisionModels, mockStepFunModels, mockOpenCodeGoModels, mockNovitaModels, mockGMIModels, mockVercelModels, mockOllamaCloudModels } from "./mockProviderTemplates";
|
||||
import { mockProviderTemplate, mockPreset, mockBundlePreset, mockKimiAPIModels, mockLongCatModels, mockTokenRhythmModels, mockTokenRhythmModelOverrides, mockMiMoDefaults, mockMiniMaxModels, mockGLMAPIModels, mockGLMCodingModels, mockGLMAnthropicModels, mockQwenAPIModels, mockQwenPlanModels, mockQwenPlanVisionModels, mockStepFunModels, mockOpenCodeGoModels, mockNovitaModels, mockGMIModels, mockVercelModels, mockOllamaCloudModels } from "./mockProviderTemplates";
|
||||
// The Electron host and the browser mock share this React-to-Go contract.
|
||||
import type {
|
||||
CancelReceipt,
|
||||
@@ -1300,14 +1300,14 @@ const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
|
||||
mockPreset("kimi-cn", "Kimi CN API", "Moonshot Kimi China OpenAI-compatible API.", "KIMI_API_KEY", mockProviderTemplate({ name: "kimi-cn", kind: "openai", baseUrl: "https://api.moonshot.cn/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "KIMI_API_KEY", balanceUrl: "https://api.moonshot.cn/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none", modelOverrides: [{ model: "kimi-k3", reasoningProtocol: "openai", supportedEfforts: ["low", "high", "max"], defaultEffort: "max", contextWindow: 1048576 }] })),
|
||||
mockPreset("kimi-global", "Kimi Global API", "Moonshot Kimi international OpenAI-compatible API.", "MOONSHOT_API_KEY", mockProviderTemplate({ name: "kimi-global", kind: "openai", baseUrl: "https://api.moonshot.ai/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "MOONSHOT_API_KEY", balanceUrl: "https://api.moonshot.ai/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none", modelOverrides: [{ model: "kimi-k3", reasoningProtocol: "openai", supportedEfforts: ["low", "high", "max"], defaultEffort: "max", contextWindow: 1048576 }] })),
|
||||
mockPreset("kimi-coding-plan", "Kimi Coding Plan", "Kimi Coding Plan via its dedicated Anthropic-compatible endpoint.", "KIMI_CODING_API_KEY", mockProviderTemplate({ name: "kimi-coding-plan", kind: "anthropic", baseUrl: "https://api.kimi.com/coding/", models: ["kimi-for-coding"], visionModels: ["kimi-for-coding"], default: "kimi-for-coding", apiKeyEnv: "KIMI_CODING_API_KEY", headers: { "User-Agent": "claude-code/0.1.0" }, thinking: "adaptive", contextWindow: 262144 })),
|
||||
mockPreset("mimo-api", "MiMo API", "Xiaomi MiMo direct API with text and vision-capable models.", "MIMO_API_KEY", mockProviderTemplate({ name: "mimo-api", kind: "openai", baseUrl: "https://api.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-anthropic", "MiMo Anthropic", "Xiaomi MiMo direct Anthropic-compatible endpoint.", "MIMO_API_KEY", mockProviderTemplate({ name: "mimo-anthropic", kind: "anthropic", baseUrl: "https://api.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-cn", "MiMo Token Plan CN", "Xiaomi MiMo token-plan China endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-cn", kind: "openai", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-cn-anthropic", "MiMo Token Plan CN Anthropic", "Xiaomi MiMo token-plan China Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-sgp", "MiMo Token Plan SGP", "Xiaomi MiMo token-plan Singapore endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-sgp", kind: "openai", baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-sgp-anthropic", "MiMo Token Plan SGP Anthropic", "Xiaomi MiMo token-plan Singapore Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-sgp-anthropic", kind: "anthropic", baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-ams", "MiMo Token Plan AMS", "Xiaomi MiMo token-plan Amsterdam endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-ams", kind: "openai", baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-ams-anthropic", "MiMo Token Plan AMS Anthropic", "Xiaomi MiMo token-plan Amsterdam Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-ams-anthropic", kind: "anthropic", baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-api", "MiMo API", "Xiaomi MiMo direct API with text and vision-capable models.", "MIMO_API_KEY", mockProviderTemplate({ ...mockMiMoDefaults, name: "mimo-api", kind: "openai", baseUrl: "https://api.xiaomimimo.com/v1", apiKeyEnv: "MIMO_API_KEY" })),
|
||||
mockPreset("mimo-anthropic", "MiMo Anthropic", "Xiaomi MiMo direct Anthropic-compatible endpoint.", "MIMO_API_KEY", mockProviderTemplate({ ...mockMiMoDefaults, name: "mimo-anthropic", kind: "anthropic", baseUrl: "https://api.xiaomimimo.com/anthropic", apiKeyEnv: "MIMO_API_KEY", thinking: "adaptive" })),
|
||||
mockPreset("mimo-token-plan-cn", "MiMo Token Plan CN", "Xiaomi MiMo token-plan China endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ ...mockMiMoDefaults, name: "mimo-token-plan-cn", kind: "openai", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY" })),
|
||||
mockPreset("mimo-token-plan-cn-anthropic", "MiMo Token Plan CN Anthropic", "Xiaomi MiMo token-plan China Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ ...mockMiMoDefaults, name: "mimo-token-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive" })),
|
||||
mockPreset("mimo-token-plan-sgp", "MiMo Token Plan SGP", "Xiaomi MiMo token-plan Singapore endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ ...mockMiMoDefaults, name: "mimo-token-plan-sgp", kind: "openai", baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY" })),
|
||||
mockPreset("mimo-token-plan-sgp-anthropic", "MiMo Token Plan SGP Anthropic", "Xiaomi MiMo token-plan Singapore Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ ...mockMiMoDefaults, name: "mimo-token-plan-sgp-anthropic", kind: "anthropic", baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive" })),
|
||||
mockPreset("mimo-token-plan-ams", "MiMo Token Plan AMS", "Xiaomi MiMo token-plan Amsterdam endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ ...mockMiMoDefaults, name: "mimo-token-plan-ams", kind: "openai", baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY" })),
|
||||
mockPreset("mimo-token-plan-ams-anthropic", "MiMo Token Plan AMS Anthropic", "Xiaomi MiMo token-plan Amsterdam Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ ...mockMiMoDefaults, name: "mimo-token-plan-ams-anthropic", kind: "anthropic", baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive" })),
|
||||
mockPreset("minimax-cn-api", "MiniMax CN API", "MiniMax China OpenAI-compatible M-series API endpoint.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-cn-api", kind: "openai", baseUrl: "https://api.minimaxi.com/v1", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", extraBody: { reasoning_split: true }, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
|
||||
mockPreset("minimax-global-api", "MiniMax Global API", "MiniMax international OpenAI-compatible M-series API endpoint.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-global-api", kind: "openai", baseUrl: "https://api.minimax.io/v1", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", extraBody: { reasoning_split: true }, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
|
||||
mockPreset("minimax-cn-anthropic", "MiniMax CN Anthropic", "MiniMax China Anthropic-compatible M-series endpoint.", "MINIMAX_PLAN_API_KEY", mockProviderTemplate({ name: "minimax-cn-anthropic", kind: "anthropic", baseUrl: "https://api.minimaxi.com/anthropic", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_PLAN_API_KEY", authHeader: true, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
|
||||
|
||||
@@ -47,7 +47,9 @@ export const mockTokenRhythmModelOverrides = mockTokenRhythmModels.flatMap((mode
|
||||
if (model.startsWith("deepseek-")) return [{ model, reasoningProtocol: "deepseek", supportedEfforts: model === "deepseek-v4-pro" ? ["disabled", "high", "max"] : ["disabled", "low", "high", "max"], defaultEffort: "high" }];
|
||||
return [];
|
||||
});
|
||||
export const mockMiMoV25Models = ["mimo-v2.5-pro", "mimo-v2.5"];
|
||||
const mockMiMoModels = ["mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.5-pro", "mimo-v2.5"];
|
||||
const mockMiMoVisionModels = ["mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.5"];
|
||||
export const mockMiMoDefaults = { models: mockMiMoModels, visionModels: mockMiMoVisionModels, default: "mimo-v2.6-pro", contextWindow: 1048576 };
|
||||
export const mockMiniMaxModels = ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"];
|
||||
export const mockGLMAPIModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7", "glm-4.7-flash", "glm-4.7-flashx", "glm-4.6", "glm-4.5", "glm-4.5-air", "glm-4.5-flash"];
|
||||
export const mockGLMCodingModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-4.7"];
|
||||
@@ -61,4 +63,3 @@ export const mockNovitaModels = ["zai-org/glm-5.2", "moonshotai/kimi-k2.7-code",
|
||||
export const mockGMIModels = ["zai-org/GLM-5.2-FP8", "deepseek-ai/DeepSeek-V4-Pro", "deepseek-ai/DeepSeek-V4-Flash", "moonshotai/Kimi-K2.7-Code", "anthropic/claude-sonnet-4.6", "openai/gpt-5.5"];
|
||||
export const mockVercelModels = ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code", "zai/glm-5.2", "deepseek/deepseek-v4-pro"];
|
||||
export const mockOllamaCloudModels = ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro", "deepseek-v4-flash", "minimax-m3", "nemotron-3-nano:30b", "qwen3-coder-next"];
|
||||
|
||||
@@ -2810,7 +2810,7 @@ export const en = {
|
||||
"settings.catalog.anthropicHint": "Uses an Anthropic API key and API billing. Native server tools are not enabled by this preset.",
|
||||
"settings.catalog.localHint": "Example model names must match your local installation. Configure or fetch the installed models after adding.",
|
||||
"settings.catalog.models": "Models",
|
||||
"settings.catalog.hint": "Choose a provider, then select your account platform, plan and API format.",
|
||||
"settings.catalog.hint": "Choose a provider, then select your access plan, region and API format.",
|
||||
"settings.connections.restartRequired": "The desktop backend has not loaded the rename API. Stop it with Ctrl+C in the launch terminal, then start the desktop app again and retry.",
|
||||
"settings.connections.rename": "Rename connection",
|
||||
"settings.connections.renameFailed": "Could not save. Please retry.",
|
||||
@@ -2865,6 +2865,10 @@ export const en = {
|
||||
"settings.catalog.search": "Search providers",
|
||||
"settings.catalog.providers": "Providers",
|
||||
"settings.catalog.region": "Account platform",
|
||||
"settings.catalog.cluster": "Service cluster",
|
||||
"settings.catalog.clusterChina": "China",
|
||||
"settings.catalog.clusterSingapore": "Singapore",
|
||||
"settings.catalog.clusterEurope": "Europe",
|
||||
"settings.catalog.product": "Access plan",
|
||||
"settings.catalog.regionChina": "China",
|
||||
"settings.catalog.regionGlobal": "International",
|
||||
@@ -2877,6 +2881,7 @@ export const en = {
|
||||
"settings.catalog.productGo": "OpenCode Go",
|
||||
"settings.catalog.productZen": "OpenCode Zen",
|
||||
"settings.catalog.productLocal": "Local models",
|
||||
"settings.catalog.mimoTokenPlanHint": "Token Plan keys are separate from pay-as-you-go API keys and may only be used with supported AI coding tools, not automation scripts or application backends.",
|
||||
"settings.catalog.formatBundle": "Recommended model routes",
|
||||
"settings.catalog.verifyFormat": "Confirm that the service URL supports this API format.",
|
||||
"settings.catalog.endpointHint": "Editable for this connection only. Preset defaults stay unchanged.",
|
||||
|
||||
@@ -2070,7 +2070,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.catalog.anthropicHint": "使用 Anthropic API 金鑰與 API 計費,此預設不啟用伺服器原生工具。",
|
||||
"settings.catalog.localHint": "範例模型名稱需要與本機安裝一致,新增後請設定或取得已安裝模型。",
|
||||
"settings.catalog.models": "模型清單",
|
||||
"settings.catalog.hint": "選擇服務商,再選擇帳號平台、方案和 API 格式。",
|
||||
"settings.catalog.hint": "選擇服務商,再選擇接入方式、地區和 API 格式。",
|
||||
"settings.connections.restartRequired": "目前桌面後端尚未載入重新命名介面。請在啟動終端按 Ctrl+C 停止桌面後端,再重新啟動後重試。",
|
||||
"settings.connections.rename": "重新命名連線",
|
||||
"settings.connections.renameFailed": "儲存失敗,請重試。",
|
||||
@@ -2125,6 +2125,10 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.catalog.search": "搜尋服務商",
|
||||
"settings.catalog.providers": "服務商",
|
||||
"settings.catalog.region": "帳號平台",
|
||||
"settings.catalog.cluster": "服務叢集",
|
||||
"settings.catalog.clusterChina": "中國",
|
||||
"settings.catalog.clusterSingapore": "新加坡",
|
||||
"settings.catalog.clusterEurope": "歐洲",
|
||||
"settings.catalog.product": "接入方式",
|
||||
"settings.catalog.regionChina": "中國站",
|
||||
"settings.catalog.regionGlobal": "國際站",
|
||||
@@ -2137,6 +2141,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.catalog.productGo": "OpenCode Go",
|
||||
"settings.catalog.productZen": "OpenCode Zen",
|
||||
"settings.catalog.productLocal": "本機模型",
|
||||
"settings.catalog.mimoTokenPlanHint": "Token Plan 金鑰與按量 API 金鑰相互獨立,僅限受支援的 AI 程式設計工具使用,不適用於自動化腳本或應用程式後端。",
|
||||
"settings.catalog.formatBundle": "推薦的模型路由組合",
|
||||
"settings.catalog.verifyFormat": "請確認服務位址支援此 API 格式。",
|
||||
"settings.catalog.endpointHint": "可修改,僅用於目前連線,不影響預設值。",
|
||||
|
||||
@@ -2813,7 +2813,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.catalog.anthropicHint": "使用 Anthropic API 密钥与 API 计费,此预设不启用服务端原生工具。",
|
||||
"settings.catalog.localHint": "示例模型名需要与本地安装一致,添加后请配置或获取已安装模型。",
|
||||
"settings.catalog.models": "模型列表",
|
||||
"settings.catalog.hint": "选择服务商,再选择账号平台、套餐和 API 格式。",
|
||||
"settings.catalog.hint": "选择服务商,再选择接入方式、地区和 API 格式。",
|
||||
"settings.connections.restartRequired": "当前桌面后端尚未加载重命名接口。请在启动终端按 Ctrl+C 停止桌面后端,再重新启动后重试。",
|
||||
"settings.connections.rename": "重命名连接",
|
||||
"settings.connections.renameFailed": "保存失败,请重试。",
|
||||
@@ -2868,6 +2868,10 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.catalog.search": "搜索服务商",
|
||||
"settings.catalog.providers": "服务商",
|
||||
"settings.catalog.region": "账号平台",
|
||||
"settings.catalog.cluster": "服务集群",
|
||||
"settings.catalog.clusterChina": "中国",
|
||||
"settings.catalog.clusterSingapore": "新加坡",
|
||||
"settings.catalog.clusterEurope": "欧洲",
|
||||
"settings.catalog.product": "接入方式",
|
||||
"settings.catalog.regionChina": "中国站",
|
||||
"settings.catalog.regionGlobal": "国际站",
|
||||
@@ -2880,6 +2884,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.catalog.productGo": "OpenCode Go",
|
||||
"settings.catalog.productZen": "OpenCode Zen",
|
||||
"settings.catalog.productLocal": "本地模型",
|
||||
"settings.catalog.mimoTokenPlanHint": "Token Plan 密钥与按量 API 密钥相互独立,仅限受支持的 AI 编程工具使用,不适用于自动化脚本或应用后端。",
|
||||
"settings.catalog.formatBundle": "推荐的模型路由组合",
|
||||
"settings.catalog.verifyFormat": "请确认服务地址支持此 API 格式。",
|
||||
"settings.catalog.endpointHint": "可修改,仅用于当前连接,不影响预设默认值。",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Provider catalog
|
||||
|
||||
The desktop preset picker has one entry per provider brand. Account platform,
|
||||
access plan and API format select a concrete existing preset. Only combinations
|
||||
The desktop preset picker has one entry per provider brand. Access plan,
|
||||
account platform and API format select a concrete existing preset, in that order. Only combinations
|
||||
registered by the host are offered. OpenCode Go and Zen remain separate plans;
|
||||
model-scoped routes remain selectable inside their plan.
|
||||
|
||||
@@ -59,12 +59,39 @@ go run scripts/generate-provider-catalog.go
|
||||
|
||||
| Contract | Behavior |
|
||||
| --- | --- |
|
||||
| Saved provider TOML and credentials | Unchanged; no migration or automatic rewrite |
|
||||
| Saved provider TOML and credentials | Browsing makes no writes; the one-time MiMo model upgrade below preserves credentials and selections |
|
||||
| Existing preset IDs | Preserved |
|
||||
| Desktop `ProviderPresetView.catalog` | Additive display metadata; old clients ignore it |
|
||||
| New frontend with older host | Generated known-ID fallback; unknown presets remain individually accessible |
|
||||
| Provider request prefix | No catalog metadata is added to model requests |
|
||||
|
||||
## MiMo API and Token Plan
|
||||
|
||||
Choose **Pay-as-you-go API** or **Token Plan** first. The API uses
|
||||
`MIMO_API_KEY` and does not show a region selector. Token Plan uses
|
||||
`MIMO_TOKEN_PLAN_API_KEY` and then offers China, Singapore and Europe service
|
||||
clusters. Changing clusters preserves a selected protocol when that preset is
|
||||
available. Token Plan is restricted to supported AI coding tools; its key and
|
||||
quota are independent of the ordinary API.
|
||||
|
||||
New connections default to `mimo-v2.6-pro`, with `mimo-v2.6-flash` also included.
|
||||
V2.5 IDs remain available for existing selections. Schema version 12 appends
|
||||
the V2.6 models once to eligible saved official V2.5 connections. It preserves
|
||||
the current default, model order, custom rates, credential references and unknown
|
||||
fields. Explicit custom request URLs and third-party endpoints are excluded.
|
||||
The model additions and version marker are written atomically under the config
|
||||
edit lock; subsequent starts do not restore models the user removes.
|
||||
|
||||
| Field or format | Old-data behavior | New reader | Previous schema-v11 reader | Conclusion |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `models`, `default`, model references | Existing V2.5 selections remain first/default | Appends V2.6 once; keeps selections | Reads and saves model IDs without switching defaults | Compatible |
|
||||
| `config_version = 12` | Earlier versions are eligible for startup migration | Prevents repeated additions after deletion | Retains the marker when saving | Compatible |
|
||||
| `prices`, `vision_models`, unknown fields | Preserves user rates and explicit vision choices | Extends known curated vision lists and missing V2.6 prices; preserves unknown data during migration | Reads the existing fields and canonical nested price tables | No new persisted field types |
|
||||
|
||||
Official references: [Token Plan](https://mimo.mi.com/docs/zh-CN/tokenplan/Token%20Plan/subscription),
|
||||
[API pricing](https://mimo.mi.com/docs/zh-CN/price/pay-as-you-go),
|
||||
[API rate limits](https://mimo.mi.com/docs/zh-CN/api/guidance/rate-limit).
|
||||
|
||||
## Connection display names
|
||||
|
||||
Optional `display_name` is UI metadata; `name` remains the stable connection,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 服务商目录
|
||||
|
||||
桌面预设目录按品牌显示一个入口。账号平台、接入方式和 API 格式共同选择一个
|
||||
桌面预设目录按品牌显示一个入口。依次选择接入方式、账号平台和 API 格式来确定一个
|
||||
具体预设,仅展示后端登记过的组合。OpenCode Go 和 Zen 保留为不同接入方式,
|
||||
模型专用路由在对应接入方式内选择。
|
||||
|
||||
@@ -39,12 +39,34 @@ go run scripts/generate-provider-catalog.go
|
||||
|
||||
| 契约 | 兼容行为 |
|
||||
| --- | --- |
|
||||
| 已保存 TOML 与密钥 | 不改变,无迁移或自动重写 |
|
||||
| 已保存 TOML 与密钥 | 浏览不写入;下述 MiMo 一次性模型升级保留密钥引用与已有选择 |
|
||||
| 原有预设 ID | 保留 |
|
||||
| 桌面 `ProviderPresetView.catalog` | 新增展示字段,旧客户端忽略 |
|
||||
| 新前端连接旧后端 | 已知 ID 使用生成的目录回退,未知预设仍可独立访问 |
|
||||
| 模型请求前缀 | 不加入目录元数据 |
|
||||
|
||||
## MiMo 按量 API 与 Token Plan
|
||||
|
||||
先选择**按量 API**或 **Token Plan**。按量 API 使用 `MIMO_API_KEY`,不显示地区选项;
|
||||
Token Plan 使用 `MIMO_TOKEN_PLAN_API_KEY`,再选择中国、新加坡或欧洲服务集群。
|
||||
切换集群时,若目标预设支持当前协议,则保留协议选择。Token Plan 仅限受支持的
|
||||
AI 编程工具使用,其密钥和额度与普通 API 相互独立。
|
||||
|
||||
新建连接默认使用 `mimo-v2.6-pro`,同时提供 `mimo-v2.6-flash`,保留 V2.5 模型 ID。
|
||||
配置版本 12 会为符合条件的官方 V2.5 连接一次性追加 V2.6 模型,保留原默认模型、
|
||||
模型顺序、自定义价格、密钥引用与未知字段。第三方端点和自定义请求 URL 不参与升级。
|
||||
模型与迁移标记在配置编辑锁内原子写入;用户之后删除模型,重启不会再次补回。
|
||||
|
||||
| 字段或格式 | 旧数据行为 | 新版读取 | 上一版(配置版本 11)读取 | 结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `models`、`default`、模型引用 | 保留 V2.5 顺序和默认选择 | 仅一次追加 V2.6,不切换默认模型 | 可读写模型 ID,保持原默认 | 兼容 |
|
||||
| `config_version = 12` | 旧版本可触发启动迁移 | 防止删除后重复追加 | 保存时保留标记 | 兼容 |
|
||||
| `prices`、`vision_models`、未知字段 | 保留用户价格与显式视觉选择 | 扩展已知预设视觉列表,补齐 V2.6 价格;迁移保留未知数据 | 可读取原有字段和规范嵌套价格表 | 未增加持久化字段类型 |
|
||||
|
||||
官方参考:[Token Plan](https://mimo.mi.com/docs/zh-CN/tokenplan/Token%20Plan/subscription)、
|
||||
[API 定价](https://mimo.mi.com/docs/zh-CN/price/pay-as-you-go)、
|
||||
[API 速率限制](https://mimo.mi.com/docs/zh-CN/api/guidance/rate-limit)。
|
||||
|
||||
## 连接显示名称
|
||||
|
||||
可选字段 `display_name` 仅用于界面;`name` 仍是连接、模型引用与凭据关联的稳定标识。
|
||||
|
||||
@@ -568,67 +568,6 @@ func TestRunKeepsChatAndCodeCompatibilityAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {
|
||||
isolateCLIConfigHome(t)
|
||||
legacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), "reasonix.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacyPath, []byte(`
|
||||
default_model = "deepseek-flash"
|
||||
|
||||
[[plugins]]
|
||||
name = "legacy-cli"
|
||||
command = "legacy-bin"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if rc := Run([]string{"mcp", "list"}, "test-version"); rc != 0 {
|
||||
t.Fatalf("mcp list rc = %d, want 0", rc)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "legacy-cli") {
|
||||
t.Fatalf("mcp list should include migrated legacy config:\n%s", out)
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(config.UserConfigPath())
|
||||
if err != nil {
|
||||
t.Fatalf("read migrated user config: %v", err)
|
||||
}
|
||||
for _, want := range []string{`config_version = 11`, `[desktop]`, `name = "legacy-cli"`} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Fatalf("migrated config missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAppliesUserConfigUpgradesOnStartup(t *testing.T) {
|
||||
isolateCLIConfigHome(t)
|
||||
path := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("config_version = 2\ndefault_model = \"deepseek-flash\"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
captureStdout(t, func() {
|
||||
if rc := Run([]string{"mcp", "list"}, "test-version"); rc != 0 {
|
||||
t.Fatalf("mcp list rc = %d, want 0", rc)
|
||||
}
|
||||
})
|
||||
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read upgraded user config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), "config_version = 11") {
|
||||
t.Fatalf("CLI startup should apply user config upgrades:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {
|
||||
isolateCLIConfigHome(t)
|
||||
legacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), "reasonix.toml")
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {
|
||||
isolateCLIConfigHome(t)
|
||||
legacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), "reasonix.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacyPath, []byte(`
|
||||
default_model = "deepseek-flash"
|
||||
|
||||
[[plugins]]
|
||||
name = "legacy-cli"
|
||||
command = "legacy-bin"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if rc := Run([]string{"mcp", "list"}, "test-version"); rc != 0 {
|
||||
t.Fatalf("mcp list rc = %d, want 0", rc)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "legacy-cli") {
|
||||
t.Fatalf("mcp list should include migrated legacy config:\n%s", out)
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(config.UserConfigPath())
|
||||
if err != nil {
|
||||
t.Fatalf("read migrated user config: %v", err)
|
||||
}
|
||||
for _, want := range []string{fmt.Sprintf("config_version = %d", config.Default().ConfigVersion), `[desktop]`, `name = "legacy-cli"`} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Fatalf("migrated config missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAppliesUserConfigUpgradesOnStartup(t *testing.T) {
|
||||
isolateCLIConfigHome(t)
|
||||
path := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("config_version = 2\ndefault_model = \"deepseek-flash\"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
captureStdout(t, func() {
|
||||
if rc := Run([]string{"mcp", "list"}, "test-version"); rc != 0 {
|
||||
t.Fatalf("mcp list rc = %d, want 0", rc)
|
||||
}
|
||||
})
|
||||
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read upgraded user config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), fmt.Sprintf("config_version = %d", config.Default().ConfigVersion)) {
|
||||
t.Fatalf("CLI startup should apply user config upgrades:\n%s", body)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -66,8 +67,8 @@ price = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = "$" }
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(raw)
|
||||
if !strings.Contains(text, "config_version = 11") {
|
||||
t.Fatalf("missing v11:\n%s", text)
|
||||
if !strings.Contains(text, fmt.Sprintf("config_version = %d", Default().ConfigVersion)) {
|
||||
t.Fatalf("missing current config version %d:\n%s", Default().ConfigVersion, text)
|
||||
}
|
||||
if !strings.Contains(text, "display_currency") && !strings.Contains(text, `currency = "CNY"`) {
|
||||
t.Fatalf("display currency not migrated:\n%s", text)
|
||||
|
||||
@@ -1810,7 +1810,7 @@ const LanguagePolicy = `Reply in the same language the user is using in their mo
|
||||
// Default returns the built-in default configuration.
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
ConfigVersion: deepSeekCatalogUpgradeVersion,
|
||||
ConfigVersion: mimoCatalogUpgradeVersion,
|
||||
DefaultModel: "deepseek-flash",
|
||||
CredentialsStore: CredentialsStoreAuto,
|
||||
UI: UIConfig{Theme: "auto", ShowTurnUsage: true},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -39,7 +40,7 @@ future_desktop_field = true
|
||||
t.Fatalf("upgrade=%v err=%v", changed, err)
|
||||
}
|
||||
got, _ := os.ReadFile(path)
|
||||
want := strings.ReplaceAll(raw, `config_version = 7`, `config_version = 11`)
|
||||
want := strings.ReplaceAll(raw, `config_version = 7`, fmt.Sprintf("config_version = %d", Default().ConfigVersion))
|
||||
want = strings.ReplaceAll(want, `models = ["deepseek-v4-flash", "deepseek-v4-pro"]`, `models = ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-flash"]`)
|
||||
want = strings.ReplaceAll(want, `kind = "anthropic"`, `kind = "openai"`)
|
||||
want = strings.ReplaceAll(want, `base_url = "https://api.deepseek.com/anthropic"`, `base_url = "https://api.deepseek.com"`)
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestOfficialDeepSeekV9MigrationAndManualChoice(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := strings.Replace(raw, "config_version = 8", "config_version = 11", 1)
|
||||
want := strings.Replace(raw, "config_version = 8", fmt.Sprintf("config_version = %d", Default().ConfigVersion), 1)
|
||||
want = strings.Replace(want, `kind="`+kind+`"`, `kind="openai"`, 1)
|
||||
want = strings.Replace(want, `base_url="`+base+`"`, `base_url="https://api.deepseek.com"`, 1)
|
||||
want = strings.Replace(want, `request_url="`+endpoint+`"`, `request_url=""`, 1)
|
||||
@@ -73,7 +73,7 @@ func TestOfficialDeepSeekV9MigrationAndManualChoice(t *testing.T) {
|
||||
c.Providers[0].Kind, c.Providers[0].BaseURL, c.Providers[0].RequestURL = kind, base, endpoint
|
||||
if inline {
|
||||
// Keep this fixture inline, as a user editing TOML would.
|
||||
if err := os.WriteFile(path, []byte(strings.Replace(raw, "config_version = 8", "config_version = 11", 1)), 0600); err != nil {
|
||||
if err := os.WriteFile(path, []byte(strings.Replace(raw, "config_version = 8", fmt.Sprintf("config_version = %d", Default().ConfigVersion), 1)), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err := c.SaveTo(path); err != nil {
|
||||
|
||||
@@ -459,52 +459,6 @@ command = "project-bin"
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateImportsLegacyV1TOMLBeforeJSON(t *testing.T) {
|
||||
srcJSON, dest, _ := legacyHome(t)
|
||||
legacyTOML := filepath.Join(filepath.Dir(dest), "reasonix.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(legacyTOML), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacyTOML, []byte(`
|
||||
default_model = "deepseek-flash"
|
||||
language = "en"
|
||||
|
||||
[ui]
|
||||
theme = "light"
|
||||
theme_style = "glacier"
|
||||
close_behavior = "quit"
|
||||
|
||||
[[plugins]]
|
||||
name = "legacy-v1"
|
||||
command = "legacy-bin"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeLegacy(t, srcJSON, `{"apiKey":"sk-json-should-not-win"}`)
|
||||
|
||||
res, err := MigrateLegacyIfNeeded()
|
||||
if err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if res == nil || res.From != legacyTOML {
|
||||
t.Fatalf("expected v1 TOML migration, got %+v", res)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("read migrated config: %v", err)
|
||||
}
|
||||
text := string(got)
|
||||
for _, want := range []string{`config_version = 11`, `[desktop]`, `close_behavior = "quit"`, `name = "legacy-v1"`} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("migrated TOML missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(UserCredentialsPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("v1 TOML migration should not import lower-priority JSON key, credentials stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateImportsLegacyV1HomeTOMLBeforeJSON(t *testing.T) {
|
||||
srcJSON, dest, home := legacyHome(t)
|
||||
legacyTOML := filepath.Join(home, ".reasonix", "reasonix.toml")
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrateImportsLegacyV1TOMLBeforeJSON(t *testing.T) {
|
||||
srcJSON, dest, _ := legacyHome(t)
|
||||
legacyTOML := filepath.Join(filepath.Dir(dest), "reasonix.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(legacyTOML), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacyTOML, []byte(`
|
||||
default_model = "deepseek-flash"
|
||||
language = "en"
|
||||
|
||||
[ui]
|
||||
theme = "light"
|
||||
theme_style = "glacier"
|
||||
close_behavior = "quit"
|
||||
|
||||
[[plugins]]
|
||||
name = "legacy-v1"
|
||||
command = "legacy-bin"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeLegacy(t, srcJSON, `{"apiKey":"sk-json-should-not-win"}`)
|
||||
|
||||
res, err := MigrateLegacyIfNeeded()
|
||||
if err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if res == nil || res.From != legacyTOML {
|
||||
t.Fatalf("expected v1 TOML migration, got %+v", res)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("read migrated config: %v", err)
|
||||
}
|
||||
text := string(got)
|
||||
for _, want := range []string{fmt.Sprintf("config_version = %d", Default().ConfigVersion), `[desktop]`, `close_behavior = "quit"`, `name = "legacy-v1"`} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("migrated TOML missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(UserCredentialsPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("v1 TOML migration should not import lower-priority JSON key, credentials stat err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Use an isolated schema-v11 checkout to exercise the actual previous reader
|
||||
// and writer, including deletion followed by a restart in the new version.
|
||||
func TestMimoCatalogPreviousReaderRoundTrip(t *testing.T) {
|
||||
checkout := os.Getenv("REASONIX_MIMO_PREVIOUS_CHECKOUT")
|
||||
if checkout == "" {
|
||||
t.Skip("set REASONIX_MIMO_PREVIOUS_CHECKOUT for previous-reader acceptance")
|
||||
}
|
||||
checkout, err := filepath.Abs(checkout)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkout, err = filepath.EvalSymlinks(checkout)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", dir)
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
const raw = `config_version = 11
|
||||
default_model = "mimo/mimo-v2.5-pro"
|
||||
[[providers]]
|
||||
name = "mimo"
|
||||
kind = "openai"
|
||||
base_url = "https://api.xiaomimimo.com/v1"
|
||||
models = ["mimo-v2.5-pro", "mimo-v2.5"]
|
||||
default = "mimo-v2.5-pro"
|
||||
vision_models = ["mimo-v2.5"]
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ApplyUserConfigUpgradesOnStartup(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe := filepath.Join(dir, "previous_reader_test.go")
|
||||
const source = `package config
|
||||
import ("os"; "slices"; "testing")
|
||||
func TestMimoPreviousReaderProbe(t *testing.T) {
|
||||
if Default().ConfigVersion != 11 { t.Fatal("probe requires a schema-v11 reader") }
|
||||
path := os.Getenv("REASONIX_COMPAT_CONFIG")
|
||||
c := LoadForEdit(path)
|
||||
p,ok := c.Provider("mimo")
|
||||
if !ok || p.DefaultModel() != "mimo-v2.5-pro" || c.ConfigVersion != 12 { t.Fatal("old reader lost selection or marker") }
|
||||
if os.Getenv("REASONIX_COMPAT_DELETE_FLASH") == "1" {
|
||||
p.Models = slices.DeleteFunc(p.Models,func(m string) bool { return m == "mimo-v2.6-flash" })
|
||||
} else if !p.HasModel("mimo-v2.6-flash") { t.Fatal("old reader lost the new option") }
|
||||
p.DisplayName = "old-reader-edited"
|
||||
if err := c.SaveToScope(path,RenderScopeFull); err != nil { t.Fatal(err) }
|
||||
}`
|
||||
if err := os.WriteFile(probe, []byte(source), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Overlay an existing test file: older Go versions do not discover a new
|
||||
// test filename supplied only by an overlay.
|
||||
data, err := json.Marshal(map[string]any{"Replace": map[string]string{filepath.Join(checkout, "internal/config/deepseek_catalog_downgrade_test.go"): probe}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overlay := filepath.Join(dir, "overlay.json")
|
||||
if err := os.WriteFile(overlay, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, deleted := range []string{"0", "1"} {
|
||||
cmd := exec.Command("go", "test", "-overlay", overlay, "./internal/config", "-run", "^TestMimoPreviousReaderProbe$", "-v", "-count=1")
|
||||
cmd.Dir = checkout
|
||||
cmd.Env = append(os.Environ(), "REASONIX_COMPAT_CONFIG="+path, "REASONIX_COMPAT_DELETE_FLASH="+deleted)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil || !strings.Contains(string(output), "--- PASS: TestMimoPreviousReaderProbe") {
|
||||
t.Fatalf("old reader: %v\n%s", err, output)
|
||||
}
|
||||
if changed, err := ApplyUserConfigUpgradesOnStartup(path); err != nil || changed {
|
||||
t.Fatalf("old writer lost migration marker: %v %v", changed, err)
|
||||
}
|
||||
c := LoadForEdit(path)
|
||||
p, ok := c.Provider("mimo")
|
||||
if !ok || c.ConfigVersion != Default().ConfigVersion || p.DisplayName != "old-reader-edited" ||
|
||||
p.DefaultModel() != "mimo-v2.5-pro" || p.HasModel("mimo-v2.6-flash") != (deleted == "0") ||
|
||||
p.Prices["mimo-v2.6-pro"] == nil || p.Prices["mimo-v2.6-pro"].Input != 3 {
|
||||
t.Fatalf("old reader round-trip lost choices: %+v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
)
|
||||
|
||||
const mimoCatalogUpgradeVersion = 12
|
||||
|
||||
type mimoCatalogProviderUpdate struct {
|
||||
models []string
|
||||
visionModels []string
|
||||
prices map[string]map[string]any
|
||||
}
|
||||
|
||||
// The caller holds the config edit lock. Model additions and the migration
|
||||
// marker are committed together so a model the user later removes stays gone.
|
||||
func upgradeMimoCatalogFileLocked(path string, write func(string, []byte, os.FileMode) error) (bool, error) {
|
||||
resolved, exists, err := statConfigPath(path)
|
||||
if err != nil || !exists {
|
||||
return false, err
|
||||
}
|
||||
raw, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
info, err := os.Stat(resolved)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
encoding, data := fileencoding.Detect(raw)
|
||||
body := string(fileencoding.Decode(data, encoding))
|
||||
crlf := strings.Contains(body, "\r\n")
|
||||
if crlf {
|
||||
body = strings.ReplaceAll(body, "\r\n", "\n")
|
||||
}
|
||||
next, changed, err := rewriteMimoCatalogUpgrade(body)
|
||||
if err != nil || !changed {
|
||||
return false, err
|
||||
}
|
||||
if crlf {
|
||||
next = strings.ReplaceAll(next, "\n", "\r\n")
|
||||
}
|
||||
if err := write(resolved, fileencoding.Encode(next, encoding), info.Mode().Perm()); err != nil {
|
||||
return false, fmt.Errorf("commit MiMo catalog upgrade: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func rewriteMimoCatalogUpgrade(body string) (string, bool, error) {
|
||||
var cfg Config
|
||||
if _, err := toml.Decode(body, &cfg); err != nil {
|
||||
return body, false, err
|
||||
}
|
||||
if cfg.ConfigVersion >= mimoCatalogUpgradeVersion {
|
||||
return body, false, nil
|
||||
}
|
||||
var expected map[string]any
|
||||
if _, err := toml.Decode(body, &expected); err != nil {
|
||||
return body, false, err
|
||||
}
|
||||
expected["config_version"] = int64(mimoCatalogUpgradeVersion)
|
||||
providerTables, _ := deepSeekCatalogDocumentValue(expected["providers"]).([]any)
|
||||
updates := make(map[int]mimoCatalogProviderUpdate)
|
||||
for i := range cfg.Providers {
|
||||
p := &cfg.Providers[i]
|
||||
if !isOfficialMimoCatalogUpgradeEntry(p) || len(p.ModelList()) == 0 ||
|
||||
(!p.HasModel("mimo-v2.5-pro") && !p.HasModel("mimo-v2.5")) {
|
||||
continue
|
||||
}
|
||||
update := mimoCatalogProviderUpdate{models: append([]string(nil), p.ModelList()...)}
|
||||
for _, model := range []string{"mimo-v2.6-pro", "mimo-v2.6-flash"} {
|
||||
if !p.HasModel(model) {
|
||||
update.models = append(update.models, model)
|
||||
}
|
||||
}
|
||||
if stringSlicesEqual(p.VisionModels, []string{"mimo-v2.5"}) {
|
||||
update.visionModels = append(append([]string(nil), p.VisionModels...), "mimo-v2.6-pro", "mimo-v2.6-flash")
|
||||
}
|
||||
missingPrices := make([]string, 0, 2)
|
||||
for _, model := range []string{"mimo-v2.6-pro", "mimo-v2.6-flash"} {
|
||||
if p.Prices[model] == nil {
|
||||
missingPrices = append(missingPrices, model)
|
||||
}
|
||||
}
|
||||
update.prices = mimoCatalogPriceDocuments(missingPrices)
|
||||
if stringSlicesEqual(update.models, p.ModelList()) && update.visionModels == nil && len(update.prices) == 0 {
|
||||
continue
|
||||
}
|
||||
updates[i] = update
|
||||
|
||||
fields, ok := providerTables[i].(map[string]any)
|
||||
if !ok {
|
||||
return body, false, fmt.Errorf("MiMo catalog upgrade cannot map provider %d", i)
|
||||
}
|
||||
fields["models"] = stringSliceDocument(update.models)
|
||||
if update.visionModels != nil {
|
||||
fields["vision_models"] = stringSliceDocument(update.visionModels)
|
||||
}
|
||||
if len(update.prices) > 0 {
|
||||
table, _ := fields["prices"].(map[string]any)
|
||||
if table == nil {
|
||||
table = make(map[string]any)
|
||||
fields["prices"] = table
|
||||
}
|
||||
for model, price := range update.prices {
|
||||
encoded, err := rawTOMLValue(price)
|
||||
if err != nil {
|
||||
return body, false, err
|
||||
}
|
||||
var rendered map[string]any
|
||||
if _, err := toml.Decode("price = "+encoded, &rendered); err != nil {
|
||||
return body, false, err
|
||||
}
|
||||
table[model] = rendered["price"]
|
||||
}
|
||||
}
|
||||
}
|
||||
next := body
|
||||
var err error
|
||||
if len(updates) > 0 {
|
||||
next, err = rewriteMimoCatalogProviders(next, len(cfg.Providers), updates)
|
||||
if err != nil {
|
||||
return body, false, err
|
||||
}
|
||||
}
|
||||
next, err = rawTOMLSet(next, []string{"config_version"}, mimoCatalogUpgradeVersion)
|
||||
if err != nil {
|
||||
return body, false, err
|
||||
}
|
||||
var actual map[string]any
|
||||
if _, err := toml.Decode(next, &actual); err != nil {
|
||||
return body, false, fmt.Errorf("MiMo catalog upgrade readback: %w", err)
|
||||
}
|
||||
if !reflect.DeepEqual(deepSeekCatalogDocumentValue(actual), deepSeekCatalogDocumentValue(expected)) {
|
||||
return body, false, fmt.Errorf("MiMo catalog upgrade changed unplanned fields; original configuration retained")
|
||||
}
|
||||
return next, true, nil
|
||||
}
|
||||
|
||||
func isOfficialMimoCatalogUpgradeEntry(p *ProviderEntry) bool {
|
||||
if p == nil || strings.TrimSpace(p.RequestURL) != "" || strings.TrimSpace(p.ChatURL) != "" {
|
||||
return false
|
||||
}
|
||||
host := officialProviderHost(p.BaseURL)
|
||||
switch host {
|
||||
case "api.xiaomimimo.com", "token-plan-cn.xiaomimimo.com", "token-plan-sgp.xiaomimimo.com", "token-plan-ams.xiaomimimo.com":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
suffix := "/v1"
|
||||
if normalizedProviderProtocol(p.Kind) == "anthropic" {
|
||||
suffix = "/anthropic"
|
||||
} else if normalizedProviderProtocol(p.Kind) != "openai" && normalizedProviderProtocol(p.Kind) != "responses" {
|
||||
return false
|
||||
}
|
||||
return normalizedBaseURLForMigration(p.BaseURL) == "https://"+host+suffix
|
||||
}
|
||||
|
||||
func stringSliceDocument(values []string) []any {
|
||||
out := make([]any, len(values))
|
||||
for i, value := range values {
|
||||
out[i] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mimoCatalogPriceDocuments(models []string) map[string]map[string]any {
|
||||
out := make(map[string]map[string]any)
|
||||
for model, price := range mimoDomesticPrices(models) {
|
||||
out[model] = map[string]any{
|
||||
"cache_hit": price.CacheHit,
|
||||
"input": price.Input,
|
||||
"output": price.Output,
|
||||
"currency": price.Currency,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rewriteMimoCatalogProviders(body string, count int, updates map[int]mimoCatalogProviderUpdate) (string, error) {
|
||||
lines := strings.Split(body, "\n")
|
||||
blocks := providerTOMLBlocks(lines)
|
||||
if len(blocks) != count {
|
||||
expanded, err := expandOpenCodeGoInlineProviders(body)
|
||||
if err != nil {
|
||||
return body, err
|
||||
}
|
||||
if len(providerTOMLBlocks(strings.Split(expanded, "\n"))) != count {
|
||||
return body, fmt.Errorf("MiMo catalog upgrade cannot map provider tables")
|
||||
}
|
||||
return rewriteMimoCatalogProviders(expanded, count, updates)
|
||||
}
|
||||
for i := range slices.Backward(blocks) {
|
||||
update, ok := updates[i]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
b := blocks[i]
|
||||
for b.end < len(lines) {
|
||||
header := tomlSectionHeader(lines[b.end])
|
||||
path := rawTOMLKeyPath(strings.Trim(header, "[]"))
|
||||
if header != "" && (len(path) < 2 || path[0] != "providers") {
|
||||
break
|
||||
}
|
||||
b.end++
|
||||
}
|
||||
part := strings.Join(lines[b.start+1:b.end], "\n")
|
||||
var err error
|
||||
part, err = rawTOMLSet(part, []string{"models"}, update.models)
|
||||
if err != nil {
|
||||
return body, err
|
||||
}
|
||||
if update.visionModels != nil {
|
||||
part, err = rawTOMLSet(part, []string{"vision_models"}, update.visionModels)
|
||||
if err != nil {
|
||||
return body, err
|
||||
}
|
||||
}
|
||||
part, err = appendMimoCatalogPriceTables(part, update.prices)
|
||||
if err != nil {
|
||||
return body, err
|
||||
}
|
||||
lines = append(lines[:b.start+1], append(strings.Split(part, "\n"), lines[b.end:]...)...)
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
func appendMimoCatalogPriceTables(body string, prices map[string]map[string]any) (string, error) {
|
||||
for _, model := range []string{"mimo-v2.6-pro", "mimo-v2.6-flash"} {
|
||||
price := prices[model]
|
||||
if price == nil {
|
||||
continue
|
||||
}
|
||||
// Write leaves so inline prices are extended in place and absent prices
|
||||
// create distinct model tables, without redeclaring an implicit parent.
|
||||
for _, key := range []string{"cache_hit", "input", "output", "currency"} {
|
||||
var err error
|
||||
body, err = rawTOMLSet(body, []string{"providers", "prices", model, key}, price[key])
|
||||
if err != nil {
|
||||
return body, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"reasonix/internal/fileutil"
|
||||
)
|
||||
|
||||
func TestMimoCatalogUpgradePreservesSelectionsAndDeletion(t *testing.T) {
|
||||
raw := `config_version = 11 # schema
|
||||
default_model = "mimo-api/mimo-v2.5-pro"
|
||||
future_root = { choice = "keep" }
|
||||
[[providers]]
|
||||
name = "mimo-api"
|
||||
kind = "openai"
|
||||
base_url = "https://api.xiaomimimo.com/v1"
|
||||
models = ["custom-ID", "mimo-v2.5-pro", "mimo-v2.5"]
|
||||
default = "mimo-v2.5-pro"
|
||||
api_key_env = "USER_MIMO_KEY"
|
||||
vision_models = ["mimo-v2.5"]
|
||||
future_provider = { value = "keep" }
|
||||
[providers.prices.mimo-v2.5-pro]
|
||||
cache_hit = 0.025
|
||||
input = 3.0
|
||||
output = 6.0
|
||||
currency = "¥"
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed, err := ApplyUserConfigUpgradesOnStartup(path); err != nil || !changed {
|
||||
t.Fatalf("upgrade=%v err=%v", changed, err)
|
||||
}
|
||||
cfg := LoadForEdit(path)
|
||||
p, ok := cfg.Provider("mimo-api")
|
||||
if !ok {
|
||||
t.Fatal("mimo-api missing after upgrade")
|
||||
}
|
||||
wantModels := []string{"custom-ID", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2.6-pro", "mimo-v2.6-flash"}
|
||||
wantVision := []string{"mimo-v2.5", "mimo-v2.6-pro", "mimo-v2.6-flash"}
|
||||
if cfg.ConfigVersion != Default().ConfigVersion || !reflect.DeepEqual(p.Models, wantModels) ||
|
||||
!reflect.DeepEqual(p.VisionModels, wantVision) || p.DefaultModel() != "mimo-v2.5-pro" ||
|
||||
cfg.DefaultModel != "mimo-api/mimo-v2.5-pro" || p.APIKeyEnv != "USER_MIMO_KEY" {
|
||||
t.Fatalf("lost settings: version=%d default=%s provider=%+v", cfg.ConfigVersion, cfg.DefaultModel, p)
|
||||
}
|
||||
if price := p.PriceForModel("mimo-v2.6-flash"); price == nil || price.Input != 1 || price.Output != 2 || price.CacheHit != 0.02 {
|
||||
t.Fatalf("mimo-v2.6-flash price = %+v", price)
|
||||
}
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, kept := range []string{`# schema`, `future_root = { choice = "keep" }`, `future_provider = { value = "keep" }`} {
|
||||
if !strings.Contains(string(before), kept) {
|
||||
t.Fatalf("lost %s", kept)
|
||||
}
|
||||
}
|
||||
p.Models = []string{"custom-ID", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2.6-pro"}
|
||||
if err := cfg.SaveTo(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ = os.ReadFile(path)
|
||||
for range 2 {
|
||||
if changed, err := ApplyUserConfigUpgradesOnStartup(path); err != nil || changed {
|
||||
t.Fatalf("reapplied upgrade: %v %v", changed, err)
|
||||
}
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if string(after) != string(before) || LoadForEdit(path).Providers[0].HasModel("mimo-v2.6-flash") {
|
||||
t.Fatal("deleted MiMo model reappeared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMimoCatalogUpgradeScopeAndCapabilities(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, kind, base, extra string
|
||||
add, addVision bool
|
||||
}{
|
||||
{"payg openai", "openai", "https://api.xiaomimimo.com/v1", `models = ["mimo-v2.5-pro", "mimo-v2.5"]
|
||||
vision_models = ["mimo-v2.5"]`, true, true},
|
||||
{"payg anthropic", "anthropic", "https://api.xiaomimimo.com/anthropic", `models = ["mimo-v2.5-pro"]`, true, false},
|
||||
{"token Singapore", "openai", "https://token-plan-sgp.xiaomimimo.com/v1", `models = ["mimo-v2.5"]`, true, false},
|
||||
{"token Europe", "anthropic", "https://token-plan-ams.xiaomimimo.com/anthropic", `models = ["mimo-v2.5-pro"]`, true, false},
|
||||
{"explicit vision off", "openai", "https://token-plan-cn.xiaomimimo.com/v1", `models = ["mimo-v2.5"]
|
||||
vision_models = []`, true, false},
|
||||
{"custom endpoint", "openai", "https://relay.example/v1", `models = ["mimo-v2.5-pro"]`, false, false},
|
||||
{"custom request", "openai", "https://api.xiaomimimo.com/v1", `models = ["mimo-v2.5-pro"]
|
||||
request_url = "https://api.xiaomimimo.com/custom"`, false, false},
|
||||
{"unrelated catalog", "openai", "https://api.xiaomimimo.com/v1", `models = ["custom-model"]`, false, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw := "config_version = 11\n[[providers]]\nname = 'mimo'\nkind = '" + tc.kind + "'\nbase_url = '" + tc.base + "'\n" + tc.extra + "\n"
|
||||
next, changed, err := rewriteMimoCatalogUpgrade(raw)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("changed=%v err=%v", changed, err)
|
||||
}
|
||||
var cfg Config
|
||||
if _, err := toml.Decode(next, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := cfg.Providers[0]
|
||||
if p.HasModel("mimo-v2.6-pro") != tc.add || p.HasModel("mimo-v2.6-flash") != tc.add {
|
||||
t.Fatalf("models = %v, add=%v", p.ModelList(), tc.add)
|
||||
}
|
||||
if p.HasVisionModel("mimo-v2.6-pro") != tc.addVision || p.HasVisionModel("mimo-v2.6-flash") != tc.addVision {
|
||||
t.Fatalf("vision models = %v, add=%v", p.VisionModels, tc.addVision)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMimoCatalogUpgradeSingularPriceAndAtomicRetry(t *testing.T) {
|
||||
raw := `config_version = 11
|
||||
[[providers]]
|
||||
name = "mimo-token-plan-cn"
|
||||
kind = "openai"
|
||||
base_url = "https://token-plan-cn.xiaomimimo.com/v1"
|
||||
model = "mimo-v2.5-pro"
|
||||
price = { cache_hit = 9.0, input = 9.0, output = 9.0, currency = "¥" }
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failedWrite := func(string, []byte, os.FileMode) error { return errors.New("interrupted") }
|
||||
if changed, err := upgradeMimoCatalogFileLocked(path, failedWrite); err == nil || changed {
|
||||
t.Fatalf("interrupted upgrade = %v, %v", changed, err)
|
||||
}
|
||||
unchanged, _ := os.ReadFile(path)
|
||||
if string(unchanged) != raw {
|
||||
t.Fatal("failed migration changed the original file")
|
||||
}
|
||||
if changed, err := upgradeMimoCatalogFileLocked(path, fileutil.AtomicWriteFile); err != nil || !changed {
|
||||
t.Fatalf("retry = %v, %v", changed, err)
|
||||
}
|
||||
cfg := LoadForEdit(path)
|
||||
p := &cfg.Providers[0]
|
||||
if p.DefaultModel() != "mimo-v2.5-pro" {
|
||||
t.Fatalf("singular default changed to %q", p.DefaultModel())
|
||||
}
|
||||
for model, wantInput := range map[string]float64{"mimo-v2.6-pro": 3, "mimo-v2.6-flash": 1} {
|
||||
if price := p.PriceForModel(model); price == nil || price.Input != wantInput {
|
||||
t.Fatalf("%s inherited singular price: %+v", model, price)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMimoCatalogUpgradeTOMLRepresentations(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"inline providers and prices": `providers = [{name="mimo", kind="openai", base_url="https://api.xiaomimimo.com/v1", models=["mimo-v2.5"], prices={"mimo-v2.5"={input=9.0, future="keep"}}}]`,
|
||||
"inline prices": `[[providers]]
|
||||
name="mimo"
|
||||
kind="openai"
|
||||
base_url="https://api.xiaomimimo.com/v1"
|
||||
models=["mimo-v2.5"]
|
||||
prices={"mimo-v2.5"={input=9.0, future="keep"}}
|
||||
`,
|
||||
"nested prices and adjacent providers": `[[providers]]
|
||||
name="mimo"
|
||||
kind="openai"
|
||||
base_url="https://api.xiaomimimo.com/v1"
|
||||
models=["mimo-v2.5"]
|
||||
[providers.prices."mimo-v2.5"]
|
||||
input=9.0
|
||||
future="keep"
|
||||
[[providers]]
|
||||
name="other"
|
||||
kind="openai"
|
||||
base_url="https://relay.example/v1"
|
||||
models=["custom"]
|
||||
[providers.prices.custom]
|
||||
input=7.0
|
||||
[future]
|
||||
value="keep"
|
||||
`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
next, changed, err := rewriteMimoCatalogUpgrade("config_version=11\n" + raw)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("changed=%v err=%v", changed, err)
|
||||
}
|
||||
var cfg Config
|
||||
if _, err := toml.Decode(next, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := cfg.Providers[0]
|
||||
if !p.HasModel("mimo-v2.6-pro") || !p.HasModel("mimo-v2.6-flash") || p.Prices["mimo-v2.5"].Input != 9 {
|
||||
t.Fatalf("unexpected migrated provider: %+v", p)
|
||||
}
|
||||
if _, changed, err := rewriteMimoCatalogUpgrade(next); err != nil || changed {
|
||||
t.Fatalf("second upgrade changed=%v err=%v", changed, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMimoPresetCapabilities(t *testing.T) {
|
||||
var cfg Config
|
||||
for _, preset := range CuratedProviderPresets() {
|
||||
for _, entry := range preset.Entries {
|
||||
if err := cfg.UpsertProvider(entry); err != nil {
|
||||
t.Fatalf("upsert preset %q: %v", preset.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
mimo, ok := cfg.Provider("mimo-api")
|
||||
if !ok {
|
||||
t.Fatal("mimo-api provider missing")
|
||||
}
|
||||
if !mimo.NoProxy {
|
||||
t.Fatal("mimo-api preset should bypass configured proxy for China-only endpoint")
|
||||
}
|
||||
if mimo.DefaultModel() != "mimo-v2.6-pro" || !mimo.HasVisionModel("mimo-v2.6-pro") ||
|
||||
!mimo.HasVisionModel("mimo-v2.6-flash") || !mimo.HasVisionModel("mimo-v2.5") || mimo.HasVisionModel("mimo-v2.5-pro") {
|
||||
t.Fatalf("mimo vision capability mismatch: %+v", mimo.VisionModels)
|
||||
}
|
||||
if price := mimo.PriceForModel("mimo-v2.6-flash"); price == nil || price.Currency != "¥" || price.Input != 1 || price.Output != 2 || price.CacheHit != 0.02 {
|
||||
t.Fatalf("mimo-v2.6-flash price = %+v, want current RMB pricing", price)
|
||||
}
|
||||
if price := mimo.PriceForModel("mimo-v2.5-pro"); price == nil || price.Currency != "¥" {
|
||||
t.Fatalf("mimo-v2.5-pro price = %+v, want RMB pricing", price)
|
||||
}
|
||||
mimoAnthropic, ok := cfg.Provider("mimo-anthropic")
|
||||
if !ok {
|
||||
t.Fatal("mimo-anthropic provider missing")
|
||||
}
|
||||
if mimoAnthropic.Kind != "anthropic" || mimoAnthropic.BaseURL != "https://api.xiaomimimo.com/anthropic" || mimoAnthropic.Thinking != "adaptive" {
|
||||
t.Fatalf("mimo-anthropic capability mismatch: %+v", mimoAnthropic)
|
||||
}
|
||||
mimoPlan, ok := cfg.Provider("mimo-token-plan-cn")
|
||||
if !ok {
|
||||
t.Fatal("mimo-token-plan-cn provider missing")
|
||||
}
|
||||
if !mimoPlan.NoProxy || mimoPlan.APIKeyEnv != "MIMO_TOKEN_PLAN_API_KEY" || !mimoPlan.HasVisionModel("mimo-v2.6-pro") || !mimoPlan.HasVisionModel("mimo-v2.5") {
|
||||
t.Fatalf("mimo-token-plan-cn capability mismatch: %+v", mimoPlan)
|
||||
}
|
||||
mimoSGP, ok := cfg.Provider("mimo-token-plan-sgp")
|
||||
if !ok {
|
||||
t.Fatal("mimo-token-plan-sgp provider missing")
|
||||
}
|
||||
if mimoSGP.NoProxy || mimoSGP.BaseURL != "https://token-plan-sgp.xiaomimimo.com/v1" {
|
||||
t.Fatalf("mimo-token-plan-sgp endpoint/proxy mismatch: %+v", mimoSGP)
|
||||
}
|
||||
mimoPlanAnthropic, ok := cfg.Provider("mimo-token-plan-cn-anthropic")
|
||||
if !ok {
|
||||
t.Fatal("mimo-token-plan-cn-anthropic provider missing")
|
||||
}
|
||||
if mimoPlanAnthropic.Kind != "anthropic" || !mimoPlanAnthropic.NoProxy || mimoPlanAnthropic.BaseURL != "https://token-plan-cn.xiaomimimo.com/anthropic" {
|
||||
t.Fatalf("mimo-token-plan-cn-anthropic capability mismatch: %+v", mimoPlanAnthropic)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -103,11 +103,22 @@ func rawTOMLSet(body string, path []string, value any) (string, error) {
|
||||
}
|
||||
keys := make([]string, len(path)-1)
|
||||
for i, k := range path[:len(path)-1] {
|
||||
keys[i] = strconv.Quote(k)
|
||||
keys[i] = rawTOMLSectionKey(k)
|
||||
}
|
||||
return strings.TrimRight(body, "\n") + "\n[" + strings.Join(keys, ".") + "]\n" + assignment + "\n", nil
|
||||
}
|
||||
|
||||
// rawTOMLSectionKey keeps ordinary section names in the renderer's canonical
|
||||
// form. Older incremental writers identify nested sections by bare parents.
|
||||
func rawTOMLSectionKey(key string) string {
|
||||
if key != "" && strings.IndexFunc(key, func(r rune) bool {
|
||||
return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-')
|
||||
}) == -1 {
|
||||
return key
|
||||
}
|
||||
return strconv.Quote(key)
|
||||
}
|
||||
|
||||
func rawTOMLKeyPath(s string) []string {
|
||||
// Let the validated TOML parser handle escapes and quoted dots.
|
||||
var m map[string]any
|
||||
|
||||
@@ -225,9 +225,9 @@ func mimoDomesticPrices(models []string) map[string]*provider.Pricing {
|
||||
prices := map[string]*provider.Pricing{}
|
||||
for _, model := range models {
|
||||
switch strings.TrimSpace(model) {
|
||||
case "mimo-v2.5-pro", "mimo-v2-pro":
|
||||
case "mimo-v2.6-pro", "mimo-v2.5-pro", "mimo-v2-pro":
|
||||
prices[model] = mimoV25ProPrice()
|
||||
case "mimo-v2.5", "mimo-v2-omni":
|
||||
case "mimo-v2.6-flash", "mimo-v2.5", "mimo-v2-omni":
|
||||
prices[model] = mimoV25Price()
|
||||
case "mimo-v2-flash":
|
||||
prices[model] = mimoV2FlashPrice()
|
||||
@@ -278,6 +278,11 @@ func ApplyUserConfigUpgradesOnStartup(path string) (bool, error) {
|
||||
return changed, err
|
||||
}
|
||||
upgraded, err := upgradeDeepSeekCatalogFileLocked(path, fileutil.AtomicWriteFile)
|
||||
changed = changed || upgraded
|
||||
if err != nil {
|
||||
return changed, err
|
||||
}
|
||||
upgraded, err = upgradeMimoCatalogFileLocked(path, fileutil.AtomicWriteFile)
|
||||
return changed || upgraded, err
|
||||
}
|
||||
|
||||
|
||||
@@ -102,8 +102,8 @@ var (
|
||||
longCat20Models = []string{"LongCat-2.0"}
|
||||
deepSeekOfficialModels = []string{"deepseek-flash", "deepseek-v4-pro"}
|
||||
deepSeekOfficialVisionModels = []string{"deepseek-flash"}
|
||||
mimoV25Models = []string{"mimo-v2.5-pro", "mimo-v2.5"}
|
||||
mimoV25VisionModels = []string{"mimo-v2.5"}
|
||||
mimoModels = []string{"mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.5-pro", "mimo-v2.5"}
|
||||
mimoCuratedVisionModels = []string{"mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.5"}
|
||||
|
||||
minimaxMSeriesModels = []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"}
|
||||
minimaxMSeriesVisionModels = []string{"MiniMax-M3"}
|
||||
@@ -315,12 +315,12 @@ var curatedProviderPresets = []ProviderPreset{
|
||||
Name: "mimo-api",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://api.xiaomimimo.com/v1",
|
||||
Models: mimoV25Models,
|
||||
VisionModels: mimoV25VisionModels,
|
||||
Default: "mimo-v2.5-pro",
|
||||
Models: mimoModels,
|
||||
VisionModels: mimoCuratedVisionModels,
|
||||
Default: "mimo-v2.6-pro",
|
||||
APIKeyEnv: "MIMO_API_KEY",
|
||||
ContextWindow: 1048576,
|
||||
Prices: mimoDomesticPrices(mimoV25Models),
|
||||
Prices: mimoDomesticPrices(mimoModels),
|
||||
NoProxy: true,
|
||||
}},
|
||||
},
|
||||
@@ -333,13 +333,13 @@ var curatedProviderPresets = []ProviderPreset{
|
||||
Name: "mimo-anthropic",
|
||||
Kind: "anthropic",
|
||||
BaseURL: "https://api.xiaomimimo.com/anthropic",
|
||||
Models: mimoV25Models,
|
||||
VisionModels: mimoV25VisionModels,
|
||||
Default: "mimo-v2.5-pro",
|
||||
Models: mimoModels,
|
||||
VisionModels: mimoCuratedVisionModels,
|
||||
Default: "mimo-v2.6-pro",
|
||||
APIKeyEnv: "MIMO_API_KEY",
|
||||
Thinking: "adaptive",
|
||||
ContextWindow: 1048576,
|
||||
Prices: mimoDomesticPrices(mimoV25Models),
|
||||
Prices: mimoDomesticPrices(mimoModels),
|
||||
NoProxy: true,
|
||||
}},
|
||||
},
|
||||
@@ -352,12 +352,12 @@ var curatedProviderPresets = []ProviderPreset{
|
||||
Name: "mimo-token-plan-cn",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://token-plan-cn.xiaomimimo.com/v1",
|
||||
Models: mimoV25Models,
|
||||
VisionModels: mimoV25VisionModels,
|
||||
Default: "mimo-v2.5-pro",
|
||||
Models: mimoModels,
|
||||
VisionModels: mimoCuratedVisionModels,
|
||||
Default: "mimo-v2.6-pro",
|
||||
APIKeyEnv: "MIMO_TOKEN_PLAN_API_KEY",
|
||||
ContextWindow: 1048576,
|
||||
Prices: mimoDomesticPrices(mimoV25Models),
|
||||
Prices: mimoDomesticPrices(mimoModels),
|
||||
NoProxy: true,
|
||||
}},
|
||||
},
|
||||
@@ -370,13 +370,13 @@ var curatedProviderPresets = []ProviderPreset{
|
||||
Name: "mimo-token-plan-cn-anthropic",
|
||||
Kind: "anthropic",
|
||||
BaseURL: "https://token-plan-cn.xiaomimimo.com/anthropic",
|
||||
Models: mimoV25Models,
|
||||
VisionModels: mimoV25VisionModels,
|
||||
Default: "mimo-v2.5-pro",
|
||||
Models: mimoModels,
|
||||
VisionModels: mimoCuratedVisionModels,
|
||||
Default: "mimo-v2.6-pro",
|
||||
APIKeyEnv: "MIMO_TOKEN_PLAN_API_KEY",
|
||||
Thinking: "adaptive",
|
||||
ContextWindow: 1048576,
|
||||
Prices: mimoDomesticPrices(mimoV25Models),
|
||||
Prices: mimoDomesticPrices(mimoModels),
|
||||
NoProxy: true,
|
||||
}},
|
||||
},
|
||||
@@ -389,12 +389,12 @@ var curatedProviderPresets = []ProviderPreset{
|
||||
Name: "mimo-token-plan-sgp",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://token-plan-sgp.xiaomimimo.com/v1",
|
||||
Models: mimoV25Models,
|
||||
VisionModels: mimoV25VisionModels,
|
||||
Default: "mimo-v2.5-pro",
|
||||
Models: mimoModels,
|
||||
VisionModels: mimoCuratedVisionModels,
|
||||
Default: "mimo-v2.6-pro",
|
||||
APIKeyEnv: "MIMO_TOKEN_PLAN_API_KEY",
|
||||
ContextWindow: 1048576,
|
||||
Prices: mimoDomesticPrices(mimoV25Models),
|
||||
Prices: mimoDomesticPrices(mimoModels),
|
||||
}},
|
||||
},
|
||||
{
|
||||
@@ -406,13 +406,13 @@ var curatedProviderPresets = []ProviderPreset{
|
||||
Name: "mimo-token-plan-sgp-anthropic",
|
||||
Kind: "anthropic",
|
||||
BaseURL: "https://token-plan-sgp.xiaomimimo.com/anthropic",
|
||||
Models: mimoV25Models,
|
||||
VisionModels: mimoV25VisionModels,
|
||||
Default: "mimo-v2.5-pro",
|
||||
Models: mimoModels,
|
||||
VisionModels: mimoCuratedVisionModels,
|
||||
Default: "mimo-v2.6-pro",
|
||||
APIKeyEnv: "MIMO_TOKEN_PLAN_API_KEY",
|
||||
Thinking: "adaptive",
|
||||
ContextWindow: 1048576,
|
||||
Prices: mimoDomesticPrices(mimoV25Models),
|
||||
Prices: mimoDomesticPrices(mimoModels),
|
||||
}},
|
||||
},
|
||||
{
|
||||
@@ -424,12 +424,12 @@ var curatedProviderPresets = []ProviderPreset{
|
||||
Name: "mimo-token-plan-ams",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://token-plan-ams.xiaomimimo.com/v1",
|
||||
Models: mimoV25Models,
|
||||
VisionModels: mimoV25VisionModels,
|
||||
Default: "mimo-v2.5-pro",
|
||||
Models: mimoModels,
|
||||
VisionModels: mimoCuratedVisionModels,
|
||||
Default: "mimo-v2.6-pro",
|
||||
APIKeyEnv: "MIMO_TOKEN_PLAN_API_KEY",
|
||||
ContextWindow: 1048576,
|
||||
Prices: mimoDomesticPrices(mimoV25Models),
|
||||
Prices: mimoDomesticPrices(mimoModels),
|
||||
}},
|
||||
},
|
||||
{
|
||||
@@ -441,13 +441,13 @@ var curatedProviderPresets = []ProviderPreset{
|
||||
Name: "mimo-token-plan-ams-anthropic",
|
||||
Kind: "anthropic",
|
||||
BaseURL: "https://token-plan-ams.xiaomimimo.com/anthropic",
|
||||
Models: mimoV25Models,
|
||||
VisionModels: mimoV25VisionModels,
|
||||
Default: "mimo-v2.5-pro",
|
||||
Models: mimoModels,
|
||||
VisionModels: mimoCuratedVisionModels,
|
||||
Default: "mimo-v2.6-pro",
|
||||
APIKeyEnv: "MIMO_TOKEN_PLAN_API_KEY",
|
||||
Thinking: "adaptive",
|
||||
ContextWindow: 1048576,
|
||||
Prices: mimoDomesticPrices(mimoV25Models),
|
||||
Prices: mimoDomesticPrices(mimoModels),
|
||||
}},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -642,48 +642,6 @@ func TestCuratedProviderPresetCapabilities(t *testing.T) {
|
||||
t.Fatalf("longcat-anthropic context_window = %d, want %d", longcatAnthropic.ContextWindow, longCat20ContextWindow)
|
||||
}
|
||||
|
||||
mimo, ok := cfg.Provider("mimo-api")
|
||||
if !ok {
|
||||
t.Fatal("mimo-api provider missing")
|
||||
}
|
||||
if !mimo.NoProxy {
|
||||
t.Fatal("mimo-api preset should bypass configured proxy for China-only endpoint")
|
||||
}
|
||||
if mimo.DefaultModel() != "mimo-v2.5-pro" || !mimo.HasVisionModel("mimo-v2.5") || mimo.HasVisionModel("mimo-v2.5-pro") {
|
||||
t.Fatalf("mimo vision capability mismatch: %+v", mimo.VisionModels)
|
||||
}
|
||||
if price := mimo.PriceForModel("mimo-v2.5-pro"); price == nil || price.Currency != "¥" {
|
||||
t.Fatalf("mimo-v2.5-pro price = %+v, want RMB pricing", price)
|
||||
}
|
||||
mimoAnthropic, ok := cfg.Provider("mimo-anthropic")
|
||||
if !ok {
|
||||
t.Fatal("mimo-anthropic provider missing")
|
||||
}
|
||||
if mimoAnthropic.Kind != "anthropic" || mimoAnthropic.BaseURL != "https://api.xiaomimimo.com/anthropic" || mimoAnthropic.Thinking != "adaptive" {
|
||||
t.Fatalf("mimo-anthropic capability mismatch: %+v", mimoAnthropic)
|
||||
}
|
||||
mimoPlan, ok := cfg.Provider("mimo-token-plan-cn")
|
||||
if !ok {
|
||||
t.Fatal("mimo-token-plan-cn provider missing")
|
||||
}
|
||||
if !mimoPlan.NoProxy || mimoPlan.APIKeyEnv != "MIMO_TOKEN_PLAN_API_KEY" || !mimoPlan.HasVisionModel("mimo-v2.5") {
|
||||
t.Fatalf("mimo-token-plan-cn capability mismatch: %+v", mimoPlan)
|
||||
}
|
||||
mimoSGP, ok := cfg.Provider("mimo-token-plan-sgp")
|
||||
if !ok {
|
||||
t.Fatal("mimo-token-plan-sgp provider missing")
|
||||
}
|
||||
if mimoSGP.NoProxy || mimoSGP.BaseURL != "https://token-plan-sgp.xiaomimimo.com/v1" {
|
||||
t.Fatalf("mimo-token-plan-sgp endpoint/proxy mismatch: %+v", mimoSGP)
|
||||
}
|
||||
mimoPlanAnthropic, ok := cfg.Provider("mimo-token-plan-cn-anthropic")
|
||||
if !ok {
|
||||
t.Fatal("mimo-token-plan-cn-anthropic provider missing")
|
||||
}
|
||||
if mimoPlanAnthropic.Kind != "anthropic" || !mimoPlanAnthropic.NoProxy || mimoPlanAnthropic.BaseURL != "https://token-plan-cn.xiaomimimo.com/anthropic" {
|
||||
t.Fatalf("mimo-token-plan-cn-anthropic capability mismatch: %+v", mimoPlanAnthropic)
|
||||
}
|
||||
|
||||
minimax, ok := cfg.ResolveModel("minimax-cn-api/MiniMax-M3")
|
||||
if !ok {
|
||||
t.Fatal("minimax-cn-api/MiniMax-M3 did not resolve")
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScopedRenderSeparatesUserAndProjectConfig(t *testing.T) {
|
||||
c := Default()
|
||||
c.Language = "zh"
|
||||
c.Desktop.Language = "zh"
|
||||
c.Desktop.Currency = "CNY"
|
||||
c.Desktop.Theme = "dark"
|
||||
c.Desktop.ThemeStyle = "graphite"
|
||||
c.Desktop.CloseBehavior = "background"
|
||||
c.Desktop.StatusBarStyle = "text"
|
||||
c.Desktop.StatusBarStyleInitialized = true
|
||||
c.Desktop.DefaultToolApprovalMode = "workspace-write"
|
||||
c.Desktop.CheckUpdates = boolPtr(false)
|
||||
c.Desktop.UpdateChannel = "preview"
|
||||
c.Agent.RecoveryModel = "deepseek-pro"
|
||||
c.Agent.RecoveryTemperature = 0.2
|
||||
|
||||
user := RenderTOMLForScope(c, RenderScopeUser)
|
||||
for _, want := range []string{fmt.Sprintf("config_version = %d", Default().ConfigVersion), "[desktop]", `currency = "CNY"`, "[billing]", `display_currency = "CNY"`, `theme = "dark"`, `terminal_theme = "auto"`, `close_behavior = "background"`, `status_bar_style = "text"`, `default_tool_approval_mode = "workspace-write"`, `check_updates = false`, "[notifications]", "[tools.shell]"} {
|
||||
if !strings.Contains(user, want) {
|
||||
t.Fatalf("user render missing %q:\n%s", want, user)
|
||||
}
|
||||
}
|
||||
if strings.Contains(user, "update_channel") || strings.Contains(user, "[cli]") {
|
||||
t.Fatalf("user render retained retired update channel:\n%s", user)
|
||||
}
|
||||
|
||||
project := RenderTOMLForScope(c, RenderScopeProject)
|
||||
for _, forbidden := range []string{"[desktop]", "[notifications]", "close_behavior =", "default_tool_approval_mode =", "default_auto_recovery_checkpoint =", "check_updates =", "update_channel =", "max_steps", "planner_max_steps"} {
|
||||
if strings.Contains(project, forbidden) {
|
||||
t.Fatalf("project render should not contain %q:\n%s", forbidden, project)
|
||||
}
|
||||
}
|
||||
for _, retired := range []string{"default_auto_recovery_checkpoint", "auto_recovery_checkpoint"} {
|
||||
if strings.Contains(user, retired) || strings.Contains(project, retired) {
|
||||
t.Fatalf("retired Auto Guard key %q must not be rendered:\nuser:\n%s\nproject:\n%s", retired, user, project)
|
||||
}
|
||||
}
|
||||
if strings.Contains(project, "\nsystem_prompt = \"\"\"") {
|
||||
t.Fatalf("project render should not pin the built-in system prompt:\n%s", project)
|
||||
}
|
||||
if !strings.Contains(project, "# system_prompt =") {
|
||||
t.Fatalf("project render should leave a system prompt hint:\n%s", project)
|
||||
}
|
||||
if strings.Contains(user, "recovery_model") || strings.Contains(project, "recovery_model") {
|
||||
t.Fatalf("retired recovery_model rendered:\nuser:\n%s\nproject:\n%s", user, project)
|
||||
}
|
||||
if strings.Contains(user, "auto_plan") || strings.Contains(project, "auto_plan") {
|
||||
t.Fatalf("retired auto-plan keys must not be rendered:\nuser:\n%s\nproject:\n%s", user, project)
|
||||
}
|
||||
if strings.Contains(user, "recovery_temperature") || strings.Contains(project, "recovery_temperature") {
|
||||
t.Fatalf("deprecated recovery_temperature must not be rendered:\nuser:\n%s\nproject:\n%s", user, project)
|
||||
}
|
||||
}
|
||||
@@ -776,60 +776,6 @@ func TestNotificationsDefaultsKeepEventSwitchesEnabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedRenderSeparatesUserAndProjectConfig(t *testing.T) {
|
||||
c := Default()
|
||||
c.Language = "zh"
|
||||
c.Desktop.Language = "zh"
|
||||
c.Desktop.Currency = "CNY"
|
||||
c.Desktop.Theme = "dark"
|
||||
c.Desktop.ThemeStyle = "graphite"
|
||||
c.Desktop.CloseBehavior = "background"
|
||||
c.Desktop.StatusBarStyle = "text"
|
||||
c.Desktop.StatusBarStyleInitialized = true
|
||||
c.Desktop.DefaultToolApprovalMode = "workspace-write"
|
||||
c.Desktop.CheckUpdates = boolPtr(false)
|
||||
c.Desktop.UpdateChannel = "preview"
|
||||
c.Agent.RecoveryModel = "deepseek-pro"
|
||||
c.Agent.RecoveryTemperature = 0.2
|
||||
|
||||
user := RenderTOMLForScope(c, RenderScopeUser)
|
||||
for _, want := range []string{"config_version = 11", "[desktop]", `currency = "CNY"`, "[billing]", `display_currency = "CNY"`, `theme = "dark"`, `terminal_theme = "auto"`, `close_behavior = "background"`, `status_bar_style = "text"`, `default_tool_approval_mode = "workspace-write"`, `check_updates = false`, "[notifications]", "[tools.shell]"} {
|
||||
if !strings.Contains(user, want) {
|
||||
t.Fatalf("user render missing %q:\n%s", want, user)
|
||||
}
|
||||
}
|
||||
if strings.Contains(user, "update_channel") || strings.Contains(user, "[cli]") {
|
||||
t.Fatalf("user render retained retired update channel:\n%s", user)
|
||||
}
|
||||
|
||||
project := RenderTOMLForScope(c, RenderScopeProject)
|
||||
for _, forbidden := range []string{"[desktop]", "[notifications]", "close_behavior =", "default_tool_approval_mode =", "default_auto_recovery_checkpoint =", "check_updates =", "update_channel =", "max_steps", "planner_max_steps"} {
|
||||
if strings.Contains(project, forbidden) {
|
||||
t.Fatalf("project render should not contain %q:\n%s", forbidden, project)
|
||||
}
|
||||
}
|
||||
for _, retired := range []string{"default_auto_recovery_checkpoint", "auto_recovery_checkpoint"} {
|
||||
if strings.Contains(user, retired) || strings.Contains(project, retired) {
|
||||
t.Fatalf("retired Auto Guard key %q must not be rendered:\nuser:\n%s\nproject:\n%s", retired, user, project)
|
||||
}
|
||||
}
|
||||
if strings.Contains(project, "\nsystem_prompt = \"\"\"") {
|
||||
t.Fatalf("project render should not pin the built-in system prompt:\n%s", project)
|
||||
}
|
||||
if !strings.Contains(project, "# system_prompt =") {
|
||||
t.Fatalf("project render should leave a system prompt hint:\n%s", project)
|
||||
}
|
||||
if strings.Contains(user, "recovery_model") || strings.Contains(project, "recovery_model") {
|
||||
t.Fatalf("retired recovery_model rendered:\nuser:\n%s\nproject:\n%s", user, project)
|
||||
}
|
||||
if strings.Contains(user, "auto_plan") || strings.Contains(project, "auto_plan") {
|
||||
t.Fatalf("retired auto-plan keys must not be rendered:\nuser:\n%s\nproject:\n%s", user, project)
|
||||
}
|
||||
if strings.Contains(user, "recovery_temperature") || strings.Contains(project, "recovery_temperature") {
|
||||
t.Fatalf("deprecated recovery_temperature must not be rendered:\nuser:\n%s\nproject:\n%s", user, project)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedRenderKeepsPluginsInTheirOwningConfig(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Plugins = []PluginEntry{
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
)
|
||||
|
||||
var mimoVisionModels = map[string]bool{
|
||||
"mimo-v2.5": true,
|
||||
"mimo-v2-omni": true,
|
||||
"mimo-v2.6-pro": true,
|
||||
"mimo-v2.6-flash": true,
|
||||
"mimo-v2.5": true,
|
||||
"mimo-v2-omni": true,
|
||||
}
|
||||
|
||||
// VisionCapability is the model-level image-input fact used by settings and
|
||||
@@ -193,7 +195,7 @@ func isOfficialMimoVisionEntry(e *ProviderEntry) bool {
|
||||
return false
|
||||
}
|
||||
switch officialMimoHost(e.BaseURL) {
|
||||
case "api.xiaomimimo.com", "token-plan-cn.xiaomimimo.com":
|
||||
case "api.xiaomimimo.com", "token-plan-cn.xiaomimimo.com", "token-plan-sgp.xiaomimimo.com", "token-plan-ams.xiaomimimo.com":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -41,10 +41,12 @@ const jobBody = (workflow, jobName) => {
|
||||
};
|
||||
|
||||
const nodeVersions = (workflow) =>
|
||||
[...workflow.matchAll(/node-version:\s*["']?(\d+)/g)].map(
|
||||
(match) => match[1],
|
||||
[...workflow.matchAll(/node-version(-file)?:\s*["']?([^\s"']+)/g)].map(
|
||||
(match) => match[1] ? read(match[2]).trim() : match[2],
|
||||
);
|
||||
|
||||
const pinnedNodeVersion = read(".node-version").trim();
|
||||
assert.match(pinnedNodeVersion, /^24\.\d+\.\d+$/);
|
||||
assert.equal(read("desktop/frontend/.nvmrc").trim(), "24");
|
||||
assert.equal(frontendPackage.engines?.node, ">=24");
|
||||
assert.equal(frontendPackage.engines?.pnpm, ">=10 <11");
|
||||
@@ -54,12 +56,12 @@ assert.ok(
|
||||
);
|
||||
|
||||
for (const jobName of ["desktop-prepare", "desktop-go", "desktop-frontend", "desktop-browser-group", "desktop-macos", "desktop-windows"]) {
|
||||
assert.deepEqual(nodeVersions(jobBody(ciWorkflow, jobName)), ["24"]);
|
||||
assert.deepEqual(nodeVersions(jobBody(ciWorkflow, jobName)), [pinnedNodeVersion]);
|
||||
}
|
||||
|
||||
const releaseNodeVersions = nodeVersions(releaseWorkflow);
|
||||
assert.ok(releaseNodeVersions.length > 0, "release workflow must set up Node");
|
||||
assert.deepEqual(new Set(releaseNodeVersions), new Set(["24"]));
|
||||
assert.deepEqual(new Set(releaseNodeVersions), new Set([pinnedNodeVersion]));
|
||||
|
||||
for (const [name, workflow] of [
|
||||
["CI", ciWorkflow],
|
||||
|
||||
@@ -89,6 +89,12 @@ test("mixed changes cannot hide affected work and unknown paths fail closed", ()
|
||||
assert.equal(result.flags[name], true, name);
|
||||
});
|
||||
|
||||
test("shared Node runtime changes validate every frontend artifact surface", () => {
|
||||
const { flags } = classifyPaths([".node-version"]);
|
||||
for (const name of ["code", "desktop", "frontend", "browser", "memory", "memory_full", "electron", "native", "packaging"])
|
||||
assert.equal(flags[name], true, name);
|
||||
});
|
||||
|
||||
test("release notes and full events are deterministic", () => {
|
||||
assert.equal(classifyPaths(["release-notes/v1.md"]).flags.notes_only, true);
|
||||
const notesPush = classifyPaths(["release-notes/v1.md"], { full: true }).flags;
|
||||
|
||||
@@ -27,6 +27,23 @@ const release = workflow("release-desktop");
|
||||
const promote = workflow("release-promote");
|
||||
const appMemory = workflow("app-memory");
|
||||
|
||||
test("frontend artifact workflows share one exact Node runtime", () => {
|
||||
const version = readFileSync(new URL("../.node-version", import.meta.url), "utf8").trim();
|
||||
assert.match(version, /^\d+\.\d+\.\d+$/);
|
||||
for (const [name, source] of [["ci", ci], ["app-memory", appMemory], ["release-desktop", release]]) {
|
||||
assert.doesNotMatch(source, /node-version: ["']?24(?:["']|\s)/, name);
|
||||
assert.match(source, /node-version-file: \.node-version/, name);
|
||||
}
|
||||
const names = [...ci.matchAll(/^ ([a-z][a-z0-9-]*):$/gm)].map(match => match[1]);
|
||||
const participants = names.map(name => [name, job(ci, name)])
|
||||
.filter(([, body]) => /artifact-identity\.mjs (create|verify)/.test(body));
|
||||
assert.ok(participants.length > 1, "cover both producer and consumers");
|
||||
for (const [name, body] of participants) {
|
||||
assert.match(body, /uses: actions\/setup-node@[^\n]+\n\s+with:\n\s+node-version-file: \.node-version/, name);
|
||||
assert.doesNotMatch(body, /node-version:/, `${name} must not override the shared runtime`);
|
||||
}
|
||||
});
|
||||
|
||||
test("Windows PR verifies credential aliases before full push CI", () => {
|
||||
assert.match(ci, /name: test \(Windows credential ACL identity\)[\s\S]*?runner\.os == 'Windows' && github\.event_name == 'pull_request'[\s\S]*?go test -timeout=2m -run '\^TestCredentialAccessRepairsLegacyCredentialDeny\|\^TestRepairLegacyCredentialDenyMatchesFileAcrossPathAliases\$' \.\/internal\/config \.\/internal\/winaclresidue/);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user