feat(mcode): let users choose the provider key instead of a random UUID

MiniMax Code shows custom_provider:<key>/<model> in its model picker, so the
UUID CC Switch used as the key leaked into the UI. New MCode providers now
take a user-entered key, prefilled from the preset, like OpenCode. Existing
providers keep their UUID and the key is locked when editing, since MCode's
default-model references embed it.

- Duplicating an MCode provider generates an unused <key>-copy key.
- Adding rejects a key already used by a database row or any live
  custom_provider node, including MCode's own account providers. The check
  and save run under the MCode switch lock, and the live write re-checks the
  key while holding config.yaml.lock so it never replaces a node MCode
  created in the meantime.
This commit is contained in:
Jason committed 2026-09-28 13:00:14 +08:00
1 parent 6064fc1c5c
commit 8f4a87d0cd
8 files changed
+340 -10

No files matched your search

+45
View File
@@ -103,6 +103,15 @@ pub(crate) fn get_providers() -> Result<IndexMap<String, Value>, AppError> {
}
}
/// Any `custom_provider` node under this key, including MCode's own account providers.
pub(crate) fn provider_key_exists(id: &str) -> Result<bool, AppError> {
let document = read(&config_path())?;
Ok(document
.get("custom_provider")
.and_then(serde_yaml::Value::as_mapping)
.is_some_and(|providers| providers.contains_key(serde_yaml::Value::from(id))))
}
pub(crate) fn validate_provider(id: &str, config: &Value) -> Result<(), AppError> {
if id.is_empty()
|| id
@@ -162,6 +171,16 @@ impl Drop for ConfigLock {
}
fn update(path: &Path, id: &str, provider: Option<Value>) -> Result<(), AppError> {
write_entry(path, id, provider, false)
}
/// `create_only` rejects a key that already exists, checked while holding MCode's lock.
fn write_entry(
path: &Path,
id: &str,
provider: Option<Value>,
create_only: bool,
) -> Result<(), AppError> {
fs::create_dir_all(path.parent().expect("MCode configuration directory"))
.map_err(|e| AppError::Message(format!("Cannot create MCode directory: {e}")))?;
let path = if path.exists() {
@@ -221,6 +240,11 @@ fn update(path: &Path, id: &str, provider: Option<Value>) -> Result<(), AppError
let providers = entry
.as_mapping_mut()
.ok_or_else(|| AppError::Config("Invalid MCode custom_provider configuration".into()))?;
if create_only && providers.contains_key(serde_yaml::Value::from(id)) {
return Err(AppError::InvalidInput(format!(
"MCode provider key '{id}' already exists"
)));
}
if providers
.get(serde_yaml::Value::from(id))
.and_then(|value| value.get("kind"))
@@ -249,6 +273,11 @@ pub(crate) fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
validate_provider(id, &config)?;
update(&config_path(), id, Some(config))
}
/// Like `set_provider`, but never replaces an existing node.
pub(crate) fn add_provider(id: &str, config: Value) -> Result<(), AppError> {
validate_provider(id, &config)?;
write_entry(&config_path(), id, Some(config), true)
}
pub(crate) fn remove_provider(id: &str) -> Result<(), AppError> {
if !config_path().exists() {
return Ok(());
@@ -295,6 +324,22 @@ mod tests {
);
}
#[test]
fn create_only_writes_never_replace_a_node_that_appeared_meanwhile() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
let text = "custom_provider:\n raced:\n name: Theirs\n";
fs::write(&path, text).unwrap();
assert!(write_entry(&path, "raced", Some(json!({"name":"Ours"})), true).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), text);
assert!(!path.with_extension("yaml.lock").exists());
write_entry(&path, "fresh", Some(json!({"name":"Ours"})), true).unwrap();
assert_eq!(
read(&path).unwrap()["custom_provider"]["fresh"]["name"],
"Ours"
);
}
#[test]
fn native_provider_can_omit_api_format() {
let mut provider = json!({"options":{"baseURL":"https://example.com","apiKey":"test-key"},"models":{"model":{}}});
+26 -5
View File
@@ -4814,13 +4814,18 @@ impl ProviderService {
state: &AppState,
provider: &Provider,
write_live: bool,
create: bool,
) -> Result<bool, AppError> {
let previous = state.db.get_provider_by_id(&provider.id, "mcode")?;
state.db.save_provider("mcode", provider)?;
if write_live {
if let Err(error) =
crate::mcode_config::set_provider(&provider.id, provider.settings_config.clone())
{
let config = provider.settings_config.clone();
let written = if create {
crate::mcode_config::add_provider(&provider.id, config)
} else {
crate::mcode_config::set_provider(&provider.id, config)
};
if let Err(error) = written {
match previous {
Some(previous) => state.db.save_provider("mcode", &previous)?,
None => state.db.delete_provider("mcode", &provider.id)?,
@@ -4859,7 +4864,23 @@ impl ProviderService {
}
if app_type == AppType::Mcode {
return Self::save_mcode_provider(state, &provider, add_to_live);
// The key is user-chosen, and saving would overwrite an existing row or live node.
// Adds are serialized here; the live write repeats the check under MCode's file lock.
let _guard = futures::executor::block_on(
state.proxy_service.lock_switch_for_app(app_type.as_str()),
);
if state
.db
.get_provider_by_id(&provider.id, "mcode")?
.is_some()
|| crate::mcode_config::provider_key_exists(&provider.id)?
{
return Err(AppError::InvalidInput(format!(
"MCode provider key '{}' already exists",
provider.id
)));
}
return Self::save_mcode_provider(state, &provider, add_to_live, true);
}
// 还没有当前供应商时这一家会被写进 live:和进入代理、切换互斥(同 `update`)。
@@ -5494,7 +5515,7 @@ impl ProviderService {
Self::set_provider_live_config_managed(&mut provider, live_config_managed);
if app_type == AppType::Mcode {
return Self::save_mcode_provider(state, &provider, live_config_managed);
return Self::save_mcode_provider(state, &provider, live_config_managed, false);
}
// Save to database after live-config presence is resolved so parse errors
+87 -1
View File
@@ -1,4 +1,4 @@
use cc_switch_lib::{AppType, Prompt, PromptService};
use cc_switch_lib::{AppType, Prompt, PromptService, Provider, ProviderService};
use serde_json::json;
use std::fs;
@@ -41,3 +41,89 @@ fn failed_mcode_prompt_writes_preserve_state_and_can_be_retried() {
assert_eq!(fs::read_to_string(&path).unwrap(), "updated");
assert!(state.db.get_prompts("mcode").unwrap()[&active.id].enabled);
}
#[test]
fn adding_an_mcode_provider_rejects_a_key_already_in_use() {
let _guard = test_mutex().lock().unwrap();
reset_test_fs();
let state = create_test_state().unwrap();
let path = ensure_test_home().join(".minimax/config.yaml");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let native = "custom_provider:\n handwritten:\n name: Mine\n models: {}\n minimax:\n kind: minimax\n name: Account\n";
fs::write(&path, native).unwrap();
let provider = |id: &str| {
Provider::with_id(
id.into(),
"Test".into(),
json!({"name":"Test", "kind":"custom", "enabled":true, "api":"openai-completions",
"options":{"baseURL":"https://api.example.com/v1","apiKey":"test-key"},
"models":{"model":{"name":"Model"}}}),
None,
)
};
// Live nodes, custom or owned by MCode, are never overwritten.
for id in ["handwritten", "minimax"] {
assert!(ProviderService::add(&state, AppType::Mcode, provider(id), true).is_err());
}
assert_eq!(fs::read_to_string(&path).unwrap(), native);
assert!(state.db.get_all_providers("mcode").unwrap().is_empty());
// Neither is a row that only lives in the database.
ProviderService::add(&state, AppType::Mcode, provider("fresh"), false).unwrap();
let mut renamed = provider("fresh");
renamed.name = "Other".into();
assert!(ProviderService::add(&state, AppType::Mcode, renamed, true).is_err());
assert_eq!(
state
.db
.get_provider_by_id("fresh", "mcode")
.unwrap()
.unwrap()
.name,
"Test"
);
assert_eq!(fs::read_to_string(&path).unwrap(), native);
}
#[test]
fn concurrent_mcode_adds_with_one_key_leave_exactly_one_winner() {
let _guard = test_mutex().lock().unwrap();
reset_test_fs();
let state = create_test_state().unwrap();
let successes = std::thread::scope(|scope| {
let handles: Vec<_> = (0..16)
.map(|index| {
let state = &state;
scope.spawn(move || {
let provider = Provider::with_id(
"same".into(),
format!("Racer {index}"),
json!({"name":"Racer", "kind":"custom", "enabled":true,
"api":"openai-completions",
"options":{"baseURL":"https://api.example.com/v1","apiKey":"test-key"},
"models":{"model":{"name":"Model"}}}),
None,
);
ProviderService::add(state, AppType::Mcode, provider, false)
.is_ok()
.then_some(index)
})
})
.collect();
handles
.into_iter()
.filter_map(|handle| handle.join().unwrap())
.collect::<Vec<_>>()
});
assert_eq!(successes.len(), 1);
assert_eq!(
state
.db
.get_provider_by_id("same", "mcode")
.unwrap()
.unwrap()
.name,
format!("Racer {}", successes[0])
);
}
+7
View File
@@ -887,6 +887,13 @@ function App() {
existingKeys,
);
duplicatedProvider.addToLive = false;
} else if (activeApp === "mcode") {
// The MCode list already includes its live custom nodes; the backend
// rejects a key that MCode itself owns.
duplicatedProvider.providerKey = generateUniqueProviderCopyKey(
provider.id,
Object.keys(providers),
);
}
if (provider.sortIndex !== undefined) {
@@ -4,9 +4,11 @@ import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import JsonEditor from "@/components/JsonEditor";
import { useDarkMode } from "@/hooks/useDarkMode";
import { useProvidersQuery } from "@/lib/query/queries";
import type { ProviderFormData } from "@/lib/schemas/provider";
import type { OpenCodeModel, OpenCodeProviderOptions } from "@/types";
import { mcodeProviderPresets } from "@/config/mcodeProviderPresets";
@@ -62,6 +64,9 @@ const configSchema = z
})
.passthrough();
type McodeConfig = z.infer<typeof configSchema>;
const KEY_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const normalizeKey = (value: string) =>
value.toLowerCase().replace(/[^a-z0-9-]/g, "");
export function McodeProviderForm({
providerId,
@@ -95,6 +100,19 @@ export function McodeProviderForm({
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
// The key becomes `custom_provider.<key>` and MCode's default-model
// references embed it, so it is fixed once the provider exists.
const isEdit = Boolean(initialData);
const [providerKey, setProviderKey] = useState(providerId ?? "");
const { data: existing } = useProvidersQuery("mcode");
const keyTaken =
!isEdit &&
Object.prototype.hasOwnProperty.call(
existing?.providers ?? {},
providerKey,
);
const keyInvalid =
!isEdit && providerKey !== "" && !KEY_PATTERN.test(providerKey);
const form = useForm<ProviderFormData>({
defaultValues: {
name: initialData?.name ?? "",
@@ -109,6 +127,7 @@ export function McodeProviderForm({
const ready = Boolean(
jsonValid &&
name.trim() &&
(isEdit || (providerKey && !keyInvalid && !keyTaken)) &&
config.options?.baseURL?.trim() &&
config.options?.apiKey?.trim() &&
Object.keys(config.models ?? {}).length,
@@ -136,6 +155,7 @@ export function McodeProviderForm({
};
update(next);
setExtraOptions(toOpencodeExtraOptions(next.options));
setProviderKey(selected?.providerKey ?? "");
form.reset({
name: selected?.name ?? "",
notes: "",
@@ -166,7 +186,7 @@ export function McodeProviderForm({
...identity,
name: identity.name.trim(),
meta: initialData?.meta,
providerKey: providerId,
providerKey: isEdit ? providerId : providerKey,
presetCategory: category,
settingsConfig: JSON.stringify({
...config,
@@ -206,7 +226,45 @@ export function McodeProviderForm({
disabled={busy || !jsonValid}
className="min-w-0 space-y-6 border-0 p-0 disabled:opacity-50"
>
<BasicFormFields form={form} />
<BasicFormFields
form={form}
beforeNameSlot={
<div className="space-y-2">
<Label htmlFor="mcode-provider-key">
{t("opencode.providerKey")}
<span aria-hidden="true" className="text-destructive ml-1">
*
</span>
</Label>
<Input
id="mcode-provider-key"
value={providerKey}
onChange={(event) =>
setProviderKey(normalizeKey(event.target.value))
}
disabled={isEdit}
placeholder={t("opencode.providerKeyPlaceholder")}
autoComplete="off"
className={keyTaken || keyInvalid ? "border-destructive" : ""}
/>
<p
className={
keyTaken || keyInvalid
? "text-xs text-destructive"
: "text-xs text-muted-foreground"
}
>
{keyTaken
? t("opencode.providerKeyDuplicate")
: keyInvalid
? t("opencode.providerKeyInvalid")
: isEdit
? t("opencode.providerKeyLockedHint")
: t("opencode.providerKeyHint")}
</p>
</div>
}
/>
<OpenCodeFormFields
apiFormats={API_FORMATS}
npm={config.api ?? "anthropic-messages"}
+2 -1
View File
@@ -67,7 +67,8 @@ export const useAddProviderMutation = (appId: AppId) => {
appId === "opencode" ||
appId === "openclaw" ||
appId === "hermes" ||
appId === "pi"
appId === "pi" ||
appId === "mcode"
) {
if (
providerInput.category === "omo" ||
+77 -1
View File
@@ -1,8 +1,17 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ReactElement } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import {
fireEvent,
render as rtlRender,
screen,
waitFor,
} from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { McodeProviderForm } from "@/components/providers/forms/McodeProviderForm";
import { mcodeProviderPresets } from "@/config/mcodeProviderPresets";
import { piProviderPresets } from "@/config/piProviderPresets";
import { setProviders } from "../msw/state";
import { createTestQueryClient } from "../utils/testQueryClient";
vi.mock("@/components/JsonEditor", () => ({
default: ({
@@ -22,6 +31,13 @@ vi.mock("@/components/JsonEditor", () => ({
),
}));
const render = (ui: ReactElement) =>
rtlRender(
<QueryClientProvider client={createTestQueryClient()}>
{ui}
</QueryClientProvider>,
);
const original = {
kind: "custom",
api: "anthropic-messages",
@@ -113,6 +129,66 @@ describe("McodeProviderForm", () => {
expect(saved.options.apiKey).toBe("test-key");
expect(saved).not.toHaveProperty("npm");
expect(saved.models).toHaveProperty("MiniMax-M3");
expect(submit.mock.calls[0][0].providerKey).toBe("cc-switch-mini-max");
});
it("requires a well-formed, unused provider key for new providers", async () => {
setProviders("mcode", {
taken: { id: "taken", name: "Taken", settingsConfig: original },
});
const submit = vi.fn();
render(
<McodeProviderForm
appId="mcode"
submitLabel="Save"
onSubmit={submit}
onCancel={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /Minimax MiniMax$/ }));
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "test-key" },
});
const key = screen.getByLabelText(/opencode\.providerKey/);
const save = screen.getByRole("button", { name: "Save" });
expect(key).toHaveValue("cc-switch-mini-max");
expect(save).toBeEnabled();
fireEvent.change(key, { target: { value: "" } });
expect(save).toBeDisabled();
fireEvent.change(key, { target: { value: "-bad-" } });
expect(screen.getByText("opencode.providerKeyInvalid")).toBeInTheDocument();
expect(save).toBeDisabled();
fireEvent.change(key, { target: { value: "taken" } });
expect(
await screen.findByText("opencode.providerKeyDuplicate"),
).toBeInTheDocument();
expect(save).toBeDisabled();
fireEvent.change(key, { target: { value: "My Key!" } });
expect(key).toHaveValue("mykey");
fireEvent.click(save);
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
expect(submit.mock.calls[0][0].providerKey).toBe("mykey");
});
it("locks the provider key when editing", () => {
render(
<McodeProviderForm
appId="mcode"
providerId="existing"
initialData={{ name: "Existing", settingsConfig: original }}
submitLabel="Save"
onSubmit={vi.fn()}
onCancel={() => {}}
/>,
);
const key = screen.getByLabelText(/opencode\.providerKey/);
expect(key).toHaveValue("existing");
expect(key).toBeDisabled();
expect(
screen.getByText("opencode.providerKeyLockedHint"),
).toBeInTheDocument();
});
it("keeps invalid JSON drafts from breaking the structured fields", () => {
+36
View File
@@ -390,6 +390,42 @@ describe("App integration with MSW", () => {
);
});
it("duplicates MiniMax Code providers under a generated unused key", async () => {
localStorage.setItem("cc-switch-last-app", "mcode");
const provider = (id: string, name: string) => ({
id,
name,
settingsConfig: {},
category: "custom" as const,
sortIndex: 0,
createdAt: Date.now(),
});
setProviders("mcode", {
kimi: provider("kimi", "Kimi"),
"kimi-copy": provider("kimi-copy", "Kimi copy"),
});
setCurrentProviderId("mcode", "kimi");
const { default: App } = await import("@/App");
renderApp(App);
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(
"kimi-copy",
),
);
fireEvent.click(screen.getByText("duplicate"));
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(
"kimi-copy-2",
),
);
expect(toastErrorMock).not.toHaveBeenCalledWith(
expect.stringContaining("Provider key is required for mcode"),
);
});
it("refreshes MiniMax Code provider membership after removing it from live config", async () => {
localStorage.setItem("cc-switch-last-app", "mcode");
let liveConfigManaged = true;