Merge pull request #7945 from SivanCola/feature/deepseek-anthropic-provider

Recommend DeepSeek Anthropic access / 推荐 DeepSeek Anthropic 接入
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-09 18:17:33 +08:00
commit cf9689ad72
69 files changed
+7679 -1040

No files matched your search

+47 -36
View File
@@ -2971,38 +2971,9 @@ func TestSetEffortForTabReanchorsDepthCapRecoveryBranch(t *testing.T) {
}
}
func TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {
isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(config.UserConfigPath(), []byte(`
[desktop]
language = "zh"
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
if _, err := NewApp().AddOfficialProviderAccess("deepseek", ""); err != nil {
t.Fatalf("AddOfficialProviderAccess: %v", err)
}
cfg := config.LoadForEdit(config.UserConfigPath())
p, ok := cfg.Provider("deepseek")
if !ok {
t.Fatal("deepseek provider not saved")
}
flash := p.Prices["deepseek-v4-flash"]
pro := p.Prices["deepseek-v4-pro"]
if flash == nil || flash.Output != 2 || flash.Currency != "¥" {
t.Fatalf("flash price = %+v, want CNY preset", flash)
}
if pro == nil || pro.Output != 6 || pro.Currency != "¥" {
t.Fatalf("pro price = %+v, want CNY preset", pro)
}
}
func TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
@@ -3049,12 +3020,12 @@ func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {
setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "deepseek-flash/deepseek-v4-flash"
cfg.Desktop.ProviderAccess = []string{"deepseek-flash", "mimo-pro"}
deepseek, _ := cfg.Provider("deepseek-flash")
deepseek.Model = ""
deepseek.Models = []string{"deepseek-v4-flash", "deepseek-v4-pro"}
deepseek.Default = "deepseek-v4-flash"
cfg.DefaultModel = "deepseek/deepseek-v4-flash"
cfg.Desktop.ProviderAccess = []string{"deepseek", "mimo-pro"}
cfg.Providers = append(cfg.Providers, config.ProviderEntry{
Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic",
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"}, Default: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
})
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
@@ -5138,6 +5109,46 @@ func TestConnectKeyRestoresDeepSeekProviderAccess(t *testing.T) {
}
}
func TestConnectKeyFreshInstallUsesDeepSeekAnthropicDefaults(t *testing.T) {
isolateDesktopUserDirs(t)
oldFetch := connectKeyBalanceFetch
connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) {
return &billing.Balance{Available: true}, nil
}
t.Cleanup(func() { connectKeyBalanceFetch = oldFetch })
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
app.setTestCtrl(control.New(control.Options{Label: "fresh-install"}), "deepseek-flash/deepseek-v4-flash")
workspace := t.TempDir()
app.tabs["test"].WorkspaceRoot = workspace
defer func() {
if ctrl := app.activeCtrl(); ctrl != nil {
ctrl.Close()
}
}()
if _, err := app.ConnectKey("sk-test"); err != nil {
t.Fatalf("ConnectKey: %v", err)
}
cfg, err := config.LoadForRootReadOnly(workspace)
if err != nil {
t.Fatalf("load fresh-install config: %v", err)
}
entry, ok := cfg.ResolveModel(cfg.DefaultModel)
if !ok {
t.Fatalf("default model %q did not resolve", cfg.DefaultModel)
}
if entry.Kind != "anthropic" || entry.BaseURL != "https://api.deepseek.com/anthropic" ||
entry.Thinking != "enabled" || !config.EffectiveWebSearch(entry) || config.EffectiveVision(entry) {
t.Fatalf("fresh-install DeepSeek entry = %+v; want Anthropic, thinking, web search, and text-only vision", entry)
}
if app.NeedsOnboarding() {
t.Fatal("fresh-install onboarding should close after the validated DeepSeek key is stored")
}
}
func TestBalanceForTabUsesDesktopPricingCurrency(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
@@ -0,0 +1,275 @@
// Run: tsx src/__tests__/provider-access-card.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import {
AddProviderPanel,
ProviderAccessCard,
type ProviderAccessGroup,
} from "../components/SettingsPanel";
import { LocaleProvider } from "../lib/i18n";
import type { ProviderView } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.sessionStorage = dom.window.sessionStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
window.scrollTo = () => {};
window.matchMedia = () => ({
matches: true,
media: "",
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
});
const deepSeekAnthropic: ProviderView = {
name: "deepseek-flash",
builtIn: true,
added: true,
kind: "anthropic",
baseUrl: "https://api.deepseek.com/anthropic",
models: ["deepseek-v4-flash"],
visionModels: [],
visionModelsConfigured: true,
visionCapability: "unsupported",
modelsUrl: "",
default: "deepseek-v4-flash",
apiKeyEnv: "DEEPSEEK_API_KEY",
keySet: true,
configured: true,
balanceUrl: "",
contextWindow: 128_000,
reasoningProtocol: "deepseek",
thinking: "",
webSearch: true,
serverWebSearchCapability: true,
supportedEfforts: [],
defaultEffort: "",
};
const deepSeekLegacyOpenAI: ProviderView = {
...deepSeekAnthropic,
name: "deepseek-pro",
kind: "openai",
baseUrl: "https://api.deepseek.com",
models: ["deepseek-v4-pro"],
default: "deepseek-v4-pro",
webSearch: false,
serverWebSearchCapability: false,
};
function group(providers: ProviderView[]): ProviderAccessGroup {
return {
id: "builtin:deepseek",
label: "DeepSeek Official",
description: "",
builtIn: true,
providers,
apiKeyEnv: "DEEPSEEK_API_KEY",
keySet: true,
requiresKey: true,
configured: true,
baseUrl: providers[0]?.baseUrl ?? "",
kind: providers[0]?.kind ?? "",
models: providers.flatMap((provider) => provider.models),
recommendedUpgradeAvailable: false,
};
}
function renderCard(
providerGroup: ProviderAccessGroup,
actions: {
onRefresh?: (provider: ProviderView) => void;
onDelete?: (providers: ProviderView[]) => Promise<void>;
} = {},
) {
return (
<LocaleProvider>
<ProviderAccessCard
group={providerGroup}
busy={false}
fetching={false}
defaultProvider=""
editing={null}
kinds={["anthropic", "openai"]}
onEdit={() => undefined}
onCancelEdit={() => undefined}
onSave={() => undefined}
onRefresh={actions.onRefresh ?? (() => undefined)}
onToggleDraftModel={() => undefined}
onToggleDraftVision={() => undefined}
onSelectAllDraftModels={() => undefined}
onClearDraftModels={() => undefined}
onCancelDraftModels={() => undefined}
onSaveDraftModels={() => undefined}
onToggleWebSearch={() => undefined}
onUpgradeRecommended={() => undefined}
onSaveEditorKey={async () => undefined}
onDelete={actions.onDelete}
/>
</LocaleProvider>
);
}
console.log("\nprovider access card");
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(renderCard(group([deepSeekAnthropic, deepSeekLegacyOpenAI])));
await flushPromises();
});
ok(
rootEl.querySelector<HTMLInputElement>('input[role="switch"]') === null,
"mixed supported and unsupported profiles hide the grouped server web-search switch",
);
ok(
rootEl.textContent?.includes("deepseek-v4-flash") === true
&& rootEl.textContent?.includes("deepseek-v4-pro") === true,
"mixed-profile cards keep the complete model summary visible",
);
let refreshedProvider = "";
await act(async () => {
root.render(renderCard(group([deepSeekAnthropic, deepSeekLegacyOpenAI]), {
onRefresh: (provider) => {
refreshedProvider = provider.name;
},
}));
await flushPromises();
});
const refreshButtons = Array.from(rootEl.querySelectorAll("button"))
.filter((button) => button.textContent?.trim() === "Refresh models") as HTMLButtonElement[];
ok(refreshButtons.length === 2, "multi-profile cards expose one model refresh action per profile");
await act(async () => {
refreshButtons[1]?.click();
await flushPromises();
});
ok(refreshedProvider === "deepseek-pro", "profile refresh targets the selected provider instead of the first profile");
let removedProviders: string[] = [];
await act(async () => {
root.render(renderCard(group([deepSeekAnthropic, deepSeekLegacyOpenAI]), {
onDelete: async (providers) => {
removedProviders = providers.map((provider) => provider.name);
},
}));
await flushPromises();
});
const moreButton = rootEl.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
await act(async () => {
moreButton?.click();
await flushPromises();
});
let removeButton = Array.from(document.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Remove access") as HTMLButtonElement | undefined;
await act(async () => {
removeButton?.click();
await flushPromises();
});
removeButton = Array.from(document.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Confirm remove access") as HTMLButtonElement | undefined;
ok(removeButton !== undefined, "grouped provider removal requires inline confirmation");
await act(async () => {
removeButton?.click();
await flushPromises();
});
ok(
removedProviders.join(",") === "deepseek-flash,deepseek-pro",
"card-level removal submits every grouped provider in one action",
);
await act(async () => {
root.render(renderCard(group([
deepSeekAnthropic,
{ ...deepSeekAnthropic, name: "deepseek-pro", models: ["deepseek-v4-pro"], default: "deepseek-v4-pro", webSearch: false },
])));
await flushPromises();
});
const mixedStateSwitch = rootEl.querySelector<HTMLInputElement>('input[role="switch"]');
ok(mixedStateSwitch !== null, "all supported grouped profiles expose server web search");
ok(mixedStateSwitch?.checked === false, "grouped switch is off until every profile has web search enabled");
await act(async () => {
root.render(renderCard(group([
deepSeekAnthropic,
{ ...deepSeekAnthropic, name: "deepseek-pro", models: ["deepseek-v4-pro"], default: "deepseek-v4-pro" },
])));
await flushPromises();
});
ok(
rootEl.querySelector<HTMLInputElement>('input[role="switch"]')?.checked === true,
"grouped switch is on when every supported profile has web search enabled",
);
await act(async () => {
root.render(
<LocaleProvider>
<AddProviderPanel
mode="official"
kinds={["anthropic", "openai"]}
officialProviders={[{ ...deepSeekAnthropic, name: "deepseek" }]}
providerPresets={[]}
busy={false}
onMode={() => undefined}
onCancel={() => undefined}
onAddOfficial={async () => undefined}
onAddPreset={async () => undefined}
onViewPresetConflict={() => undefined}
onResetPreset={async () => undefined}
onAddCustom={() => undefined}
/>
</LocaleProvider>,
);
await flushPromises();
});
const installedOfficialChoice = Array.from(rootEl.querySelectorAll("button"))
.find((button) => button.textContent?.includes("DeepSeek"));
ok(installedOfficialChoice?.disabled === true, "installed official providers cannot be added again");
ok(installedOfficialChoice?.textContent?.includes("Added") === true, "installed official providers show their backend status");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -5,7 +5,13 @@ import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { LocaleProvider } from "../lib/i18n";
import { ProviderEditor, ProviderEditorModelPicker, providerSupportsServerWebSearch } from "../components/SettingsPanel";
import {
ProviderEditor,
ProviderEditorModelPicker,
providerSupportsServerWebSearch,
providerSupportsServerWebSearchForView,
providerVisionCapabilityForView,
} from "../components/SettingsPanel";
import type { ProviderView } from "../lib/types";
let passed = 0;
@@ -102,6 +108,54 @@ ok(!providerSupportsServerWebSearch("responses", "https://relay.deepseek.com"),
ok(!providerSupportsServerWebSearch("responses", "https://api.deepseek.com/anthropic"), "Responses rejects the Anthropic base path");
ok(!providerSupportsServerWebSearch("anthropic", "https://api.deepseek.com"), "Anthropic requires its documented base path");
ok(!providerSupportsServerWebSearch("responses", "http://api.deepseek.com"), "insecure DeepSeek URLs do not inherit official defaults");
ok(
providerSupportsServerWebSearchForView({
kind: "anthropic",
baseUrl: "https://gateway.example/anthropic",
serverWebSearchCapability: true,
}),
"backend verification can enable server-side web search for future curated endpoints",
);
ok(
!providerSupportsServerWebSearchForView({
kind: "anthropic",
baseUrl: "https://api.deepseek.com/anthropic",
serverWebSearchCapability: false,
}),
"backend capability overrides endpoint inference when explicitly unsupported",
);
ok(
providerSupportsServerWebSearchForView({ kind: "anthropic", baseUrl: "https://api.deepseek.com/anthropic" }),
"older backend payloads retain the exact DeepSeek endpoint fallback",
);
ok(
!providerSupportsServerWebSearchForView({ kind: "anthropic", baseUrl: "https://gateway.example/anthropic" }),
"older backend payloads do not infer support for arbitrary compatible endpoints",
);
ok(
providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://custom.example/v1", visionCapability: "unsupported" }) === "unsupported",
"backend vision capability overrides frontend endpoint inference",
);
ok(
providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://api.deepseek.com" }) === "unsupported",
"older backend payloads retain the endpoint-based vision fallback",
);
ok(
providerVisionCapabilityForView({ kind: "anthropic", baseUrl: "https://eu.deepseek.com/anthropic" }) === "unsupported",
"older backend payloads treat regional DeepSeek subdomains as text-only",
);
ok(
providerVisionCapabilityForView({ kind: "responses", baseUrl: "https://deepseek.com" }) === "configurable",
"the DeepSeek apex does not inherit official vision restrictions",
);
ok(
providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://deepseek.com.example/v1" }) === "configurable",
"lookalike domains with a DeepSeek prefix do not inherit official vision restrictions",
);
ok(
providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://api.deepseek.com.example/v1" }) === "configurable",
"lookalike domains with an official-host prefix remain configurable",
);
const builtInProvider: ProviderView = {
name: "deepseek",
@@ -132,12 +186,35 @@ const deepSeekResponsesProvider: ProviderView = {
models: ["deepseek-v4-flash"],
default: "deepseek-v4-flash",
webSearch: true,
serverWebSearchCapability: true,
};
const longCatAnthropicProvider: ProviderView = {
...builtInProvider,
name: "longcat-anthropic",
builtIn: false,
kind: "anthropic",
baseUrl: "https://api.longcat.chat/anthropic",
models: ["LongCat-2.0"],
default: "LongCat-2.0",
serverWebSearchCapability: false,
};
const backendUnsupportedCustomProvider: ProviderView = {
...builtInProvider,
name: "deepseek-regional",
builtIn: false,
baseUrl: "https://eu.deepseek.com/v1",
models: ["deepseek-v4-pro"],
default: "deepseek-v4-pro",
visionCapability: "unsupported",
};
function renderProviderEditor(initial?: ProviderView) {
return (
<LocaleProvider>
<ProviderEditor
key={initial?.name ?? "new-provider"}
initial={initial}
kinds={["openai"]}
busy={false}
@@ -180,6 +257,22 @@ ok(rootEl.textContent?.includes("Server-side web search") === true, "DeepSeek Re
ok(webSearchSwitch?.checked === true, "curated DeepSeek Responses capability is enabled in the editor");
ok(rootEl.textContent?.includes("No image input") === true, "DeepSeek Responses editor labels image input as unsupported");
await act(async () => {
root.render(renderProviderEditor(longCatAnthropicProvider));
await flushPromises();
});
ok(rootEl.textContent?.includes("Server-side web search") === false, "unverified LongCat Anthropic endpoint hides server-side web search");
ok(rootEl.querySelector<HTMLInputElement>('input[role="switch"]') === null, "unverified LongCat endpoint exposes no recommended web-search switch");
await act(async () => {
root.render(renderProviderEditor(backendUnsupportedCustomProvider));
await flushPromises();
});
ok(
rootEl.textContent?.includes("No image input") === true,
"provider editor honors backend vision capability for endpoints outside the legacy frontend heuristic",
);
await act(async () => {
root.unmount();
});
@@ -2,6 +2,7 @@
import {
apiKeyEnvFromProviderName,
createLatestRequestGate,
inferredVisionModels,
isLikelyChatModel,
isLikelyVisionModel,
@@ -14,7 +15,9 @@ import {
providerModelContextWindowDrafts,
providerModelContextWindowIsSmall,
providerRequiresKey,
removeProviderAccessesForMock,
} from "../lib/providerModels";
import type { ProviderView } from "../lib/types";
let passed = 0;
let failed = 0;
@@ -31,6 +34,21 @@ function eq(a: unknown, b: unknown, label: string) {
console.log("\nprovider model refresh");
const requestGate = createLatestRequestGate();
const firstRefresh = requestGate.begin("builtin:deepseek");
const secondRefresh = requestGate.begin("builtin:deepseek");
eq(
[requestGate.isCurrent("builtin:deepseek", firstRefresh), requestGate.isCurrent("builtin:deepseek", secondRefresh)],
[false, true],
"newer grouped-provider refresh invalidates an older in-flight completion",
);
requestGate.cancel("builtin:deepseek");
eq(
requestGate.isCurrent("builtin:deepseek", secondRefresh),
false,
"provider edits invalidate an in-flight model refresh before it can publish stale state",
);
eq(
mergedFetchedProviderModels(["coding-pro"], ["coding-pro", "chat", "vision"]),
["coding-pro", "chat", "vision"],
@@ -133,6 +151,7 @@ const contextOverrides = [
defaultEffort: "",
vision: null,
contextWindow: 32768,
maxOutputTokens: -1,
},
{
model: "long-model",
@@ -175,6 +194,29 @@ eq(
"drops non-finite model context values before crossing the desktop bridge",
);
eq(
mergeProviderModelContextWindows([
{
model: "output-only",
reasoningProtocol: "",
supportedEfforts: [],
defaultEffort: "",
vision: null,
maxOutputTokens: 4096,
},
], ["output-only"], { "output-only": "" }),
[{
model: "output-only",
reasoningProtocol: "",
supportedEfforts: [],
defaultEffort: "",
vision: null,
contextWindow: 0,
maxOutputTokens: 4096,
}],
"keeps output-only model overrides while editing context windows",
);
eq(
[
providerModelContextWindowIsSmall("8192"),
@@ -238,5 +280,21 @@ eq(
"separates provider selectability from key presence for no-auth providers",
);
const mockProviders = [
{ name: "deepseek", builtIn: true, added: true },
{ name: "custom", builtIn: false, added: true },
] as ProviderView[];
const removedMockProviders = removeProviderAccessesForMock(mockProviders, ["deepseek", "custom"]);
eq(
removedMockProviders.map(({ name, added }) => ({ name, added })),
[{ name: "deepseek", added: false }],
"mock access removal hides built-ins and deletes custom providers",
);
eq(
mockProviders.map(({ name, added }) => ({ name, added })),
[{ name: "deepseek", added: true }, { name: "custom", added: true }],
"mock access removal does not mutate the previous settings snapshot",
);
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -22,6 +22,12 @@ import {
createDefaultTypographyPreferences,
getTypographyPreferences,
} from "../lib/typographyPreferences";
import {
baseSettings,
flushPromises,
installCanvasMock,
waitFor,
} from "../test-support/settingsTestFixtures";
let passed = 0;
let failed = 0;
@@ -44,113 +50,6 @@ function eq(actual: unknown, expected: unknown, label: string) {
}
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function installCanvasMock(win: Window) {
Object.defineProperty(win.HTMLCanvasElement.prototype, "getContext", {
configurable: true,
value(type: string) {
if (type !== "2d") return null;
return {
font: "",
measureText: () => ({ width: 0 }),
} as unknown as CanvasRenderingContext2D;
},
});
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await act(async () => {
await flushPromises();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
function baseSettings(displayMode: "standard" | "compact" = "standard"): SettingsView {
return {
defaultModel: "",
plannerModel: "",
subagentModel: "",
subagentEffort: "",
autoPlan: "off",
providers: [],
officialProviders: [],
providerPresets: [],
permissions: { mode: "ask", allow: [], ask: [], deny: [] },
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto" },
network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } },
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto", compactRatio: 0.8 },
bot: {
enabled: false,
model: "",
toolApprovalMode: "",
maxSteps: 0,
debounceMs: 0,
queueMode: "steer",
queueCap: 20,
queueDrop: "summarize",
ignoreSelfMessages: true,
selfUserIds: { qq: [], feishu: [], weixin: [] },
control: { enabled: false, addr: "127.0.0.1:37913", tokenEnv: "REASONIX_BOT_CONTROL_TOKEN" },
pairing: { enabled: true, requestTtlMinutes: 60, maxPendingPerPlatform: 3 },
routes: [],
allowlist: {
enabled: false,
allowAll: false,
qqUsers: [],
feishuUsers: [],
weixinUsers: [],
qqApprovers: [],
feishuApprovers: [],
weixinApprovers: [],
qqAdmins: [],
feishuAdmins: [],
weixinAdmins: [],
qqGroups: [],
feishuGroups: [],
weixinGroups: [],
},
qq: {
enabled: false,
appId: "",
appSecretEnv: "",
secretSet: false,
sandbox: false,
model: "",
toolApprovalMode: "ask",
workspaceRoot: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] },
},
feishu: { enabled: false, domain: "feishu", appId: "", appSecretEnv: "", secretSet: false, verificationToken: "", mode: "webhook", webhookPort: 0, requireMention: false },
weixin: { enabled: false, accountId: "", tokenEnv: "", tokenSet: false, apiBase: "" },
connections: [],
},
desktopLanguage: "en",
desktopLayoutStyle: "workbench",
desktopTheme: "auto",
desktopThemeStyle: "graphite",
desktopTerminalTheme: "auto",
closeBehavior: "background",
displayMode,
statusBarStyle: "text",
statusBarItems: ["model", "workspace", "git_branch", "cache", "balance"],
defaultToolApprovalMode: "auto",
checkUpdates: true,
updateChannel: "stable",
telemetry: true,
metrics: true,
configPath: "/tmp/reasonix/config.toml",
providerKinds: [],
autoApproveTools: false,
bypass: false,
};
}
console.log("\nsettings refresh snapshot");
const nullableProvider = normalizeProviderView({
@@ -167,6 +66,21 @@ const glmProvider = normalizeProviderView({
} as ProviderView);
eq(glmProvider.reasoningProtocol, "glm", "provider snapshots preserve the explicit GLM reasoning protocol");
const serverWebSearchProvider = normalizeProviderView({
name: "custom-anthropic",
kind: "anthropic",
baseUrl: "https://gateway.example/anthropic",
serverWebSearchCapability: true,
} as ProviderView);
eq(serverWebSearchProvider.serverWebSearchCapability, true, "provider snapshots preserve backend server web-search capability");
const legacyServerWebSearchProvider = normalizeProviderView({
name: "legacy-anthropic",
kind: "anthropic",
baseUrl: "https://api.deepseek.com/anthropic",
} as ProviderView);
eq(legacyServerWebSearchProvider.serverWebSearchCapability, undefined, "older provider snapshots keep an absent capability distinguishable");
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "anthropic", "new custom providers keep the selected Anthropic-compatible kind");
eq(providerEditorEffectiveKind(false, "anthropic", ["anthropic", "openai"]), "anthropic", "existing providers preserve their stored kind");
eq(providerChatURLPreview("https://proxy.example.com/v1", "", false), "https://proxy.example.com/v1/chat/completions", "base URL mode previews chat completions URL");
@@ -783,6 +697,225 @@ eq(providerCatalogSaveCalls, 0, "leaving the models usage tab suppresses the sta
await act(async () => {
providerRaceRoot.unmount();
});
// Cancelling a freshly fetched model catalog must also clear the success copy
// that asks the user to confirm and save that now-hidden draft.
const providerRefreshCancelRootEl = document.createElement("div");
document.body.appendChild(providerRefreshCancelRootEl);
const providerRefreshCancelRoot = createRoot(providerRefreshCancelRootEl);
const providerRefreshCancelSettings = baseSettings("standard");
providerRefreshCancelSettings.defaultModel = "deepseek/deepseek-v4-flash";
providerRefreshCancelSettings.providers = [{
name: "deepseek",
builtIn: true,
added: true,
kind: "anthropic",
baseUrl: "https://api.deepseek.com/anthropic",
chatUrl: "",
models: ["deepseek-v4-flash"],
visionModels: [],
visionModelsConfigured: true,
visionCapability: "unsupported",
modelsUrl: "https://api.deepseek.com/models",
default: "deepseek-v4-flash",
apiKeyEnv: "DEEPSEEK_API_KEY",
keySet: true,
requiresKey: true,
configured: true,
balanceUrl: "https://api.deepseek.com/user/balance",
contextWindow: 1_000_000,
reasoningProtocol: "",
thinking: "enabled",
webSearch: true,
serverWebSearchCapability: true,
supportedEfforts: [],
defaultEffort: "",
}];
window.go = {
main: {
App: {
Settings: async () => providerRefreshCancelSettings,
FetchAllProviderModels: async () => ({}),
FetchProviderModels: async () => ["deepseek-v4-flash", "deepseek-v4-pro"],
} as Partial<AppBindings> as AppBindings,
},
};
await act(async () => {
providerRefreshCancelRoot.render(
<LocaleProvider>
<SettingsPanel initialTab="models" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
</LocaleProvider>,
);
await flushPromises();
});
const providerRefreshCancelAccessButton = Array.from(providerRefreshCancelRootEl.querySelectorAll(".settings-subtab")).find(
(button) => button.textContent?.trim() === "Access",
) as HTMLButtonElement | undefined;
if (!providerRefreshCancelAccessButton) throw new Error("provider refresh cancel Access subtab did not render");
await act(async () => {
providerRefreshCancelAccessButton.click();
await flushPromises();
});
const providerRefreshCancelButton = Array.from(providerRefreshCancelRootEl.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Refresh models",
) as HTMLButtonElement | undefined;
if (!providerRefreshCancelButton) throw new Error("provider refresh action did not render");
await act(async () => {
providerRefreshCancelButton.click();
await flushPromises();
});
await waitFor(
"provider model draft",
() => providerRefreshCancelRootEl.textContent?.includes("Found 2 models for DeepSeek Official. Review and save the enabled list.") === true,
);
const providerModelDraftCancelButton = providerRefreshCancelRootEl.querySelector<HTMLButtonElement>(
".provider-model-draft__actions button",
);
if (!providerModelDraftCancelButton) throw new Error("provider model draft cancel action did not render");
await act(async () => {
providerModelDraftCancelButton.click();
await flushPromises();
});
ok(
providerRefreshCancelRootEl.textContent?.includes("Found 2 models for DeepSeek Official. Review and save the enabled list.") === false,
"cancelling a provider model draft clears its stale save instruction",
);
ok(
providerRefreshCancelRootEl.querySelector(".provider-model-draft") === null,
"cancelling a provider model draft closes the candidate editor",
);
await act(async () => {
providerRefreshCancelRoot.unmount();
});
// A settings mutation may persist before a workspace-specific runtime rebuild
// fails. The panel must re-read the authoritative snapshot on that error so an
// already-completed protocol upgrade is not offered again.
const upgradeFailureRootEl = document.createElement("div");
document.body.appendChild(upgradeFailureRootEl);
const upgradeFailureRoot = createRoot(upgradeFailureRootEl);
let upgradeFailureSettings = baseSettings("standard");
upgradeFailureSettings.defaultModel = "deepseek/deepseek-v4-flash";
upgradeFailureSettings.providers = [{
name: "deepseek-flash",
builtIn: true,
added: true,
kind: "openai",
baseUrl: "https://api.deepseek.com",
chatUrl: "",
models: ["deepseek-v4-flash"],
visionModels: [],
visionModelsConfigured: true,
visionCapability: "unsupported",
modelsUrl: "https://api.deepseek.com/models",
default: "deepseek-v4-flash",
apiKeyEnv: "DEEPSEEK_API_KEY",
keySet: true,
requiresKey: true,
configured: true,
balanceUrl: "https://api.deepseek.com/user/balance",
contextWindow: 1_000_000,
reasoningProtocol: "deepseek",
thinking: "enabled",
webSearch: false,
serverWebSearchCapability: false,
supportedEfforts: ["low", "high", "max"],
defaultEffort: "high",
recommendedUpgradeAvailable: true,
}];
let upgradeFailureSettingsCalls = 0;
let upgradeFailureMutationCalls = 0;
let upgradeFailureChanged: SettingsView | undefined;
window.go = {
main: {
App: {
Settings: async () => {
upgradeFailureSettingsCalls += 1;
return upgradeFailureSettings;
},
FetchAllProviderModels: async () => ({}),
UpgradeDeepSeekProviderAccess: async () => {
upgradeFailureMutationCalls += 1;
upgradeFailureSettings = {
...upgradeFailureSettings,
providers: upgradeFailureSettings.providers.map((provider) => ({
...provider,
kind: "anthropic",
baseUrl: "https://api.deepseek.com/anthropic",
webSearch: true,
serverWebSearchCapability: true,
recommendedUpgradeAvailable: false,
})),
};
throw new Error("workspace runtime boot failed after protocol upgrade");
},
} as Partial<AppBindings> as AppBindings,
},
};
await act(async () => {
upgradeFailureRoot.render(
<LocaleProvider>
<SettingsPanel
initialTab="models"
desktopPlatform="linux"
onClose={() => {}}
onChanged={(settings?: SettingsView) => {
upgradeFailureChanged = settings;
}}
/>
</LocaleProvider>,
);
await flushPromises();
});
const upgradeFailureAccessButton = Array.from(upgradeFailureRootEl.querySelectorAll(".settings-subtab")).find(
(button) => button.textContent?.trim() === "Access",
) as HTMLButtonElement | undefined;
if (!upgradeFailureAccessButton) throw new Error("upgrade failure Access subtab did not render");
await act(async () => {
upgradeFailureAccessButton.click();
await flushPromises();
});
await waitFor(
"legacy DeepSeek protocol upgrade action",
() => upgradeFailureRootEl.textContent?.includes("Upgrade to recommended protocol") === true,
);
let upgradeFailureButton = Array.from(upgradeFailureRootEl.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Upgrade to recommended protocol"),
) as HTMLButtonElement | undefined;
if (!upgradeFailureButton) throw new Error("DeepSeek protocol upgrade button did not render");
await act(async () => {
upgradeFailureButton?.click();
await flushPromises();
});
upgradeFailureButton = upgradeFailureRootEl.querySelector<HTMLButtonElement>(
".provider-protocol-upgrade .inline-confirm > button",
) ?? undefined;
if (upgradeFailureButton?.textContent?.trim() !== "Confirm") throw new Error("DeepSeek protocol upgrade confirmation did not render");
await act(async () => {
upgradeFailureButton?.click();
await flushPromises();
});
await waitFor("post-error settings reload", () => upgradeFailureSettingsCalls === 2);
eq(upgradeFailureMutationCalls, 1, "DeepSeek protocol upgrade mutation is invoked once");
ok(
upgradeFailureRootEl.textContent?.includes("Upgrade to recommended protocol") === false,
"persisted DeepSeek protocol upgrade disappears after a runtime refresh error",
);
ok(
upgradeFailureRootEl.textContent?.includes("workspace runtime boot failed after protocol upgrade") === true,
"post-mutation reload preserves the original runtime error",
);
ok(
upgradeFailureChanged?.providers[0]?.kind === "anthropic",
"onChanged receives the authoritative persisted protocol after a runtime error",
);
await act(async () => {
upgradeFailureRoot.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
@@ -27,6 +27,7 @@ const here = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
const bridgeSource = readFileSync(resolve(here, "../lib/bridge.ts"), "utf8");
const settingsSource = readFileSync(resolve(here, "../components/SettingsPanel.tsx"), "utf8");
const stylesSource = readFileSync(resolve(here, "../styles.css"), "utf8") + readFileSync(resolve(here, "../components/ProviderAccessSettings.css"), "utf8");
const enLocaleSource = readFileSync(resolve(here, "../locales/en.ts"), "utf8");
const zhLocaleSource = readFileSync(resolve(here, "../locales/zh.ts"), "utf8");
const zhTWLocaleSource = readFileSync(resolve(here, "../locales/zh-TW.ts"), "utf8");
@@ -71,18 +72,22 @@ ok(
"DeepSeek Responses preset uses a localized description",
);
ok(
/case "deepseek-anthropic":\s*return t\("settings\.addProvider\.preset\.deepseekAnthropicDesc"\)/.test(settingsSource),
"DeepSeek Anthropic preset uses a localized description",
!/case "deepseek-anthropic":\s*return t\("settings\.addProvider\.preset\.deepseekAnthropicDesc"\)/.test(settingsSource),
"redundant DeepSeek Anthropic preset is not separately localized in the provider list",
);
ok(
/case "token-rhythm":\s*return t\("settings\.addProvider\.preset\.tokenRhythmDesc"\)/.test(settingsSource) &&
/preset\.id === "token-rhythm"\) return t\("settings\.addProvider\.preset\.tokenRhythmLabel"\)/.test(settingsSource),
"Token Rhythm preset localizes the English and Chinese brand names",
/case "deepseek-responses":\s*return t\("settings\.addProvider\.preset\.deepseekResponsesLabel"\)/.test(settingsSource) &&
!/case "deepseek-anthropic":\s*return t\("settings\.addProvider\.preset\.deepseekAnthropicLabel"\)/.test(settingsSource) &&
/case "token-rhythm":\s*return t\("settings\.addProvider\.preset\.tokenRhythmLabel"\)/.test(settingsSource),
"visible official protocol presets and Token Rhythm localize their display names",
);
ok(
[enLocaleSource, zhLocaleSource, zhTWLocaleSource].every((source) =>
source.includes('"settings.addProvider.preset.deepseekResponsesDesc"') &&
source.includes('"settings.addProvider.preset.deepseekAnthropicDesc"') &&
source.includes('"settings.addProvider.preset.deepseekResponsesLabel"') &&
!source.includes('"settings.addProvider.preset.deepseekAnthropicDesc"') &&
!source.includes('"settings.addProvider.preset.deepseekAnthropicLabel"') &&
source.includes('"settings.addProvider.preset.tokenRhythmLabel"') &&
source.includes('"settings.addProvider.preset.tokenRhythmDesc"'),
),
@@ -116,16 +121,105 @@ ok(
"reasoning-summary switch labels are localized in every supported locale",
);
ok(
/mockPreset\("deepseek-anthropic",\s*"DeepSeek Anthropic"/.test(bridgeSource),
"browser mock exposes the DeepSeek Anthropic preset",
!/mockPreset\("deepseek-anthropic",/.test(bridgeSource),
"browser mock hides the redundant DeepSeek Anthropic preset",
);
ok(
bridgeSource.includes('value === "deepseek-upgrade"') &&
bridgeSource.includes('recommendedUpgradeAvailable: deepSeekUpgradeMock') &&
bridgeSource.includes('headers: deepSeekUpgradeMock ? { "X-Route": "official-custom" } : undefined'),
"browser mock can preview the customized legacy DeepSeek upgrade flow",
);
ok(
/async ConnectKey\(apiKey: string\)[\s\S]*?await this\.AddOfficialProviderAccess\("deepseek", apiKey\)/.test(bridgeSource),
"browser onboarding installs the same current DeepSeek official template as production",
);
ok(
settingsSource.includes("officialProviders={s.officialProviders}") &&
settingsSource.includes("added: Boolean(state?.added)") &&
settingsSource.includes("keySet: Boolean(state?.keySet)"),
"official provider templates honor the backend installed state",
);
ok(
/onUpgradeRecommended=\{\(name\) => \{[\s\S]*?cancelGroupFetch\(group\.id\);[\s\S]*?return apply\(\(\) => app\.UpgradeDeepSeekProviderAccess\(name\)\)/.test(settingsSource) &&
settingsSource.includes("onConfirm={() => onUpgradeRecommended(canonicalOfficialProviderName(upgradeProvider.name))}") &&
settingsSource.includes('className="provider-protocol-upgrade"') &&
settingsSource.includes('t("settings.providerProtocol")}: OpenAI Chat Completions') &&
/<InlineConfirmButton[\s\S]*?label=\{<>\{t\("settings\.upgradeRecommendedProtocol"\)\}[\s\S]*?primary[\s\S]*?onConfirm=\{\(\) => onUpgradeRecommended/.test(settingsSource),
"legacy official DeepSeek cards expose an explicit recommended-protocol action",
);
ok(
settingsSource.includes("const providerNames = group.providers.map((provider) => provider.name)") &&
settingsSource.includes("app.SetProviderWebSearch(providerNames, enabled)") &&
!settingsSource.includes("app.SaveProvider({ ...provider, webSearch: enabled })"),
"grouped DeepSeek profiles update server-side web search through one atomic backend call",
);
ok(
/<div className="provider-access-card__actions">[\s\S]*?<ProviderAccessMoreMenu[\s\S]*?<\/div>\s*<\/div>\s*\{group\.description && <div className="provider-access-card__desc">[\s\S]*?\{upgradeProvider && \(/.test(settingsSource) &&
settingsSource.includes('className="provider-access-more__menu"') &&
settingsSource.includes('buttonRole="menuitem"') &&
stylesSource.includes(".provider-protocol-upgrade") &&
stylesSource.includes(".provider-access-more__menu"),
"provider protocol migration stays in a stable row and removal lives in the overflow menu",
);
ok(
settingsSource.includes('className="provider-technical-details"') &&
settingsSource.includes('t("settings.providerCapabilities")') &&
settingsSource.includes('showModelSummary && (') &&
settingsSource.includes('hiddenModelCount={hiddenModelCount ?? 0}') &&
!settingsSource.includes('className="provider-capability-badges"') &&
!settingsSource.includes('t("settings.anthropicCompatible")') &&
stylesSource.includes('.provider-card-block--inline') &&
stylesSource.includes('.provider-technical-details'),
"provider cards keep descriptions out of the action row and collapse repeated diagnostics into compact details",
);
ok(
!settingsSource.includes("return p.baseUrl;") &&
settingsSource.includes("group.providers.every(providerSupportsServerWebSearchForView)") &&
settingsSource.includes("group.providers.every((provider) => Boolean(provider.webSearch))") &&
settingsSource.includes("supported={supportsServerWebSearch}"),
"all provider cards keep endpoint details collapsed and use backend web-search capability authority",
);
ok(
/existing\.recommendedUpgradeAvailable = existing\.recommendedUpgradeAvailable \|\| Boolean\(p\.recommendedUpgradeAvailable\)/.test(settingsSource) &&
/case "deepseek-flash":\s*case "deepseek-pro":\s*return "deepseek";/.test(settingsSource),
"legacy DeepSeek aliases remain grouped into one official provider card",
);
ok(
settingsSource.includes('className="btn btn--small provider-profile-row__refresh"') &&
settingsSource.includes('className="btn btn--small provider-profile-row__configure"') &&
stylesSource.includes('"profile-refresh profile-configure"') &&
stylesSource.includes(".provider-profile-row__refresh") &&
stylesSource.includes(".provider-profile-row__configure"),
"multi-profile provider actions move into stable narrow-screen grid areas",
);
ok(
/@media \(max-width: 900px\)[\s\S]*?\.settings-section__head\s*\{[\s\S]*?flex-direction:\s*column;[\s\S]*?align-items:\s*stretch;/.test(stylesSource),
"narrow settings section headings stretch so descriptions wrap inside the viewport",
);
ok(
[enLocaleSource, zhLocaleSource, zhTWLocaleSource].every((source) =>
source.includes('"settings.upgradeRecommendedProtocol"') &&
source.includes('"settings.providerAccess"') &&
source.includes('"settings.providerBaseUrlLabel"') &&
source.includes('"settings.providerApiKeyEnv"') &&
!source.includes('"settings.anthropicCompatible"'),
),
"the compact DeepSeek access and upgrade flows are localized in every supported locale",
);
ok(
enLocaleSource.includes('"settings.serverWebSearchHint": "Searches only when needed; content is sent to the current model provider') &&
zhLocaleSource.includes('"settings.serverWebSearchHint": "仅在需要时搜索;内容会发送给当前供应商') &&
zhTWLocaleSource.includes('"settings.serverWebSearchHint": "僅在需要時搜尋;內容會傳送給目前供應商'),
"server web-search disclosure stays provider-neutral for compatible services",
);
ok(
/mockPreset\("token-rhythm",\s*"Token Rhythm"/.test(bridgeSource),
"browser mock exposes the Token Rhythm preset",
);
ok(
/function mockProviderPresetDisplayRank\(id: string\): number \{\s*if \(id === "deepseek-responses"\) return -1;\s*if \(id === "deepseek-anthropic"\) return 0;/.test(bridgeSource),
"browser mock ranks Responses first and Anthropic as the adjacent compatibility preset",
/function mockProviderPresetDisplayRank\(id: string\): number \{\s*if \(id === "deepseek-responses"\) return -2;/.test(bridgeSource),
"browser mock keeps the visible DeepSeek Responses preset first",
);
const values = new Map<string, string>();
@@ -1,21 +1,26 @@
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
// Row-level destructive actions should confirm in place instead of opening a
// global modal. First click arms the action, second click confirms it, and the
// adjacent Cancel button or any disabled state returns the button to normal.
// Compact row and menu actions confirm in place instead of opening a global
// modal. First click arms the action, second click confirms it, and the adjacent
// Cancel button or any disabled state returns the button to normal.
export function InlineConfirmButton({
label,
confirmLabel,
cancelLabel,
disabled = false,
danger = false,
primary = false,
buttonRole,
onConfirm,
}: {
label: string;
confirmLabel: string;
cancelLabel: string;
label: ReactNode;
confirmLabel: ReactNode;
cancelLabel: ReactNode;
disabled?: boolean;
danger?: boolean;
primary?: boolean;
buttonRole?: "menuitem";
onConfirm: () => void | Promise<void>;
}) {
const [armed, setArmed] = useState(false);
@@ -34,17 +39,18 @@ export function InlineConfirmButton({
};
return (
<span className="inline-confirm">
<span className="inline-confirm" role={buttonRole ? "none" : undefined}>
<button
className={`btn btn--small${armed && danger ? " btn--danger" : ""}`}
className={`btn btn--small${armed && danger ? " btn--danger" : primary ? " btn--primary" : ""}`}
disabled={disabled}
type="button"
role={buttonRole}
onClick={run}
>
{armed ? confirmLabel : label}
</button>
{armed && (
<button className="btn btn--small" disabled={disabled} type="button" onClick={() => setArmed(false)}>
<button className="btn btn--small" disabled={disabled} type="button" role={buttonRole} onClick={() => setArmed(false)}>
{cancelLabel}
</button>
)}
@@ -0,0 +1,147 @@
.provider-protocol-upgrade {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-inline: -14px;
padding: 11px 14px;
border-top: 1px solid var(--border-soft);
border-bottom: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--accent) 5%, var(--bg-elev-2));
}
.provider-protocol-upgrade__copy {
flex: 1 1 360px;
min-width: 0;
}
.provider-protocol-upgrade__title {
font-size: var(--font-control-small);
font-weight: 650;
line-height: 1.35;
}
.provider-protocol-upgrade__desc {
margin-top: 3px;
color: var(--fg-dim);
font-size: var(--text-2xs);
}
.provider-protocol-upgrade__actions {
flex: 0 0 auto;
}
.provider-protocol-upgrade__actions .inline-confirm {
flex-wrap: wrap;
justify-content: flex-end;
}
.provider-access-more__trigger {
width: var(--button-small-height);
padding-inline: 0;
}
.provider-access-more__menu {
min-width: 188px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-elev);
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.42);
}
.provider-access-more__items .inline-confirm {
flex-direction: column;
align-items: stretch;
width: 100%;
}
.provider-access-more__items .btn {
width: 100%;
justify-content: flex-start;
}
.provider-access-more__items .btn:not(.btn--danger) {
border-color: transparent;
background: transparent;
}
.provider-access-more__items .btn:not(.btn--danger):hover,
.provider-access-more__items .btn:not(.btn--danger):focus-visible {
background: var(--bg-soft);
color: var(--fg);
}
.provider-card-block--inline {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.provider-technical-details {
padding-top: 8px;
border-top: 1px solid var(--border-soft);
color: var(--fg-dim);
}
.provider-technical-details > summary {
display: inline-flex;
align-items: center;
gap: 6px;
list-style: none;
cursor: pointer;
font-size: var(--text-2xs);
font-weight: 650;
user-select: none;
}
.provider-technical-details > summary::-webkit-details-marker {
display: none;
}
.provider-technical-details > summary::after {
content: "›";
color: var(--fg-muted);
font-size: 15px;
line-height: 1;
}
.provider-technical-details[open] > summary::after {
transform: rotate(90deg);
}
.provider-technical-details dl {
display: grid;
gap: 7px;
margin: 9px 0 0;
padding: 10px 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--bg-elev-1) 68%, transparent);
}
.provider-technical-details dl > div {
display: grid;
grid-template-columns: minmax(90px, 0.28fr) minmax(0, 1fr);
gap: 12px;
}
.provider-technical-details dt {
color: var(--fg-muted);
font-size: var(--text-2xs);
}
.provider-technical-details dd {
min-width: 0;
margin: 0;
font-family: var(--mono);
font-size: var(--text-2xs);
overflow-wrap: anywhere;
}
@media (max-width: 980px) {
.provider-protocol-upgrade {
flex-wrap: wrap;
}
}
@media (max-width: 760px) {
.provider-card-block--inline,
.provider-technical-details dl > div {
grid-template-columns: 1fr;
}
.provider-card-block--inline {
gap: 6px;
}
.provider-technical-details dl > div {
gap: 2px;
}
.provider-protocol-upgrade {
align-items: flex-start;
}
.provider-protocol-upgrade__actions {
width: 100%;
}
.provider-protocol-upgrade__actions .inline-confirm {
justify-content: flex-start;
}
}
+430 -136
View File
@@ -1,10 +1,10 @@
import { lazy, memo, Suspense, startTransition, useCallback, useDeferredValue, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent, type ReactNode } from "react";
import { Bot as BotIcon, Check, CheckCircle2, ChevronDown, ChevronUp, Clipboard, ExternalLink, GripVertical, KeyRound, Loader2, MessageCircle, Play, QrCode, RefreshCw, Send } from "lucide-react";
import { ArrowRight, Bot as BotIcon, Check, CheckCircle2, ChevronDown, ChevronUp, Clipboard, ExternalLink, GripVertical, KeyRound, Loader2, MessageCircle, MoreHorizontal, Play, QrCode, RefreshCw, Send, Trash2 } from "lucide-react";
import { asArray } from "../lib/array";
import { useDeferredClose } from "../lib/useMountTransition";
import { app, openExternal } from "../lib/bridge";
import { normalizeLangPref, useI18n, useT, type DictKey, type LangPref } from "../lib/i18n";
import { apiKeyEnvFromProviderName, inferredVisionModels, mergedFetchedProviderModels, mergeProviderModelContextWindows, providerApiKeyEnvForSave, providerDefaultModel, providerIsConfigured, providerModelCandidates, providerModelContextWindowDrafts, providerModelContextWindowIsSmall, providerRequiresKey } from "../lib/providerModels";
import { apiKeyEnvFromProviderName, createLatestRequestGate, inferredVisionModels, mergedFetchedProviderModels, mergeProviderModelContextWindows, providerApiKeyEnvForSave, providerDefaultModel, providerIsConfigured, providerModelCandidates, providerModelContextWindowDrafts, providerModelContextWindowIsSmall, providerRequiresKey } from "../lib/providerModels";
import { cachedFetchProviderModels, invalidateProviderCacheByAPIKeyEnv, shouldSkipAutoRefresh } from "../lib/providerModelCache";
import { useUpdater } from "../lib/useUpdater";
import {
@@ -210,6 +210,17 @@ export function SettingsPanel({
}
return true;
} catch (e) {
// Settings writes can be two-phase: persistence may succeed before a
// runtime refresh reports a real boot error. Re-read the authoritative
// state even on failure so the UI never offers an action that already
// committed (for example, a DeepSeek protocol upgrade).
try {
const next = await reload();
onChanged(next);
window.dispatchEvent(new Event("reasonix:model-catalog-changed"));
} catch {
// Keep the original mutation error; it is the actionable failure.
}
setErr(formatSettingsError(e, t));
return false;
} finally {
@@ -940,10 +951,21 @@ function formatSettingsError(error: unknown, t: ReturnType<typeof useT>): string
if (providerNotAdded) return t("settings.errorModelProviderMissing", { model: providerNotAdded[1], provider: providerNotAdded[2] });
const providerNoKey = /^model (.+) is not available because provider (.+) has no key$/i.exec(msg);
if (providerNoKey) return t("settings.errorModelProviderNoKey", { model: providerNoKey[1], provider: providerNoKey[2] });
if (/^background session is still open; reopen or close it before upgrading the DeepSeek provider protocol$/i.test(msg)) {
return t("settings.errorProviderDetached");
}
const removeAccessBusy = /^finish or cancel active work using (.+) before removing the provider access$/i.exec(msg);
if (removeAccessBusy) return t("settings.errorRemoveAccessBusy", { provider: removeAccessBusy[1] });
const removeAccessDetached = /^background session is still using (.+); reopen or close it before removing the provider access$/i.exec(msg);
if (removeAccessDetached) return t("settings.errorProviderDetached");
const removeAccessNoFallback = /^remove provider access: (.+) is in use and no other configured provider exists$/i.exec(msg);
if (removeAccessNoFallback) return t("settings.errorRemoveProviderNoFallback", { provider: removeAccessNoFallback[1] });
const deleteProviderNoFallback = /^remove provider: (.+) is in use and no other configured provider exists$/i.exec(msg);
if (deleteProviderNoFallback) return t("settings.errorRemoveProviderNoFallback", { provider: deleteProviderNoFallback[1] });
const deleteProviderBusy = /^finish or cancel active work using (.+) before deleting the provider$/i.exec(msg);
if (deleteProviderBusy) return t("settings.errorDeleteProviderBusy", { provider: deleteProviderBusy[1] });
const deleteProviderDetached = /^background session is still using (.+); reopen or close it before deleting the provider$/i.exec(msg);
if (deleteProviderDetached) return t("settings.errorProviderDetached");
const saveBeforeRemoveAccess = /^save current session before removing provider access: (.+)$/is.exec(msg);
if (saveBeforeRemoveAccess) return t("settings.errorSaveBeforeRemoveAccess", { err: saveBeforeRemoveAccess[1] });
const saveBeforeDeleteProvider = /^save current session before deleting provider: (.+)$/is.exec(msg);
@@ -1322,6 +1344,9 @@ export function normalizeProviderView(p: ProviderView): ProviderView {
models: asArray(p.models),
visionModels,
visionModelsConfigured: Boolean(p.visionModelsConfigured ?? visionModels.length > 0),
visionCapability: p.visionCapability === "unsupported" || p.visionCapability === "configurable"
? p.visionCapability
: undefined,
modelsUrl: p.modelsUrl ?? "",
headers: normalizeStringMap(p.headers),
extraBody: normalizeExtraBodyMap(p.extraBody),
@@ -1329,8 +1354,12 @@ export function normalizeProviderView(p: ProviderView): ProviderView {
reasoningProtocol: normalizeReasoningProtocol(p.reasoningProtocol),
thinking: normalizeThinkingMode(p.thinking),
webSearch: Boolean(p.webSearch),
serverWebSearchCapability: typeof p.serverWebSearchCapability === "boolean"
? p.serverWebSearchCapability
: undefined,
supportedEfforts: asArray(p.supportedEfforts),
modelOverrides: asArray(p.modelOverrides),
recommendedUpgradeAvailable: Boolean(p.recommendedUpgradeAvailable),
requiresKey,
configured: providerIsConfigured({ ...p, requiresKey }),
keySource: p.keySource ?? "",
@@ -4777,7 +4806,8 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
const [editing, setEditing] = useState<string | null>(null);
const [adding, setAdding] = useState<AddProviderMode>(null);
const [revealedProvider, setRevealedProvider] = useState<string | null>(null);
const [fetchingProvider, setFetchingProvider] = useState<string | null>(null);
const [fetchingProviders, setFetchingProviders] = useState<Set<string>>(() => new Set());
const fetchGate = useMemo(createLatestRequestGate, []);
const [fetchResults, setFetchResults] = useState<Record<string, ProviderFetchResult>>({});
const [modelDrafts, setModelDrafts] = useState<Record<string, ProviderModelDraft>>({});
const visibleProviders = useMemo(() => s.providers.filter((p) => p.added || p.name === revealedProvider), [s.providers, revealedProvider]);
@@ -4808,10 +4838,45 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
});
};
const beginGroupFetch = (groupID: string): number => {
const generation = fetchGate.begin(groupID);
setFetchingProviders((current) => {
if (current.has(groupID)) return current;
const next = new Set(current);
next.add(groupID);
return next;
});
return generation;
};
const groupFetchIsCurrent = (groupID: string, generation: number): boolean => (
fetchGate.isCurrent(groupID, generation)
);
const finishGroupFetch = (groupID: string, generation: number) => {
if (!groupFetchIsCurrent(groupID, generation)) return;
setFetchingProviders((current) => {
if (!current.has(groupID)) return current;
const next = new Set(current);
next.delete(groupID);
return next;
});
};
const cancelGroupFetch = (groupID: string) => {
fetchGate.cancel(groupID);
setFetchingProviders((current) => {
if (!current.has(groupID)) return current;
const next = new Set(current);
next.delete(groupID);
return next;
});
};
const modelDraftForFetch = (p: ProviderView, fetched: string[]): ProviderModelDraft => {
const candidates = providerModelCandidates(p.models, fetched);
const selected = mergedFetchedProviderModels(p.models, fetched, { preserveCurated: true });
const visionCapability = providerVisionCapability(p.kind, p.baseUrl);
const visionCapability = providerVisionCapabilityForView(p);
const visionSource = visionCapability === "unsupported"
? []
: (p.visionModelsConfigured ? p.visionModels : inferredVisionModels(candidates));
@@ -4856,7 +4921,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
};
const refreshModels = async (group: ProviderAccessGroup, p: ProviderView) => {
setFetchingProvider(group.id);
const generation = beginGroupFetch(group.id);
setGroupFetchResult(group.id, null);
setGroupModelDraft(group.id, null);
try {
@@ -4864,12 +4929,14 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
try {
fetched = await cachedFetchProviderModels((provider) => app.FetchProviderModels(provider), p, true);
} catch (e) {
if (!groupFetchIsCurrent(group.id, generation)) return;
setGroupFetchResult(group.id, {
kind: "warn",
text: t("settings.fetchModelsFailedForProvider", { provider: group.label, err: String((e as Error)?.message ?? e) }),
});
return;
}
if (!groupFetchIsCurrent(group.id, generation)) return;
if (fetched.length === 0) {
setGroupFetchResult(group.id, {
kind: "warn",
@@ -4886,20 +4953,14 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
});
});
} finally {
setFetchingProvider(null);
finishGroupFetch(group.id, generation);
}
};
const refreshGroup = async (group: ProviderAccessGroup) => {
const probe = group.providers[0];
if (!probe) return;
await refreshModels(group, probe);
};
const saveKeyEnvAndAutoRefresh = async (group: ProviderAccessGroup, apiKeyEnv: string, value: string) => {
const probe = group.providers[0];
if (!probe || !apiKeyEnv) return;
setFetchingProvider(group.id);
const generation = beginGroupFetch(group.id);
setGroupFetchResult(group.id, null);
setGroupModelDraft(group.id, null);
try {
@@ -4908,6 +4969,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
invalidateProviderCacheByAPIKeyEnv(apiKeyEnv);
try {
const fetched = await cachedFetchProviderModels((provider) => app.FetchProviderModels(provider), { ...probe, apiKeyEnv });
if (!groupFetchIsCurrent(group.id, generation)) return;
if (fetched.length > 0) {
const draft = modelDraftForFetch({ ...probe, apiKeyEnv }, fetched);
setGroupModelDraft(group.id, draft);
@@ -4922,6 +4984,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
text: t("settings.fetchModelsEmptyForProvider", { provider: group.label }),
});
} catch (e) {
if (!groupFetchIsCurrent(group.id, generation)) return;
setGroupFetchResult(group.id, {
kind: "warn",
text: t("settings.fetchModelsAfterKeyFailedForProvider", { provider: group.label, err: String((e as Error)?.message ?? e) }),
@@ -4929,12 +4992,13 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
}
});
} finally {
setFetchingProvider(null);
finishGroupFetch(group.id, generation);
}
};
const saveProviderKey = async (group: ProviderAccessGroup, apiKeyEnv: string, value: string) => {
if (!apiKeyEnv) return;
cancelGroupFetch(group.id);
setGroupFetchResult(group.id, null);
setGroupModelDraft(group.id, null);
await apply(async () => {
@@ -4944,8 +5008,9 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
});
};
const clearProviderKey = async (apiKeyEnv: string) => {
const clearProviderKey = async (group: ProviderAccessGroup, apiKeyEnv: string) => {
if (!apiKeyEnv) return;
cancelGroupFetch(group.id);
await apply(async () => {
await app.ClearProviderKey(apiKeyEnv);
invalidateProviderCacheByAPIKeyEnv(apiKeyEnv);
@@ -5015,6 +5080,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
<AddProviderPanel
mode={adding}
kinds={s.providerKinds}
officialProviders={s.officialProviders}
providerPresets={s.providerPresets}
busy={busy}
onMode={setAdding}
@@ -5035,7 +5101,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
key={group.id}
group={group}
busy={busy}
fetching={fetchingProvider === group.id || group.providers.some((p) => fetchingProvider === p.name)}
fetching={fetchingProviders.has(group.id)}
fetchResult={fetchResults[group.id]}
modelDraft={modelDrafts[group.id]}
defaultProvider={defaultProvider}
@@ -5043,11 +5109,14 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
kinds={s.providerKinds}
onEdit={setEditing}
onCancelEdit={() => setEditing(null)}
onSave={(pv, key) => apply(() => saveProvider(pv, key ?? "")).then(() => {
setEditing(null);
setGroupModelDraft(group.id, null);
})}
onRefresh={() => void refreshGroup(group)}
onSave={(pv, key) => {
cancelGroupFetch(group.id);
return apply(() => saveProvider(pv, key ?? "")).then(() => {
setEditing(null);
setGroupModelDraft(group.id, null);
});
}}
onRefresh={(provider) => void refreshModels(group, provider)}
onToggleDraftModel={(model) => updateModelDraftSelection(group.id, (draft) => (
draft.selected.includes(model)
? draft.selected.filter((candidate) => candidate !== model)
@@ -5056,21 +5125,37 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
onToggleDraftVision={(model) => toggleModelDraftVision(group.id, model)}
onSelectAllDraftModels={() => updateModelDraftSelection(group.id, (draft) => draft.candidates)}
onClearDraftModels={() => updateModelDraftSelection(group.id, () => [])}
onCancelDraftModels={() => setGroupModelDraft(group.id, null)}
onCancelDraftModels={() => {
setGroupModelDraft(group.id, null);
setGroupFetchResult(group.id, null);
}}
onSaveDraftModels={() => void saveModelDraft(group)}
onToggleWebSearch={(enabled) => {
const provider = group.providers[0];
if (!provider) return;
void apply(() => app.SaveProvider({ ...provider, webSearch: enabled }));
const providerNames = group.providers.map((provider) => provider.name);
if (providerNames.length === 0) return;
void apply(() => app.SetProviderWebSearch(providerNames, enabled));
}}
onUpgradeRecommended={(name) => {
cancelGroupFetch(group.id);
return apply(() => app.UpgradeDeepSeekProviderAccess(name)).then((upgraded) => {
if (upgraded) {
setEditing(null);
setGroupModelDraft(group.id, null);
}
});
}}
onSaveEditorKey={(env, value) => group.builtIn ? saveProviderKey(group, env, value) : saveKeyEnvAndAutoRefresh(group, env, value)}
onClearEditorKey={clearProviderKey}
onDelete={(p) => apply(() => app.RemoveProviderAccess(p.name)).then(() => {
if (revealedProvider === p.name) {
setRevealedProvider(null);
setEditing(null);
}
})}
onClearEditorKey={(env) => clearProviderKey(group, env)}
onDelete={(providers) => {
cancelGroupFetch(group.id);
const providerNames = providers.map(({ name }) => name);
return apply(() => app.RemoveProviderAccesses(providerNames)).then(() => {
if (revealedProvider && providerNames.includes(revealedProvider)) {
setRevealedProvider(null);
setEditing(null);
}
});
}}
/>
))}
</div>
@@ -5078,7 +5163,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
);
}
type ProviderAccessGroup = {
export type ProviderAccessGroup = {
id: string;
label: string;
description: string;
@@ -5093,6 +5178,7 @@ type ProviderAccessGroup = {
baseUrl: string;
kind: string;
models: string[];
recommendedUpgradeAvailable: boolean;
};
type ProviderFetchResult = {
@@ -5155,8 +5241,6 @@ function providerPresetDescription(preset: ProviderPresetView, t: ReturnType<typ
switch (preset.id) {
case "deepseek-responses":
return t("settings.addProvider.preset.deepseekResponsesDesc");
case "deepseek-anthropic":
return t("settings.addProvider.preset.deepseekAnthropicDesc");
case "longcat-openai":
return t("settings.addProvider.preset.longcatOpenAIDesc");
case "longcat-anthropic":
@@ -5247,13 +5331,20 @@ function providerPresetDescription(preset: ProviderPresetView, t: ReturnType<typ
}
function providerPresetLabel(preset: ProviderPresetView, t: ReturnType<typeof useT>): string {
if (preset.id === "token-rhythm") return t("settings.addProvider.preset.tokenRhythmLabel");
return preset.label;
switch (preset.id) {
case "deepseek-responses":
return t("settings.addProvider.preset.deepseekResponsesLabel");
case "token-rhythm":
return t("settings.addProvider.preset.tokenRhythmLabel");
default:
return preset.label;
}
}
function AddProviderPanel({
export function AddProviderPanel({
mode,
kinds,
officialProviders,
providerPresets,
busy,
onMode,
@@ -5264,8 +5355,7 @@ function AddProviderPanel({
onResetPreset,
onAddCustom,
}: {
mode: AddProviderMode;
kinds: string[];
mode: AddProviderMode; kinds: string[]; officialProviders: ProviderView[];
providerPresets: ProviderPresetView[];
busy: boolean;
onMode: (mode: AddProviderMode) => void;
@@ -5278,16 +5368,16 @@ function AddProviderPanel({
}) {
const t = useT();
const templateChoices = useMemo<ProviderTemplateChoice[]>(() => [
...OFFICIAL_PROVIDER_CHOICES.map((choice) => ({
id: `official:${choice.kind}`,
source: "official" as const,
kind: choice.kind,
label: t(choice.labelKey),
description: t(choice.descKey),
keyEnv: choice.keyEnv,
added: false,
keySet: false,
})),
...OFFICIAL_PROVIDER_CHOICES.map((choice) => {
const state = officialProviders.find((provider) => officialProviderKind(provider) === choice.kind);
return {
id: `official:${choice.kind}`,
source: "official" as const, kind: choice.kind,
label: t(choice.labelKey), description: t(choice.descKey),
keyEnv: state?.apiKeyEnv || choice.keyEnv,
added: Boolean(state?.added), keySet: Boolean(state?.keySet),
};
}),
...providerPresets.map((preset) => ({
id: `preset:${preset.id}`,
source: "preset" as const,
@@ -5300,7 +5390,7 @@ function AddProviderPanel({
statusProviderNames: asArray(preset.statusProviderNames),
keySet: preset.keySet,
})),
], [providerPresets, t]);
], [officialProviders, providerPresets, t]);
const [templateID, setTemplateID] = useState("official:deepseek");
const [key, setKey] = useState("");
const firstAvailableTemplateID = templateChoices.find(providerTemplateCanAdd)?.id ?? templateChoices[0]?.id ?? "";
@@ -5456,7 +5546,7 @@ function AddProviderPanel({
return null;
}
function ProviderAccessCard({
export function ProviderAccessCard({
group,
busy,
fetching,
@@ -5476,6 +5566,7 @@ function ProviderAccessCard({
onCancelDraftModels,
onSaveDraftModels,
onToggleWebSearch,
onUpgradeRecommended,
onSaveEditorKey,
onClearEditorKey,
onDelete,
@@ -5491,7 +5582,7 @@ function ProviderAccessCard({
onEdit: (name: string) => void;
onCancelEdit: () => void;
onSave: (p: ProviderView, key?: string) => void | Promise<void>;
onRefresh: () => void;
onRefresh: (p: ProviderView) => void;
onToggleDraftModel: (model: string) => void;
onToggleDraftVision: (model: string) => void;
onSelectAllDraftModels: () => void;
@@ -5499,15 +5590,19 @@ function ProviderAccessCard({
onCancelDraftModels: () => void;
onSaveDraftModels: () => void;
onToggleWebSearch: (enabled: boolean) => void;
onUpgradeRecommended: (name: string) => void | Promise<void>;
onSaveEditorKey: (apiKeyEnv: string, value: string) => Promise<void>;
onClearEditorKey?: (apiKeyEnv: string) => Promise<void>;
onDelete?: (p: ProviderView) => Promise<void>;
onDelete?: (providers: ProviderView[]) => Promise<void>;
}) {
const t = useT();
const editableProvider = group.providers[0];
const isDefault = group.providers.some((p) => p.name === defaultProvider);
const editingProvider = group.providers.find((p) => editing === p.name);
const upgradeProvider = group.providers.find((p) => p.recommendedUpgradeAvailable);
const primaryProviderExpanded = Boolean(editableProvider && editing === editableProvider.name);
const supportsServerWebSearch = group.providers.length > 0 && group.providers.every(providerSupportsServerWebSearchForView);
const webSearchEnabled = supportsServerWebSearch && group.providers.every((provider) => Boolean(provider.webSearch));
const visibleModels = group.models.slice(0, 6);
const hiddenModelCount = Math.max(0, group.models.length - visibleModels.length);
return (
@@ -5523,7 +5618,6 @@ function ProviderAccessCard({
{providerKeyStatusLabel(group, t)}
</span>
</div>
<div className="provider-access-card__desc">{group.description}</div>
</div>
<div className="provider-access-card__actions">
{editableProvider && (
@@ -5536,64 +5630,66 @@ function ProviderAccessCard({
{primaryProviderExpanded ? t("common.collapse") : t("settings.configureProvider")}
</button>
)}
<button
className="btn btn--small"
disabled={busy || fetching || !group.baseUrl || !group.configured}
onClick={onRefresh}
>
{fetching ? t("settings.fetchingModels") : t("settings.fetchModels")}
</button>
{editableProvider && group.providers.length === 1 && (
<button
className="btn btn--small"
disabled={busy || fetching || !editableProvider.baseUrl || !group.configured}
onClick={() => onRefresh(editableProvider)}
>
{fetching ? t("settings.fetchingModels") : t("settings.fetchModels")}
</button>
)}
{editableProvider && onDelete && (
isDefault && !group.builtIn ? (
<Tooltip label={t("settings.cantDeleteDefault")}>
<button className="btn btn--small" disabled>{t("settings.removeProviderAccess")}</button>
</Tooltip>
) : (
<InlineConfirmButton
label={t("settings.removeProviderAccess")}
confirmLabel={group.builtIn ? t("settings.confirmRemoveProviderAccess") : t("settings.confirmDeleteProvider")}
cancelLabel={t("common.cancel")}
disabled={busy}
danger={!group.builtIn}
onConfirm={() => onDelete(editableProvider)}
/>
)
<ProviderAccessMoreMenu
busy={busy}
removeDisabled={isDefault && !group.builtIn}
builtIn={group.builtIn}
onRemove={() => onDelete(group.providers)}
/>
)}
</div>
</div>
{group.description && <div className="provider-access-card__desc">{group.description}</div>}
<div className="provider-access-meta">
<span>{group.kind}</span>
<span>{group.baseUrl}</span>
<span>{group.apiKeyEnv || t("common.none")}</span>
{group.keySource && <span title={group.keySourcePath || undefined}>{t("settings.keySource", { source: group.keySource })}</span>}
</div>
<div className="provider-card-block">
<div className="provider-card-block__label">{t(group.configured ? "settings.enabledModels" : "settings.modelList")}</div>
<div className="provider-model-chips" aria-label={t(group.configured ? "settings.enabledModels" : "settings.modelList")}>
{visibleModels.length > 0 ? visibleModels.map((model) => (
<span className="provider-model-chip" key={model}>
{model}
</span>
)) : <span className="provider-model-chip provider-model-chip--empty">{t("settings.noModelsConfigured")}</span>}
{hiddenModelCount > 0 && (
<span className="provider-model-chip provider-model-chip--more">
{t("settings.moreModels", { n: hiddenModelCount })}
</span>
)}
{upgradeProvider && (
<div className="provider-protocol-upgrade">
<div className="provider-protocol-upgrade__copy">
<div className="provider-protocol-upgrade__title">
{t("settings.providerProtocol")}: OpenAI Chat Completions
</div>
<div className="provider-protocol-upgrade__desc">{t("settings.addProvider.official.deepseekDesc")}</div>
</div>
<div className="provider-protocol-upgrade__actions">
<InlineConfirmButton
label={<>{t("settings.upgradeRecommendedProtocol")}<ArrowRight size={13} aria-hidden="true" /></>}
confirmLabel={t("common.confirm")}
cancelLabel={t("common.cancel")}
disabled={busy}
primary
onConfirm={() => onUpgradeRecommended(canonicalOfficialProviderName(upgradeProvider.name))}
/>
</div>
</div>
{!group.configured && group.requiresKey && (
<div className="provider-card-status provider-card-status--warn">
{t("settings.modelsRequireKey")}
</div>
)}
{fetchResult && (
<div className={`provider-card-status provider-card-status--${fetchResult.kind}`}>
{fetchResult.text}
</div>
)}
</div>
)}
{!supportsServerWebSearch && (
<ProviderModelSummary
configured={group.configured}
models={visibleModels}
hiddenModelCount={hiddenModelCount}
/>
)}
{!group.configured && group.requiresKey && (
<div className="provider-card-status provider-card-status--warn">
{t("settings.modelsRequireKey")}
</div>
)}
{fetchResult && (
<div className={`provider-card-status provider-card-status--${fetchResult.kind}`}>
{fetchResult.text}
</div>
)}
{modelDraft && (
<ProviderModelDraftPicker
@@ -5611,15 +5707,19 @@ function ProviderAccessCard({
{editableProvider && (
<ProviderServiceCapabilities
kind={editableProvider.kind}
baseUrl={editableProvider.baseUrl}
models={editableProvider.models}
enabled={Boolean(editableProvider.webSearch)}
supported={supportsServerWebSearch}
configured={group.configured}
models={visibleModels}
hiddenModelCount={hiddenModelCount}
showModelSummary
enabled={webSearchEnabled}
disabled={busy}
onChange={onToggleWebSearch}
/>
)}
<ProviderTechnicalDetails group={group} />
{group.providers.length > 1 && (
<div className="provider-profiles">
{group.providers.map((p) => {
@@ -5629,7 +5729,14 @@ function ProviderAccessCard({
<span>{p.name}</span>
<span>{p.models.join(", ") || t("common.none")}</span>
<button
className="btn btn--small"
className="btn btn--small provider-profile-row__refresh"
disabled={busy || fetching || !p.baseUrl || !providerIsConfigured(p)}
onClick={() => onRefresh(p)}
>
{fetching ? t("settings.fetchingModels") : t("settings.fetchModels")}
</button>
<button
className="btn btn--small provider-profile-row__configure"
disabled={busy}
aria-expanded={profileExpanded}
onClick={() => profileExpanded ? onCancelEdit() : onEdit(p.name)}
@@ -5644,6 +5751,7 @@ function ProviderAccessCard({
{editingProvider && (
<ProviderEditor
key={editingProvider.name}
initial={editingProvider}
kinds={kinds}
busy={busy}
@@ -5657,6 +5765,146 @@ function ProviderAccessCard({
);
}
function ProviderModelSummary({
configured,
models,
hiddenModelCount,
compact = false,
}: {
configured: boolean;
models: string[];
hiddenModelCount: number;
compact?: boolean;
}) {
const t = useT();
const label = t(configured ? "settings.enabledModels" : "settings.modelList");
return (
<div className={`provider-card-block${compact ? " provider-card-block--inline" : ""}`}>
<div className="provider-card-block__label">{label}</div>
<div className="provider-model-chips" aria-label={label}>
{models.length > 0 ? models.map((model) => (
<span className="provider-model-chip" key={model}>
{model}
</span>
)) : <span className="provider-model-chip provider-model-chip--empty">{t("settings.noModelsConfigured")}</span>}
{hiddenModelCount > 0 && (
<span className="provider-model-chip provider-model-chip--more">
{t("settings.moreModels", { n: hiddenModelCount })}
</span>
)}
</div>
</div>
);
}
function ProviderTechnicalDetails({ group }: { group: ProviderAccessGroup }) {
const t = useT();
const imageInputUnsupported = group.providers.length > 0 && group.providers.every((provider) => providerVisionCapabilityForView(provider) === "unsupported");
return (
<details className="provider-technical-details">
<summary>{t("settings.providerAccess")}</summary>
<dl>
{group.providers.length === 1 ? (
<>
<div><dt>{t("settings.providerProtocol")}</dt><dd>{providerProtocolDisplayName(group.kind)}</dd></div>
<div><dt>{t("settings.providerBaseUrlLabel")}</dt><dd>{group.baseUrl || t("common.none")}</dd></div>
</>
) : group.providers.map((provider) => (
<div key={provider.name}><dt>{provider.name}</dt><dd>{providerProtocolDisplayName(provider.kind)} · {provider.baseUrl || t("common.none")}</dd></div>
))}
<div>
<dt>{t("settings.providerApiKeyEnv")}</dt>
<dd>{group.apiKeyEnv || t("common.none")}</dd>
</div>
{imageInputUnsupported && (
<div>
<dt>{t("settings.visionModel")}</dt>
<dd>{t("settings.imageInputUnsupported")}</dd>
</div>
)}
{group.keySource && (
<div>
<dt>{t("settings.providerKey")}</dt>
<dd title={group.keySourcePath || undefined}>{group.keySource}</dd>
</div>
)}
</dl>
</details>
);
}
function providerProtocolDisplayName(kind: string): string {
switch (kind.trim().toLowerCase()) {
case "anthropic":
return "Anthropic Messages";
case "responses":
return "Responses API";
case "openai":
return "OpenAI Chat Completions";
default:
return kind;
}
}
function ProviderAccessMoreMenu({
busy,
removeDisabled,
builtIn,
onRemove,
}: {
busy: boolean;
removeDisabled: boolean;
builtIn: boolean;
onRemove: () => void | Promise<void>;
}) {
const t = useT();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const disabled = busy || removeDisabled;
const tooltip = removeDisabled ? t("settings.cantDeleteDefault") : t("settings.themeGallery.moreActions");
return (
<div className="provider-access-more">
<Tooltip label={tooltip}>
<button
ref={triggerRef}
type="button"
className="btn btn--small provider-access-more__trigger"
aria-label={t("settings.themeGallery.moreActions")}
aria-haspopup="menu"
aria-expanded={open}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
>
<MoreHorizontal size={16} aria-hidden="true" />
</button>
</Tooltip>
<AnchoredPopover
open={open && !disabled}
anchorRef={triggerRef}
onClose={() => setOpen(false)}
className="provider-access-more__menu"
align="end"
placement="bottom"
>
<div className="provider-access-more__items" role="menu" aria-label={t("settings.themeGallery.moreActions")}>
<InlineConfirmButton
label={<><Trash2 size={14} aria-hidden="true" />{t("settings.removeProviderAccess")}</>}
confirmLabel={builtIn ? t("settings.confirmRemoveProviderAccess") : t("settings.confirmDeleteProvider")}
cancelLabel={t("common.cancel")}
danger={!builtIn}
buttonRole="menuitem"
onConfirm={async () => {
setOpen(false);
await onRemove();
}}
/>
</div>
</AnchoredPopover>
</div>
);
}
function ProviderModelDraftPicker({
draft,
busy,
@@ -5769,30 +6017,40 @@ function ProviderModelDraftPicker({
}
function ProviderServiceCapabilities({
kind,
baseUrl,
supported,
configured,
models,
hiddenModelCount,
showModelSummary = false,
enabled,
disabled,
onChange,
}: {
kind: string;
baseUrl: string;
supported: boolean;
configured?: boolean;
models: string[];
hiddenModelCount?: number;
showModelSummary?: boolean;
enabled: boolean;
disabled: boolean;
onChange: (enabled: boolean) => void;
}) {
const t = useT();
const capabilityID = useId();
const costID = `${capabilityID}-cost`;
if (!providerSupportsServerWebSearch(kind, baseUrl)) return null;
const normalizedKind = kind.trim().toLowerCase();
if (!supported) return null;
return (
<section className="provider-capabilities" aria-labelledby={capabilityID}>
<div className="provider-card-block__label" id={capabilityID}>
{t("settings.providerCapabilities")}
</div>
{showModelSummary && (
<ProviderModelSummary
configured={Boolean(configured)}
models={models}
hiddenModelCount={hiddenModelCount ?? 0}
compact
/>
)}
<label className="provider-capability-row">
<span className="provider-capability-row__copy">
<span className="provider-capability-row__title">
@@ -5807,18 +6065,9 @@ function ProviderServiceCapabilities({
role="switch"
checked={enabled}
disabled={disabled}
aria-describedby={costID}
onChange={(event) => onChange(event.target.checked)}
/>
</label>
<div className="provider-capability-row__cost" id={costID}>
{t("settings.serverWebSearchCostHint")}
</div>
<div className="provider-capability-badges" aria-label={t("settings.providerCompatibility")}>
<span>{normalizedKind === "responses" ? t("settings.responsesStateless") : t("settings.anthropicCompatible")}</span>
<span>{t("settings.imageInputUnsupported")}</span>
{models.length > 0 && <span>{t("settings.serverWebSearchApplies", { models: models.join(", ") })}</span>}
</div>
</section>
);
}
@@ -5834,6 +6083,10 @@ function providerAccessGroups(providers: ProviderView[], t: ReturnType<typeof us
existing.keySet = existing.keySet || p.keySet;
existing.requiresKey = existing.requiresKey && providerRequiresKey(p);
existing.configured = existing.configured || providerIsConfigured(p);
existing.recommendedUpgradeAvailable = existing.recommendedUpgradeAvailable || Boolean(p.recommendedUpgradeAvailable);
if (existing.recommendedUpgradeAvailable && existing.id === "builtin:deepseek") {
existing.description = "";
}
if (!existing.keySource && p.keySource) existing.keySource = p.keySource;
if (!existing.keySourcePath && p.keySourcePath) existing.keySourcePath = p.keySourcePath;
existing.models = uniqueStrings([...existing.models, ...p.models]);
@@ -5854,6 +6107,7 @@ function providerAccessGroups(providers: ProviderView[], t: ReturnType<typeof us
baseUrl: p.baseUrl,
kind: p.kind,
models: uniqueStrings(p.models),
recommendedUpgradeAvailable: Boolean(p.recommendedUpgradeAvailable),
});
}
return Array.from(groups.values());
@@ -5870,7 +6124,7 @@ function providerBaseHost(baseUrl: string): string {
type ProviderVisionCapability = "configurable" | "unsupported";
function isDeepSeekOfficialEndpoint(baseUrl: string): boolean {
return providerBaseHost(baseUrl) === "api.deepseek.com";
return providerBaseHost(baseUrl).endsWith(".deepseek.com");
}
export function providerSupportsServerWebSearch(kind: string, baseUrl: string): boolean {
@@ -5899,6 +6153,15 @@ export function providerSupportsServerWebSearch(kind: string, baseUrl: string):
}
}
export function providerSupportsServerWebSearchForView(
provider: Pick<ProviderView, "kind" | "baseUrl" | "serverWebSearchCapability">,
): boolean {
if (typeof provider.serverWebSearchCapability === "boolean") {
return provider.serverWebSearchCapability;
}
return providerSupportsServerWebSearch(provider.kind, provider.baseUrl);
}
function providerVisionCapability(kind: string, baseUrl: string): ProviderVisionCapability {
if (!isDeepSeekOfficialEndpoint(baseUrl)) return "configurable";
switch (kind.trim().toLowerCase()) {
@@ -5911,6 +6174,15 @@ function providerVisionCapability(kind: string, baseUrl: string): ProviderVision
}
}
export function providerVisionCapabilityForView(
provider: Pick<ProviderView, "kind" | "baseUrl" | "visionCapability">,
): ProviderVisionCapability {
if (provider.visionCapability === "unsupported" || provider.visionCapability === "configurable") {
return provider.visionCapability;
}
return providerVisionCapability(provider.kind, provider.baseUrl);
}
function canonicalOfficialProviderName(name: string): string {
switch (name.trim()) {
case "deepseek-flash":
@@ -5943,8 +6215,10 @@ function providerGroupLabel(p: ProviderView, t?: ReturnType<typeof useT>): strin
function providerGroupDescription(p: ProviderView, t: ReturnType<typeof useT>): string {
const id = providerGroupID(p);
if (id === "builtin:deepseek") return t("settings.providerDesc.deepseek");
return p.baseUrl;
if (id === "builtin:deepseek") {
return p.recommendedUpgradeAvailable ? "" : t("settings.providerDesc.deepseek");
}
return "";
}
function uniqueStrings(values: string[]): string[] {
@@ -6188,7 +6462,26 @@ export function ProviderEditor({
const effectiveBaseUrl = fullChatUrl ? providerBaseURLFromChatURL(chatUrl) : baseUrl.trim();
const effectiveChatUrl = fullChatUrl ? trimmedURL(chatUrl) : "";
const effectiveModelsUrl = modelsUrl.trim();
const effectiveVisionCapability = providerVisionCapability(effectiveKind, effectiveBaseUrl);
const initialEffectiveBaseUrl = initial
? ((initial.chatUrl ?? "").trim() ? providerBaseURLFromChatURL(initial.chatUrl ?? "") : trimmedURL(initial.baseUrl))
: "";
const retainedVisionCapability = initial &&
effectiveKind.trim().toLowerCase() === initial.kind.trim().toLowerCase() &&
trimmedURL(effectiveBaseUrl) === initialEffectiveBaseUrl
? initial.visionCapability
: undefined;
const effectiveVisionCapability = providerVisionCapabilityForView({
kind: effectiveKind,
baseUrl: effectiveBaseUrl,
visionCapability: retainedVisionCapability,
});
const retainedServerWebSearchCapability = initial &&
effectiveKind.trim().toLowerCase() === initial.kind.trim().toLowerCase() &&
trimmedURL(effectiveBaseUrl) === initialEffectiveBaseUrl
? initial.serverWebSearchCapability
: undefined;
const effectiveServerWebSearchCapability = retainedServerWebSearchCapability ??
providerSupportsServerWebSearch(effectiveKind, effectiveBaseUrl);
const effectiveHeaders = parseProviderHeaders(headersDraft);
const extraBodyParse = useMemo(() => {
try {
@@ -6259,7 +6552,8 @@ export function ProviderEditor({
contextWindow: Number(ctx) || 0,
reasoningProtocol,
thinking,
webSearch: providerSupportsServerWebSearch(effectiveKind, effectiveBaseUrl) && webSearch,
webSearch: effectiveServerWebSearchCapability && webSearch,
serverWebSearchCapability: effectiveServerWebSearchCapability,
supportedEfforts: cleanedSupportedEfforts,
defaultEffort: cleanDefaultEffort,
modelOverrides: mergeProviderModelContextWindows(initial?.modelOverrides, parseProviderListInput(models), modelContextWindows),
@@ -6314,7 +6608,8 @@ export function ProviderEditor({
contextWindow: Number(ctx) || 0,
reasoningProtocol,
thinking,
webSearch: providerSupportsServerWebSearch(effectiveKind, effectiveBaseUrl) && webSearch,
webSearch: effectiveServerWebSearchCapability && webSearch,
serverWebSearchCapability: effectiveServerWebSearchCapability,
supportedEfforts: cleanedSupportedEfforts,
// Clear the stored default if no levels are selected; the backend's
// NormalizeEffort would otherwise silently ignore an unsupported value.
@@ -6620,8 +6915,7 @@ export function ProviderEditor({
onClear={clearEditorModels}
/>
<ProviderServiceCapabilities
kind={effectiveKind}
baseUrl={effectiveBaseUrl}
supported={effectiveServerWebSearchCapability}
models={modelNames}
enabled={webSearch}
disabled={busy || fetchingModels}
@@ -1,3 +1,4 @@
import "./CompactRatioSettings.css";
import "./ProviderAccessSettings.css";
export { SettingsPanel } from "./SettingsPanel";
+48 -21
View File
@@ -12,7 +12,7 @@ import type { InvocationRequest } from "./invocationDisplay";
import { addBreadcrumb } from "./breadcrumbs";
import { t } from "./i18n";
import { providerIsConfigured, providerRequiresKey } from "./providerModels";
import { providerIsConfigured, providerRequiresKey, removeProviderAccessesForMock } from "./providerModels";
import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarItems";
import { registerTrustedThemeBackgroundURLs } from "./themePack";
import { modeHasAutoApproveTools, modeWithAutoApproveTools, modeWithPlan, normalizeCollaborationMode, normalizeMode, normalizeTokenMode, normalizeToolApprovalMode } from "./types";
@@ -456,15 +456,18 @@ export interface AppBindings {
SetDefaultAutoRecoveryCheckpoint(enabled: boolean): Promise<void>;
SaveProvider(p: ProviderView): Promise<void>;
SetProviderWebSearch(names: string[], enabled: boolean): Promise<void>;
SaveProviderModelCatalogs(updates: ProviderModelCatalogUpdate[]): Promise<string[]>;
SaveProviderWithKey(p: ProviderView, key: string): Promise<string>;
AddOfficialProviderAccess(kind: string, key: string): Promise<string>;
UpgradeDeepSeekProviderAccess(name: string): Promise<string>;
AddProviderPresetAccess(id: string, key: string): Promise<string>;
ResetProviderPresetAccess(id: string): Promise<void>;
FetchProviderModels(p: ProviderView): Promise<string[]>;
FetchAllProviderModels(providers: ProviderView[]): Promise<Record<string, string[]>>;
DeleteProvider(name: string): Promise<void>;
RemoveProviderAccess(name: string): Promise<void>;
RemoveProviderAccesses(names: string[]): Promise<void>;
SaveProviderKey(apiKeyEnv: string, value: string): Promise<string>;
SetProviderKey(apiKeyEnv: string, value: string): Promise<string>;
ClearProviderKey(apiKeyEnv: string): Promise<void>;
@@ -981,7 +984,7 @@ function bridgeBreadcrumb(method: string): string {
return `model ${method}`;
if (/^(SetDesktop|SetCloseBehavior|SetDisplayMode|SetStatusBar|SetExpandThinking|SetAutoPlan|SetDefaultToolApprovalMode|SetCompactRatio|SetReasoningLanguage)/.test(method))
return `settings ${method}`;
if (/^(SaveProvider|SaveProviderModelCatalogs|AddOfficialProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|RemoveProviderAccess|DeleteProvider|SaveProviderKey|SetProviderKey|ClearProviderKey|FetchProviderModels|FetchAllProviderModels|ConnectKey)/.test(method))
if (/^(SaveProvider|SetProviderWebSearch|SaveProviderModelCatalogs|AddOfficialProviderAccess|UpgradeDeepSeekProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|RemoveProviderAccess|RemoveProviderAccesses|DeleteProvider|SaveProviderKey|SetProviderKey|ClearProviderKey|FetchProviderModels|FetchAllProviderModels|ConnectKey)/.test(method))
return `provider ${method}`;
if (/^(CheckUpdate|ApplyUpdateRequest|OpenDownloadPage|OpenUserConfigPath|ReloadUserConfig)/.test(method)) return `update ${method}`;
if (/^(AddMCPServer|InstallMCPServer|UpdateMCPServer|RemoveMCPServer|AuthorizeAndConnectMCPServer|AuthenticateMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|SetMCPServer)/.test(method))
@@ -1117,7 +1120,7 @@ function browserPreviewEffectiveShell(prefer = "auto"): "bash" | "git-bash" | "p
return browserPlatformOverride() === "windows" ? "git-bash" : "bash";
}
function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "sandbox_escape" | "notice" | "bench" {
function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "sandbox_escape" | "notice" | "deepseek_upgrade" | "bench" {
if (typeof window === "undefined") return "demo";
const value = new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase();
if (value === "fresh" || value === "empty" || value === "first-run") return "fresh";
@@ -1125,6 +1128,7 @@ function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "sandbox_es
if (value === "running" || value === "busy" || value === "streaming") return "running";
if (value === "sandbox_escape" || value === "sandbox-escape" || value === "sandboxescape") return "sandbox_escape";
if (value === "notice" || value === "notices" || value === "notice-preview") return "notice";
if (value === "deepseek_upgrade" || value === "deepseek-upgrade") return "deepseek_upgrade";
if (value === "bench" || value === "benchmark" || value === "perf") return "bench";
return "demo";
}
@@ -1148,6 +1152,7 @@ function mockProviderTemplate(p: Pick<ProviderView, "name" | "kind" | "baseUrl"
models: p.models,
visionModels: p.visionModels ?? [],
visionModelsConfigured: Boolean(p.visionModelsConfigured ?? ((p.visionModels ?? []).length > 0)),
visionCapability: p.visionCapability,
default: p.default,
apiKeyEnv: p.apiKeyEnv,
headers: p.headers,
@@ -1159,6 +1164,7 @@ function mockProviderTemplate(p: Pick<ProviderView, "name" | "kind" | "baseUrl"
reasoningProtocol: p.reasoningProtocol ?? "",
thinking: p.thinking ?? "",
webSearch: Boolean(p.webSearch),
serverWebSearchCapability: Boolean(p.serverWebSearchCapability),
supportedEfforts: p.supportedEfforts ?? [],
defaultEffort: p.defaultEffort ?? "",
modelOverrides: p.modelOverrides,
@@ -1195,8 +1201,7 @@ const mockVercelModels = ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-
const mockOllamaCloudModels = ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro", "deepseek-v4-flash", "minimax-m3", "nemotron-3-nano:30b", "qwen3-coder-next"];
const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
mockPreset("deepseek-responses", "DeepSeek Responses API", "DeepSeek official stateless Responses API for deepseek-v4-flash with server-side web search; search may add charges.", "DEEPSEEK_API_KEY", mockProviderTemplate({ name: "deepseek-responses", kind: "responses", baseUrl: "https://api.deepseek.com", models: ["deepseek-v4-flash"], default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", balanceUrl: "https://api.deepseek.com/user/balance", webSearch: true, contextWindow: 1000000, supportedEfforts: ["low", "high", "max"], defaultEffort: "high" })),
mockPreset("deepseek-anthropic", "DeepSeek Anthropic", "Official DeepSeek Anthropic-compatible endpoint for Flash and Pro with server-side web search; search may add charges.", "DEEPSEEK_API_KEY", mockProviderTemplate({ name: "deepseek-anthropic", kind: "anthropic", baseUrl: "https://api.deepseek.com/anthropic", models: ["deepseek-v4-flash", "deepseek-v4-pro"], default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", balanceUrl: "https://api.deepseek.com/user/balance", thinking: "enabled", webSearch: true, contextWindow: 1000000, modelOverrides: [{ model: "deepseek-v4-flash", reasoningProtocol: "", supportedEfforts: ["disabled", "low", "high", "max"], defaultEffort: "high" }, { model: "deepseek-v4-pro", reasoningProtocol: "", supportedEfforts: ["disabled", "high", "max"], defaultEffort: "high" }] })),
mockPreset("deepseek-responses", "DeepSeek Official Responses API", "Official stateless DeepSeek Responses API for Flash with server-side web search.", "DEEPSEEK_API_KEY", mockProviderTemplate({ name: "deepseek-responses", kind: "responses", baseUrl: "https://api.deepseek.com", models: ["deepseek-v4-flash"], default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", balanceUrl: "https://api.deepseek.com/user/balance", webSearch: true, serverWebSearchCapability: true, contextWindow: 1000000, supportedEfforts: ["low", "high", "max"], defaultEffort: "high" })),
mockPreset("longcat-openai", "LongCat OpenAI", "LongCat Platform OpenAI-compatible endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-openai", kind: "openai", baseUrl: "https://api.longcat.chat/openai/v1", modelsUrl: "https://api.longcat.chat/openai/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("longcat-anthropic", "LongCat Anthropic", "LongCat Platform Anthropic-compatible Messages endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-anthropic", kind: "anthropic", baseUrl: "https://api.longcat.chat/anthropic", modelsUrl: "https://api.longcat.chat/anthropic/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", authHeader: true, contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("token-rhythm", "Token Rhythm", "Token Rhythm (基元律动) multi-model OpenAI-compatible gateway.", "TOKEN_RHYTHM_API_KEY", mockProviderTemplate({ name: "token-rhythm", kind: "openai", baseUrl: "https://tokenrhythm.studio/v1", modelsUrl: "https://tokenrhythm.studio/v1/models", models: mockTokenRhythmModels, visionModels: ["kimi-k2.5", "kimi-k2.6", "kimi-k2.7-code"], default: "deepseek-v4-flash", apiKeyEnv: "TOKEN_RHYTHM_API_KEY", contextWindow: 1000000, modelOverrides: mockTokenRhythmModelOverrides })),
@@ -1259,8 +1264,7 @@ function mockProviderPresetViews(): ProviderPresetView[] {
}
function mockProviderPresetDisplayRank(id: string): number {
if (id === "deepseek-responses") return -1;
if (id === "deepseek-anthropic") return 0;
if (id === "deepseek-responses") return -2;
if (id === "glm-cn" || id === "zai-global" || id.startsWith("glm-coding-plan-") || id.startsWith("zai-coding-plan-")) return 0;
if (id.startsWith("longcat-")) return 1;
if (id === "token-rhythm") return 1;
@@ -1293,6 +1297,7 @@ function makeMockApp(): AppBindings {
const runningMock = scenario === "running" || guidanceMock;
const sandboxEscapeMock = scenario === "sandbox_escape";
const noticePreviewMock = scenario === "notice";
const deepSeekUpgradeMock = scenario === "deepseek_upgrade";
const benchMock = scenario === "bench";
const mockAttachmentDataURLs = new Map<string, string>();
let cancelled = false;
@@ -1560,7 +1565,7 @@ function makeMockApp(): AppBindings {
subagentEffort: "",
autoPlan: "off",
providers: [
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
{ name: "deepseek", builtIn: true, added: deepSeekUpgradeMock, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash"], visionModels: [], visionModelsConfigured: false, visionCapability: "unsupported", default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", headers: deepSeekUpgradeMock ? { "X-Route": "official-custom" } : undefined, keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "", recommendedUpgradeAvailable: deepSeekUpgradeMock },
],
officialProviders: [
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
@@ -4351,6 +4356,12 @@ function makeMockApp(): AppBindings {
if (i >= 0) settings.providers[i] = p;
else settings.providers.push(p);
},
async SetProviderWebSearch(names: string[], enabled: boolean) {
const requested = new Set(names);
settings.providers = settings.providers.map((provider) => (
requested.has(provider.name) ? { ...provider, webSearch: enabled } : provider
));
},
async SaveProviderModelCatalogs(updates: ProviderModelCatalogUpdate[]) {
const applied: string[] = [];
for (const update of updates) {
@@ -4379,7 +4390,7 @@ function makeMockApp(): AppBindings {
},
async AddOfficialProviderAccess(kind: string, key: string) {
const templates: Record<string, ProviderView> = {
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "anthropic", baseUrl: "https://api.deepseek.com/anthropic", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, visionCapability: "unsupported", default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "enabled", webSearch: true, serverWebSearchCapability: true, supportedEfforts: [], defaultEffort: "", modelOverrides: [{ model: "deepseek-v4-flash", reasoningProtocol: "", supportedEfforts: ["disabled", "low", "high", "max"], defaultEffort: "high" }, { model: "deepseek-v4-pro", reasoningProtocol: "", supportedEfforts: ["disabled", "high", "max"], defaultEffort: "high" }] },
};
const next = templates[kind];
if (!next) throw new Error(`unknown official provider template ${kind}`);
@@ -4388,6 +4399,28 @@ function makeMockApp(): AppBindings {
else settings.providers.push(next);
return "";
},
async UpgradeDeepSeekProviderAccess(name: string) {
const family = new Set(["deepseek", "deepseek-flash", "deepseek-pro"]);
let changed = false;
settings.providers = settings.providers.map((provider) => {
if ((name === "deepseek" ? family.has(provider.name) : provider.name === name) && provider.recommendedUpgradeAvailable) {
changed = true;
return {
...provider,
kind: "anthropic",
baseUrl: "https://api.deepseek.com/anthropic",
thinking: provider.thinking || "enabled",
webSearch: provider.webSearch ?? true,
serverWebSearchCapability: true,
visionCapability: "unsupported",
recommendedUpgradeAvailable: false,
};
}
return provider;
});
if (!changed) throw new Error(`DeepSeek provider ${name} is not eligible for upgrade`);
return "";
},
async AddProviderPresetAccess(id: string, key: string) {
const preset = settings.providerPresets.find((p) => p.id === id);
if (!preset) throw new Error(`unknown provider preset ${id}`);
@@ -4445,11 +4478,8 @@ function makeMockApp(): AppBindings {
async DeleteProvider(name: string) {
settings.providers = settings.providers.filter((p) => p.name !== name);
},
async RemoveProviderAccess(name: string) {
const p = settings.providers.find((x) => x.name === name);
if (p?.builtIn) p.added = false;
else settings.providers = settings.providers.filter((x) => x.name !== name);
},
async RemoveProviderAccess(name: string) { settings.providers = removeProviderAccessesForMock(settings.providers, [name]); },
async RemoveProviderAccesses(names: string[]) { settings.providers = removeProviderAccessesForMock(settings.providers, names); },
async SaveProviderKey(apiKeyEnv: string, _value: string) {
settings.providers.forEach((p) => {
if (p.apiKeyEnv === apiKeyEnv) p.keySet = true;
@@ -4862,13 +4892,10 @@ function makeMockApp(): AppBindings {
},
async ConnectKey(apiKey: string) {
if (!apiKey.trim()) throw new Error("key is required");
settings.providers.forEach((p) => {
if (p.apiKeyEnv === "DEEPSEEK_API_KEY") {
p.added = true;
p.keySet = true;
p.configured = true;
}
});
// Match the production onboarding path: saving a DeepSeek key also
// restores the current official provider template instead of merely
// marking a possibly stale legacy entry as configured.
await this.AddOfficialProviderAccess("deepseek", apiKey);
await delay(300);
return "";
},
+37 -1
View File
@@ -1,4 +1,36 @@
import type { ProviderModelOverrideView } from "./types";
import type { ProviderModelOverrideView, ProviderView } from "./types";
export type LatestRequestGate = {
begin(key: string): number;
cancel(key: string): void;
isCurrent(key: string, generation: number): boolean;
};
// Async provider discovery is last-request-wins per access card. A stale
// completion may settle, but it cannot replace a newer draft or loading state.
export function createLatestRequestGate(): LatestRequestGate {
const generations = new Map<string, number>();
const advance = (key: string) => {
const generation = (generations.get(key) ?? 0) + 1;
generations.set(key, generation);
return generation;
};
return {
begin: advance,
cancel: (key) => {
advance(key);
},
isCurrent: (key, generation) => generations.get(key) === generation,
};
}
export function removeProviderAccessesForMock(providers: ProviderView[], names: string[]): ProviderView[] {
const requested = new Set(names);
return providers.flatMap((provider) => {
if (!requested.has(provider.name)) return [provider];
return provider.builtIn ? [{ ...provider, added: false }] : [];
});
}
export function mergedFetchedProviderModels(current: string[], fetched: string[], options: { preserveCurated?: boolean } = {}): string[] {
const saved = uniqueStrings(current);
@@ -50,6 +82,9 @@ export function mergeProviderModelContextWindows(
defaultEffort: previous?.defaultEffort ?? "",
vision: previous?.vision ?? null,
contextWindow: Math.max(parsedContextWindow, 0),
...(typeof previous?.maxOutputTokens === "number"
? { maxOutputTokens: previous.maxOutputTokens }
: {}),
};
if (
override.reasoningProtocol.trim()
@@ -57,6 +92,7 @@ export function mergeProviderModelContextWindows(
|| override.defaultEffort.trim()
|| override.vision != null
|| (override.contextWindow ?? 0) > 0
|| (override.maxOutputTokens ?? 0) !== 0
) {
merged.push(override);
}
+4
View File
@@ -1640,6 +1640,7 @@ export interface ProviderView {
models: string[];
visionModels: string[]; // subset of models that accepts image input
visionModelsConfigured: boolean; // true when an empty list is an explicit choice
visionCapability?: "configurable" | "unsupported"; // backend authority; absent on older Wails payloads
modelsUrl: string; // optional override for model discovery; empty derives from baseUrl
default: string;
apiKeyEnv: string;
@@ -1656,9 +1657,11 @@ export interface ProviderView {
reasoningProtocol: string; // auto|deepseek|glm|kimi-k3|openai|none; empty = auto/model registry
thinking: string; // provider-specific thinking override: ""|enabled|disabled|adaptive
webSearch?: boolean; // expose a provider-executed web search tool when supported
serverWebSearchCapability?: boolean; // backend-verified provider capability; absent on older Wails payloads
supportedEfforts: string[]; // custom /effort levels; empty = use built-in Kind/BaseURL default
defaultEffort: string; // /effort level when user picks "auto" or unset; "" = supportedEfforts[0]
modelOverrides?: ProviderModelOverrideView[] | null;
recommendedUpgradeAvailable?: boolean; // official legacy OpenAI entry can switch to recommended Anthropic access
modelCatalogFingerprint?: string; // opaque compare-and-apply token for background model discovery
}
@@ -1694,6 +1697,7 @@ export interface ProviderModelOverrideView {
defaultEffort: string;
vision?: boolean | null;
contextWindow?: number;
maxOutputTokens?: number;
}
// BalanceInfo is the wallet-balance readout (desktop/app.go Balance). available
+8 -11
View File
@@ -1790,6 +1790,7 @@ export const en = {
"settings.addProviderAccess": "Add access",
"settings.removeProviderAccess": "Remove access",
"settings.confirmRemoveProviderAccess": "Confirm remove access",
"settings.upgradeRecommendedProtocol": "Upgrade to recommended protocol",
"settings.providerAccess": "Provider access",
"settings.providerAccessHint": "Providers appear here after you add them. Only saved enabled models appear in the model picker.",
"settings.providerAccessEmptyTitle": "No providers added yet",
@@ -1807,13 +1808,8 @@ export const en = {
"settings.imageInputUnsupported": "No image input",
"settings.providerCapabilities": "Service capabilities",
"settings.recommended": "Recommended",
"settings.providerCompatibility": "Endpoint compatibility",
"settings.serverWebSearch": "Server-side web search",
"settings.serverWebSearchHint": "Allow the model to search the web through DeepSeek when needed. Enabling this does not search on every request.",
"settings.serverWebSearchCostHint": "Search queries are sent to DeepSeek and may increase token usage; current billing follows DeepSeek's pricing rules.",
"settings.serverWebSearchApplies": "Models: {models}",
"settings.responsesStateless": "Responses · stateless",
"settings.anthropicCompatible": "Anthropic · Claude-compatible",
"settings.serverWebSearchHint": "Searches only when needed; content is sent to the current model provider and may add usage or cost.",
"settings.modelCandidateSearch": "Search discovered models...",
"settings.noMatchingCandidateModels": "No matching models",
"settings.selectAllModels": "Select all",
@@ -2512,6 +2508,7 @@ export const en = {
"settings.errorModelProviderMissing": "Model {model} is unavailable because provider {provider} is not added.",
"settings.errorModelProviderNoKey": "Model {model} is unavailable because provider {provider} has no API key.",
"settings.errorRemoveAccessBusy": "Stop active work using {provider} before removing provider access.",
"settings.errorProviderDetached": "Reopen or close the background session, then try again.",
"settings.errorDeleteProviderBusy": "Stop active work using {provider} before deleting the provider.",
"settings.errorSaveBeforeRemoveAccess": "Could not save the current session before removing provider access: {err}",
"settings.errorSaveBeforeDeleteProvider": "Could not save the current session before deleting the provider: {err}",
@@ -2525,7 +2522,7 @@ export const en = {
"settings.configureProvider": "Configure",
"settings.configureProfile": "Configure",
"settings.noModelsConfigured": "No models configured",
"settings.providerDesc.deepseek": "DeepSeek official OpenAI-compatible access",
"settings.providerDesc.deepseek": "Anthropic Messages protocol access",
"settings.plannerNoneHint": "Use the default model for both planning and execution.",
"settings.plannerNoneHintShort": "single-model mode",
"settings.searchModels": "Search models...",
@@ -2540,10 +2537,10 @@ export const en = {
"settings.addProvider.officialHint": "Recommended presets fill endpoints, models, vision, and reasoning defaults; usually only the key is needed.",
"settings.addProvider.customTitle": "Add custom provider",
"settings.addProvider.customHint": "Use this for proxies, aggregators, or other OpenAI-compatible services.",
"settings.addProvider.official.deepseek": "DeepSeek Official",
"settings.addProvider.official.deepseekDesc": "Official API key access with DeepSeek V4 Flash / Pro.",
"settings.addProvider.preset.deepseekResponsesDesc": "DeepSeek official stateless Responses API for Flash with server-side web search enabled; search may add charges.",
"settings.addProvider.preset.deepseekAnthropicDesc": "DeepSeek official Anthropic-compatible endpoint for Flash and Pro with server-side web search enabled; search may add charges.",
"settings.addProvider.official.deepseek": "DeepSeek Official (Recommended)",
"settings.addProvider.official.deepseekDesc": "Default Anthropic Messages API for Flash and Pro; server-side web search is enabled.",
"settings.addProvider.preset.deepseekResponsesLabel": "DeepSeek Official Responses API",
"settings.addProvider.preset.deepseekResponsesDesc": "Stateless Responses API for Flash; server-side web search is enabled and may add cost.",
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat Platform OpenAI-compatible endpoint with LongCat-2.0 default.",
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat Platform Anthropic-compatible Messages endpoint using Bearer auth.",
"settings.addProvider.preset.tokenRhythmLabel": "Token Rhythm",
+8 -11
View File
@@ -1367,6 +1367,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.addProviderAccess": "新增接入",
"settings.removeProviderAccess": "移除接入",
"settings.confirmRemoveProviderAccess": "確認移除接入",
"settings.upgradeRecommendedProtocol": "升級至推薦協議",
"settings.providerAccess": "供應商接入",
"settings.providerAccessHint": "新增官方或自訂供應商後,才會出現在這裡。可用模型以供應商返回的列表為準。",
"settings.providerAccessEmptyTitle": "還沒有新增供應商",
@@ -1682,6 +1683,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.errorModelProviderMissing": "模型 {model} 不可用,因為提供商 {provider} 尚未添加。",
"settings.errorModelProviderNoKey": "模型 {model} 不可用,因為提供商 {provider} 尚未設定 API Key。",
"settings.errorRemoveAccessBusy": "請先停止正在使用 {provider} 的任務,再移除模型服務接入。",
"settings.errorProviderDetached": "請先重新開啟或關閉背景工作階段,再重試。",
"settings.errorDeleteProviderBusy": "請先停止正在使用 {provider} 的任務,再刪除模型服務。",
"settings.errorSaveBeforeRemoveAccess": "移除模型服務接入前儲存目前會話失敗:{err}",
"settings.errorSaveBeforeDeleteProvider": "刪除模型服務前儲存目前會話失敗:{err}",
@@ -1695,7 +1697,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.configureProvider": "設定",
"settings.configureProfile": "設定",
"settings.noModelsConfigured": "未設定模型",
"settings.providerDesc.deepseek": "DeepSeek 官方 OpenAI-compatible 接入",
"settings.providerDesc.deepseek": "Anthropic Messages 協議接入",
"settings.plannerNoneHint": "使用預設模型同時負責規劃與執行。",
"settings.plannerNoneHintShort": "單模型模式",
"settings.searchModels": "搜尋模型...",
@@ -1710,10 +1712,10 @@ export const zhTW: Record<DictKey, string> = {
"settings.addProvider.officialHint": "推薦預設會填好端點、模型、視覺和推理預設值,通常只需要填 API Key。",
"settings.addProvider.customTitle": "新增自訂供應商",
"settings.addProvider.customHint": "適合代理、聚合平台或其它 OpenAI-compatible 服務。",
"settings.addProvider.official.deepseek": "DeepSeek 官方",
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
"settings.addProvider.preset.deepseekResponsesDesc": "DeepSeek 官方無狀態 Responses API,適用於 Flash;預置服務端網頁搜尋,搜尋可能產生額外費用。",
"settings.addProvider.preset.deepseekAnthropicDesc": "DeepSeek 官方 Anthropic 相容端點,支援 Flash、Pro;預置服務端網頁搜尋,搜尋可能產生額外費用。",
"settings.addProvider.official.deepseek": "DeepSeek 官方(推薦)",
"settings.addProvider.official.deepseekDesc": "Anthropic Messages API,支援 Flash / Pro,預設開啟網頁搜尋。",
"settings.addProvider.preset.deepseekResponsesLabel": "DeepSeek 官方 Responses API",
"settings.addProvider.preset.deepseekResponsesDesc": "無狀態 Responses API,僅支援 Flash,預設開啟網頁搜尋。",
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 開放平台 OpenAI-compatible 端點,預設 LongCat-2.0。",
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 開放平台 Anthropic-compatible Messages 端點,使用 Bearer 認證。",
"settings.addProvider.preset.tokenRhythmLabel": "基元律动",
@@ -2657,13 +2659,8 @@ export const zhTW: Record<DictKey, string> = {
"settings.imageInputUnsupported": "不支援圖片",
"settings.providerCapabilities": "服務能力",
"settings.recommended": "推薦",
"settings.providerCompatibility": "介面相容性",
"settings.serverWebSearch": "服務端網頁搜尋",
"settings.serverWebSearchHint": "允許模型在需要時透過 DeepSeek 搜尋網際網路;開啟不代表每次請求都會搜尋。",
"settings.serverWebSearchCostHint": "搜尋查詢會傳送給 DeepSeek,並可能增加 Token 用量;實際計費以 DeepSeek 目前規則為準。",
"settings.serverWebSearchApplies": "適用模型:{models}",
"settings.responsesStateless": "Responses · 無狀態",
"settings.anthropicCompatible": "Anthropic · Claude 相容",
"settings.serverWebSearchHint": "僅在需要時搜尋;內容會傳送給目前供應商,可能增加用量與費用。",
"settings.modelCandidateSearch": "搜尋發現的模型...",
"settings.noMatchingCandidateModels": "沒有匹配的模型",
"settings.selectAllModels": "全選",
+8 -11
View File
@@ -1792,6 +1792,7 @@ export const zh: Record<DictKey, string> = {
"settings.addProviderAccess": "添加接入",
"settings.removeProviderAccess": "移除接入",
"settings.confirmRemoveProviderAccess": "确认移除接入",
"settings.upgradeRecommendedProtocol": "升级到推荐协议",
"settings.providerAccess": "供应商接入",
"settings.providerAccessHint": "添加官方或自定义供应商后,才会出现在这里。会话模型列表只显示已保存的启用模型。",
"settings.providerAccessEmptyTitle": "还没有添加供应商",
@@ -1809,13 +1810,8 @@ export const zh: Record<DictKey, string> = {
"settings.imageInputUnsupported": "不支持图片",
"settings.providerCapabilities": "服务能力",
"settings.recommended": "推荐",
"settings.providerCompatibility": "接口兼容性",
"settings.serverWebSearch": "服务端网页搜索",
"settings.serverWebSearchHint": "允许模型在需要时通过 DeepSeek 搜索互联网;开启不代表每次请求都会搜索。",
"settings.serverWebSearchCostHint": "搜索查询会发送给 DeepSeek,并可能增加 Token 用量;实际计费以 DeepSeek 当前规则为准。",
"settings.serverWebSearchApplies": "适用模型:{models}",
"settings.responsesStateless": "Responses · 无状态",
"settings.anthropicCompatible": "Anthropic · Claude 兼容",
"settings.serverWebSearchHint": "仅在需要时搜索;内容会发送给当前供应商,可能增加用量与费用。",
"settings.modelCandidateSearch": "搜索发现的模型...",
"settings.noMatchingCandidateModels": "没有匹配的模型",
"settings.selectAllModels": "全选",
@@ -2515,6 +2511,7 @@ export const zh: Record<DictKey, string> = {
"settings.errorModelProviderMissing": "模型 {model} 不可用,因为提供商 {provider} 尚未添加。",
"settings.errorModelProviderNoKey": "模型 {model} 不可用,因为提供商 {provider} 尚未配置 API Key。",
"settings.errorRemoveAccessBusy": "请先停止正在使用 {provider} 的任务,再移除模型服务接入。",
"settings.errorProviderDetached": "请先重新打开或关闭后台会话,再重试。",
"settings.errorDeleteProviderBusy": "请先停止正在使用 {provider} 的任务,再删除模型服务。",
"settings.errorSaveBeforeRemoveAccess": "移除模型服务接入前保存当前会话失败:{err}",
"settings.errorSaveBeforeDeleteProvider": "删除模型服务前保存当前会话失败:{err}",
@@ -2528,7 +2525,7 @@ export const zh: Record<DictKey, string> = {
"settings.configureProvider": "配置",
"settings.configureProfile": "配置",
"settings.noModelsConfigured": "未配置模型",
"settings.providerDesc.deepseek": "DeepSeek 官方 OpenAI-compatible 接入",
"settings.providerDesc.deepseek": "Anthropic Messages 协议接入",
"settings.plannerNoneHint": "使用默认模型同时负责规划与执行。",
"settings.plannerNoneHintShort": "单模型模式",
"settings.searchModels": "搜索模型...",
@@ -2543,10 +2540,10 @@ export const zh: Record<DictKey, string> = {
"settings.addProvider.officialHint": "推荐预设会填好端点、模型、视觉和推理默认值,通常只需要填 API Key。",
"settings.addProvider.customTitle": "添加自定义供应商",
"settings.addProvider.customHint": "适合代理、聚合平台或其它 OpenAI-compatible 服务。",
"settings.addProvider.official.deepseek": "DeepSeek 官方",
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
"settings.addProvider.preset.deepseekResponsesDesc": "DeepSeek 官方无状态 Responses API,适用于 Flash;预置服务端网页搜索,搜索可能产生额外费用。",
"settings.addProvider.preset.deepseekAnthropicDesc": "DeepSeek 官方 Anthropic 兼容端点,支持 Flash、Pro;预置服务端网页搜索,搜索可能产生额外费用。",
"settings.addProvider.official.deepseek": "DeepSeek 官方(推荐)",
"settings.addProvider.official.deepseekDesc": "Anthropic Messages API,支持 Flash / Pro,默认开启网页搜索。",
"settings.addProvider.preset.deepseekResponsesLabel": "DeepSeek 官方 Responses API",
"settings.addProvider.preset.deepseekResponsesDesc": "无状态 Responses API,仅支持 Flash,默认开启网页搜索。",
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 开放平台 OpenAI-compatible 端点,默认 LongCat-2.0。",
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 开放平台 Anthropic-compatible Messages 端点,使用 Bearer 认证。",
"settings.addProvider.preset.tokenRhythmLabel": "基元律动",
+36 -42
View File
@@ -16319,6 +16319,7 @@ body > .mermaid-diagram--fullscreen {
.settings-section__head {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
@@ -16876,7 +16877,7 @@ body > .mermaid-diagram--fullscreen {
display: flex;
flex-direction: column;
min-width: 0;
gap: 12px;
gap: 10px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 8px;
@@ -16905,7 +16906,7 @@ body > .mermaid-diagram--fullscreen {
font-weight: 750;
}
.provider-access-card__desc {
margin-top: 4px;
margin-top: -4px;
color: var(--fg-dim);
font-size: var(--font-control-small);
line-height: 1.4;
@@ -16921,20 +16922,6 @@ body > .mermaid-diagram--fullscreen {
.provider-access-card__actions .btn {
white-space: nowrap;
}
.provider-access-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
color: var(--fg-dim);
font-family: var(--mono);
font-size: var(--text-2xs);
word-break: break-all;
}
.provider-access-meta span:not(:last-child)::after {
content: "·";
margin-left: 6px;
color: var(--fg-faint);
}
.provider-model-chips {
display: flex;
flex-wrap: wrap;
@@ -17128,19 +17115,20 @@ body > .mermaid-diagram--fullscreen {
.provider-capabilities {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--border-soft);
}
.provider-capabilities > .provider-card-block--inline {
margin-top: 8px;
}
.provider-capability-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--bg-elev-1) 72%, transparent);
margin-top: 10px;
padding: 10px 0 0;
border-top: 1px solid var(--border-soft);
cursor: pointer;
}
.provider-capability-row__copy {
@@ -17206,25 +17194,6 @@ body > .mermaid-diagram--fullscreen {
font-size: 11px;
line-height: 1.35;
}
.provider-capability-row__cost {
color: var(--fg-muted);
font-size: var(--text-2xs);
line-height: 1.4;
}
.provider-capability-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.provider-capability-badges span {
padding: 3px 7px;
border: 1px solid var(--border-soft);
border-radius: 999px;
color: var(--fg-muted);
font-size: var(--text-2xs);
line-height: 1.35;
overflow-wrap: anywhere;
}
.provider-key-status {
display: flex;
align-items: center;
@@ -17274,7 +17243,7 @@ body > .mermaid-diagram--fullscreen {
}
.provider-profile-row {
display: grid;
grid-template-columns: minmax(120px, 0.8fr) minmax(0, 1.4fr) auto;
grid-template-columns: minmax(120px, 0.8fr) minmax(0, 1.4fr) auto auto;
align-items: center;
gap: 8px;
padding: 7px 8px;
@@ -20455,6 +20424,31 @@ body > .mermaid-diagram--fullscreen {
.provider-access-card__actions {
flex-wrap: wrap;
}
.provider-profile-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas:
"profile-name profile-name"
"profile-models profile-models"
"profile-refresh profile-configure";
}
.provider-profile-row span:first-child {
grid-area: profile-name;
}
.provider-profile-row span:nth-child(2) {
grid-area: profile-models;
}
.provider-profile-row__refresh {
grid-area: profile-refresh;
}
.provider-profile-row__configure {
grid-area: profile-configure;
}
.provider-profile-row__refresh,
.provider-profile-row__configure {
width: 100%;
min-width: 0;
white-space: normal;
}
.set-hint {
margin-left: 0;
}
@@ -0,0 +1,111 @@
import { act } from "react";
import type { SettingsView } from "../lib/types";
export function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
export function installCanvasMock(win: Window) {
const canvasElement = (win as Window & typeof globalThis).HTMLCanvasElement;
Object.defineProperty(canvasElement.prototype, "getContext", {
configurable: true,
value(type: string) {
if (type !== "2d") return null;
return {
font: "",
measureText: () => ({ width: 0 }),
} as unknown as CanvasRenderingContext2D;
},
});
}
export async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await act(async () => {
await flushPromises();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
export function baseSettings(displayMode: "standard" | "compact" = "standard"): SettingsView {
return {
defaultModel: "",
plannerModel: "",
subagentModel: "",
subagentEffort: "",
autoPlan: "off",
providers: [],
officialProviders: [],
providerPresets: [],
permissions: { mode: "ask", allow: [], ask: [], deny: [] },
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto" },
network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } },
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto", compactRatio: 0.8 },
bot: {
enabled: false,
model: "",
toolApprovalMode: "",
maxSteps: 0,
debounceMs: 0,
queueMode: "steer",
queueCap: 20,
queueDrop: "summarize",
ignoreSelfMessages: true,
selfUserIds: { qq: [], feishu: [], weixin: [] },
control: { enabled: false, addr: "127.0.0.1:37913", tokenEnv: "REASONIX_BOT_CONTROL_TOKEN" },
pairing: { enabled: true, requestTtlMinutes: 60, maxPendingPerPlatform: 3 },
routes: [],
allowlist: {
enabled: false,
allowAll: false,
qqUsers: [],
feishuUsers: [],
weixinUsers: [],
qqApprovers: [],
feishuApprovers: [],
weixinApprovers: [],
qqAdmins: [],
feishuAdmins: [],
weixinAdmins: [],
qqGroups: [],
feishuGroups: [],
weixinGroups: [],
},
qq: {
enabled: false,
appId: "",
appSecretEnv: "",
secretSet: false,
sandbox: false,
model: "",
toolApprovalMode: "ask",
workspaceRoot: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] },
},
feishu: { enabled: false, domain: "feishu", appId: "", appSecretEnv: "", secretSet: false, verificationToken: "", mode: "webhook", webhookPort: 0, requireMention: false },
weixin: { enabled: false, accountId: "", tokenEnv: "", tokenSet: false, apiBase: "" },
connections: [],
},
desktopLanguage: "en",
desktopLayoutStyle: "workbench",
desktopTheme: "auto",
desktopThemeStyle: "graphite",
desktopTerminalTheme: "auto",
closeBehavior: "background",
displayMode,
statusBarStyle: "text",
statusBarItems: ["model", "workspace", "git_branch", "cache", "balance"],
defaultToolApprovalMode: "auto",
checkUpdates: true,
updateChannel: "stable",
telemetry: true,
metrics: true,
configPath: "/tmp/reasonix/config.toml",
providerKinds: [],
autoApproveTools: false,
bypass: false,
};
}
+176
View File
@@ -0,0 +1,176 @@
package main
import (
"fmt"
"slices"
"strings"
"reasonix/internal/config"
)
// AddOfficialProviderAccess adds one curated desktop provider template to the
// Settings > Model > Access list. The runtime default providers still exist
// independently; this only records the user's explicit access setup.
func (a *App) AddOfficialProviderAccess(kind, key string) (string, error) {
if err := a.ensureActiveTabRebuildAllowed("provider access"); err != nil {
return "", err
}
keyWarning := ""
if err := func() error {
// Keep conflict validation, credential selection, and the config mutation
// under one config revision. The lock order is config -> credentials.
unlock := config.LockUserConfigEdits()
defer unlock()
cfg, path, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return err
}
entries, keyEnv, err := officialProviderTemplate(kind, cfg.DeepSeekOfficialPricingLanguage())
if err != nil {
return err
}
keyEnv, err = validateOfficialProviderAccessInstall(cfg, kind, entries, keyEnv)
if err != nil {
return err
}
if strings.TrimSpace(key) != "" && keyEnv != "" {
keyWarning, err = a.saveProviderCredential(keyEnv, key)
if err != nil {
return err
}
}
names, err := installOfficialProviderAccess(cfg, kind, entries)
if err != nil {
return err
}
addProviderAccess(cfg, names...)
return cfg.SaveTo(path)
}(); err != nil {
return "", err
}
rebuildWarning := ""
if err := a.rebuildSetting("provider access"); err != nil {
var deferred bool
rebuildWarning, deferred = a.deferredRebuildWarning("provider access", err)
if !deferred {
return "", err
}
}
return appendSettingsWarning(keyWarning, rebuildWarning), nil
}
func validateOfficialProviderAccessInstall(c *config.Config, kind string, entries []config.ProviderEntry, fallbackKeyEnv string) (string, error) {
keyEnv := strings.TrimSpace(fallbackKeyEnv)
wantKind := strings.ToLower(strings.TrimSpace(kind))
for _, entry := range entries {
existing, ok := c.Provider(strings.TrimSpace(entry.Name))
if !ok {
continue
}
if officialProviderKindFromEntry(*existing) != wantKind {
return "", fmt.Errorf("official provider %q cannot be added because provider name %q already belongs to a custom endpoint; edit, rename, or remove the existing provider first", kind, entry.Name)
}
if env := strings.TrimSpace(existing.APIKeyEnv); env != "" {
keyEnv = env
}
}
return keyEnv, nil
}
func installOfficialProviderAccess(c *config.Config, kind string, entries []config.ProviderEntry) ([]string, error) {
if c == nil {
return nil, fmt.Errorf("config is nil")
}
wantKind := strings.ToLower(strings.TrimSpace(kind))
names := make([]string, 0, len(entries))
for _, entry := range entries {
name := strings.TrimSpace(entry.Name)
existing, ok := c.Provider(name)
if ok {
if officialProviderKindFromEntry(*existing) != wantKind {
return nil, fmt.Errorf("official provider %q cannot be added because provider name %q already belongs to a custom endpoint; edit, rename, or remove the existing provider first", kind, name)
}
// Re-adding access must not reset customized official transport fields.
// Only repair an unusable legacy entry that has no model declaration.
if len(existing.ModelList()) == 0 {
repaired := repairOfficialProviderCatalog(*existing, entry)
if err := c.UpsertProvider(repaired); err != nil {
return nil, err
}
}
} else if err := c.UpsertProvider(entry); err != nil {
return nil, err
}
names = append(names, name)
}
if wantKind == "deepseek" && slices.Contains(names, "deepseek") {
retargetDeepSeekOfficialReferences(c)
}
return names, nil
}
func repairOfficialProviderCatalog(existing, template config.ProviderEntry) config.ProviderEntry {
existing.Model = ""
existing.Models = append([]string(nil), template.ModelList()...)
existing.Default = template.DefaultModel()
if existing.ContextWindow == 0 {
existing.ContextWindow = template.ContextWindow
}
if existing.MaxOutputTokens == 0 {
existing.MaxOutputTokens = template.MaxOutputTokens
}
if strings.TrimSpace(existing.BalanceURL) == "" {
existing.BalanceURL = template.BalanceURL
}
if strings.TrimSpace(existing.Thinking) == "" {
existing.Thinking = template.Thinking
}
if existing.WebSearch == nil && template.WebSearch != nil {
enabled := *template.WebSearch
existing.WebSearch = &enabled
}
if existing.Prices == nil {
existing.Prices = template.Prices
}
if existing.Price == nil {
existing.Price = template.Price
}
if existing.ModelOverrides == nil {
existing.ModelOverrides = map[string]config.ProviderModelOverride{}
}
for model, override := range template.ModelOverrides {
if _, ok := existing.ModelOverrides[model]; !ok {
existing.ModelOverrides[model] = override
}
}
return existing
}
func retargetDeepSeekOfficialReferences(c *config.Config) {
if c == nil {
return
}
retarget := func(ref string) string {
ref = strings.TrimSpace(ref)
providerName, model, hasModel := strings.Cut(ref, "/")
switch providerName {
case "deepseek-flash":
if !hasModel || strings.TrimSpace(model) == "" {
model = "deepseek-v4-flash"
}
case "deepseek-pro":
if !hasModel || strings.TrimSpace(model) == "" {
model = "deepseek-v4-pro"
}
default:
return ref
}
return "deepseek/" + model
}
c.DefaultModel = retarget(c.DefaultModel)
c.Agent.PlannerModel = retarget(c.Agent.PlannerModel)
c.Agent.SubagentModel = retarget(c.Agent.SubagentModel)
for skillName, ref := range c.Agent.SubagentModels {
c.Agent.SubagentModels[skillName] = retarget(ref)
}
}
@@ -0,0 +1,174 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/config"
)
func TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {
isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(config.UserConfigPath(), []byte(`
[desktop]
language = "zh"
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
if _, err := NewApp().AddOfficialProviderAccess("deepseek", ""); err != nil {
t.Fatalf("AddOfficialProviderAccess: %v", err)
}
cfg := config.LoadForEdit(config.UserConfigPath())
p, ok := cfg.Provider("deepseek")
if !ok {
t.Fatal("deepseek provider not saved")
}
flash := p.Prices["deepseek-v4-flash"]
pro := p.Prices["deepseek-v4-pro"]
if flash == nil || flash.Output != 2 || flash.Currency != "¥" {
t.Fatalf("flash price = %+v, want CNY preset", flash)
}
if pro == nil || pro.Output != 6 || pro.Currency != "¥" {
t.Fatalf("pro price = %+v, want CNY preset", pro)
}
}
func TestAddOfficialProviderAccessPreservesExistingOfficialCustomization(t *testing.T) {
isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(config.UserConfigPath(), []byte(`
[[providers]]
name = "deepseek"
kind = "openai"
base_url = "https://api.deepseek.com/v1"
models = ["deepseek-v4-flash"]
default = "deepseek-v4-flash"
api_key_env = "MY_DEEPSEEK_KEY"
headers = { X-Route = "official-custom" }
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
if _, err := NewApp().AddOfficialProviderAccess("deepseek", ""); err != nil {
t.Fatalf("AddOfficialProviderAccess: %v", err)
}
cfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
p, ok := cfg.Provider("deepseek")
if !ok {
t.Fatal("deepseek provider not saved")
}
if p.Kind != "openai" || p.BaseURL != "https://api.deepseek.com/v1" || p.APIKeyEnv != "MY_DEEPSEEK_KEY" || p.Headers["X-Route"] != "official-custom" {
t.Fatalf("existing official customization was overwritten: %+v", p)
}
if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] {
t.Fatalf("provider_access missing preserved DeepSeek provider: %+v", cfg.Desktop.ProviderAccess)
}
}
func TestAddOfficialProviderAccessRepairsCatalogWithoutResettingOfficialTransport(t *testing.T) {
isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(config.UserConfigPath(), []byte(`
[[providers]]
name = "deepseek"
kind = "openai"
base_url = "https://api.deepseek.com/v1"
api_key_env = "MY_DEEPSEEK_KEY"
headers = { X-Route = "official-custom" }
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
if _, err := NewApp().AddOfficialProviderAccess("deepseek", "test-key"); err != nil {
t.Fatalf("AddOfficialProviderAccess: %v", err)
}
cfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
p, ok := cfg.Provider("deepseek")
if !ok {
t.Fatal("deepseek provider not saved")
}
if p.Kind != "openai" || p.BaseURL != "https://api.deepseek.com/v1" || p.APIKeyEnv != "MY_DEEPSEEK_KEY" || p.Headers["X-Route"] != "official-custom" {
t.Fatalf("official transport customization was overwritten: %+v", p)
}
if got := p.ModelList(); len(got) != 2 || got[0] != "deepseek-v4-flash" || got[1] != "deepseek-v4-pro" {
t.Fatalf("repaired model catalog = %v, want official Flash/Pro models", got)
}
if !config.CredentialStored("MY_DEEPSEEK_KEY") || config.CredentialStored("DEEPSEEK_API_KEY") {
t.Fatal("credential was not saved exclusively under the preserved api_key_env")
}
}
func TestAddOfficialProviderAccessRetargetsLegacyDeepSeekReferences(t *testing.T) {
isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(config.UserConfigPath(), []byte(`
default_model = "deepseek-flash"
[agent]
planner_model = "deepseek-pro"
subagent_model = "deepseek-flash/custom-flash"
subagent_models = { vision = "deepseek-pro/custom-pro", keep = "other/model" }
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
api_key_env = "DEEPSEEK_API_KEY"
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
if _, err := NewApp().AddOfficialProviderAccess("deepseek", ""); err != nil {
t.Fatalf("AddOfficialProviderAccess: %v", err)
}
cfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
if cfg.DefaultModel != "deepseek/deepseek-v4-flash" || cfg.Agent.PlannerModel != "deepseek/deepseek-v4-pro" ||
cfg.Agent.SubagentModel != "deepseek/custom-flash" || cfg.Agent.SubagentModels["vision"] != "deepseek/custom-pro" ||
cfg.Agent.SubagentModels["keep"] != "other/model" {
t.Fatalf("legacy DeepSeek references were not retargeted: default=%q planner=%q subagent=%q skills=%v",
cfg.DefaultModel, cfg.Agent.PlannerModel, cfg.Agent.SubagentModel, cfg.Agent.SubagentModels)
}
}
func TestAddOfficialProviderAccessRejectsSameNameCustomEndpointBeforeSavingKey(t *testing.T) {
isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(config.UserConfigPath(), []byte(`
[[providers]]
name = "deepseek"
kind = "openai"
base_url = "https://proxy.example.invalid/v1"
models = ["deepseek-v4-flash"]
default = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
_, err := NewApp().AddOfficialProviderAccess("deepseek", "test-key")
if err == nil || !strings.Contains(err.Error(), "already belongs to a custom endpoint") {
t.Fatalf("AddOfficialProviderAccess error = %v, want same-name custom endpoint rejection", err)
}
if data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), "DEEPSEEK_API_KEY") {
t.Fatalf("credential was saved before provider name conflict rejection:\n%s", data)
}
cfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
p, ok := cfg.Provider("deepseek")
if !ok || p.BaseURL != "https://proxy.example.invalid/v1" {
t.Fatalf("same-name custom provider changed after rejection: %+v", p)
}
}
+575
View File
@@ -0,0 +1,575 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"reasonix/internal/config"
"reasonix/internal/control"
)
type providerRemovalPlan struct {
config *config.Config
root string
fingerprint string
targets []string
fallbackRef string
}
type providerRemovalTab struct {
id string
ctrl control.SessionAPI
readOnly bool
retargetModel bool
}
// DeleteProvider removes a provider and retargets open idle tabs that used it.
func (a *App) DeleteProvider(name string) error {
return a.deleteProviderAndRetargetTabs(name)
}
// RemoveProviderAccess hides one provider access card. The plural form keeps
// official provider profiles represented by a single card failure-atomic.
func (a *App) RemoveProviderAccess(name string) error {
return a.RemoveProviderAccesses([]string{name})
}
func (a *App) RemoveProviderAccesses(rawNames []string) error {
names := uniqueNonEmptyStrings(rawNames)
if len(names) == 0 {
return fmt.Errorf("remove provider access: provider list is empty")
}
cfg, _, err := a.loadDesktopUserConfigForView()
if err != nil {
return err
}
officialKind := ""
for _, name := range names {
p, ok := cfg.Provider(name)
if !ok {
return fmt.Errorf("remove provider access: provider %q not found", name)
}
kind := officialProviderKindFromEntry(*p)
if kind == "" {
if len(names) == 1 {
return a.deleteProviderAndRetargetTabs(name)
}
return fmt.Errorf("remove provider access: custom provider %q cannot be removed as part of a group", name)
}
if officialKind != "" && kind != officialKind {
return fmt.Errorf("remove provider access: providers do not belong to one official group")
}
officialKind = kind
}
return a.removeBuiltInProviderAccessAndRetargetTabs(names)
}
func validateOfficialProviderRemoval(c *config.Config, names []string) error {
officialKind := ""
for _, name := range names {
p, ok := c.Provider(name)
if !ok {
return fmt.Errorf("remove provider access: provider %q not found", name)
}
kind := officialProviderKindFromEntry(*p)
if kind == "" {
return fmt.Errorf("remove provider access: provider %q is no longer an official provider", name)
}
if officialKind != "" && kind != officialKind {
return fmt.Errorf("remove provider access: providers do not belong to one official group")
}
officialKind = kind
}
return nil
}
func lockProviderRemovalState() (func(), error) {
unlockConfig := config.LockUserConfigEdits()
unlockCredentials, err := config.LockUserCredentialEdits()
if err != nil {
unlockConfig()
return nil, err
}
return func() {
unlockCredentials()
unlockConfig()
}, nil
}
func (a *App) loadProviderRemovalConfigForEdit(root string) (*config.Config, string, error) {
cfg, path, err := a.loadDesktopUserConfigForEditForRoot(root)
if err != nil {
return nil, "", err
}
for i := range cfg.Providers {
cfg.Providers[i].ResolveAPIKeyForRoot(root)
}
return cfg, path, nil
}
// providerRemovalStateFingerprint covers every config or credential-store
// value used to classify a removal, choose its fallback, and resolve model
// references. The keyed digest remains process-local and contains no raw secret.
func providerRemovalStateFingerprint(c *config.Config, credentialsRevision string) string {
h := hmac.New(sha256.New, providerStateFingerprintKey)
write := func(value string) {
_, _ = fmt.Fprintf(h, "%d:", len(value))
_, _ = h.Write([]byte(value))
}
write("provider-removal-state-v2")
write(credentialsRevision)
write(c.DefaultModel)
write(c.Agent.PlannerModel)
write(c.Agent.SubagentModel)
for _, name := range c.Desktop.ProviderAccess {
write(name)
}
skills := make([]string, 0, len(c.Agent.SubagentModels))
for skill := range c.Agent.SubagentModels {
skills = append(skills, skill)
}
sort.Strings(skills)
for _, skill := range skills {
write(skill)
write(c.Agent.SubagentModels[skill])
}
for i := range c.Providers {
p := &c.Providers[i]
write(p.Name)
write(p.Kind)
write(p.BaseURL)
write(p.APIKeyEnv)
write(fmt.Sprintf("%t", p.Configured()))
write(p.Model)
write(p.Default)
for _, model := range p.Models {
write(model)
}
}
return string(h.Sum(nil))
}
func officialProviderRemovalTargets(names []string) []string {
targets := append([]string(nil), names...)
for _, name := range names {
switch canonical := config.CanonicalDesktopOfficialProviderName(name); canonical {
case "deepseek":
targets = append(targets, canonical, "deepseek-flash", "deepseek-pro")
default:
targets = append(targets, canonical)
}
}
return uniqueNonEmptyStrings(targets)
}
func providerAccessFallbackRef(c *config.Config, names []string) string {
removed := providerAccessSet(names)
for _, candidate := range c.Desktop.ProviderAccess {
candidate = strings.TrimSpace(candidate)
if candidate == "" || removed[candidate] {
continue
}
p, ok := c.Provider(candidate)
if ok && p.Configured() && len(p.ModelList()) > 0 {
return p.Name + "/" + p.DefaultModel()
}
}
return ""
}
func providerRefMatchesAny(c *config.Config, ref string, names []string) bool {
for _, name := range names {
if desktopModelRefsProvider(c, ref, name) {
return true
}
}
return false
}
func retargetProviderReferences(c *config.Config, names []string, fallbackRef string) {
fallbackRef = strings.TrimSpace(fallbackRef)
if providerRefMatchesAny(c, c.DefaultModel, names) {
if fallbackRef != "" {
c.DefaultModel = fallbackRef
}
}
if providerRefMatchesAny(c, c.Agent.PlannerModel, names) {
c.Agent.PlannerModel = fallbackRef
}
if providerRefMatchesAny(c, c.Agent.SubagentModel, names) {
c.Agent.SubagentModel = fallbackRef
}
for skill, ref := range c.Agent.SubagentModels {
if providerRefMatchesAny(c, ref, names) {
if fallbackRef == "" {
delete(c.Agent.SubagentModels, skill)
} else {
c.Agent.SubagentModels[skill] = fallbackRef
}
}
}
}
func providerReferencesAny(c *config.Config, names []string) bool {
if providerRefMatchesAny(c, c.DefaultModel, names) ||
providerRefMatchesAny(c, c.Agent.PlannerModel, names) ||
providerRefMatchesAny(c, c.Agent.SubagentModel, names) {
return true
}
for _, ref := range c.Agent.SubagentModels {
if providerRefMatchesAny(c, ref, names) {
return true
}
}
return false
}
// providerAuxiliaryReferencesAny reports whether a controller's planner or
// sub-agent wiring was assembled from a provider being removed. Unlike
// default_model, these references affect the whole runtime regardless of the
// tab's selected chat model.
func providerAuxiliaryReferencesAny(c *config.Config, names []string) bool {
if providerRefMatchesAny(c, c.Agent.PlannerModel, names) ||
providerRefMatchesAny(c, c.Agent.SubagentModel, names) {
return true
}
for _, ref := range c.Agent.SubagentModels {
if providerRefMatchesAny(c, ref, names) {
return true
}
}
return false
}
func (a *App) planProviderRemoval(names []string, official bool) (providerRemovalPlan, error) {
root := a.activeWorkspaceRoot()
unlock, err := lockProviderRemovalState()
if err != nil {
return providerRemovalPlan{}, err
}
defer unlock()
cfg, _, err := a.loadProviderRemovalConfigForEdit(root)
if err != nil {
return providerRemovalPlan{}, err
}
if official {
if err := validateOfficialProviderRemoval(cfg, names); err != nil {
return providerRemovalPlan{}, err
}
} else {
p, ok := cfg.Provider(names[0])
if !ok {
return providerRemovalPlan{}, fmt.Errorf("remove provider: %q not found", names[0])
}
if isOfficialBuiltInProvider(*p) {
return providerRemovalPlan{}, fmt.Errorf("remove provider: %q is now an official provider; retry", names[0])
}
}
targets := names
fallbackRef := providerAccessFallbackRef(cfg, targets)
if official {
targets = officialProviderRemovalTargets(names)
fallbackRef = providerAccessFallbackRef(cfg, targets)
}
return providerRemovalPlan{
config: cfg, root: root, targets: targets, fallbackRef: fallbackRef,
fingerprint: providerRemovalStateFingerprint(cfg, providerCredentialsRevision()),
}, nil
}
func (a *App) affectedProviderRemovalTabs(plan providerRemovalPlan, label, action string) ([]providerRemovalTab, bool, error) {
a.mu.RLock()
for _, tab := range a.detachedSessions {
if tab != nil && tab.Ctrl != nil {
a.mu.RUnlock()
return nil, false, fmt.Errorf("background session is still using %q; reopen or close it before %s", label, action)
}
}
visibleTabs := make([]*WorkspaceTab, 0, len(a.tabs))
for _, id := range a.orderedTabIDsLocked() {
if tab := a.tabs[id]; tab != nil {
visibleTabs = append(visibleTabs, tab)
}
}
a.mu.RUnlock()
affected := make([]providerRemovalTab, 0, len(visibleTabs))
referencesRemoved := providerReferencesAny(plan.config, plan.targets)
for _, tab := range visibleTabs {
snap := a.tabRuntimeSnapshot(tab)
root := strings.TrimSpace(snap.workspaceRoot)
if root == "" {
root = "."
}
effective, err := config.LoadForRootReadOnly(root)
if err != nil {
return nil, false, fmt.Errorf("load workspace config before %s: %w", action, err)
}
ref := snap.model
if strings.TrimSpace(ref) == "" {
ref = effective.DefaultModel
}
retargetModel := providerRefMatchesAny(effective, ref, plan.targets)
referencesRemoved = referencesRemoved || retargetModel || providerAuxiliaryReferencesAny(effective, plan.targets)
if controllerHasActiveRuntimeWork(snap.ctrl) {
return nil, false, fmt.Errorf("finish or cancel active work using %q before %s", label, action)
}
if snap.ctrl == nil && !retargetModel {
// A controller-less placeholder has no stale runtime. Its first
// build will read the updated global and project configuration.
continue
}
// Provider configuration is user-global. Rebuild every visible live
// controller so a non-active workspace cannot retain a project-level
// planner or sub-agent reference that the active workspace does not use.
affected = append(affected, providerRemovalTab{
id: tab.ID, ctrl: snap.ctrl, readOnly: snap.readOnly, retargetModel: retargetModel,
})
}
return affected, referencesRemoved, nil
}
func snapshotProviderRemovalTabs(affected []providerRemovalTab, label, action string) error {
for _, item := range affected {
if item.ctrl == nil || item.readOnly {
continue
}
if err := item.ctrl.Snapshot(); err != nil {
slog.Warn("desktop: snapshot before provider removal failed", "tab", item.id, "provider", label, "action", action, "err", err)
return fmt.Errorf("save current session before %s: %w", action, err)
}
}
return nil
}
func validateProviderRemovalFingerprint(fresh *config.Config, planned string) error {
if providerRemovalStateFingerprint(fresh, providerCredentialsRevision()) != planned {
return fmt.Errorf("provider configuration or credentials changed while removing access; retry")
}
return nil
}
func (a *App) commitOfficialProviderRemoval(plan providerRemovalPlan, names []string) (string, error) {
unlock, err := lockProviderRemovalState()
if err != nil {
return "", err
}
defer unlock()
fresh, path, err := a.loadProviderRemovalConfigForEdit(plan.root)
if err != nil {
return "", err
}
if err := validateOfficialProviderRemoval(fresh, names); err != nil {
return "", fmt.Errorf("provider configuration changed while removing access; retry: %w", err)
}
if err := validateProviderRemovalFingerprint(fresh, plan.fingerprint); err != nil {
return "", err
}
fallbackRef := providerAccessFallbackRef(fresh, plan.targets)
retargetProviderReferences(fresh, plan.targets, fallbackRef)
removeProviderAccess(fresh, plan.targets...)
return fallbackRef, fresh.SaveTo(path)
}
func (a *App) commitCustomProviderRemoval(plan providerRemovalPlan, name string) (string, error) {
unlock, err := lockProviderRemovalState()
if err != nil {
return "", err
}
defer unlock()
fresh, path, err := a.loadProviderRemovalConfigForEdit(plan.root)
if err != nil {
return "", err
}
p, ok := fresh.Provider(name)
if !ok {
return "", fmt.Errorf("provider configuration changed while removing %q; retry", name)
}
if isOfficialBuiltInProvider(*p) {
return "", fmt.Errorf("provider configuration changed while removing %q; it is now an official provider; retry", name)
}
if err := validateProviderRemovalFingerprint(fresh, plan.fingerprint); err != nil {
return "", err
}
fallbackRef := providerAccessFallbackRef(fresh, plan.targets)
// Config.RemoveProvider has a compatibility fallback across every configured
// provider. Settings access removal is narrower: hidden providers must not
// silently become the new default after restart. Retarget first so the
// persisted config and every rebuilt tab use the same visible provider. Keep
// the historical provider-only persisted form while the runtime uses the
// exact provider/model reference returned below.
persistedFallback := fallbackRef
if providerName, _, ok := strings.Cut(fallbackRef, "/"); ok {
persistedFallback = providerName
}
retargetProviderReferences(fresh, plan.targets, persistedFallback)
if err := fresh.RemoveProvider(name); err != nil {
return "", err
}
removeProviderAccess(fresh, name)
return fallbackRef, fresh.SaveTo(path)
}
func (a *App) applyProviderRemovalRuntime(affected []providerRemovalTab, fallbackRef, setting string) error {
if len(affected) == 0 {
if err := a.rebuildActiveSettingRuntimeMutationLocked(setting); err != nil {
if _, ok := a.deferredRebuildWarning(setting, err); ok {
return nil
}
return err
}
return nil
}
reset := make([]providerRemovalTab, 0, len(affected))
var rebuildErrs []error
if a.ctx != nil {
for _, item := range affected {
tab := a.tabByID(item.id)
if tab == nil || a.controllerForTab(tab) != item.ctrl {
continue
}
if item.ctrl == nil {
reset = append(reset, item)
continue
}
// lockRuntimeTurnGates already holds this tab's turnStartMu, and the
// outer runtime mutation owns runtimeRebuildMu plus admission. Reuse
// the settings build-and-swap core without reacquiring either lock so
// the old controller remains usable if replacement construction fails.
modelOverride := ""
if item.retargetModel {
modelOverride = fallbackRef
}
if err := a.rebuildSettingTurnLockedWithModel(setting, tab, modelOverride, true, false); err != nil {
if _, ok := a.deferredRebuildWarningForTab(setting, err, tab); ok {
continue
}
rebuildErrs = append(rebuildErrs, err)
}
}
} else {
reset = affected
}
for _, item := range reset {
if item.ctrl != nil {
item.ctrl.Close()
}
}
rebuildTabs, releasedHostKeys := a.resetProviderRemovalTabs(reset, fallbackRef)
for _, key := range releasedHostKeys {
a.releaseSharedHost(key)
}
for _, tab := range rebuildTabs {
go a.buildTabController(tab)
}
return errors.Join(rebuildErrs...)
}
func (a *App) resetProviderRemovalTabs(affected []providerRemovalTab, fallbackRef string) ([]*WorkspaceTab, []string) {
var rebuildTabs []*WorkspaceTab
var releasedHostKeys []string
a.mu.Lock()
defer a.mu.Unlock()
for _, item := range affected {
tab := a.tabs[item.id]
if tab == nil || tab.Ctrl != item.ctrl {
continue
}
tab.Ctrl = nil
if key := takeTabSharedHostKey(tab); key != "" {
releasedHostKeys = append(releasedHostKeys, key)
}
a.supersedeTabBuildLocked(tab)
if item.retargetModel {
tab.model = fallbackRef
tab.Label = fallbackRef
}
clearTabStartupError(tab)
tab.Ready = a.ctx == nil
if a.ctx == nil {
a.setSessionRuntimePhaseLocked(tab, sessionRuntimeFailed, fmt.Errorf("desktop runtime is not started"))
continue
}
a.setSessionRuntimePhaseLocked(tab, sessionRuntimeStarting, nil)
rebuildTabs = append(rebuildTabs, tab)
}
a.saveTabsLocked()
return rebuildTabs, releasedHostKeys
}
func (a *App) removeBuiltInProviderAccessAndRetargetTabs(names []string) error {
defer a.lockRuntimeMutation("remove-provider-access")()
releaseGates, err := a.lockRuntimeTurnGates("provider access", nil)
if err != nil {
return err
}
defer releaseGates()
plan, err := a.planProviderRemoval(names, true)
if err != nil {
return err
}
label := strings.Join(names, ", ")
affected, referencesRemoved, err := a.affectedProviderRemovalTabs(plan, label, "removing the provider access")
if err != nil {
return err
}
if plan.fallbackRef == "" && referencesRemoved {
return fmt.Errorf("remove provider access: %q is in use and no other configured provider exists", label)
}
if len(affected) == 0 {
if err := a.ensureActiveTabRebuildAllowed("provider access"); err != nil {
return err
}
}
if err := snapshotProviderRemovalTabs(affected, label, "removing provider access"); err != nil {
return err
}
fallbackRef, err := a.commitOfficialProviderRemoval(plan, names)
if err != nil {
return err
}
return a.applyProviderRemovalRuntime(affected, fallbackRef, "provider access")
}
func (a *App) deleteProviderAndRetargetTabs(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("remove provider: empty provider name")
}
defer a.lockRuntimeMutation("delete-provider")()
releaseGates, err := a.lockRuntimeTurnGates("provider", nil)
if err != nil {
return err
}
defer releaseGates()
plan, err := a.planProviderRemoval([]string{name}, false)
if err != nil {
return err
}
affected, referencesRemoved, err := a.affectedProviderRemovalTabs(plan, name, "deleting the provider")
if err != nil {
return err
}
if plan.fallbackRef == "" && referencesRemoved {
return fmt.Errorf("remove provider: %q is in use and no other configured provider exists", name)
}
if len(affected) == 0 {
if err := a.ensureActiveTabRebuildAllowed("provider"); err != nil {
return err
}
}
if err := snapshotProviderRemovalTabs(affected, name, "deleting provider"); err != nil {
return err
}
fallbackRef, err := a.commitCustomProviderRemoval(plan, name)
if err != nil {
return err
}
return a.applyProviderRemovalRuntime(affected, fallbackRef, "provider")
}
@@ -0,0 +1,68 @@
package main
import (
"testing"
"reasonix/internal/config"
"reasonix/internal/control"
)
func TestRemoveProviderAccessRejectsCustomProviderThatBecameOfficialDuringSnapshot(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "deepseek-flash/deepseek-v4-flash"
cfg.Desktop.ProviderAccess = []string{"deepseek-flash", "mimo-pro"}
cfg.Providers = []config.ProviderEntry{
{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://proxy.example/v1", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY"},
{Name: "mimo-pro", Kind: "openai", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1", Model: "mimo-v2.5-pro", APIKeyEnv: "MIMO_API_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
app := NewApp()
ctrl := newBlockingSnapshotCtrl(control.New(control.Options{Label: "custom-deepseek"}))
tab := &WorkspaceTab{ID: "custom-deepseek", Scope: "global", model: cfg.DefaultModel, Ctrl: ctrl}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
done := make(chan error, 1)
go func() { done <- app.RemoveProviderAccess("deepseek-flash") }()
<-ctrl.firstSnapshotStarted
unlock := config.LockUserConfigEdits()
changed := config.LoadForEdit(config.UserConfigPath())
provider, ok := changed.Provider("deepseek-flash")
if !ok {
unlock()
t.Fatal("deepseek-flash provider missing")
}
provider.BaseURL = "https://api.deepseek.com/anthropic"
provider.Kind = "anthropic"
if err := changed.SaveTo(config.UserConfigPath()); err != nil {
unlock()
t.Fatalf("save overlapping config edit: %v", err)
}
unlock()
close(ctrl.releaseSnapshot)
if err := <-done; err == nil {
t.Fatal("RemoveProviderAccess deleted a custom provider that became official during snapshot")
}
got := config.LoadForEdit(config.UserConfigPath())
provider, ok = got.Provider("deepseek-flash")
if !ok || officialProviderKindFromEntry(*provider) != "deepseek" {
t.Fatalf("updated official provider was not preserved: %+v/%v", provider, ok)
}
access := providerAccessSet(got.Desktop.ProviderAccess)
if !access["deepseek"] && !access["deepseek-flash"] {
t.Fatalf("provider access changed after rejected overlap: %+v", got.Desktop.ProviderAccess)
}
if ctrl.closeCount.Load() != 0 || tab.Ctrl != ctrl || tab.model != cfg.DefaultModel {
t.Fatalf("runtime mutated after rejected overlap: closes=%d ctrl=%T model=%q", ctrl.closeCount.Load(), tab.Ctrl, tab.model)
}
}
+764
View File
@@ -0,0 +1,764 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
)
func TestRemoveProviderAccessesRemovesGroupedOfficialAliasesAtomically(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "deepseek-flash/deepseek-v4-flash"
cfg.Agent.PlannerModel = "deepseek-pro/deepseek-v4-pro"
cfg.Agent.SubagentModel = "deepseek-flash/deepseek-v4-flash"
cfg.Agent.SubagentModels = map[string]string{"review": "deepseek-pro/deepseek-v4-pro"}
cfg.Desktop.ProviderAccess = []string{"deepseek-flash", "deepseek-pro", "mimo-pro"}
cfg.Providers = []config.ProviderEntry{
{
Name: "deepseek-flash", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic",
Models: []string{"deepseek-v4-flash"}, Default: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
Headers: map[string]string{"X-Route": "flash"},
},
{
Name: "deepseek-pro", Kind: "openai", BaseURL: "https://api.deepseek.com",
Models: []string{"deepseek-v4-pro"}, Default: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY",
Headers: map[string]string{"X-Route": "pro"},
},
{Name: "mimo-pro", Kind: "openai", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1", Model: "mimo-v2.5-pro", APIKeyEnv: "MIMO_API_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
app := NewApp()
flashTab := &WorkspaceTab{ID: "flash", Scope: "global", model: "deepseek-flash/deepseek-v4-flash"}
proTab := &WorkspaceTab{ID: "pro", Scope: "global", model: "deepseek-pro/deepseek-v4-pro"}
app.tabs = map[string]*WorkspaceTab{flashTab.ID: flashTab, proTab.ID: proTab}
app.tabOrder = []string{flashTab.ID, proTab.ID}
app.activeTabID = flashTab.ID
if err := app.RemoveProviderAccesses([]string{"deepseek-flash", "deepseek-pro", "deepseek-flash"}); err != nil {
t.Fatalf("RemoveProviderAccesses: %v", err)
}
got := config.LoadForEdit(config.UserConfigPath())
access := providerAccessSet(got.Desktop.ProviderAccess)
if access["deepseek"] || access["deepseek-flash"] || access["deepseek-pro"] || !access["mimo-pro"] {
t.Fatalf("provider_access = %+v, want only mimo-pro", got.Desktop.ProviderAccess)
}
fallback := "mimo-pro/mimo-v2.5-pro"
if got.DefaultModel != fallback || got.Agent.PlannerModel != fallback || got.Agent.SubagentModel != fallback || got.Agent.SubagentModels["review"] != fallback {
t.Fatalf("grouped provider refs were not retargeted: default=%q planner=%q subagent=%q skills=%+v", got.DefaultModel, got.Agent.PlannerModel, got.Agent.SubagentModel, got.Agent.SubagentModels)
}
if flashTab.model != fallback || proTab.model != fallback {
t.Fatalf("grouped provider tabs = %q, %q; want %q", flashTab.model, proTab.model, fallback)
}
flash, flashOK := got.Provider("deepseek-flash")
pro, proOK := got.Provider("deepseek-pro")
if !flashOK || !proOK || flash.Headers["X-Route"] != "flash" || pro.Headers["X-Route"] != "pro" {
t.Fatalf("built-in profiles or custom fields changed: flash=%+v/%v pro=%+v/%v", flash, flashOK, pro, proOK)
}
}
func TestDeleteProviderKeepsOldControllerWhenFallbackBuildFails(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "prov-a/model-a"
cfg.Desktop.ProviderAccess = []string{"prov-a", "broken"}
cfg.Providers = []config.ProviderEntry{
{Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.invalid/v1", Model: "model-a", APIKeyEnv: "REASONIX_TEST_KEY"},
{Name: "broken", Kind: "missing-provider-kind", BaseURL: "https://broken.example.invalid", Model: "model-b", APIKeyEnv: "REASONIX_TEST_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
base := control.New(control.Options{Label: "prov-a/model-a"})
ctrl := newBlockingSnapshotCtrl(base)
close(ctrl.releaseSnapshot)
app := NewApp()
app.ctx = context.Background()
tab := &WorkspaceTab{ID: "active", Scope: "global", Ready: true, Ctrl: ctrl, model: cfg.DefaultModel, Label: cfg.DefaultModel}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
})
err := app.DeleteProvider("prov-a")
if err == nil || !strings.Contains(err.Error(), "missing-provider-kind") {
t.Fatalf("DeleteProvider error = %v, want replacement build failure", err)
}
if tab.Ctrl != ctrl || ctrl.closeCount.Load() != 0 {
t.Fatalf("failed replacement closed or replaced the old controller: ctrl=%T closes=%d", tab.Ctrl, ctrl.closeCount.Load())
}
if tab.model != cfg.DefaultModel || tab.Label != cfg.DefaultModel {
t.Fatalf("failed replacement changed live tab identity: model=%q label=%q", tab.model, tab.Label)
}
}
func TestProviderRemovalContinuesRebuildingSiblingTabsAfterOneFails(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "good/model-b"
cfg.Desktop.ProviderAccess = []string{"good"}
cfg.Providers = []config.ProviderEntry{
{Name: "good", Kind: "openai", BaseURL: "https://good.example.invalid/v1", Model: "model-b", APIKeyEnv: "REASONIX_TEST_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
brokenRoot := t.TempDir()
brokenProject := `[agent]
system_prompt_file = "/outside-workspace/system.md"
`
if err := os.WriteFile(filepath.Join(brokenRoot, "reasonix.toml"), []byte(brokenProject), 0o600); err != nil {
t.Fatalf("write project config: %v", err)
}
workingRoot := t.TempDir()
newOld := func(label string) *blockingSnapshotCtrl {
wrapped := newBlockingSnapshotCtrl(control.New(control.Options{Label: label, Sink: event.Discard}))
close(wrapped.releaseSnapshot)
return wrapped
}
oldBroken := newOld("removed/model-a")
oldWorking := newOld("removed/model-a")
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
broken := &WorkspaceTab{
ID: "a-broken", Scope: "project", WorkspaceRoot: brokenRoot, Ready: true,
Ctrl: oldBroken, model: "removed/model-a", sink: &tabEventSink{tabID: "a-broken", app: app},
disabledMCP: map[string]ServerView{},
}
working := &WorkspaceTab{
ID: "b-working", Scope: "project", WorkspaceRoot: workingRoot, Ready: true,
Ctrl: oldWorking, model: "removed/model-a", sink: &tabEventSink{tabID: "b-working", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{broken.ID: broken, working.ID: working}
app.tabOrder = []string{broken.ID, working.ID}
app.activeTabID = broken.ID
t.Cleanup(func() {
for _, tab := range []*WorkspaceTab{broken, working} {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
tab.releaseSessionLease()
}
})
err := app.applyProviderRemovalRuntime([]providerRemovalTab{
{id: broken.ID, ctrl: oldBroken, retargetModel: true},
{id: working.ID, ctrl: oldWorking, retargetModel: true},
}, "good/model-b", "provider")
if err == nil || !strings.Contains(err.Error(), "relative path within the workspace") {
t.Fatalf("applyProviderRemovalRuntime error = %v, want first tab build failure", err)
}
if broken.Ctrl != oldBroken || oldBroken.closeCount.Load() != 0 || broken.model != "removed/model-a" {
t.Fatalf("failed tab was not failure-atomic: ctrl=%T closes=%d model=%q", broken.Ctrl, oldBroken.closeCount.Load(), broken.model)
}
if working.Ctrl == oldWorking || oldWorking.closeCount.Load() != 1 || working.model != "good/model-b" {
t.Fatalf("working sibling was not rebuilt: ctrl=%T closes=%d model=%q", working.Ctrl, oldWorking.closeCount.Load(), working.model)
}
}
func TestRemoveOfficialProviderAccessRetargetsLiveTabAfterReplacementBuild(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
setDesktopTestCredential(t, "GOOD_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "deepseek/deepseek-v4-flash"
cfg.Desktop.ProviderAccess = []string{"deepseek", "good"}
cfg.Providers = []config.ProviderEntry{
{
Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic",
Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
},
{Name: "good", Kind: "openai", BaseURL: "https://good.example.invalid/v1", Model: "good-model", APIKeyEnv: "GOOD_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
old := newBlockingSnapshotCtrl(control.New(control.Options{Label: cfg.DefaultModel, Sink: event.Discard}))
close(old.releaseSnapshot)
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
tab := &WorkspaceTab{
ID: "active", Scope: "global", Ready: true, Ctrl: old,
model: cfg.DefaultModel, Label: cfg.DefaultModel,
sink: &tabEventSink{tabID: "active", app: app}, disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
tab.releaseSessionLease()
})
if err := app.RemoveProviderAccess("deepseek"); err != nil {
t.Fatalf("RemoveProviderAccess: %v", err)
}
if tab.model != "good/good-model" || tab.Ctrl == old || old.closeCount.Load() != 1 {
t.Fatalf("live tab after removal: model=%q ctrl=%T old closes=%d; want fallback replacement", tab.model, tab.Ctrl, old.closeCount.Load())
}
got := config.LoadForEdit(config.UserConfigPath())
if providerAccessSet(got.Desktop.ProviderAccess)["deepseek"] {
t.Fatalf("provider_access still contains DeepSeek: %v", got.Desktop.ProviderAccess)
}
}
func TestDeleteProviderRebuildsEveryVisibleRuntimeUsingAuxiliaryProvider(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "REMOVED_KEY", "sk-test")
setDesktopTestCredential(t, "GOOD_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "good/good-model"
cfg.Agent.SubagentModel = "removed/vision-model"
cfg.Desktop.ProviderAccess = []string{"removed", "good"}
cfg.Providers = []config.ProviderEntry{
{Name: "removed", Kind: "openai", BaseURL: "https://removed.example.invalid/v1", Model: "vision-model", APIKeyEnv: "REMOVED_KEY", Vision: true},
{Name: "good", Kind: "openai", BaseURL: "https://good.example.invalid/v1", Model: "good-model", APIKeyEnv: "GOOD_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
newTab := func(id string) (*WorkspaceTab, *blockingSnapshotCtrl) {
old := newBlockingSnapshotCtrl(control.New(control.Options{Label: cfg.DefaultModel, Sink: event.Discard}))
close(old.releaseSnapshot)
tab := &WorkspaceTab{
ID: id, Scope: "global", Ready: true, Ctrl: old,
model: cfg.DefaultModel, Label: cfg.DefaultModel,
sink: &tabEventSink{tabID: id, app: app}, disabledMCP: map[string]ServerView{},
}
return tab, old
}
first, oldFirst := newTab("first")
second, oldSecond := newTab("second")
app.tabs = map[string]*WorkspaceTab{first.ID: first, second.ID: second}
app.tabOrder = []string{first.ID, second.ID}
app.activeTabID = first.ID
t.Cleanup(func() {
for _, tab := range []*WorkspaceTab{first, second} {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
tab.releaseSessionLease()
}
})
if err := app.DeleteProvider("removed"); err != nil {
t.Fatalf("DeleteProvider: %v", err)
}
if first.Ctrl == oldFirst || second.Ctrl == oldSecond || oldFirst.closeCount.Load() != 1 || oldSecond.closeCount.Load() != 1 {
t.Fatalf("auxiliary-provider refresh: first=%T/%d second=%T/%d; want both replaced once", first.Ctrl, oldFirst.closeCount.Load(), second.Ctrl, oldSecond.closeCount.Load())
}
if first.model != cfg.DefaultModel || second.model != cfg.DefaultModel {
t.Fatalf("unaffected chat models changed: first=%q second=%q", first.model, second.model)
}
got := config.LoadForEdit(config.UserConfigPath())
if _, ok := got.Provider("removed"); ok {
t.Fatal("removed auxiliary provider still exists")
}
if got.Agent.SubagentModel != "good" {
t.Fatalf("subagent_model = %q, want persisted visible fallback", got.Agent.SubagentModel)
}
}
func TestDeleteProviderRebuildsNonActiveWorkspaceWithProjectAuxiliaryProvider(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "REMOVED_KEY", "sk-test")
setDesktopTestCredential(t, "GOOD_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "good/good-model"
cfg.Desktop.ProviderAccess = []string{"removed", "good"}
cfg.Providers = []config.ProviderEntry{
{Name: "removed", Kind: "openai", BaseURL: "https://removed.example.invalid/v1", Model: "vision-model", APIKeyEnv: "REMOVED_KEY", Vision: true},
{Name: "good", Kind: "openai", BaseURL: "https://good.example.invalid/v1", Model: "good-model", APIKeyEnv: "GOOD_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
activeRoot := t.TempDir()
backgroundRoot := t.TempDir()
if err := os.WriteFile(filepath.Join(backgroundRoot, "reasonix.toml"), []byte("[agent]\nsubagent_model = \"removed/vision-model\"\n"), 0o600); err != nil {
t.Fatalf("write project config: %v", err)
}
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
newTab := func(id, root string) (*WorkspaceTab, *blockingSnapshotCtrl) {
old := newBlockingSnapshotCtrl(control.New(control.Options{Label: cfg.DefaultModel, Sink: event.Discard}))
close(old.releaseSnapshot)
tab := &WorkspaceTab{
ID: id, Scope: "project", WorkspaceRoot: root, Ready: true, Ctrl: old,
model: cfg.DefaultModel, Label: cfg.DefaultModel,
sink: &tabEventSink{tabID: id, app: app}, disabledMCP: map[string]ServerView{},
}
return tab, old
}
active, oldActive := newTab("active", activeRoot)
background, oldBackground := newTab("background", backgroundRoot)
app.tabs = map[string]*WorkspaceTab{active.ID: active, background.ID: background}
app.tabOrder = []string{active.ID, background.ID}
app.activeTabID = active.ID
t.Cleanup(func() {
for _, tab := range []*WorkspaceTab{active, background} {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
tab.releaseSessionLease()
}
})
if err := app.DeleteProvider("removed"); err != nil {
t.Fatalf("DeleteProvider: %v", err)
}
if active.Ctrl == oldActive || background.Ctrl == oldBackground || oldActive.closeCount.Load() != 1 || oldBackground.closeCount.Load() != 1 {
t.Fatalf("workspace provider refresh: active=%T/%d background=%T/%d; want both replaced once", active.Ctrl, oldActive.closeCount.Load(), background.Ctrl, oldBackground.closeCount.Load())
}
projectRaw, err := os.ReadFile(filepath.Join(backgroundRoot, "reasonix.toml"))
if err != nil {
t.Fatalf("read project config: %v", err)
}
if !strings.Contains(string(projectRaw), "removed/vision-model") {
t.Fatalf("global provider deletion rewrote project-owned model reference: %s", projectRaw)
}
}
func TestDeleteProviderRejectsDetachedRuntimeUsingAuxiliaryProvider(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "REMOVED_KEY", "sk-test")
setDesktopTestCredential(t, "GOOD_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "good/good-model"
cfg.Agent.SubagentModel = "removed/vision-model"
cfg.Desktop.ProviderAccess = []string{"removed", "good"}
cfg.Providers = []config.ProviderEntry{
{Name: "removed", Kind: "openai", BaseURL: "https://removed.example.invalid/v1", Model: "vision-model", APIKeyEnv: "REMOVED_KEY", Vision: true},
{Name: "good", Kind: "openai", BaseURL: "https://good.example.invalid/v1", Model: "good-model", APIKeyEnv: "GOOD_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
app := NewApp()
app.ctx = context.Background()
detachedCtrl := control.New(control.Options{Label: cfg.DefaultModel, Sink: event.Discard})
detached := &WorkspaceTab{ID: "detached", Scope: "global", Ctrl: detachedCtrl, model: cfg.DefaultModel}
app.detachedSessions = map[string]*WorkspaceTab{detached.ID: detached}
t.Cleanup(detachedCtrl.Close)
err := app.DeleteProvider("removed")
if err == nil || !strings.Contains(err.Error(), "background session is still using") {
t.Fatalf("DeleteProvider error = %v, want detached auxiliary-runtime guard", err)
}
got := config.LoadForEdit(config.UserConfigPath())
if _, ok := got.Provider("removed"); !ok || got.Agent.SubagentModel != "removed/vision-model" {
t.Fatalf("rejected removal mutated config: provider=%v subagent_model=%q", ok, got.Agent.SubagentModel)
}
}
func TestDeleteProviderRebuildsLiveTabAndReusesSharedHost(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "prov-a/model-a"
cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"}
cfg.Providers = []config.ProviderEntry{
{Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.invalid/v1", Model: "model-a", APIKeyEnv: "REASONIX_TEST_KEY"},
{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.invalid/v1", Model: "model-b", APIKeyEnv: "REASONIX_TEST_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
path := filepath.Join(dir, "provider-removal-success.jsonl")
session := agent.NewSession("system")
session.Add(provider.Message{Role: provider.RoleUser, Content: "preserve this history"})
exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
hostKey := "provider-removal-success-host"
host := app.acquireSharedHost(hostKey)
old := newBlockingSnapshotCtrl(control.New(control.Options{
Executor: exec, SessionDir: dir, SessionPath: path, Label: cfg.DefaultModel, Host: host, Sink: event.Discard,
}))
close(old.releaseSnapshot)
tab := &WorkspaceTab{
ID: "active", Scope: "global", Ready: true, Ctrl: old, SessionPath: path,
model: cfg.DefaultModel, Label: cfg.DefaultModel, SharedHostKey: hostKey, disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
tab.releaseSessionLease()
app.releaseSharedHost(hostKey)
})
if err := app.DeleteProvider("prov-a"); err != nil {
t.Fatalf("DeleteProvider: %v", err)
}
if tab.Ctrl == nil || tab.Ctrl == old || old.closeCount.Load() != 1 {
t.Fatalf("controller swap = %T, old closes = %d; want one successful replacement", tab.Ctrl, old.closeCount.Load())
}
if tab.model != "prov-b/model-b" || tab.Label != "model-b" {
t.Fatalf("replacement identity = model:%q label:%q, want prov-b/model-b and model-b", tab.model, tab.Label)
}
if !sameDesktopPath(tab.Ctrl.SessionPath(), path) || !sameDesktopPath(tab.SessionPath, path) {
t.Fatalf("replacement session path = ctrl:%q tab:%q, want %q", tab.Ctrl.SessionPath(), tab.SessionPath, path)
}
if tab.Ctrl.Host() != host || tab.SharedHostKey != hostKey {
t.Fatalf("replacement did not reuse shared host: host=%p want=%p key=%q", tab.Ctrl.Host(), host, tab.SharedHostKey)
}
history := tab.Ctrl.History()
preserved := false
for _, message := range history {
if message.Content == "preserve this history" {
preserved = true
break
}
}
if !preserved {
t.Fatalf("replacement history = %+v, want preserved user message", history)
}
}
func TestRemoveProviderAccessRejectsDetachedRuntimeBeforeMutation(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "mimo-pro/mimo-v2.5-pro"
cfg.Desktop.ProviderAccess = []string{"deepseek", "mimo-pro"}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
app := NewApp()
detachedCtrl := control.New(control.Options{Label: "deepseek"})
detached := &WorkspaceTab{
ID: detachedRuntimeTabID("detached-provider-removal"), Scope: "global", Ready: true,
Ctrl: detachedCtrl, model: "deepseek/deepseek-v4-flash", Label: "deepseek",
}
app.detachedSessions = map[string]*WorkspaceTab{"detached-provider-removal": detached}
t.Cleanup(detachedCtrl.Close)
err := app.RemoveProviderAccess("deepseek")
if err == nil || !strings.Contains(err.Error(), "background session is still using") {
t.Fatalf("RemoveProviderAccess error = %v, want detached-runtime guard", err)
}
got := config.LoadForEdit(config.UserConfigPath())
if !providerAccessSet(got.Desktop.ProviderAccess)["deepseek"] {
t.Fatalf("provider access changed after detached-runtime rejection: %+v", got.Desktop.ProviderAccess)
}
if detached.Ctrl != detachedCtrl || detached.model != "deepseek/deepseek-v4-flash" {
t.Fatalf("detached runtime changed after rejection: ctrl=%T model=%q", detached.Ctrl, detached.model)
}
}
func TestRemoveProviderAccessesRejectsMixedGroupBeforeMutation(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
cfg.Desktop.ProviderAccess = []string{"deepseek", "custom"}
cfg.Providers = []config.ProviderEntry{
{Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic", Model: "deepseek-v4-flash"},
{Name: "custom", Kind: "openai", BaseURL: "https://proxy.example/v1", Model: "custom-model"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
if err := NewApp().RemoveProviderAccesses([]string{"deepseek", "custom"}); err == nil {
t.Fatal("RemoveProviderAccesses accepted mixed official and custom providers")
}
got := config.LoadForEdit(config.UserConfigPath())
access := providerAccessSet(got.Desktop.ProviderAccess)
if !access["deepseek"] || !access["custom"] {
t.Fatalf("provider access was partially mutated after rejection: %+v", got.Desktop.ProviderAccess)
}
}
func TestProviderAccessFallbackSkipsUnconfiguredProviders(t *testing.T) {
cfg := &config.Config{
Desktop: config.DesktopConfig{ProviderAccess: []string{"deepseek", "unconfigured", "local"}},
Providers: []config.ProviderEntry{
{Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic", Model: "deepseek-v4-flash"},
{Name: "unconfigured", Kind: "openai", BaseURL: "https://api.example.invalid/v1", Model: "remote", APIKeyEnv: "MISSING_API_KEY"},
{Name: "local", Kind: "openai", BaseURL: "http://127.0.0.1:11434/v1", Model: "local-model"},
},
}
if got := providerAccessFallbackRef(cfg, []string{"deepseek"}); got != "local/local-model" {
t.Fatalf("fallback = %q, want configured local provider", got)
}
}
func TestProviderRemovalStateFingerprintCoversConfigAndCredentialRevision(t *testing.T) {
cfg := config.Default()
cfg.Providers = []config.ProviderEntry{{
Name: "candidate", Kind: "openai", BaseURL: "https://example.invalid/v1",
Model: "model-a", APIKeyEnv: "CANDIDATE_API_KEY",
}}
base := providerRemovalStateFingerprint(cfg, "credential-revision-a")
if got := providerRemovalStateFingerprint(cfg, "credential-revision-a"); got != base {
t.Fatal("unchanged provider removal state produced an unstable fingerprint")
}
if strings.Contains(base, "CANDIDATE_API_KEY") {
t.Fatal("provider removal fingerprint exposed the credential environment name")
}
if got := providerRemovalStateFingerprint(cfg, "credential-revision-b"); got == base {
t.Fatal("credential revision change did not invalidate provider removal fingerprint")
}
cfg.Providers[0].APIKeyEnv = "ROTATED_API_KEY"
if got := providerRemovalStateFingerprint(cfg, "credential-revision-a"); got == base {
t.Fatal("provider configuration change did not invalidate provider removal fingerprint")
}
}
func TestProviderAccessFallbackSkipsConfiguredProvidersOutsideAccessList(t *testing.T) {
cfg := &config.Config{
Desktop: config.DesktopConfig{ProviderAccess: []string{"removed", "visible"}},
Providers: []config.ProviderEntry{
{Name: "removed", Kind: "openai", BaseURL: "https://removed.example/v1", Model: "removed-model"},
{Name: "hidden", Kind: "openai", BaseURL: "http://127.0.0.1:11434/v1", Model: "hidden-model"},
{Name: "visible", Kind: "openai", BaseURL: "http://127.0.0.1:11434/v1", Model: "visible-model"},
},
}
if got := providerAccessFallbackRef(cfg, []string{"removed"}); got != "visible/visible-model" {
t.Fatalf("fallback = %q, want remaining accessed provider", got)
}
}
func TestDeleteProviderPersistsVisibleFallbackInsteadOfHiddenConfiguredProvider(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
cfg.DefaultModel = "removed/removed-model"
cfg.Agent.PlannerModel = "removed"
cfg.Agent.SubagentModel = "removed/removed-model"
cfg.Agent.SubagentModels = map[string]string{"review": "removed/removed-model"}
cfg.Desktop.ProviderAccess = []string{"removed", "visible"}
cfg.Providers = []config.ProviderEntry{
{Name: "removed", Kind: "openai", BaseURL: "https://removed.example/v1", Model: "removed-model"},
{Name: "hidden", Kind: "openai", BaseURL: "http://127.0.0.1:11434/v1", Model: "hidden-model"},
{Name: "visible", Kind: "openai", BaseURL: "http://127.0.0.1:11434/v1", Model: "visible-model"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
if err := NewApp().DeleteProvider("removed"); err != nil {
t.Fatalf("DeleteProvider: %v", err)
}
got := config.LoadForEdit(config.UserConfigPath())
want := "visible"
if got.DefaultModel != want || got.Agent.PlannerModel != want || got.Agent.SubagentModel != want || got.Agent.SubagentModels["review"] != want {
t.Fatalf("persisted refs used a hidden fallback: default=%q planner=%q subagent=%q skills=%+v", got.DefaultModel, got.Agent.PlannerModel, got.Agent.SubagentModel, got.Agent.SubagentModels)
}
if _, ok := got.Provider("hidden"); !ok {
t.Fatal("hidden provider should remain configured even though it is not a removal fallback")
}
}
func TestDeleteProviderRejectsDefaultWhenOnlyHiddenProviderRemains(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
cfg.DefaultModel = "removed/removed-model"
cfg.Desktop.ProviderAccess = []string{"removed"}
cfg.Providers = []config.ProviderEntry{
{Name: "removed", Kind: "openai", BaseURL: "http://127.0.0.1:11434/v1", Model: "removed-model"},
{Name: "hidden", Kind: "openai", BaseURL: "http://127.0.0.1:11435/v1", Model: "hidden-model"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
err := NewApp().DeleteProvider("removed")
if err == nil || !strings.Contains(err.Error(), "no other configured provider exists") {
t.Fatalf("DeleteProvider error = %v, want no visible fallback", err)
}
got := config.LoadForEdit(config.UserConfigPath())
if got.DefaultModel != cfg.DefaultModel {
t.Fatalf("default model = %q, want unchanged %q", got.DefaultModel, cfg.DefaultModel)
}
if len(got.Desktop.ProviderAccess) != 1 || got.Desktop.ProviderAccess[0] != "removed" {
t.Fatalf("provider access = %+v, want unchanged removed entry", got.Desktop.ProviderAccess)
}
if _, ok := got.Provider("removed"); !ok {
t.Fatal("default provider was deleted despite the rejected operation")
}
if _, ok := got.Provider("hidden"); !ok {
t.Fatal("hidden provider changed despite the rejected operation")
}
}
func TestRemoveProviderAccessesRejectsInUseProviderWithoutConfiguredFallback(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "deepseek/deepseek-v4-flash"
cfg.Desktop.ProviderAccess = []string{"deepseek", "mimo-pro"}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
app := NewApp()
tab := &WorkspaceTab{ID: "deepseek", Scope: "global", model: cfg.DefaultModel}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
err := app.RemoveProviderAccess("deepseek")
if err == nil || !strings.Contains(err.Error(), "no other configured provider exists") {
t.Fatalf("RemoveProviderAccess error = %v, want no configured fallback", err)
}
got := config.LoadForEdit(config.UserConfigPath())
access := providerAccessSet(got.Desktop.ProviderAccess)
if !access["deepseek"] || !access["mimo-pro"] {
t.Fatalf("provider access changed after rejected removal: %+v", got.Desktop.ProviderAccess)
}
if got.DefaultModel != cfg.DefaultModel || tab.model != cfg.DefaultModel {
t.Fatalf("model refs changed after rejected removal: config=%q tab=%q", got.DefaultModel, tab.model)
}
}
func TestRemoveProviderAccessesRejectsOfficialProviderChangedDuringSnapshot(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "deepseek/deepseek-v4-flash"
cfg.Desktop.ProviderAccess = []string{"deepseek", "mimo-pro"}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
app := NewApp()
ctrl := newBlockingSnapshotCtrl(control.New(control.Options{Label: "deepseek"}))
tab := &WorkspaceTab{ID: "deepseek", Scope: "global", model: cfg.DefaultModel, Ctrl: ctrl}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
done := make(chan error, 1)
go func() { done <- app.RemoveProviderAccess("deepseek") }()
<-ctrl.firstSnapshotStarted
unlock := config.LockUserConfigEdits()
changed := config.LoadForEdit(config.UserConfigPath())
provider, ok := changed.Provider("deepseek")
if !ok {
unlock()
t.Fatal("deepseek provider missing")
}
provider.BaseURL = "https://proxy.example/v1"
if err := changed.SaveTo(config.UserConfigPath()); err != nil {
unlock()
t.Fatalf("save overlapping config edit: %v", err)
}
unlock()
close(ctrl.releaseSnapshot)
if err := <-done; err == nil {
t.Fatal("RemoveProviderAccess accepted an official provider changed during snapshot")
}
got := config.LoadForEdit(config.UserConfigPath())
access := providerAccessSet(got.Desktop.ProviderAccess)
if !access["deepseek"] || !access["mimo-pro"] {
t.Fatalf("provider access changed after rejected overlap: %+v", got.Desktop.ProviderAccess)
}
if ctrl.closeCount.Load() != 0 || tab.Ctrl != ctrl || tab.model != cfg.DefaultModel {
t.Fatalf("runtime mutated after rejected overlap: closes=%d ctrl=%T model=%q", ctrl.closeCount.Load(), tab.Ctrl, tab.model)
}
}
func TestRemoveProviderAccessesRejectsCredentialChangeDuringSnapshot(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
setDesktopTestCredential(t, "MIMO_API_KEY", "old-key")
cfg := config.Default()
cfg.DefaultModel = "deepseek/deepseek-v4-flash"
cfg.Desktop.ProviderAccess = []string{"deepseek", "mimo-pro"}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
app := NewApp()
ctrl := newBlockingSnapshotCtrl(control.New(control.Options{Label: "deepseek"}))
tab := &WorkspaceTab{ID: "deepseek", Scope: "global", model: cfg.DefaultModel, Ctrl: ctrl}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
done := make(chan error, 1)
go func() { done <- app.RemoveProviderAccess("deepseek") }()
<-ctrl.firstSnapshotStarted
setDesktopTestCredential(t, "MIMO_API_KEY", "new-key")
close(ctrl.releaseSnapshot)
if err := <-done; err == nil {
t.Fatal("RemoveProviderAccess accepted credentials changed during snapshot")
}
got := config.LoadForEdit(config.UserConfigPath())
access := providerAccessSet(got.Desktop.ProviderAccess)
if !access["deepseek"] || !access["mimo-pro"] {
t.Fatalf("provider access changed after rejected credential overlap: %+v", got.Desktop.ProviderAccess)
}
if ctrl.closeCount.Load() != 0 || tab.Ctrl != ctrl || tab.model != cfg.DefaultModel {
t.Fatalf("runtime mutated after rejected credential overlap: closes=%d ctrl=%T model=%q", ctrl.closeCount.Load(), tab.Ctrl, tab.model)
}
}
+240 -439
View File
@@ -40,34 +40,37 @@ import (
// read
type ProviderView struct {
Name string `json:"name"`
BuiltIn bool `json:"builtIn"`
Added bool `json:"added"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
ChatURL string `json:"chatUrl"`
Models []string `json:"models"`
VisionModels []string `json:"visionModels"`
VisionModelsSet bool `json:"visionModelsConfigured"`
ModelsURL string `json:"modelsUrl"`
Default string `json:"default"`
APIKeyEnv string `json:"apiKeyEnv"`
Headers map[string]string `json:"headers"`
ExtraBody map[string]any `json:"extraBody"`
AuthHeader bool `json:"authHeader"`
KeySet bool `json:"keySet"` // the env var currently resolves to a non-empty value
RequiresKey bool `json:"requiresKey"`
Configured bool `json:"configured"` // selectable: either key is present or no key is required
KeySource string `json:"keySource,omitempty"`
KeySourcePath string `json:"keySourcePath,omitempty"`
BalanceURL string `json:"balanceUrl"`
ContextWindow int `json:"contextWindow"`
ReasoningProtocol string `json:"reasoningProtocol"`
Thinking string `json:"thinking"`
WebSearch bool `json:"webSearch"`
SupportedEfforts []string `json:"supportedEfforts"`
DefaultEffort string `json:"defaultEffort"`
ModelOverrides []ProviderModelOverrideView `json:"modelOverrides"`
Name string `json:"name"`
BuiltIn bool `json:"builtIn"`
Added bool `json:"added"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
ChatURL string `json:"chatUrl"`
Models []string `json:"models"`
VisionModels []string `json:"visionModels"`
VisionModelsSet bool `json:"visionModelsConfigured"`
VisionCapability string `json:"visionCapability,omitempty"`
ModelsURL string `json:"modelsUrl"`
Default string `json:"default"`
APIKeyEnv string `json:"apiKeyEnv"`
Headers map[string]string `json:"headers"`
ExtraBody map[string]any `json:"extraBody"`
AuthHeader bool `json:"authHeader"`
KeySet bool `json:"keySet"` // the env var currently resolves to a non-empty value
RequiresKey bool `json:"requiresKey"`
Configured bool `json:"configured"` // selectable: either key is present or no key is required
KeySource string `json:"keySource,omitempty"`
KeySourcePath string `json:"keySourcePath,omitempty"`
BalanceURL string `json:"balanceUrl"`
ContextWindow int `json:"contextWindow"`
ReasoningProtocol string `json:"reasoningProtocol"`
Thinking string `json:"thinking"`
WebSearch bool `json:"webSearch"`
ServerWebSearchCapability bool `json:"serverWebSearchCapability"`
SupportedEfforts []string `json:"supportedEfforts"`
DefaultEffort string `json:"defaultEffort"`
ModelOverrides []ProviderModelOverrideView `json:"modelOverrides"`
RecommendedUpgradeAvailable bool `json:"recommendedUpgradeAvailable,omitempty"`
// ModelCatalogFingerprint is an opaque digest of the provider identity and
// current model selection. Background discovery must compare it while holding
// the config edit lock before applying a narrow catalog-only update.
@@ -115,6 +118,7 @@ type ProviderModelOverrideView struct {
DefaultEffort string `json:"defaultEffort"`
Vision *bool `json:"vision"`
ContextWindow int `json:"contextWindow,omitempty"`
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
}
type PermissionsView struct {
@@ -399,10 +403,10 @@ func providerCredentialsRevision() string {
return config.CredentialStoreRevision()
}
var providerModelCatalogFingerprintKey = func() []byte {
var providerStateFingerprintKey = func() []byte {
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
panic(fmt.Sprintf("initialize provider catalog fingerprint key: %v", err))
panic(fmt.Sprintf("initialize provider state fingerprint key: %v", err))
}
return key
}()
@@ -414,7 +418,7 @@ func providerModelCatalogFingerprint(p config.ProviderEntry) string {
func providerModelCatalogFingerprintForCredentials(p config.ProviderEntry, credentialsRevision string) string {
// This token crosses the Wails boundary, so key the digest instead of exposing
// a reusable hash of header or credential-store metadata to the frontend.
h := hmac.New(sha256.New, providerModelCatalogFingerprintKey)
h := hmac.New(sha256.New, providerStateFingerprintKey)
write := func(value string) {
_, _ = fmt.Fprintf(h, "%d:", len(value))
_, _ = h.Write([]byte(value))
@@ -494,6 +498,7 @@ func providerModelOverridesForView(overrides map[string]config.ProviderModelOver
DefaultEffort: ov.DefaultEffort,
Vision: ov.Vision,
ContextWindow: ov.ContextWindow,
MaxOutputTokens: ov.MaxOutputTokens,
})
}
return out
@@ -519,8 +524,9 @@ func providerModelOverridesForSave(overrides []ProviderModelOverrideView, models
DefaultEffort: strings.TrimSpace(item.DefaultEffort),
Vision: item.Vision,
ContextWindow: max(item.ContextWindow, 0),
MaxOutputTokens: item.MaxOutputTokens,
}
if strings.TrimSpace(ov.ReasoningProtocol) == "" && len(ov.SupportedEfforts) == 0 && strings.TrimSpace(ov.DefaultEffort) == "" && ov.Vision == nil && ov.ContextWindow == 0 {
if strings.TrimSpace(ov.ReasoningProtocol) == "" && len(ov.SupportedEfforts) == 0 && strings.TrimSpace(ov.DefaultEffort) == "" && ov.Vision == nil && ov.ContextWindow == 0 && ov.MaxOutputTokens == 0 {
continue
}
out[model] = ov
@@ -531,17 +537,6 @@ func providerModelOverridesForSave(overrides []ProviderModelOverrideView, models
return out
}
func providerRemovalFallbackRef(c *config.Config, name string) string {
for i := range c.Providers {
p := &c.Providers[i]
if p.Name == name || !p.Configured() || len(p.ModelList()) == 0 {
continue
}
return p.Name + "/" + p.DefaultModel()
}
return ""
}
func desktopModelRefsProvider(c *config.Config, ref, name string) bool {
if config.ModelRefsProvider(ref, name) {
return true
@@ -636,27 +631,33 @@ func providerViewFromEntryForRootWithResolverAndCredentials(p config.ProviderEnt
}
key := resolver.ResolveGlobalFirst(p.APIKeyEnv)
requiresKey := p.RequiresAPIKey()
visionCapability := "configurable"
if !config.CanConfigureVision(&p) {
visionCapability = "unsupported"
}
return ProviderView{
Name: p.Name, BuiltIn: builtIn, Added: added, Kind: p.Kind, BaseURL: p.BaseURL, ChatURL: p.ChatURL,
Models: nonNil(models), VisionModels: nonNil(providerVisionModels(models, visionModels)), VisionModelsSet: visionModelsSet, ModelsURL: p.ModelsURL, Default: p.DefaultModel(),
APIKeyEnv: p.APIKeyEnv,
Headers: nonNilStringMap(p.Headers),
ExtraBody: nonNilAnyMap(p.ExtraBody),
AuthHeader: p.AuthHeader,
KeySet: key.Set,
RequiresKey: requiresKey,
Configured: !requiresKey || key.Set,
KeySource: key.Source.Label,
KeySourcePath: key.Source.Path,
BalanceURL: p.BalanceURL,
ContextWindow: p.ContextWindow,
ReasoningProtocol: p.ReasoningProtocol,
Thinking: providerThinkingForSettings(p.Thinking),
WebSearch: config.EffectiveWebSearch(&p),
SupportedEfforts: nonNil(p.SupportedEfforts),
DefaultEffort: p.DefaultEffort,
ModelOverrides: providerModelOverridesForView(p.ModelOverrides, models),
ModelCatalogFingerprint: providerModelCatalogFingerprintForCredentials(p, credentialsRevision),
Models: nonNil(models), VisionModels: nonNil(providerVisionModels(models, visionModels)), VisionModelsSet: visionModelsSet, VisionCapability: visionCapability, ModelsURL: p.ModelsURL, Default: p.DefaultModel(),
APIKeyEnv: p.APIKeyEnv,
Headers: nonNilStringMap(p.Headers),
ExtraBody: nonNilAnyMap(p.ExtraBody),
AuthHeader: p.AuthHeader,
KeySet: key.Set,
RequiresKey: requiresKey,
Configured: !requiresKey || key.Set,
KeySource: key.Source.Label,
KeySourcePath: key.Source.Path,
BalanceURL: p.BalanceURL,
ContextWindow: p.ContextWindow,
ReasoningProtocol: p.ReasoningProtocol,
Thinking: providerThinkingForSettings(p.Thinking),
WebSearch: config.EffectiveWebSearch(&p),
ServerWebSearchCapability: config.IsOfficialDeepSeekWebSearchEndpoint(&p),
SupportedEfforts: nonNil(p.SupportedEfforts),
DefaultEffort: p.DefaultEffort,
ModelOverrides: providerModelOverridesForView(p.ModelOverrides, models),
RecommendedUpgradeAvailable: config.CanUpgradeDeepSeekProviderProtocol(&p),
ModelCatalogFingerprint: providerModelCatalogFingerprintForCredentials(p, credentialsRevision),
}
}
@@ -1113,7 +1114,9 @@ func (a *App) Settings() SettingsView {
v.ProviderPresets = providerPresetViewsForRootWithResolver(cfg, root, resolver)
for i := range cfg.Providers {
p := &cfg.Providers[i]
v.Providers = append(v.Providers, providerViewFromEntryForRootWithResolverAndCredentials(*p, isOfficialBuiltInProvider(*p), added[p.Name], root, resolver, credentialsRevision))
providerView := providerViewFromEntryForRootWithResolverAndCredentials(*p, isOfficialBuiltInProvider(*p), added[p.Name], root, resolver, credentialsRevision)
providerView.RecommendedUpgradeAvailable = providerView.RecommendedUpgradeAvailable && config.CanUpgradeDeepSeekProviderProtocolUserConfig(p.Name)
v.Providers = append(v.Providers, providerView)
}
return v
}
@@ -1396,6 +1399,53 @@ func (a *App) applyConfigChangeWithWarning(setting string, mutate func(*config.C
return "", nil
}
// applyGlobalProviderConfigChange persists a provider-wide setting and refreshes
// every visible runtime while runtime admission and all turn gates are frozen.
// Detached runtimes cannot participate in the failure-atomic build-and-swap, so
// reject before writing instead of leaving one session on stale provider state.
func (a *App) applyGlobalProviderConfigChange(setting, operation, detachedAction string, mutate func(*config.Config) error) error {
defer a.lockRuntimeMutation(operation)()
releaseGates, err := a.lockRuntimeTurnGates(setting, nil)
if err != nil {
return err
}
defer releaseGates()
tabs, err := a.visibleTabsForGlobalRuntimeMutation(detachedAction)
if err != nil {
return err
}
if err := func() error {
unlock := config.LockUserConfigEdits()
defer unlock()
cfg, path, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return err
}
if err := mutate(cfg); err != nil {
return err
}
return cfg.SaveTo(path)
}(); err != nil {
return err
}
var rebuildErrs []error
for _, tab := range tabs {
if a.controllerForTab(tab) == nil {
// A startup placeholder has no stale provider runtime. Its eventual
// build reads the just-persisted config.
continue
}
if err := a.rebuildSettingTurnLocked(setting, tab, true, false); err != nil {
if _, ok := a.deferredRebuildWarningForTab(setting, err, tab); ok {
continue
}
rebuildErrs = append(rebuildErrs, err)
}
}
return errors.Join(rebuildErrs...)
}
func (a *App) applyConfigOnly(mutate func(*config.Config) error) error {
unlock := config.LockUserConfigEdits()
defer unlock()
@@ -1438,6 +1488,10 @@ func (a *App) ensureLiveControllersRuntimeMutationAllowed(setting string) error
}
func (a *App) deferredRebuildWarning(setting string, err error) (string, bool) {
return a.deferredRebuildWarningForTab(setting, err, a.activeTab())
}
func (a *App) deferredRebuildWarningForTab(setting string, err error, tab *WorkspaceTab) (string, bool) {
if err == nil || !errors.Is(err, agent.ErrSessionLeaseHeld) {
return "", false
}
@@ -1448,10 +1502,9 @@ func (a *App) deferredRebuildWarning(setting string, err error) (string, bool) {
userErr := userFacingSessionLeaseError(setting, err)
warning := fmt.Sprintf("%s saved, but the current session could not refresh yet: %s", setting, userErr.Error())
slog.Warn("desktop: deferred settings rebuild", "setting", setting, "err", err)
// Bind both the warning and the retry to the tab whose refresh failed (the
// rebuild acts on the active tab), so a tab switch right after the failure
// cannot misroute the notice or the deferred rebuild.
if tab := a.activeTab(); tab != nil {
// Bind both the warning and the retry to the tab whose refresh failed, so a
// tab switch or a multi-tab mutation cannot misroute either one.
if tab != nil {
a.warnForTab(tab.ID, warning)
a.scheduleDeferredRebuild(tab.ID, setting)
}
@@ -1837,6 +1890,14 @@ func (a *App) rebuildSettingLocked(setting string) error {
// else — active-work guards, workspace prep, lease moves, swap, close-after-
// swap, fence — is shared.
func (a *App) rebuildSettingTurnLocked(setting string, tab *WorkspaceTab, admissionHeld bool, reload bool) error {
return a.rebuildSettingTurnLockedWithModel(setting, tab, "", admissionHeld, reload)
}
// rebuildSettingTurnLockedWithModel optionally builds the replacement for a
// target model without changing tab.model before the swap. Provider removal
// uses this to remain failure-atomic: a failed fallback build leaves both the
// old controller and its visible model identity untouched.
func (a *App) rebuildSettingTurnLockedWithModel(setting string, tab *WorkspaceTab, modelOverride string, admissionHeld bool, reload bool) error {
if a.ctx == nil {
return nil
}
@@ -1885,6 +1946,9 @@ func (a *App) rebuildSettingTurnLocked(setting string, tab *WorkspaceTab, admiss
snap := a.tabRuntimeSnapshot(tab)
runtime := snap.normalizedRuntime()
model := snap.model
if override := strings.TrimSpace(modelOverride); override != "" {
model = override
}
if cfg, err := config.LoadForRoot(snap.workspaceRoot); err == nil {
if resolved, fallback, ok := cfg.ResolveModelWithFallback(model); ok {
if fallback && strings.TrimSpace(model) != "" {
@@ -2327,18 +2391,25 @@ func (a *App) SetDefaultToolApprovalMode(mode string) error {
func (a *App) SetDefaultAutoRecoveryCheckpoint(_ bool) error { return nil }
func officialProviderTemplate(kind, pricingLanguage string) ([]config.ProviderEntry, string, error) {
webSearchEnabled := true
switch strings.ToLower(strings.TrimSpace(kind)) {
case "deepseek", "deepseek-official":
return []config.ProviderEntry{{
Name: "deepseek",
Kind: "openai",
BaseURL: "https://api.deepseek.com",
Kind: "anthropic",
BaseURL: "https://api.deepseek.com/anthropic",
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
Default: "deepseek-v4-flash",
APIKeyEnv: "DEEPSEEK_API_KEY",
BalanceURL: "https://api.deepseek.com/user/balance",
Thinking: "enabled",
WebSearch: &webSearchEnabled,
ContextWindow: 1_000_000,
Prices: config.DeepSeekV4PricesForLanguage(pricingLanguage),
ModelOverrides: map[string]config.ProviderModelOverride{
"deepseek-v4-flash": {SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high"},
"deepseek-v4-pro": {SupportedEfforts: []string{"disabled", "high", "max"}, DefaultEffort: "high"},
},
}}, "DEEPSEEK_API_KEY", nil
default:
return nil, "", fmt.Errorf("unknown official provider template %q", kind)
@@ -2391,12 +2462,15 @@ func saveProviderConfig(c *config.Config, p ProviderView) error {
return fmt.Errorf("config is nil")
}
e := config.ProviderEntry{Name: p.Name}
existing := false
for i := range c.Providers {
if c.Providers[i].Name == p.Name {
e = c.Providers[i]
existing = true
break
}
}
original := e
e.Name = p.Name
e.Kind = p.Kind
e.BaseURL = p.BaseURL
@@ -2410,10 +2484,12 @@ func saveProviderConfig(c *config.Config, p ProviderView) error {
e.ContextWindow = p.ContextWindow
e.ReasoningProtocol = p.ReasoningProtocol
e.Thinking = providerThinkingForSettings(p.Thinking)
if config.SupportsServerWebSearch(&e) {
// Settings exposes this switch only for verified endpoints. Preserve an
// existing advanced override, but never carry an official default to a new URL.
if config.IsOfficialDeepSeekWebSearchEndpoint(&e) {
enabled := p.WebSearch
e.WebSearch = &enabled
} else {
} else if !config.SupportsServerWebSearch(&e) || !existing || config.IsOfficialDeepSeekWebSearchEndpoint(&original) {
e.WebSearch = nil
}
e.SupportedEfforts = p.SupportedEfforts
@@ -2455,6 +2531,44 @@ func (a *App) SaveProvider(p ProviderView) error {
})
}
// SetProviderWebSearch updates every provider represented by one Settings
// access card in a single config transaction. Legacy DeepSeek aliases can
// remain separate when their custom transport fields differ, so changing only
// the first profile would leave the grouped control in a contradictory state.
func (a *App) SetProviderWebSearch(names []string, enabled bool) error {
return a.applyGlobalProviderConfigChange(
"DeepSeek server-side web search",
"set-provider-web-search",
"changing DeepSeek server-side web search",
func(c *config.Config) error {
seen := make(map[string]bool, len(names))
providers := make([]*config.ProviderEntry, 0, len(names))
for _, rawName := range names {
name := strings.TrimSpace(rawName)
if name == "" || seen[name] {
continue
}
seen[name] = true
entry, ok := c.Provider(name)
if !ok {
return fmt.Errorf("provider %q not found", name)
}
if !config.IsOfficialDeepSeekWebSearchEndpoint(entry) {
return fmt.Errorf("provider %q does not support configurable server-side web search", name)
}
providers = append(providers, entry)
}
if len(providers) == 0 {
return fmt.Errorf("provider list is empty")
}
for _, entry := range providers {
value := enabled
entry.WebSearch = &value
}
return nil
})
}
func providerModelOverridesForCatalog(overrides map[string]config.ProviderModelOverride, models []string) map[string]config.ProviderModelOverride {
if len(overrides) == 0 {
return nil
@@ -2610,46 +2724,73 @@ func (a *App) SaveProviderWithKey(p ProviderView, key string) (string, error) {
return warning, nil
}
// AddOfficialProviderAccess adds one curated desktop provider template to the
// Settings > Model > Access list. The runtime default providers still exist
// independently; this only records the user's explicit access setup.
func (a *App) AddOfficialProviderAccess(kind, key string) (string, error) {
// Read-only pre-read (pricing language); the actual write happens inside
// applyConfigChange below, under the config edit lock.
cfg, _, err := a.loadDesktopUserConfigForView()
// UpgradeDeepSeekProviderAccess applies the explicit Settings action for an
// official legacy OpenAI entry. The config package performs a narrow raw-TOML
// edit so unrelated and future fields are not lost to a full config render.
func (a *App) UpgradeDeepSeekProviderAccess(name string) (string, error) {
const setting = "DeepSeek provider protocol"
defer a.lockRuntimeMutation("upgrade-deepseek-provider")()
releaseGates, err := a.lockRuntimeTurnGates(setting, nil)
if err != nil {
return "", err
}
entries, keyEnv, err := officialProviderTemplate(kind, cfg.DeepSeekOfficialPricingLanguage())
defer releaseGates()
visibleTabs, err := a.visibleTabsForGlobalRuntimeMutation("upgrading the DeepSeek provider protocol")
if err != nil {
return "", err
}
if err := a.ensureActiveTabRebuildAllowed("provider access"); err != nil {
changed, err := config.UpgradeDeepSeekProviderProtocolUserConfig(name)
if err != nil {
return "", err
}
keyWarning := ""
if strings.TrimSpace(key) != "" && keyEnv != "" {
var err error
keyWarning, err = a.saveProviderCredential(keyEnv, key)
if err != nil {
return "", err
if !changed {
return "", fmt.Errorf("DeepSeek provider %q is not eligible for the recommended protocol upgrade", name)
}
warning := ""
var rebuildErrs []error
for _, tab := range visibleTabs {
if a.controllerForTab(tab) == nil {
// A visible startup placeholder has no stale provider runtime. Its
// eventual build will read the upgraded config directly.
continue
}
}
rebuildWarning, err := a.applyConfigChangeWithWarning("provider access", func(c *config.Config) error {
names := make([]string, 0, len(entries))
for _, e := range entries {
if err := c.UpsertProvider(e); err != nil {
return err
if err := a.rebuildSettingTurnLocked(setting, tab, true, false); err != nil {
if deferred, ok := a.deferredRebuildWarningForTab(setting, err, tab); ok {
if warning == "" {
warning = deferred
}
continue
}
names = append(names, e.Name)
// The protocol is user-global and already persisted. Keep refreshing
// sibling tabs so one workspace-specific boot failure cannot leave
// every later runtime pinned to the old protocol.
rebuildErrs = append(rebuildErrs, err)
}
addProviderAccess(c, names...)
return nil
})
if err != nil {
return "", err
}
return appendSettingsWarning(keyWarning, rebuildWarning), nil
return warning, errors.Join(rebuildErrs...)
}
// visibleTabsForGlobalRuntimeUpgrade returns every visible runtime that must
// observe a user-global provider protocol change. A detached controller cannot
// use rebuildSettingTurnLocked because it is intentionally absent from a.tabs;
// reject before mutating config so it never remains silently pinned to the old
// protocol. runtime mutation admission and all turn gates are held by callers.
func (a *App) visibleTabsForGlobalRuntimeMutation(detachedAction string) ([]*WorkspaceTab, error) {
a.mu.RLock()
defer a.mu.RUnlock()
for _, tab := range a.detachedSessions {
if tab != nil && tab.Ctrl != nil {
return nil, fmt.Errorf("background session is still open; reopen or close it before %s", detachedAction)
}
}
tabs := make([]*WorkspaceTab, 0, len(a.tabs))
for _, id := range a.orderedTabIDsLocked() {
if tab := a.tabs[id]; tab != nil {
tabs = append(tabs, tab)
}
}
return tabs, nil
}
// AddProviderPresetAccess installs one editable custom-provider preset. Unlike
@@ -2840,346 +2981,6 @@ func (a *App) FetchAllProviderModels(providers []ProviderView) map[string][]stri
return results
}
// DeleteProvider removes a provider and retargets open idle tabs that used it.
func (a *App) DeleteProvider(name string) error {
return a.deleteProviderAndRetargetTabs(name)
}
// RemoveProviderAccess hides a provider from Settings > Model > Access and from
// settings model pickers. Built-in provider entries remain in the runtime config
// for back-compat, but visible defaults and idle tabs are retargeted away from
// the removed access entry when another accessed provider is available. Custom
// providers are deleted outright.
func (a *App) RemoveProviderAccess(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("remove provider access: empty provider name")
}
// Read-only dispatch check (built-in vs custom); the removal paths below
// reload and write under the config edit lock.
cfg, _, err := a.loadDesktopUserConfigForView()
if err != nil {
return err
}
if p, ok := cfg.Provider(name); ok && isOfficialBuiltInProvider(*p) {
return a.removeBuiltInProviderAccessAndRetargetTabs(name)
}
return a.deleteProviderAndRetargetTabs(name)
}
type providerRemovalTab struct {
id string
ctrl control.SessionAPI
readOnly bool
}
func providerAccessFallbackRef(c *config.Config, name string) string {
name = strings.TrimSpace(name)
for _, candidate := range c.Desktop.ProviderAccess {
candidate = strings.TrimSpace(candidate)
if candidate == "" || candidate == name {
continue
}
p, ok := c.Provider(candidate)
if !ok || len(p.ModelList()) == 0 {
continue
}
return p.Name + "/" + p.DefaultModel()
}
return ""
}
func retargetProviderReferences(c *config.Config, name, fallbackRef string) {
if strings.TrimSpace(fallbackRef) == "" {
return
}
if desktopModelRefsProvider(c, c.DefaultModel, name) {
c.DefaultModel = fallbackRef
}
if desktopModelRefsProvider(c, c.Agent.PlannerModel, name) {
c.Agent.PlannerModel = fallbackRef
}
if desktopModelRefsProvider(c, c.Agent.SubagentModel, name) {
c.Agent.SubagentModel = fallbackRef
}
for skill, ref := range c.Agent.SubagentModels {
if desktopModelRefsProvider(c, ref, name) {
c.Agent.SubagentModels[skill] = fallbackRef
}
}
}
func (a *App) removeBuiltInProviderAccessAndRetargetTabs(name string) error {
defer a.lockRuntimeMutation("remove-provider-access")()
releaseGates, err := a.lockRuntimeTurnGates("provider access", nil)
if err != nil {
return err
}
defer releaseGates()
// This first load is a read-only planning copy (fallback ref + affected-tab
// scan); it loads credentials because the fallback choice depends on which
// providers resolve a key. The saved edit below reloads under the config
// edit lock so the slow snapshot work in between cannot widen the
// read-modify-write window.
cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
if err != nil {
return err
}
fallbackRef := providerAccessFallbackRef(cfg, name)
var affected []providerRemovalTab
if fallbackRef != "" {
a.mu.RLock()
for _, id := range a.orderedTabIDsLocked() {
tab := a.tabs[id]
if tab == nil {
continue
}
ref := tab.model
if strings.TrimSpace(ref) == "" {
ref = cfg.DefaultModel
}
if !desktopModelRefsProvider(cfg, ref, name) {
continue
}
if controllerHasActiveRuntimeWork(tab.Ctrl) {
a.mu.RUnlock()
return fmt.Errorf("finish or cancel active work using %q before removing the provider access", name)
}
affected = append(affected, providerRemovalTab{id: id, ctrl: tab.Ctrl, readOnly: tab.ReadOnly})
}
a.mu.RUnlock()
}
if len(affected) == 0 {
if err := a.ensureActiveTabRebuildAllowed("provider access"); err != nil {
return err
}
}
for _, item := range affected {
if item.ctrl != nil && !item.readOnly {
if err := item.ctrl.Snapshot(); err != nil {
slog.Warn("desktop: snapshot before removing provider access failed", "tab", item.id, "provider", name, "err", err)
return fmt.Errorf("save current session before removing provider access: %w", err)
}
}
}
// Reload-modify-save under the config edit lock: the pre-save snapshots
// above are slow and must not hold the lock, so mutate a fresh copy here
// instead of the stale planning copy loaded before them.
if err := func() error {
unlock := config.LockUserConfigEdits()
defer unlock()
fresh, path, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return err
}
retargetProviderReferences(fresh, name, fallbackRef)
removeProviderAccess(fresh, name)
return fresh.SaveTo(path)
}(); err != nil {
return err
}
if len(affected) == 0 {
if err := a.rebuildActiveSettingRuntimeMutationLocked("provider access"); err != nil {
if _, ok := a.deferredRebuildWarning("provider access", err); ok {
return nil
}
return err
}
return nil
}
for _, item := range affected {
if item.ctrl != nil {
item.ctrl.Close()
}
}
var rebuildTabs []*WorkspaceTab
var releasedHostKeys []string
a.mu.Lock()
for _, item := range affected {
tab := a.tabs[item.id]
if tab == nil {
continue
}
if tab.Ctrl != item.ctrl {
// The tab swapped controllers while we worked off-lock; nil-ing the
// replacement would leak it. Leave the new runtime alone.
continue
}
tab.Ctrl = nil
if key := takeTabSharedHostKey(tab); key != "" {
releasedHostKeys = append(releasedHostKeys, key)
}
// Supersede any in-flight startup build: it was planned against the
// removed provider and would otherwise finish later, pass its
// generation check, and reinstall a controller for it.
a.supersedeTabBuildLocked(tab)
tab.model = fallbackRef
tab.Label = fallbackRef
clearTabStartupError(tab)
tab.Ready = a.ctx == nil
if a.ctx != nil {
a.setSessionRuntimePhaseLocked(tab, sessionRuntimeStarting, nil)
} else {
a.setSessionRuntimePhaseLocked(tab, sessionRuntimeFailed, fmt.Errorf("desktop runtime is not started"))
}
if a.ctx != nil {
rebuildTabs = append(rebuildTabs, tab)
}
}
a.saveTabsLocked()
a.mu.Unlock()
for _, key := range releasedHostKeys {
a.releaseSharedHost(key)
}
for _, tab := range rebuildTabs {
go a.buildTabController(tab)
}
return nil
}
func (a *App) deleteProviderAndRetargetTabs(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("remove provider: empty provider name")
}
defer a.lockRuntimeMutation("delete-provider")()
releaseGates, err := a.lockRuntimeTurnGates("provider", nil)
if err != nil {
return err
}
defer releaseGates()
// Read-only planning copy (with credentials — the fallback choice depends
// on which providers resolve a key); the saved edit below reloads under the
// config edit lock (see removeBuiltInProviderAccessAndRetargetTabs).
cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
if err != nil {
return err
}
fallbackRef := providerRemovalFallbackRef(cfg, name)
var affected []providerRemovalTab
a.mu.RLock()
for _, id := range a.orderedTabIDsLocked() {
tab := a.tabs[id]
if tab == nil {
continue
}
ref := tab.model
if strings.TrimSpace(ref) == "" {
ref = cfg.DefaultModel
}
if !desktopModelRefsProvider(cfg, ref, name) {
continue
}
if controllerHasActiveRuntimeWork(tab.Ctrl) {
a.mu.RUnlock()
return fmt.Errorf("finish or cancel active work using %q before deleting the provider", name)
}
affected = append(affected, providerRemovalTab{id: id, ctrl: tab.Ctrl, readOnly: tab.ReadOnly})
}
a.mu.RUnlock()
if len(affected) > 0 && fallbackRef == "" {
return fmt.Errorf("remove provider: %q is used by open tabs and no other configured provider exists", name)
}
if len(affected) == 0 {
if err := a.ensureActiveTabRebuildAllowed("provider"); err != nil {
return err
}
}
for _, item := range affected {
if item.ctrl != nil && !item.readOnly {
if err := item.ctrl.Snapshot(); err != nil {
slog.Warn("desktop: snapshot before deleting provider failed", "tab", item.id, "provider", name, "err", err)
return fmt.Errorf("save current session before deleting provider: %w", err)
}
}
}
// Reload-modify-save under the config edit lock; the snapshots above ran
// off-lock against the stale planning copy.
if err := func() error {
unlock := config.LockUserConfigEdits()
defer unlock()
fresh, path, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return err
}
if err := fresh.RemoveProvider(name); err != nil {
return err
}
removeProviderAccess(fresh, name)
return fresh.SaveTo(path)
}(); err != nil {
return err
}
if len(affected) == 0 {
if err := a.rebuildActiveSettingRuntimeMutationLocked("provider"); err != nil {
if _, ok := a.deferredRebuildWarning("provider", err); ok {
return nil
}
return err
}
return nil
}
for _, item := range affected {
if item.ctrl != nil {
item.ctrl.Close()
}
}
var rebuildTabs []*WorkspaceTab
var releasedHostKeys []string
a.mu.Lock()
for _, item := range affected {
tab := a.tabs[item.id]
if tab == nil {
continue
}
if tab.Ctrl != item.ctrl {
// The tab swapped controllers while we worked off-lock; nil-ing the
// replacement would leak it. Leave the new runtime alone.
continue
}
tab.Ctrl = nil
if key := takeTabSharedHostKey(tab); key != "" {
releasedHostKeys = append(releasedHostKeys, key)
}
// Supersede any in-flight startup build: it was planned against the
// removed provider and would otherwise finish later, pass its
// generation check, and reinstall a controller for it.
a.supersedeTabBuildLocked(tab)
tab.model = fallbackRef
tab.Label = fallbackRef
clearTabStartupError(tab)
tab.Ready = a.ctx == nil
if a.ctx != nil {
a.setSessionRuntimePhaseLocked(tab, sessionRuntimeStarting, nil)
} else {
a.setSessionRuntimePhaseLocked(tab, sessionRuntimeFailed, fmt.Errorf("desktop runtime is not started"))
}
if a.ctx != nil {
rebuildTabs = append(rebuildTabs, tab)
}
}
a.saveTabsLocked()
a.mu.Unlock()
for _, key := range releasedHostKeys {
a.releaseSharedHost(key)
}
for _, tab := range rebuildTabs {
go a.buildTabController(tab)
}
return nil
}
// rebuildActiveSettingRuntimeMutationLocked refreshes the active controller
// while lockRuntimeMutation and all runtime turn gates are held.
func (a *App) rebuildActiveSettingRuntimeMutationLocked(setting string) error {
+829
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -9,9 +10,12 @@ import (
"reflect"
"strings"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
fileencoding "reasonix/internal/fileutil/encoding"
"reasonix/internal/hook"
"reasonix/internal/provider"
@@ -112,6 +116,51 @@ func TestProviderViewFromEntry_MigratesProviderWideVision(t *testing.T) {
}
}
func TestProviderViewFromEntryReportsOfficialDeepSeekVisionUnsupported(t *testing.T) {
p := config.ProviderEntry{
Name: "deepseek",
Kind: "openai",
BaseURL: "https://api.deepseek.com",
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
VisionModels: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
}
view := providerViewFromEntry(p, true, true)
if view.VisionCapability != "unsupported" {
t.Fatalf("VisionCapability = %q, want unsupported", view.VisionCapability)
}
if !reflect.DeepEqual(view.VisionModels, p.VisionModels) {
t.Fatalf("ProviderView should preserve stale metadata for a lossless settings round trip: got %v want %v", view.VisionModels, p.VisionModels)
}
resolved := p
resolved.Model = "deepseek-v4-pro"
if config.EffectiveVision(&resolved) {
t.Fatal("preserved stale settings metadata must not enable runtime image input")
}
}
func TestProviderViewFromEntryOffersOnlySafeDeepSeekProtocolUpgrade(t *testing.T) {
legacy := config.ProviderEntry{
Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com",
Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
}
if view := providerViewFromEntry(legacy, true, true); !view.RecommendedUpgradeAvailable {
t.Fatal("standard official OpenAI entry did not offer the recommended protocol upgrade")
}
proxy := legacy
proxy.BaseURL = "https://deepseek-proxy.example/v1"
if view := providerViewFromEntry(proxy, false, true); view.RecommendedUpgradeAvailable {
t.Fatal("proxy entry unexpectedly offered the official protocol upgrade")
}
anthropic := legacy
anthropic.Kind = "anthropic"
anthropic.BaseURL = "https://api.deepseek.com/anthropic"
if view := providerViewFromEntry(anthropic, true, true); view.RecommendedUpgradeAvailable {
t.Fatal("already-upgraded entry still offered the protocol upgrade")
}
}
func TestProviderViewFromEntryIncludesThinking(t *testing.T) {
view := providerViewFromEntry(config.ProviderEntry{
Name: "anthropic",
@@ -151,6 +200,19 @@ func TestProviderViewFromEntryUsesEffectiveWebSearch(t *testing.T) {
if custom.WebSearch {
t.Fatal("custom provider unexpectedly enabled web search")
}
if custom.ServerWebSearchCapability {
t.Fatal("unverified custom Responses provider unexpectedly exposed server web search in Settings")
}
openAI := providerViewFromEntry(config.ProviderEntry{
Name: "custom-openai",
Kind: "openai",
BaseURL: "https://gateway.example/v1",
WebSearch: func() *bool { enabled := true; return &enabled }(),
}, false, true)
if openAI.ServerWebSearchCapability || openAI.WebSearch {
t.Fatal("OpenAI Chat Completions unexpectedly reported server web-search support")
}
}
func TestProviderViewFromEntryShowsKeySource(t *testing.T) {
@@ -1045,6 +1107,770 @@ func TestSaveProviderPersistsExplicitWebSearchOff(t *testing.T) {
}
}
func TestSetProviderWebSearchUpdatesGroupedDeepSeekAliasesAtomically(t *testing.T) {
isolateDesktopUserDirs(t)
enabled := true
cfg := config.LoadForEdit(config.UserConfigPath())
cfg.Desktop.ProviderAccess = []string{"deepseek-flash", "deepseek-pro"}
cfg.Providers = []config.ProviderEntry{
{
Name: "deepseek-flash", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic",
Models: []string{"deepseek-v4-flash"}, Headers: map[string]string{"X-Route": "flash"}, WebSearch: &enabled,
},
{
Name: "deepseek-pro", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic",
Models: []string{"deepseek-v4-pro"}, Headers: map[string]string{"X-Route": "pro"}, WebSearch: &enabled,
},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("SaveTo: %v", err)
}
if err := NewApp().SetProviderWebSearch([]string{"deepseek-flash", "deepseek-pro", "deepseek-flash"}, false); err != nil {
t.Fatalf("SetProviderWebSearch: %v", err)
}
got := config.LoadForEdit(config.UserConfigPath())
for _, name := range []string{"deepseek-flash", "deepseek-pro"} {
entry, ok := got.Provider(name)
if !ok || entry.WebSearch == nil || *entry.WebSearch {
t.Fatalf("provider %q = %+v, found=%v; want explicit web_search=false", name, entry, ok)
}
}
if flash, _ := got.Provider("deepseek-flash"); flash.Headers["X-Route"] != "flash" {
t.Fatalf("Flash custom transport fields changed: %+v", flash)
}
if pro, _ := got.Provider("deepseek-pro"); pro.Headers["X-Route"] != "pro" {
t.Fatalf("Pro custom transport fields changed: %+v", pro)
}
}
func TestSetProviderWebSearchRejectsWholeGroupBeforeWriting(t *testing.T) {
isolateDesktopUserDirs(t)
enabled := true
cfg := config.LoadForEdit(config.UserConfigPath())
cfg.Providers = []config.ProviderEntry{
{Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic", Models: []string{"deepseek-v4-flash"}, WebSearch: &enabled},
{Name: "proxy", Kind: "anthropic", BaseURL: "https://gateway.example/anthropic", Models: []string{"custom-model"}, WebSearch: &enabled},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("SaveTo: %v", err)
}
if err := NewApp().SetProviderWebSearch([]string{"deepseek", "proxy"}, false); err == nil {
t.Fatal("SetProviderWebSearch accepted an unverified endpoint")
}
got := config.LoadForEdit(config.UserConfigPath())
entry, ok := got.Provider("deepseek")
if !ok || entry.WebSearch == nil || !*entry.WebSearch {
t.Fatalf("official provider was partially updated after group rejection: %+v, found=%v", entry, ok)
}
}
func TestSetProviderWebSearchRebuildsEveryVisibleRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
enabled := true
cfg := config.Default()
cfg.DefaultModel = "deepseek/deepseek-v4-flash"
cfg.Desktop.ProviderAccess = []string{"deepseek"}
cfg.Providers = []config.ProviderEntry{{
Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic",
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"}, Default: "deepseek-v4-flash",
APIKeyEnv: "DEEPSEEK_API_KEY", WebSearch: &enabled,
}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
newTab := func(id string) (*WorkspaceTab, *blockingSnapshotCtrl) {
old := newBlockingSnapshotCtrl(control.New(control.Options{Label: cfg.DefaultModel, Sink: event.Discard}))
close(old.releaseSnapshot)
tab := &WorkspaceTab{
ID: id, Scope: "global", Ready: true, Ctrl: old,
model: cfg.DefaultModel, Label: cfg.DefaultModel,
sink: &tabEventSink{tabID: id, app: app}, disabledMCP: map[string]ServerView{},
}
return tab, old
}
first, oldFirst := newTab("first")
second, oldSecond := newTab("second")
app.tabs = map[string]*WorkspaceTab{first.ID: first, second.ID: second}
app.tabOrder = []string{first.ID, second.ID}
app.activeTabID = first.ID
t.Cleanup(func() {
for _, tab := range []*WorkspaceTab{first, second} {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
tab.releaseSessionLease()
}
})
if err := app.SetProviderWebSearch([]string{"deepseek"}, false); err != nil {
t.Fatalf("SetProviderWebSearch: %v", err)
}
if first.Ctrl == oldFirst || second.Ctrl == oldSecond || oldFirst.closeCount.Load() != 1 || oldSecond.closeCount.Load() != 1 {
t.Fatalf("web-search refresh: first=%T/%d second=%T/%d; want both replaced once", first.Ctrl, oldFirst.closeCount.Load(), second.Ctrl, oldSecond.closeCount.Load())
}
got := config.LoadForEdit(config.UserConfigPath())
provider, ok := got.Provider("deepseek")
if !ok || provider.WebSearch == nil || *provider.WebSearch {
t.Fatalf("persisted DeepSeek web_search = %+v, want false", provider)
}
}
func TestSetProviderWebSearchRejectsDetachedRuntimeBeforeMutation(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
enabled := true
cfg := config.Default()
cfg.DefaultModel = "deepseek/deepseek-v4-flash"
cfg.Desktop.ProviderAccess = []string{"deepseek"}
cfg.Providers = []config.ProviderEntry{{
Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic",
Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", WebSearch: &enabled,
}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
app := NewApp()
app.ctx = context.Background()
detachedCtrl := control.New(control.Options{Label: cfg.DefaultModel, Sink: event.Discard})
detached := &WorkspaceTab{ID: "detached", Scope: "global", Ctrl: detachedCtrl, model: cfg.DefaultModel}
app.detachedSessions = map[string]*WorkspaceTab{detached.ID: detached}
t.Cleanup(detachedCtrl.Close)
err := app.SetProviderWebSearch([]string{"deepseek"}, false)
if err == nil || !strings.Contains(err.Error(), "background session is still open") {
t.Fatalf("SetProviderWebSearch error = %v, want detached-runtime guard", err)
}
got := config.LoadForEdit(config.UserConfigPath())
provider, ok := got.Provider("deepseek")
if !ok || provider.WebSearch == nil || !*provider.WebSearch {
t.Fatalf("rejected web-search mutation changed provider: %+v", provider)
}
}
func TestSaveProviderPreservesHiddenCustomWebSearchOverride(t *testing.T) {
isolateDesktopUserDirs(t)
enabled := true
cfg := config.LoadForEdit(config.UserConfigPath())
cfg.Providers = []config.ProviderEntry{{
Name: "custom-anthropic",
Kind: "anthropic",
BaseURL: "https://gateway.example/anthropic",
Models: []string{"custom-model"},
WebSearch: &enabled,
}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("SaveTo: %v", err)
}
if err := NewApp().SaveProvider(ProviderView{
Name: "custom-anthropic",
Kind: "anthropic",
BaseURL: "https://gateway.example/anthropic",
Models: []string{"custom-model"},
Default: "custom-model",
ContextWindow: 200_000,
WebSearch: false, // The hidden Settings control must not overwrite advanced TOML.
}); err != nil {
t.Fatalf("SaveProvider: %v", err)
}
gotCfg := config.LoadForEdit(config.UserConfigPath())
got, ok := gotCfg.Provider("custom-anthropic")
if !ok || got.WebSearch == nil || !*got.WebSearch || !config.EffectiveWebSearch(got) {
t.Fatalf("saved provider = %+v, found=%v; want preserved advanced web_search=true", got, ok)
}
}
func TestSaveProviderDoesNotCarryOfficialWebSearchToCustomEndpoint(t *testing.T) {
isolateDesktopUserDirs(t)
enabled := true
cfg := config.LoadForEdit(config.UserConfigPath())
cfg.Providers = []config.ProviderEntry{{
Name: "deepseek-customized",
Kind: "anthropic",
BaseURL: "https://api.deepseek.com/anthropic",
Models: []string{"deepseek-v4-flash"},
WebSearch: &enabled,
}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("SaveTo: %v", err)
}
if err := NewApp().SaveProvider(ProviderView{
Name: "deepseek-customized",
Kind: "anthropic",
BaseURL: "https://gateway.example/anthropic",
Models: []string{"deepseek-v4-flash"},
Default: "deepseek-v4-flash",
WebSearch: false,
}); err != nil {
t.Fatalf("SaveProvider: %v", err)
}
gotCfg := config.LoadForEdit(config.UserConfigPath())
got, ok := gotCfg.Provider("deepseek-customized")
if !ok || got.WebSearch != nil || config.EffectiveWebSearch(got) {
t.Fatalf("saved provider = %+v, found=%v; want official web search cleared after endpoint change", got, ok)
}
}
func TestUpgradeDeepSeekProviderAccessPreservesCustomizedFields(t *testing.T) {
isolateDesktopUserDirs(t)
path := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
raw := `default_model = "deepseek-flash/deepseek-v4-flash"
[desktop]
provider_access = ["deepseek"]
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
vision = true
chat_url = "https://api.deepseek.com/anthropic/v1/messages"
models_url = "https://api.deepseek.com/models"
headers = { X-Trace = "keep" }
extra_body = { route = "keep" }
auth_header = true
thinking = "enabled"
web_search = true
no_proxy = true
cache_ttl_minutes = 17
context_window = 900000
max_output_tokens = 111111
supported_efforts = ["disabled", "low", "high"]
default_effort = "low"
price = { cache_hit = 0.1, input = 1.25, output = 2.25, currency = "T" }
future_capability = "keep"
[[providers]]
name = "deepseek-pro"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-pro"
api_key_env = "DEEPSEEK_API_KEY"
chat_url = "https://api.deepseek.com/anthropic/v1/messages"
models_url = "https://api.deepseek.com/models"
headers = { X-Trace = "keep" }
extra_body = { route = "keep" }
auth_header = true
thinking = "enabled"
web_search = true
no_proxy = true
cache_ttl_minutes = 17
context_window = 800000
max_output_tokens = 222222
reasoning_protocol = "none"
supported_efforts = ["disabled", "high", "max"]
default_effort = "max"
price = { cache_hit = 0.2, input = 3.75, output = 6.75, currency = "T" }
`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
if _, err := NewApp().UpgradeDeepSeekProviderAccess("deepseek"); err != nil {
t.Fatalf("UpgradeDeepSeekProviderAccess: %v", err)
}
updated, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(updated)
if strings.Count(text, `kind = "anthropic"`) != 2 ||
strings.Count(text, `base_url = "https://api.deepseek.com/anthropic"`) != 2 {
t.Fatalf("provider family was not upgraded:\n%s", text)
}
for _, preserved := range []string{`vision = true`, `headers = { X-Trace = "keep" }`, `extra_body = { route = "keep" }`, `future_capability = "keep"`, `reasoning_protocol = "none"`} {
if !strings.Contains(text, preserved) {
t.Errorf("upgrade dropped %q:\n%s", preserved, text)
}
}
cfg, err := config.LoadForRootReadOnly(t.TempDir())
if err != nil {
t.Fatalf("load upgraded config: %v", err)
}
if got := cfg.Desktop.ProviderAccess; len(got) != 1 || got[0] != "deepseek" {
t.Fatalf("provider_access = %v, want one canonical DeepSeek entry", got)
}
canonical, ok := cfg.Provider("deepseek")
if !ok {
t.Fatal("effective canonical DeepSeek provider missing after upgrade")
}
if canonical.ChatURL != "https://api.deepseek.com/anthropic/v1/messages" ||
canonical.ModelsURL != "https://api.deepseek.com/models" ||
canonical.Headers["X-Trace"] != "keep" || canonical.ExtraBody["route"] != "keep" ||
!canonical.AuthHeader || !canonical.NoProxy || canonical.CacheTTLMinutes != 17 {
t.Fatalf("canonical transport fields were not preserved: %+v", canonical)
}
flash, ok := cfg.ResolveModel("deepseek/deepseek-v4-flash")
if !ok {
t.Fatal("canonical DeepSeek Flash model did not resolve")
}
if flash.ContextWindow != 900000 || flash.MaxOutputTokens != 111111 || flash.DefaultEffort != "low" ||
flash.Price == nil || flash.Price.Output != 2.25 {
t.Fatalf("Flash model fields were not preserved: %+v", flash)
}
if config.EffectiveVision(flash) {
t.Fatal("preserved stale Flash vision metadata must not enable images on the official DeepSeek endpoint")
}
flashOverride := canonical.ModelOverrides["deepseek-v4-flash"]
if flashOverride.Vision == nil || !*flashOverride.Vision {
t.Fatalf("Flash vision metadata was dropped instead of being safely ignored: %+v", flashOverride)
}
pro, ok := cfg.ResolveModel("deepseek/deepseek-v4-pro")
if !ok {
t.Fatal("canonical DeepSeek Pro model did not resolve")
}
if pro.ContextWindow != 800000 || pro.MaxOutputTokens != 222222 || pro.ReasoningProtocol != "none" ||
pro.DefaultEffort != "max" || pro.Price == nil || pro.Price.Output != 6.75 {
t.Fatalf("Pro model fields were not preserved: %+v", pro)
}
}
func TestUpgradeDeepSeekProviderAccessSerializesRuntimeMutation(t *testing.T) {
isolateDesktopUserDirs(t)
path := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
raw := `[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
app := NewApp()
app.runtimeRebuildMu.Lock()
rebuildLocked := true
defer func() {
if rebuildLocked {
app.runtimeRebuildMu.Unlock()
}
}()
entered := make(chan struct{})
app.runtimeMutationBeforeLockHook = func(operation string) {
if operation == "upgrade-deepseek-provider" {
close(entered)
}
}
done := make(chan error, 1)
go func() {
_, err := app.UpgradeDeepSeekProviderAccess("deepseek")
done <- err
}()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("protocol upgrade did not reach the runtime mutation barrier")
}
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(before), `kind = "openai"`) {
t.Fatalf("protocol changed before acquiring the runtime mutation lock:\n%s", before)
}
app.runtimeRebuildMu.Unlock()
rebuildLocked = false
select {
case err := <-done:
if err != nil {
t.Fatalf("UpgradeDeepSeekProviderAccess: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("protocol upgrade did not complete after releasing the runtime mutation lock")
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(after), `kind = "anthropic"`) {
t.Fatalf("protocol was not upgraded after acquiring the runtime mutation lock:\n%s", after)
}
}
func TestUpgradeDeepSeekProviderAccessRebuildsEveryVisibleRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
path := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
raw := `default_model = "deepseek-flash/deepseek-v4-flash"
[desktop]
provider_access = ["deepseek"]
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
workspace := t.TempDir()
sessionDir := config.SessionDir()
if err := os.MkdirAll(sessionDir, 0o700); err != nil {
t.Fatal(err)
}
newOldController := func(id string) *blockingSnapshotCtrl {
session := agent.NewSession("old system prompt")
session.Add(provider.Message{Role: provider.RoleUser, Content: "history " + id})
exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{
Executor: exec, SessionDir: sessionDir,
SessionPath: filepath.Join(sessionDir, id+".jsonl"), Label: id, Sink: event.Discard,
})
wrapped := newBlockingSnapshotCtrl(ctrl)
close(wrapped.releaseSnapshot)
return wrapped
}
oldA := newOldController("tab-a")
oldB := newOldController("tab-b")
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
tabA := &WorkspaceTab{
ID: "tab-a", Scope: "global", WorkspaceRoot: workspace, Ready: true,
Ctrl: oldA, model: "deepseek/deepseek-v4-flash", sink: &tabEventSink{tabID: "tab-a", app: app},
disabledMCP: map[string]ServerView{},
}
tabB := &WorkspaceTab{
ID: "tab-b", Scope: "global", WorkspaceRoot: workspace, Ready: true,
Ctrl: oldB, model: "deepseek/deepseek-v4-flash", sink: &tabEventSink{tabID: "tab-b", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{tabA.ID: tabA, tabB.ID: tabB}
app.tabOrder = []string{tabA.ID, tabB.ID}
app.activeTabID = tabA.ID
t.Cleanup(func() {
for _, tab := range []*WorkspaceTab{tabA, tabB} {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
tab.releaseSessionLease()
}
})
if _, err := app.UpgradeDeepSeekProviderAccess("deepseek"); err != nil {
t.Fatalf("UpgradeDeepSeekProviderAccess: %v", err)
}
if tabA.Ctrl == oldA || tabB.Ctrl == oldB {
t.Fatalf("visible runtimes were not both rebuilt: active=%T inactive=%T", tabA.Ctrl, tabB.Ctrl)
}
if oldA.closeCount.Load() != 1 || oldB.closeCount.Load() != 1 {
t.Fatalf("old controller close counts = %d, %d; want one each", oldA.closeCount.Load(), oldB.closeCount.Load())
}
for _, tab := range []*WorkspaceTab{tabA, tabB} {
history := tab.Ctrl.History()
if len(history) < 2 || !strings.HasPrefix(history[1].Content, "history ") {
t.Fatalf("rebuilt tab %q lost history: %+v", tab.ID, history)
}
}
}
func TestUpgradeDeepSeekProviderAccessContinuesAfterWorkspaceBuildFailure(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
path := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
raw := `default_model = "deepseek-flash/deepseek-v4-flash"
[desktop]
provider_access = ["deepseek"]
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
brokenRoot := t.TempDir()
if err := os.WriteFile(filepath.Join(brokenRoot, "reasonix.toml"), []byte(`[agent]
system_prompt_file = "/outside-workspace/system.md"
`), 0o600); err != nil {
t.Fatal(err)
}
workingRoot := t.TempDir()
sessionDir := config.SessionDir()
if err := os.MkdirAll(sessionDir, 0o700); err != nil {
t.Fatal(err)
}
newOldController := func(id string) *blockingSnapshotCtrl {
session := agent.NewSession("old system prompt")
session.Add(provider.Message{Role: provider.RoleUser, Content: "history " + id})
exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{
Executor: exec, SessionDir: sessionDir,
SessionPath: filepath.Join(sessionDir, id+".jsonl"), Label: id, Sink: event.Discard,
})
wrapped := newBlockingSnapshotCtrl(ctrl)
close(wrapped.releaseSnapshot)
return wrapped
}
oldBroken := newOldController("upgrade-broken")
oldWorking := newOldController("upgrade-working")
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
broken := &WorkspaceTab{
ID: "a-broken", Scope: "project", WorkspaceRoot: brokenRoot, Ready: true,
Ctrl: oldBroken, model: "deepseek/deepseek-v4-flash", sink: &tabEventSink{tabID: "a-broken", app: app},
disabledMCP: map[string]ServerView{},
}
working := &WorkspaceTab{
ID: "b-working", Scope: "project", WorkspaceRoot: workingRoot, Ready: true,
Ctrl: oldWorking, model: "deepseek/deepseek-v4-flash", sink: &tabEventSink{tabID: "b-working", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{broken.ID: broken, working.ID: working}
app.tabOrder = []string{broken.ID, working.ID}
app.activeTabID = broken.ID
t.Cleanup(func() {
for _, tab := range []*WorkspaceTab{broken, working} {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
tab.releaseSessionLease()
}
})
warning, err := app.UpgradeDeepSeekProviderAccess("deepseek")
if err == nil || !strings.Contains(err.Error(), "relative path within the workspace") {
t.Fatalf("UpgradeDeepSeekProviderAccess error = %v, want first workspace build failure", err)
}
if warning != "" {
t.Fatalf("UpgradeDeepSeekProviderAccess warning = %q, want no lease warning", warning)
}
updated, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatal(readErr)
}
if !strings.Contains(string(updated), `kind = "anthropic"`) {
t.Fatalf("protocol was not persisted before the runtime error:\n%s", updated)
}
if broken.Ctrl != oldBroken || oldBroken.closeCount.Load() != 0 {
t.Fatalf("failed tab changed controller: ctrl=%T closes=%d", broken.Ctrl, oldBroken.closeCount.Load())
}
if working.Ctrl == oldWorking || oldWorking.closeCount.Load() != 1 {
t.Fatalf("working sibling was not rebuilt: ctrl=%T closes=%d", working.Ctrl, oldWorking.closeCount.Load())
}
}
func TestUpgradeDeepSeekProviderAccessDefersLeasedTabAndRebuildsSibling(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
path := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
raw := `default_model = "deepseek-flash/deepseek-v4-flash"
[desktop]
provider_access = ["deepseek"]
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
workspace := t.TempDir()
sessionDir := config.SessionDir()
if err := os.MkdirAll(sessionDir, 0o700); err != nil {
t.Fatal(err)
}
leasedPath := filepath.Join(sessionDir, "upgrade-leased.jsonl")
workingPath := filepath.Join(sessionDir, "upgrade-working.jsonl")
externalLease, err := agent.TryAcquireSessionLease(leasedPath)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
t.Cleanup(externalLease.Release)
newOldController := func(id, sessionPath string) *blockingSnapshotCtrl {
session := agent.NewSession("old system prompt")
session.Add(provider.Message{Role: provider.RoleUser, Content: "history " + id})
exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{
Executor: exec, SessionDir: sessionDir, SessionPath: sessionPath, Label: id, Sink: event.Discard,
})
wrapped := newBlockingSnapshotCtrl(ctrl)
close(wrapped.releaseSnapshot)
return wrapped
}
oldLeased := newOldController("upgrade-leased", leasedPath)
oldWorking := newOldController("upgrade-working", workingPath)
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
leased := &WorkspaceTab{
ID: "a-leased", Scope: "global", WorkspaceRoot: workspace, SessionPath: leasedPath, Ready: true,
Ctrl: oldLeased, model: "deepseek/deepseek-v4-flash", sink: &tabEventSink{tabID: "a-leased", app: app},
disabledMCP: map[string]ServerView{},
}
working := &WorkspaceTab{
ID: "b-working", Scope: "global", WorkspaceRoot: workspace, SessionPath: workingPath, Ready: true,
Ctrl: oldWorking, model: "deepseek/deepseek-v4-flash", sink: &tabEventSink{tabID: "b-working", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{leased.ID: leased, working.ID: working}
app.tabOrder = []string{leased.ID, working.ID}
app.activeTabID = leased.ID
t.Cleanup(func() {
for _, tab := range []*WorkspaceTab{leased, working} {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
tab.releaseSessionLease()
}
})
warning, err := app.UpgradeDeepSeekProviderAccess("deepseek")
if err != nil {
t.Fatalf("UpgradeDeepSeekProviderAccess: %v", err)
}
if !strings.Contains(warning, "saved, but the current session could not refresh yet") {
t.Fatalf("UpgradeDeepSeekProviderAccess warning = %q, want deferred lease warning", warning)
}
if !app.deferredRebuildPending(leased.ID) {
t.Fatal("leased tab did not receive a tab-bound deferred rebuild")
}
if app.deferredRebuildPending(working.ID) {
t.Fatal("working sibling unexpectedly received a deferred rebuild")
}
if leased.Ctrl != oldLeased || oldLeased.closeCount.Load() != 0 {
t.Fatalf("leased tab changed controller: ctrl=%T closes=%d", leased.Ctrl, oldLeased.closeCount.Load())
}
if working.Ctrl == oldWorking || oldWorking.closeCount.Load() != 1 {
t.Fatalf("working sibling was not rebuilt: ctrl=%T closes=%d", working.Ctrl, oldWorking.closeCount.Load())
}
}
func TestUpgradeDeepSeekProviderAccessRejectsDetachedRuntimeBeforeMutation(t *testing.T) {
isolateDesktopUserDirs(t)
path := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
raw := `[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
app := NewApp()
detachedCtrl := control.New(control.Options{Label: "detached", Sink: event.Discard})
app.detachedSessions = map[string]*WorkspaceTab{
"detached": {ID: "detached", Scope: "global", Ready: true, Ctrl: detachedCtrl},
}
t.Cleanup(detachedCtrl.Close)
_, err := app.UpgradeDeepSeekProviderAccess("deepseek")
if err == nil || !strings.Contains(err.Error(), "background session is still open") {
t.Fatalf("UpgradeDeepSeekProviderAccess error = %v, want detached-runtime guard", err)
}
after, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatal(readErr)
}
if string(after) != raw {
t.Fatalf("protocol changed despite detached-runtime rejection:\n%s", after)
}
}
func TestProviderModelOverrideViewPreservesMaxOutputTokens(t *testing.T) {
input := map[string]config.ProviderModelOverride{
"limited": {ContextWindow: 64_000, MaxOutputTokens: 8_192},
"omitted": {MaxOutputTokens: -1},
}
views := providerModelOverridesForView(input, []string{"limited", "omitted"})
if len(views) != 2 || views[0].MaxOutputTokens != 8_192 || views[1].MaxOutputTokens != -1 {
t.Fatalf("model override views = %+v, want positive and negative output-token semantics preserved", views)
}
roundTrip := providerModelOverridesForSave(views, []string{"limited", "omitted"})
if got := roundTrip["limited"]; got.ContextWindow != 64_000 || got.MaxOutputTokens != 8_192 {
t.Fatalf("limited override = %+v, want context and output limits preserved", got)
}
if got := roundTrip["omitted"]; got.MaxOutputTokens != -1 {
t.Fatalf("omitted override = %+v, want negative wire-omission marker preserved", got)
}
}
func TestDeepSeekProtocolUpgradeSourceAvailableWithLegacyGlobalAndProjectConfig(t *testing.T) {
isolateDesktopUserDirs(t)
legacyPath := config.LegacyUserConfigPath()
if legacyPath == "" {
t.Skip("platform has no distinct legacy user-config path")
}
if err := os.MkdirAll(filepath.Dir(legacyPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyPath, []byte(`[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
`), 0o600); err != nil {
t.Fatal(err)
}
project := t.TempDir()
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("# project config\n"), 0o600); err != nil {
t.Fatal(err)
}
if !config.CanUpgradeDeepSeekProviderProtocolUserConfig("deepseek") {
t.Fatal("project config must not hide an available legacy global upgrade source")
}
}
func TestOfficialMimoAPITemplateRemoved(t *testing.T) {
if entries, keyEnv, err := officialProviderTemplate("mimo-api", "en"); err == nil {
t.Fatalf("officialProviderTemplate(mimo-api) = entries=%v key=%q nil error, want unknown template", entries, keyEnv)
@@ -1069,6 +1895,9 @@ func TestOfficialDeepSeekTemplateUsesRegionalPricing(t *testing.T) {
t.Fatalf("template = %v/%q, want one DEEPSEEK_API_KEY entry", entries, keyEnv)
}
got := entries[0]
if got.Kind != "anthropic" || got.BaseURL != "https://api.deepseek.com/anthropic" || !config.EffectiveWebSearch(&got) || got.Thinking != "enabled" {
t.Fatalf("%s DeepSeek template = kind:%q base_url:%q web_search:%t thinking:%q, want Anthropic-compatible with web search", tt.language, got.Kind, got.BaseURL, config.EffectiveWebSearch(&got), got.Thinking)
}
if price := got.Prices["deepseek-v4-flash"]; price == nil || price.Currency != tt.currency || price.Output != tt.flashOutput {
t.Fatalf("%s deepseek-v4-flash price = %+v", tt.language, price)
}
+3 -2
View File
@@ -81,11 +81,12 @@ provider_access = ["deepseek"]
[[providers]]
name = "deepseek"
kind = "openai"
base_url = "https://api.deepseek.com"
kind = "anthropic"
base_url = "https://api.deepseek.com/anthropic"
models = ["deepseek-v4-flash", "deepseek-v4-pro"]
default = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
web_search = true
[[plugins]]
name = "example"
+3 -2
View File
@@ -69,11 +69,12 @@ provider_access = ["deepseek"]
[[providers]]
name = "deepseek"
kind = "openai"
base_url = "https://api.deepseek.com"
kind = "anthropic"
base_url = "https://api.deepseek.com/anthropic"
models = ["deepseek-v4-flash", "deepseek-v4-pro"]
default = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
web_search = true
[[plugins]]
name = "example"
+13 -6
View File
@@ -72,10 +72,11 @@ tool_result_snip_ratio = 0.6 # shorten stale tool output before summary co
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
kind = "anthropic"
base_url = "https://api.deepseek.com/anthropic"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
web_search = true
# also preset: deepseek-pro
[tools]
@@ -356,10 +357,16 @@ Custom provider** for proxies, aggregators, or self-hosted services that speak
the OpenAI-compatible chat API or Anthropic-compatible Messages API.
For common providers, choose **Add model service -> Recommended preset** instead.
The official DeepSeek service continues to use its specially adapted OpenAI Chat
Completions path by default; add the optional **DeepSeek Anthropic** preset only
when Anthropic Messages compatibility is needed. The two entries do not replace
each other. Reasonix can prefill editable custom-provider entries for Kimi CN,
New official DeepSeek entries use the Anthropic-compatible Messages endpoint by
default and enable provider-side `web_search`; the same `DEEPSEEK_API_KEY` works
for both protocols. On startup, Reasonix upgrades unmodified legacy
`deepseek-flash` / `deepseek-pro` entries that still use the official endpoint
and standard key/model settings. Customized official Chat Completions entries
stay unchanged and show an **Upgrade protocol** action in Settings. Proxy
endpoints, custom headers, model lists, and capability overrides are never
migrated automatically. Existing
separately named `deepseek-anthropic` entries remain compatible, but that
redundant preset is no longer offered for new access. Reasonix can prefill editable custom-provider entries for Kimi CN,
Kimi Global,
Kimi Coding Plan, MiMo API, MiMo Anthropic, MiMo Token Plan CN/SGP/AMS and their
Anthropic-compatible variants, MiniMax CN/Global API, MiniMax CN/Global
+10 -5
View File
@@ -66,10 +66,11 @@ tool_result_snip_ratio = 0.6 # 在摘要 compaction 前先缩短旧工具
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
kind = "anthropic"
base_url = "https://api.deepseek.com/anthropic"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
web_search = true
# 还有预设:deepseek-pro
[tools]
@@ -305,9 +306,13 @@ Serve。认证失败或主机密钥错误属于终止性故障,此时会关闭
在桌面端打开 **设置 -> 模型 -> 接入 -> 添加模型服务 -> 自定义供应商**,用于接入代理、
聚合平台或自建 OpenAI-compatible chat API / Anthropic-compatible Messages API 服务。
常用服务优先使用 **添加模型服务 -> 推荐预设**。DeepSeek 官方服务默认继续使用经过专项适配的
OpenAI Chat Completions;需要 Anthropic Messages 兼容时,可单独添加 **DeepSeek Anthropic**
可选预设,两者不会互相替换。Reasonix 还可以预填以下可编辑的自定义 provider:
常用服务优先使用 **添加模型服务 -> 推荐预设**。新建的官方 DeepSeek provider 默认使用
Anthropic-compatible Messages 端点,并开启 provider 侧 `web_search`;两种协议都复用同一个
`DEEPSEEK_API_KEY`。启动时,Reasonix 会自动升级仍使用官方端点、标准密钥和标准模型设置且
未修改过的旧 `deepseek-flash` / `deepseek-pro` 条目。修改过的官方 Chat Completions 配置保持
原样,设置页会提供 **升级到推荐协议** 操作。代理地址、自定义 Headers、模型列表和能力覆盖
都不会自动迁移。已有单独命名的 `deepseek-anthropic` 条目继续兼容,但新增
接入不再展示这个重复预设。Reasonix 还可以预填以下可编辑的自定义 provider:
Kimi CN、Kimi Global、Kimi Coding Plan、MiMo API、MiMo Anthropic、MiMo Token Plan
CN/SGP/AMS 及其 Anthropic-compatible 变体、MiniMax CN/Global API、MiniMax
CN/Global Anthropic、GLM CN、Z.AI Global、GLM/Z.AI Coding Plan 的
+4 -4
View File
@@ -61,10 +61,10 @@ persisted `supported_efforts` metadata is retained but does not override it.
## DeepSeek Anthropic-compatible endpoint
The optional `deepseek-anthropic` preset targets
`https://api.deepseek.com/anthropic`. It keeps the official Chat Completions
provider as Reasonix's default, but provides a native Messages API path for
compatibility testing and Anthropic-oriented clients. Reasonix emits
The default official DeepSeek provider targets `https://api.deepseek.com/anthropic`.
New official entries use this native Messages API path and enable provider-side
`web_search`; existing explicit providers, including legacy
`deepseek-anthropic` entries, keep their configured protocol. Reasonix emits
`thinking.type=enabled|disabled` with `output_config.effort`, replays unsigned
DeepSeek thinking blocks from historical tool-call turns, omits unsupported
images, and relies on DeepSeek's automatic prefix cache instead of ignored
+3 -3
View File
@@ -60,9 +60,9 @@ reasoning_protocol = "kimi-k3"
## DeepSeek Anthropic-compatible 端点
可选的 `deepseek-anthropic` 预设指向 `https://api.deepseek.com/anthropic`。
Reasonix 默认仍使用官方 Chat Completions provider,但它为兼容性测试和面向
Anthropic 的客户端提供了一条原生 Messages API 路径。Reasonix 会发送
默认官方 DeepSeek provider 指向 `https://api.deepseek.com/anthropic`。
新建的官方条目使用原生 Messages API 路径并开启 provider 侧 `web_search`;已有显式
provider(包括旧的 `deepseek-anthropic` 条目)保留其原协议。Reasonix 会发送
`thinking.type=enabled|disabled` 与 `output_config.effort`,回放历史工具调用轮次
中未签名的 DeepSeek 思考块,省略不支持的图片,并依赖 DeepSeek 的自动前缀缓存,
而不是被忽略的 `cache_control` 标记。
+3 -2
View File
@@ -715,13 +715,14 @@ reasoning_language = "auto" # visible reasoning text: auto|zh|en
# A vendor endpoint exposing several models under one base_url/key.
[[providers]]
name = "deepseek"
kind = "openai"
base_url = "https://api.deepseek.com"
kind = "anthropic"
base_url = "https://api.deepseek.com/anthropic"
# chat_url = "https://proxy.example.com/v1/chat/completions" # optional full chat request URL
# models_url = "https://proxy.example.com/v1/models" # optional model discovery URL
models = ["deepseek-v4-flash", "deepseek-v4-pro"]
default = "deepseek-v4-flash" # optional; defaults to models[0]
api_key_env = "DEEPSEEK_API_KEY"
web_search = true
context_window = 1000000 # tokens; harness compacts older history near this limit (0 disables)
max_output_tokens = 32768 # total visible + reasoning + tool-call output; 0 = provider default
# model_overrides = { "deepseek-v4-flash" = { context_window = 1000000, max_output_tokens = 32768 } }
+3 -2
View File
@@ -258,11 +258,12 @@ reasoning_language = "auto"
[[providers]]
name = "deepseek"
kind = "openai"
base_url = "https://api.deepseek.com"
kind = "anthropic"
base_url = "https://api.deepseek.com/anthropic"
models = ["deepseek-v4-flash", "deepseek-v4-pro"]
default = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
web_search = true
context_window = 1000000
max_output_tokens = 32768 # 正文、reasoning 与工具调用共用的总输出预算;0 使用 provider 默认值
+4
View File
@@ -1663,6 +1663,9 @@ func (t *TaskTool) runSubSession(ctx context.Context, prompt string, subReg *too
// intent classification must judge the task, not the wrapper.
opts.ClassifierTaskText = prompt
prompt = t.withWorkspaceContext(prompt)
// The child provider owns the final vision decision. Text-only providers
// retain the attachment metadata but omit image parts during serialization.
ctx = WithUserImages(ctx, SubagentImageCandidates(ctx))
return RunSubAgentWithSession(ctx, prov, subReg, sess, prompt, opts, sink)
}
@@ -1673,6 +1676,7 @@ func (t *TaskTool) runReadOnlySubSession(ctx context.Context, prompt string, sub
// intent classification must judge the task, not the wrapper.
opts.ClassifierTaskText = prompt
prompt = t.withWorkspaceContext(prompt)
ctx = WithUserImages(ctx, SubagentImageCandidates(ctx))
return RunReadOnlySubAgentWithSession(ctx, prov, subReg, sess, prompt, opts, sink)
}
+21
View File
@@ -153,6 +153,27 @@ func TestTaskToolInheritsReasoningLanguageFromContext(t *testing.T) {
}
}
func TestTaskToolPropagatesSubagentImageCandidates(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "image received"},
{Type: provider.ChunkDone},
}}
task := newTestTaskTool(t, sub, tool.NewRegistry(), "sys", "", "", nil)
ctx := WithSubagentImageCandidates(testTaskContext(), []string{"data:image/png;base64,AAAA"})
if _, err := task.Execute(ctx, []byte(`{"prompt":"inspect the attached image"}`)); err != nil {
t.Fatalf("Execute: %v", err)
}
var images []string
for _, msg := range sub.lastReq.Messages {
if msg.Role == provider.RoleUser {
images = msg.Images
}
}
if len(images) != 1 || images[0] != "data:image/png;base64,AAAA" {
t.Fatalf("sub-agent images = %v, want the parent candidate", images)
}
}
// TestTaskToolFiltersTools verifies the whitelist behaviour: when the caller
// names a subset of tools, the sub-agent's registry contains exactly that set
// with recursive delegation tools available while max_subagent_depth leaves one
+23
View File
@@ -3,6 +3,7 @@ package agent
import "context"
type rawUserInputKey struct{}
type subagentImageCandidatesKey struct{}
// WithRawUserInput keeps user-authored text separate from host-rendered turn
// context. Runner implementations can persist the raw text while sending their
@@ -25,3 +26,25 @@ func RawUserInput(ctx context.Context, fallback string) string {
}
return fallback
}
// WithSubagentImageCandidates carries attachment data resolved by the parent
// controller for a child model to opt into when it supports vision. Keeping
// this separate from UserImages avoids changing the parent's provider-visible
// text-only turn while allowing a vision-capable child to receive the same
// authorized attachments.
func WithSubagentImageCandidates(ctx context.Context, images []string) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, subagentImageCandidatesKey{}, append([]string(nil), images...))
}
// SubagentImageCandidates returns the parent-resolved attachment data that a
// child provider may use when its own model supports vision.
func SubagentImageCandidates(ctx context.Context) []string {
if ctx == nil {
return nil
}
images, _ := ctx.Value(subagentImageCandidatesKey{}).([]string)
return append([]string(nil), images...)
}
+15
View File
@@ -70,3 +70,18 @@ func TestRunPersistsRawUserInputSeparatelyFromProviderContext(t *testing.T) {
t.Fatalf("previous-release reader sees %q, want provider-visible %q", legacy.Content, composed)
}
}
func TestSubagentImageCandidatesAreCopiedAndIsolated(t *testing.T) {
images := []string{"data:image/png;base64,AAAA"}
ctx := WithSubagentImageCandidates(context.Background(), images)
images[0] = "mutated"
got := SubagentImageCandidates(ctx)
if len(got) != 1 || got[0] != "data:image/png;base64,AAAA" {
t.Fatalf("candidates = %v, want an isolated copy of the original image", got)
}
got[0] = "mutated again"
if again := SubagentImageCandidates(ctx); again[0] != "data:image/png;base64,AAAA" {
t.Fatalf("candidate accessor exposed mutable context state: %v", again)
}
}
+42 -20
View File
@@ -223,6 +223,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
// written config + ~/.env are picked up this same boot. CLI Run also calls this
// before config-only commands; this call stays as the shared frontend fallback.
migrated, migErr := config.MigrateLegacyIfNeededForRoot(root)
deepSeekProtocolMigrated, deepSeekProtocolMigErr := config.MigrateLegacyDeepSeekProtocolUserConfig()
stepLimitsMigrated, stepLimitMigErr := config.MigrateLegacyAgentStepLimitsForRoot(root)
redactToolOutputMigrated, redactToolOutputMigErr := config.MigrateLegacyRedactToolOutputForRoot(root)
memoryCompilerMigrated, memoryCompilerMigErr := config.MigrateLegacyMemoryCompilerForRoot(root)
@@ -427,6 +428,21 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
} else if migrated != nil {
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: migrated.Notice()})
}
if deepSeekProtocolMigrated {
sink.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelInfo,
Text: "DeepSeek official access was upgraded to Anthropic Messages.",
Detail: "Your unmodified legacy OpenAI Chat Completions configuration now uses DeepSeek's recommended Anthropic endpoint with server-side web search. Existing model names and pricing were preserved. The first request starts a new provider cache prefix; later requests rebuild normal prefix-cache reuse.",
})
} else if deepSeekProtocolMigErr != nil {
sink.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Text: "DeepSeek protocol migration did not complete.",
Detail: deepSeekProtocolMigErr.Error(),
})
}
if stepLimitsMigrated || cfg.IgnoredLegacyAgentStepLimits() {
level := event.LevelInfo
text := "Deprecated agent step limits were removed."
@@ -1257,7 +1273,11 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
if runOptions.DeliveryProfile {
runOptions.RequireReviewReportKind = agent.ReviewReportKindForSkill(sk.Name)
}
return agent.RunReadOnlySubAgentWithSession(sctx, prov, subReg, agent.NewSession(sysPrompt), task,
// Provider serializers decide whether these images are wire-visible from
// the child model's own vision capability. Text-only children retain the
// attachment metadata locally but never receive image parts on the wire.
childCtx := agent.WithUserImages(sctx, agent.SubagentImageCandidates(sctx))
return agent.RunReadOnlySubAgentWithSession(childCtx, prov, subReg, agent.NewSession(sysPrompt), task,
runOptions, agent.NestedSink(sctx, event.Discard))
}
// Writer-capable subagent skills reuse the sub-agent machinery via this
@@ -1379,11 +1399,14 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
runOptions.RequireReviewReportKind = agent.ReviewReportKindForSkill(sk.Name)
}
var answer string
// See the read-only runner above: the child provider, not the parent
// model, owns the final vision decision.
childCtx := agent.WithUserImages(sctx, agent.SubagentImageCandidates(sctx))
if sk.ReadOnly {
answer, err = agent.RunReadOnlySubAgentWithSession(sctx, prov, subReg, run.Session, task,
answer, err = agent.RunReadOnlySubAgentWithSession(childCtx, prov, subReg, run.Session, task,
runOptions, agent.NestedSink(sctx, event.Discard))
} else {
answer, err = agent.RunSubAgentWithSession(sctx, prov, subReg, run.Session, task,
answer, err = agent.RunSubAgentWithSession(childCtx, prov, subReg, run.Session, task,
runOptions, agent.NestedSink(sctx, event.Discard))
}
if err != nil {
@@ -2614,23 +2637,22 @@ func NewProviderWithProxy(e *config.ProviderEntry, proxy netclient.ProxySpec) (p
// provider-kind-specific knobs. EffectiveEffort applies a configured
// default_effort when the user has not explicitly selected /effort.
Extra: map[string]any{
"api_key_env": e.APIKeyEnv,
"api_key_source": e.APIKeySourceLabel(),
"thinking": e.Thinking,
"effort": config.EffectiveEffort(e),
"supported_efforts": e.SupportedEfforts,
"reasoning_protocol": config.ReasoningProtocolForEntry(e),
"max_output_tokens": e.MaxOutputTokens,
"chat_url": e.ChatURL,
"headers": e.Headers,
"extra_body": e.ExtraBody,
"auth_header": e.AuthHeader,
"proxy_spec": proxy,
"vision": config.EffectiveVision(e),
"vision_model_explicit": config.ExplicitModelVision(e),
"vision_detail": e.VisionDetail,
"web_search": config.EffectiveWebSearch(e),
"mode": e.ResponsesMode,
"api_key_env": e.APIKeyEnv,
"api_key_source": e.APIKeySourceLabel(),
"thinking": e.Thinking,
"effort": config.EffectiveEffort(e),
"supported_efforts": e.SupportedEfforts,
"reasoning_protocol": config.ReasoningProtocolForEntry(e),
"max_output_tokens": e.MaxOutputTokens,
"chat_url": e.ChatURL,
"headers": e.Headers,
"extra_body": e.ExtraBody,
"auth_header": e.AuthHeader,
"proxy_spec": proxy,
"vision": config.EffectiveVision(e),
"vision_detail": e.VisionDetail,
"web_search": config.EffectiveWebSearch(e),
"mode": e.ResponsesMode,
// Keep nil as nil so the responses provider can vendor-detect its
// default instead of accidentally treating every endpoint as stateful.
"stateful": e.ResponsesStateful,
+267 -11
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -18,6 +19,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -625,6 +627,165 @@ model = "x"
}
}
func TestBuildDeepSeekTextParentCanUseImageReturningMCPAndVisionSubagent(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerBootSubagentTestProvider()
prov := &bootSubagentTestProvider{combinedVision: true}
setBootSubagentTestProvider(t, prov)
if _, err := config.SetCredential("BOOT_DEEPSEEK_TEST_KEY", "test-key"); err != nil {
t.Fatalf("store test DeepSeek credential: %v", err)
}
writeFile(t, dir, "reasonix.toml", `
default_model = "parent"
[agent]
system_prompt = "BASE"
subagent_model = "vision-model"
[[providers]]
name = "parent"
kind = "boot-subagent-test"
base_url = "https://api.deepseek.com/anthropic"
model = "x"
api_key_env = "BOOT_DEEPSEEK_TEST_KEY"
[[providers]]
name = "vision-model"
kind = "boot-subagent-test"
base_url = "https://vision.example.invalid"
model = "x"
vision = true
`)
png, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
if err != nil {
t.Fatalf("decode test png: %v", err)
}
if err := os.MkdirAll(filepath.Join(dir, ".reasonix", "attachments"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".reasonix", "attachments", "shot.png"), png, 0o644); err != nil {
t.Fatal(err)
}
mcpImage := base64.StdEncoding.EncodeToString(png)
var mcpCalls atomic.Int32
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID *int `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if request.ID == nil {
w.WriteHeader(http.StatusAccepted)
return
}
var result any
switch request.Method {
case "initialize":
result = map[string]any{
"protocolVersion": "2024-11-05",
"serverInfo": map[string]any{"name": "vision-reader", "version": "1"},
"capabilities": map[string]any{"tools": map[string]any{}},
}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "inspect",
"description": "Inspect an image file by path.",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{"path": map[string]any{"type": "string"}},
"required": []string{"path"},
},
"annotations": map[string]any{"readOnlyHint": true},
}}}
case "tools/call":
mcpCalls.Add(1)
result = map[string]any{"content": []map[string]any{
{"type": "text", "text": "vision-mcp-ok"},
{"type": "image", "mimeType": "image/png", "data": mcpImage},
}}
default:
http.Error(w, "unsupported method", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result})
}))
defer mcpServer.Close()
ctrl, err := Build(context.Background(), Options{
Sink: event.Discard,
ExtraPlugins: []plugin.Spec{{
Name: "vision-reader",
Type: "http",
URL: mcpServer.URL,
Authorized: true,
}},
})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if ctrl.ImageInputEnabled() {
loaded, loadErr := config.LoadForRoot(dir)
resolved, _ := loaded.ResolveModel(ctrl.ModelRef())
t.Fatalf("official DeepSeek parent unexpectedly enables direct images: ref=%q entry=%+v load_err=%v", ctrl.ModelRef(), resolved, loadErr)
}
if err := ctrl.Run(context.Background(), "review @.reasonix/attachments/shot.png"); err != nil {
t.Fatalf("Run: %v", err)
}
reqs := prov.requestsSnapshot()
if len(reqs) != 4 {
t.Fatalf("provider requests = %d, want parent MCP call, parent subagent call, vision child, and parent final", len(reqs))
}
if got := mcpCalls.Load(); got != 1 {
t.Fatalf("vision MCP calls = %d, want 1", got)
}
if !requestHasTool(reqs[0], "mcp__vision-reader__inspect") || !requestHasTool(reqs[0], "review") {
t.Fatalf("parent tools = %v, want both vision MCP and review subagent", toolSchemaNames(reqs[0].Tools))
}
if got := bootLastUser(reqs[0]); !strings.Contains(got, "@.reasonix/attachments/shot.png") {
t.Fatalf("text-only parent lost the attachment reference needed by MCP: %q", got)
}
// The direct attachment remains candidate-only for the text parent. An MCP
// image is intentionally retained on its local tool-result message; the real
// DeepSeek adapter tests assert that this exact role is omitted on the wire.
for _, requestIndex := range []int{0, 1, 3} {
for _, msg := range reqs[requestIndex].Messages {
if msg.Role == provider.RoleUser && len(msg.Images) != 0 {
t.Fatalf("text-only parent request %d embedded %d direct attachment(s): %+v", requestIndex, len(msg.Images), reqs[requestIndex].Messages)
}
}
}
if !requestMessageContains(reqs[1].Messages, provider.RoleTool, "vision-mcp-ok") {
t.Fatalf("parent did not receive the vision MCP text result: %+v", reqs[1].Messages)
}
var parentMCPImageCount int
for _, msg := range reqs[1].Messages {
if msg.Role == provider.RoleTool && msg.Name == "mcp__vision-reader__inspect" {
parentMCPImageCount += len(msg.Images)
}
}
if parentMCPImageCount != 1 {
t.Fatalf("parent local MCP result images = %d, want one image for provider-boundary filtering", parentMCPImageCount)
}
var childImageCount int
for _, msg := range reqs[2].Messages {
if msg.Role == provider.RoleUser {
childImageCount = len(msg.Images)
}
}
if childImageCount != 1 {
t.Fatalf("vision child user images = %d, want one attachment; request = %+v", childImageCount, reqs[2].Messages)
}
}
func TestBuildUsesConfiguredLanguageForResponsePreference(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
@@ -848,10 +1009,11 @@ func setBootSubagentTestProvider(t *testing.T, p *bootSubagentTestProvider) {
}
type bootSubagentTestProvider struct {
mu sync.Mutex
calls int
continueRef string
requests []provider.Request
mu sync.Mutex
calls int
continueRef string
requests []provider.Request
combinedVision bool
}
func (p *bootSubagentTestProvider) Name() string { return "boot-subagent-test" }
@@ -868,9 +1030,35 @@ func (p *bootSubagentTestProvider) Stream(_ context.Context, req provider.Reques
p.calls++
ref := p.continueRef
p.requests = append(p.requests, req)
combinedVision := p.combinedVision
p.mu.Unlock()
var chunks []provider.Chunk
if combinedVision {
switch call {
case 0:
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
ID: "vision-mcp-1", Name: "mcp__vision-reader__inspect",
Arguments: `{"path":".reasonix/attachments/shot.png"}`,
}}}
case 1:
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
ID: "vision-review-1", Name: "review", Arguments: `{"task":"inspect the attached image"}`,
}}}
case 2:
chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "vision child answer"}, {Type: provider.ChunkDone}}
case 3:
chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "parent done"}, {Type: provider.ChunkDone}}
default:
chunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf("unexpected combined vision provider call %d", call)}}
}
ch := make(chan provider.Chunk, len(chunks))
for _, chunk := range chunks {
ch <- chunk
}
close(ch)
return ch, nil
}
switch call {
case 0:
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "review-1", Name: "review", Arguments: `{"task":"first skill task"}`}}}
@@ -1517,7 +1705,7 @@ func TestNewProviderBuildsDeepSeekAnthropicPreset(t *testing.T) {
}
}
func TestNewProviderAllowsExplicitOfficialDeepSeekVisionModel(t *testing.T) {
func TestNewProviderRejectsExplicitOfficialDeepSeekVisionModel(t *testing.T) {
var gotReq map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
@@ -1562,13 +1750,15 @@ func TestNewProviderAllowsExplicitOfficialDeepSeekVisionModel(t *testing.T) {
if !ok {
t.Fatalf("message = %#v, want object", messages[0])
}
parts, ok := message["content"].([]any)
if !ok || len(parts) != 2 {
t.Fatalf("content = %#v, want [text, image_url]", message["content"])
if got, ok := message["content"].(string); !ok || got != "describe" {
t.Fatalf("content = %#v, want plain text despite stale explicit vision metadata", message["content"])
}
imagePart, ok := parts[1].(map[string]any)
if !ok || imagePart["type"] != "image_url" {
t.Fatalf("image part = %#v, want image_url", parts[1])
encoded, err := json.Marshal(gotReq)
if err != nil {
t.Fatalf("marshal captured request: %v", err)
}
if bytes.Contains(encoded, []byte("image_url")) || bytes.Contains(encoded, []byte("base64,AAAA")) {
t.Fatalf("official DeepSeek request leaked image payload: %s", encoded)
}
}
@@ -4024,6 +4214,72 @@ func TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {
}
}
func TestBuildMigratesLegacyDeepSeekProtocolWithOneNotice(t *testing.T) {
home := isolateConfigHome(t)
t.Setenv("REASONIX_HOME", filepath.Join(home, "reasonix-home"))
userPath := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(userPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(userPath, []byte(`default_model = "deepseek-flash/deepseek-v4-flash"
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
`), 0o600); err != nil {
t.Fatal(err)
}
var notices []event.Event
sink := event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e)
}
})
build := func() {
t.Helper()
ctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: t.TempDir()})
if err != nil {
t.Fatalf("Build: %v", err)
}
ctrl.Close()
}
build()
migrationNotices := 0
for _, notice := range notices {
if notice.Text != "DeepSeek official access was upgraded to Anthropic Messages." {
continue
}
migrationNotices++
if notice.Level != event.LevelInfo || !strings.Contains(notice.Detail, "prefix-cache") {
t.Fatalf("migration notice = %+v", notice)
}
}
if migrationNotices != 1 {
t.Fatalf("migration notices = %d, want 1; got %+v", migrationNotices, notices)
}
raw, err := os.ReadFile(userPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `kind = "anthropic"`) ||
!strings.Contains(string(raw), `base_url = "https://api.deepseek.com/anthropic"`) {
t.Fatalf("legacy DeepSeek protocol remained on disk:\n%s", raw)
}
notices = nil
build()
for _, notice := range notices {
if strings.Contains(notice.Text, "DeepSeek official access was upgraded") {
t.Fatalf("second boot repeated migration notice: %+v", notice)
}
}
}
func TestBuildMigratesDeprecatedAgentStepLimitsWithOneNotice(t *testing.T) {
home := isolateConfigHome(t)
t.Setenv("REASONIX_HOME", filepath.Join(home, "reasonix-home"))
+155
View File
@@ -777,6 +777,9 @@ func TestNormalizeDesktopOfficialProviderAccessCanonicalizesOnlyDeepSeekIDs(t *t
if _, ok := c.Provider("deepseek"); !ok {
t.Fatal("canonical deepseek provider missing")
}
if resolved, ok := c.ResolveModel("deepseek-pro/deepseek-v4-pro"); !ok || resolved.Name != "deepseek" {
t.Fatalf("compatible legacy reference resolved to %+v, found=%v; want canonical DeepSeek", resolved, ok)
}
if _, ok := c.Provider("mimo-token-plan"); ok {
t.Fatal("mimo-token-plan should not be injected as an official provider")
}
@@ -856,6 +859,158 @@ func TestNormalizeDesktopOfficialProviderAccessKeepsCustomAlias(t *testing.T) {
}
}
func TestNormalizeDesktopOfficialProviderAccessKeepsIncompatibleOfficialAliases(t *testing.T) {
c := &Config{
DefaultModel: "deepseek-pro/deepseek-v4-pro",
Desktop: DesktopConfig{ProviderAccess: []string{"deepseek"}},
Providers: []ProviderEntry{
{
Name: "deepseek-flash", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
Headers: map[string]string{"X-Route": "flash"},
},
{
Name: "deepseek-pro", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY",
Headers: map[string]string{"X-Route": "pro"},
},
},
}
normalizeDesktopOfficialProviderAccess(c)
if got := c.Desktop.ProviderAccess; !reflect.DeepEqual(got, []string{"deepseek-flash", "deepseek-pro"}) {
t.Fatalf("provider_access = %v, want incompatible aliases preserved separately", got)
}
if _, ok := c.Provider("deepseek"); ok {
t.Fatal("incompatible provider-wide headers must not be collapsed into a canonical provider")
}
if c.DefaultModel != "deepseek-pro/deepseek-v4-pro" {
t.Fatalf("default_model = %q, want legacy Pro reference preserved", c.DefaultModel)
}
resolved, ok := c.ResolveModel(c.DefaultModel)
if !ok || resolved.Headers["X-Route"] != "pro" {
t.Fatalf("resolved Pro provider = %+v, found=%v; want its original transport", resolved, ok)
}
}
func TestNormalizeDesktopOfficialProviderAccessKeepsAliasIncompatibleWithExistingCanonical(t *testing.T) {
c := &Config{
DefaultModel: "deepseek-pro/deepseek-v4-pro",
Desktop: DesktopConfig{ProviderAccess: []string{"deepseek", "deepseek-pro"}},
Providers: []ProviderEntry{
{
Name: "deepseek", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"}, Default: "deepseek-v4-flash",
APIKeyEnv: "DEEPSEEK_API_KEY",
},
{
Name: "deepseek-pro", Kind: "openai", BaseURL: "https://api.deepseek.com",
Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY",
Headers: map[string]string{"X-Route": "legacy-pro"},
},
},
}
normalizeDesktopOfficialProviderAccess(c)
if got := c.Desktop.ProviderAccess; !reflect.DeepEqual(got, []string{"deepseek", "deepseek-pro"}) {
t.Fatalf("provider_access = %v, want canonical and incompatible alias preserved", got)
}
if c.DefaultModel != "deepseek-pro/deepseek-v4-pro" {
t.Fatalf("default_model = %q, want incompatible legacy reference preserved", c.DefaultModel)
}
resolved, ok := c.ResolveModel(c.DefaultModel)
if !ok || resolved.Kind != "openai" || resolved.Headers["X-Route"] != "legacy-pro" {
t.Fatalf("resolved legacy provider = %+v, found=%v; want original transport", resolved, ok)
}
}
func TestNormalizeDesktopOfficialProviderAccessKeepsConflictingModelAliases(t *testing.T) {
c := &Config{
DefaultModel: "deepseek-pro/deepseek-v4-flash",
Desktop: DesktopConfig{ProviderAccess: []string{"deepseek"}},
Providers: []ProviderEntry{
{
Name: "deepseek-flash", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", ContextWindow: 900_000,
},
{
Name: "deepseek-pro", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", ContextWindow: 800_000,
},
},
}
normalizeDesktopOfficialProviderAccess(c)
if got := c.Desktop.ProviderAccess; !reflect.DeepEqual(got, []string{"deepseek-flash", "deepseek-pro"}) {
t.Fatalf("provider_access = %v, want aliases with conflicting model metadata preserved", got)
}
if _, ok := c.Provider("deepseek"); ok {
t.Fatal("conflicting model metadata must not be collapsed into a canonical provider")
}
resolved, ok := c.ResolveModel(c.DefaultModel)
if !ok || resolved.ContextWindow != 800_000 {
t.Fatalf("resolved Pro alias = %+v, found=%v; want its original context window", resolved, ok)
}
}
func TestNormalizeDesktopOfficialProviderAccessKeepsUnsupportedOfficialProtocolAlias(t *testing.T) {
c := &Config{
DefaultModel: "deepseek-flash/deepseek-v4-flash",
Desktop: DesktopConfig{ProviderAccess: []string{"deepseek-flash"}},
Providers: []ProviderEntry{{
Name: "deepseek-flash", Kind: "responses", BaseURL: "https://api.deepseek.com",
Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
}},
}
normalizeDesktopOfficialProviderAccess(c)
if got := c.Desktop.ProviderAccess; !reflect.DeepEqual(got, []string{"deepseek-flash"}) {
t.Fatalf("provider_access = %v, want unsupported legacy protocol alias preserved", got)
}
if _, ok := c.Provider("deepseek"); ok {
t.Fatal("a legacy Responses alias must not be rewritten into the Anthropic canonical provider")
}
if c.DefaultModel != "deepseek-flash/deepseek-v4-flash" {
t.Fatalf("default_model = %q, want unsupported protocol reference preserved", c.DefaultModel)
}
}
func TestNormalizeDesktopOfficialProviderAccessPreservesDefaultAndUnknownPrices(t *testing.T) {
futurePrice := &provider.Pricing{Input: 4.2, Output: 8.4, Currency: "T"}
c := &Config{
Desktop: DesktopConfig{ProviderAccess: []string{"deepseek"}},
Providers: []ProviderEntry{
{
Name: "deepseek-flash", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
Models: []string{"deepseek-v4-flash", "deepseek-v5-preview"}, Default: "deepseek-v5-preview",
APIKeyEnv: "DEEPSEEK_API_KEY", Prices: map[string]*provider.Pricing{"deepseek-v5-preview": futurePrice},
},
{
Name: "deepseek-pro", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY",
},
},
}
normalizeDesktopOfficialProviderAccess(c)
canonical, ok := c.Provider("deepseek")
if !ok {
t.Fatal("canonical DeepSeek provider missing")
}
if canonical.Default != "deepseek-v5-preview" {
t.Fatalf("canonical default = %q, want explicit legacy default preserved", canonical.Default)
}
got := canonical.Prices["deepseek-v5-preview"]
if got == nil || !reflect.DeepEqual(got, futurePrice) {
t.Fatalf("future model price = %+v, want %+v", got, futurePrice)
}
}
func TestNormalizeOfficialDeepSeekModelsRepairsCanonicalProvider(t *testing.T) {
c := &Config{
DefaultModel: "deepseek-flash/deepseek-v4-flash",
+21 -2
View File
@@ -1905,9 +1905,25 @@ func Default() *Config {
Feishu: FeishuBotConfig{Domain: "feishu", AppSecretEnv: "FEISHU_BOT_APP_SECRET", Mode: "webhook", WebhookPort: 8080, RequireMention: true},
Weixin: WeixinBotConfig{AccountID: "default", TokenEnv: "WEIXIN_BOT_TOKEN", APIBase: "https://ilinkai.weixin.qq.com"},
},
// New installs use DeepSeek's Anthropic-compatible Messages endpoint so
// provider-executed web search is available by default. Existing explicit
// provider entries are merged on top of these defaults and keep their
// configured protocol unchanged.
Providers: []ProviderEntry{
{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", BalanceURL: "https://api.deepseek.com/user/balance", ContextWindow: 1_000_000, Price: deepSeekV4FlashPriceUSD()},
{Name: "deepseek-pro", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY", BalanceURL: "https://api.deepseek.com/user/balance", ContextWindow: 1_000_000, Price: deepSeekV4ProPriceUSD()},
{
Name: "deepseek-flash", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
BalanceURL: "https://api.deepseek.com/user/balance", Thinking: "enabled",
WebSearch: boolPointer(true), SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high",
ContextWindow: 1_000_000, Price: deepSeekV4FlashPriceUSD(),
},
{
Name: "deepseek-pro", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY",
BalanceURL: "https://api.deepseek.com/user/balance", Thinking: "enabled",
WebSearch: boolPointer(true), SupportedEfforts: []string{"disabled", "high", "max"}, DefaultEffort: "high",
ContextWindow: 1_000_000, Price: deepSeekV4ProPriceUSD(),
},
},
}
}
@@ -1945,6 +1961,9 @@ func (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {
return nil, false
}
if access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {
if access["deepseek"] && !canCanonicalizeLegacyDeepSeekProviders(c) {
delete(access, "deepseek")
}
ref = retargetDesktopOfficialRef(ref, access)
}
// "provider/model"
@@ -0,0 +1,769 @@
package config
import (
"fmt"
"net/url"
"os"
"slices"
"sort"
"strconv"
"strings"
"github.com/BurntSushi/toml"
"reasonix/internal/fileutil"
fileencoding "reasonix/internal/fileutil/encoding"
)
const deepSeekOfficialBalanceURL = "https://api.deepseek.com/user/balance"
// MigrateLegacyDeepSeekProtocolUserConfig upgrades only unmodified legacy
// DeepSeek provider aliases in the user-global config. It deliberately edits
// the original TOML in place instead of rendering Config, so comments, future
// fields, and unrelated provider blocks survive byte-for-byte.
func MigrateLegacyDeepSeekProtocolUserConfig() (bool, error) {
path := userConfigLoadPath()
if strings.TrimSpace(path) == "" {
return false, nil
}
return editLegacyDeepSeekProtocolFile(path, "", true)
}
// UpgradeDeepSeekProviderProtocol switches one official DeepSeek provider
// family to Anthropic Messages after an explicit user action. Passing the
// canonical name "deepseek" upgrades matching canonical/legacy alias blocks.
func UpgradeDeepSeekProviderProtocol(path, name string) (bool, error) {
name = strings.TrimSpace(name)
if name == "" {
return false, fmt.Errorf("upgrade DeepSeek protocol: empty provider name")
}
return editLegacyDeepSeekProtocolFile(path, name, false)
}
// UpgradeDeepSeekProviderProtocolUserConfig applies the explicit upgrade to
// the active user-global source, including a legacy config location.
func UpgradeDeepSeekProviderProtocolUserConfig(name string) (bool, error) {
return UpgradeDeepSeekProviderProtocol(userConfigLoadPath(), name)
}
// CanUpgradeDeepSeekProviderProtocolUserConfig reports whether the active
// user-global source contains a safely mappable provider in the requested
// DeepSeek family. Settings uses the same rewrite parser as the mutation path,
// so a project-only provider or an unsupported TOML shape cannot expose an
// action that would later edit a different file or fail unexpectedly.
func CanUpgradeDeepSeekProviderProtocolUserConfig(name string) bool {
path := userConfigLoadPath()
if strings.TrimSpace(path) == "" {
return false
}
resolved, exists, err := statConfigPath(path)
if err != nil || !exists {
return false
}
raw, err := fileencoding.ReadFileUTF8(resolved)
if err != nil {
return false
}
_, changed, err := rewriteLegacyDeepSeekProtocol(string(raw), name, false)
return err == nil && changed
}
// CanUpgradeDeepSeekProviderProtocol reports whether Settings may offer the
// explicit protocol upgrade. Custom transport/capability fields prevent the
// automatic migration but remain preserved when the user confirms this action.
func CanUpgradeDeepSeekProviderProtocol(p *ProviderEntry) bool {
if p == nil || !strings.EqualFold(strings.TrimSpace(p.Kind), "openai") ||
!isOfficialDeepSeekOpenAIEndpoint(p.BaseURL) ||
strings.TrimSpace(p.APIKeyEnv) == "" {
return false
}
models := p.ModelList()
switch strings.TrimSpace(p.Name) {
case "deepseek-flash":
return len(models) == 1 && strings.TrimSpace(models[0]) == "deepseek-v4-flash"
case "deepseek-pro":
return len(models) == 1 && strings.TrimSpace(models[0]) == "deepseek-v4-pro"
case "deepseek":
if len(models) == 0 {
return false
}
for _, model := range models {
switch strings.TrimSpace(model) {
case "deepseek-v4-flash", "deepseek-v4-pro":
default:
return false
}
}
return true
default:
return false
}
}
func editLegacyDeepSeekProtocolFile(path, target string, automatic bool) (bool, error) {
unlock, err := LockConfigFileEdits(path)
if err != nil {
return false, err
}
defer unlock()
resolved, exists, err := statConfigPath(path)
if err != nil || !exists {
return false, err
}
info, err := os.Stat(resolved)
if err != nil {
return false, err
}
rawBytes, err := os.ReadFile(resolved)
if err != nil {
return false, err
}
encoding, detected := fileencoding.Detect(rawBytes)
raw := fileencoding.Decode(detected, encoding)
next, changed, err := rewriteLegacyDeepSeekProtocol(string(raw), target, automatic)
if err != nil || !changed {
return changed, err
}
if err := fileutil.AtomicWriteFile(resolved, fileencoding.Encode(next, encoding), info.Mode().Perm()); err != nil {
return false, err
}
return true, nil
}
func rewriteLegacyDeepSeekProtocol(raw, target string, automatic bool) (string, bool, error) {
var decoded struct {
Providers []ProviderEntry `toml:"providers"`
}
if _, err := toml.Decode(raw, &decoded); err != nil {
return raw, false, err
}
var generic struct {
Providers []map[string]any `toml:"providers"`
}
if _, err := toml.Decode(raw, &generic); err != nil {
return raw, false, err
}
lines := strings.Split(raw, "\n")
blocks := providerTOMLBlocks(lines)
if len(blocks) == len(decoded.Providers) && len(generic.Providers) == len(decoded.Providers) {
changed := false
for i := range decoded.Providers {
entry := &decoded.Providers[i]
eligible := CanUpgradeDeepSeekProviderProtocol(entry)
if automatic {
eligible = eligible && isUnmodifiedLegacyDeepSeekProvider(*entry, generic.Providers[i])
} else {
eligible = eligible && deepSeekUpgradeTargetMatches(target, entry.Name)
}
if !eligible {
continue
}
if err := rewriteDeepSeekProviderBlock(lines, blocks[i]); err != nil {
return raw, false, err
}
changed = true
}
return strings.Join(lines, "\n"), changed, nil
}
inlineBlocks, err := providerTOMLInlineBlocks(raw)
if err != nil || len(inlineBlocks) != len(decoded.Providers) || len(generic.Providers) != len(decoded.Providers) {
return raw, false, fmt.Errorf("upgrade DeepSeek protocol: could not map provider tables safely")
}
replacements := make([]tomlReplacement, 0, len(decoded.Providers)*2)
for i := range decoded.Providers {
entry := &decoded.Providers[i]
eligible := CanUpgradeDeepSeekProviderProtocol(entry)
if automatic {
eligible = eligible && isUnmodifiedLegacyDeepSeekProvider(*entry, generic.Providers[i])
} else {
eligible = eligible && deepSeekUpgradeTargetMatches(target, entry.Name)
}
if !eligible {
continue
}
block := inlineBlocks[i]
if block.kindStart < 0 || block.baseURLStart < 0 {
return raw, false, fmt.Errorf("upgrade DeepSeek protocol: inline provider table is missing kind or base_url")
}
replacements = append(replacements,
tomlReplacement{start: block.kindStart, end: block.kindEnd, value: strconv.Quote("anthropic")},
tomlReplacement{start: block.baseURLStart, end: block.baseURLEnd, value: strconv.Quote(deepSeekAnthropicBaseURL)},
)
}
if len(replacements) == 0 {
return raw, false, nil
}
return applyTOMLReplacements(raw, replacements), true, nil
}
func isUnmodifiedLegacyDeepSeekProvider(p ProviderEntry, raw map[string]any) bool {
if p.Name != "deepseek-flash" && p.Name != "deepseek-pro" {
return false
}
if !isExactDeepSeekOpenAIEndpoint(p.BaseURL) {
return false
}
// Automatic migration is intentionally narrower than the explicit Settings
// upgrade: only the stock environment variable is unambiguous enough to
// change without user confirmation.
if strings.TrimSpace(p.APIKeyEnv) != "DEEPSEEK_API_KEY" {
return false
}
allowed := map[string]bool{
"name": true, "kind": true, "base_url": true, "model": true,
"api_key_env": true, "balance_url": true, "context_window": true,
"price": true,
}
for key := range raw {
if !allowed[key] {
return false
}
}
for _, required := range []string{"name", "kind", "base_url", "model", "api_key_env"} {
if _, ok := raw[required]; !ok {
return false
}
}
if p.BalanceURL != "" && strings.TrimRight(strings.TrimSpace(p.BalanceURL), "/") != deepSeekOfficialBalanceURL {
return false
}
if p.ContextWindow != 0 && p.ContextWindow != 1_000_000 {
return false
}
return p.Price == nil || IsKnownDeepSeekOfficialPricing(p.Model, p.Price)
}
func deepSeekUpgradeTargetMatches(target, providerName string) bool {
target = strings.TrimSpace(target)
providerName = strings.TrimSpace(providerName)
if target == providerName {
return true
}
if CanonicalDesktopOfficialProviderName(target) != "deepseek" {
return false
}
return CanonicalDesktopOfficialProviderName(providerName) == "deepseek"
}
func isExactDeepSeekOpenAIEndpoint(raw string) bool {
path, ok := deepSeekOpenAIEndpointPath(raw)
return ok && path == ""
}
func isOfficialDeepSeekOpenAIEndpoint(raw string) bool {
path, ok := deepSeekOpenAIEndpointPath(raw)
return ok && (path == "" || path == "/v1")
}
func deepSeekOpenAIEndpointPath(raw string) (string, bool) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || !strings.EqualFold(u.Scheme, "https") ||
!strings.EqualFold(u.Hostname(), "api.deepseek.com") || u.Port() != "" ||
u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return "", false
}
return strings.TrimRight(u.EscapedPath(), "/"), true
}
type providerTOMLBlock struct {
start int
end int
}
func providerTOMLBlocks(lines []string) []providerTOMLBlock {
headerLines := make([]int, 0)
providerStarts := make([]int, 0)
state := tomlOutside
for i, line := range lines {
if state != tomlOutside {
state = advanceTOMLStringState(state, line)
continue
}
if tomlSectionHeader(line) != "" {
headerLines = append(headerLines, i)
if isProviderArrayTableHeader(line) {
providerStarts = append(providerStarts, i)
}
}
state = advanceTOMLStringState(tomlOutside, line)
}
out := make([]providerTOMLBlock, 0, len(providerStarts))
for _, start := range providerStarts {
end := len(lines)
for _, header := range headerLines {
if header > start {
end = header
break
}
}
out = append(out, providerTOMLBlock{start: start, end: end})
}
return out
}
type providerTOMLInlineBlock struct {
start, end int
kindStart, kindEnd int
baseURLStart, baseURLEnd int
}
type tomlReplacement struct {
start, end int
value string
}
// providerTOMLInlineBlocks locates providers declared as an inline TOML array
// while preserving byte offsets so migration can edit only two scalar values.
// The parser is deliberately lexical: BurntSushi/toml validates the document,
// while this scan handles nested arrays/tables and quoted delimiters without
// re-rendering comments or unknown fields.
func providerTOMLInlineBlocks(raw string) ([]providerTOMLInlineBlock, error) {
arrayStart, arrayEnd, err := providerTOMLInlineArrayRange(raw)
if err != nil {
return nil, err
}
return collectProviderTOMLInlineBlocks(raw, arrayStart, arrayEnd)
}
func providerTOMLInlineArrayRange(raw string) (int, int, error) {
arrayStart, arrayEnd := -1, -1
section := ""
state := tomlOutside
for _, span := range tomlLineSpans(raw) {
if state != tomlOutside {
state = advanceTOMLStringState(state, span.text)
continue
}
if header := tomlSectionHeader(span.text); header != "" {
section = header
state = advanceTOMLStringState(tomlOutside, span.text)
continue
}
if section != "" {
state = advanceTOMLStringState(tomlOutside, span.text)
continue
}
line := strings.TrimRight(span.text, "\r\n")
nextState := advanceTOMLStringState(tomlOutside, line)
key, _, ok := tomlKeyValue(line)
if !ok || strings.Trim(key, `"'`) != "providers" {
state = nextState
continue
}
equals := strings.IndexByte(line, '=')
valueStart := span.start + equals + 1
for valueStart < len(raw) && (raw[valueStart] == ' ' || raw[valueStart] == '\t' || raw[valueStart] == '\r' || raw[valueStart] == '\n') {
valueStart++
}
if valueStart >= len(raw) || raw[valueStart] != '[' {
state = nextState
continue
}
valueEnd, err := scanTOMLDelimitedValue(raw, valueStart, '[', ']')
if err != nil {
return -1, -1, err
}
arrayStart, arrayEnd = valueStart, valueEnd
break
}
if arrayStart < 0 {
return -1, -1, fmt.Errorf("providers inline array not found")
}
return arrayStart, arrayEnd, nil
}
func collectProviderTOMLInlineBlocks(raw string, arrayStart, arrayEnd int) ([]providerTOMLInlineBlock, error) {
var tables []providerTOMLInlineBlock
stack := make([]byte, 0, 4)
tableStart := -1
var scanErr error
err := scanTOMLOutsideStrings(raw, arrayStart, arrayEnd+1, func(pos int, ch byte) bool {
if scanErr != nil {
return false
}
switch ch {
case '[', '{':
stack = append(stack, ch)
if ch == '{' && len(stack) == 2 && stack[0] == '[' {
tableStart = pos
}
case ']', '}':
if len(stack) == 0 || (ch == ']' && stack[len(stack)-1] != '[') || (ch == '}' && stack[len(stack)-1] != '{') {
scanErr = fmt.Errorf("invalid providers inline array nesting")
return false
}
if ch == '}' && len(stack) == 2 && tableStart >= 0 {
block, err := parseProviderTOMLInlineBlock(raw, tableStart, pos)
if err != nil {
scanErr = err
return false
}
tables = append(tables, block)
tableStart = -1
}
stack = stack[:len(stack)-1]
}
return true
})
if scanErr != nil {
return nil, scanErr
}
if err != nil {
return nil, err
}
if len(stack) != 0 || len(tables) == 0 {
return nil, fmt.Errorf("providers inline array contains no provider tables")
}
return tables, nil
}
func parseProviderTOMLInlineBlock(raw string, start, end int) (providerTOMLInlineBlock, error) {
block := providerTOMLInlineBlock{start: start, end: end, kindStart: -1, baseURLStart: -1}
segmentStart := start + 1
depth := 0
var segments [][2]int
var scanErr error
err := scanTOMLOutsideStrings(raw, start+1, end, func(pos int, ch byte) bool {
if scanErr != nil {
return false
}
switch ch {
case '[', '{':
depth++
case ']', '}':
depth--
if depth < 0 {
scanErr = fmt.Errorf("invalid inline provider table nesting")
return false
}
case ',':
if depth == 0 {
segments = append(segments, [2]int{segmentStart, pos})
segmentStart = pos + 1
}
}
return true
})
if scanErr != nil {
return block, scanErr
}
if err != nil {
return block, err
}
segments = append(segments, [2]int{segmentStart, end})
for _, segment := range segments {
start, end := trimTOMLWhitespace(raw, segment[0], segment[1])
if start >= end {
continue
}
equals, err := findTOMLAssignmentEquals(raw, start, end)
if err != nil {
return block, err
}
if equals < 0 {
return block, fmt.Errorf("inline provider table contains a value without a key")
}
key := strings.Trim(strings.TrimSpace(raw[start:equals]), `"'`)
valueStart, valueEnd := trimTOMLWhitespace(raw, equals+1, end)
if comment := tomlInlineCommentIndex(raw[valueStart:valueEnd]); comment >= 0 {
valueEnd = valueStart + comment
valueStart, valueEnd = trimTOMLWhitespace(raw, valueStart, valueEnd)
}
switch key {
case "kind":
block.kindStart, block.kindEnd = valueStart, valueEnd
case "base_url":
block.baseURLStart, block.baseURLEnd = valueStart, valueEnd
}
}
return block, nil
}
func scanTOMLDelimitedValue(raw string, start int, open, close byte) (int, error) {
depth := 0
end := -1
var scanErr error
err := scanTOMLOutsideStrings(raw, start, len(raw), func(pos int, ch byte) bool {
switch ch {
case open:
depth++
case close:
depth--
if depth == 0 {
end = pos
return false
}
if depth < 0 {
scanErr = fmt.Errorf("invalid TOML array nesting")
return false
}
}
return true
})
if scanErr != nil {
return -1, scanErr
}
if err != nil {
return -1, err
}
if end < 0 {
return -1, fmt.Errorf("unterminated TOML inline array")
}
return end, nil
}
// scanTOMLOutsideStrings visits structural bytes outside TOML strings and
// comments. It is used only after BurntSushi/toml has validated the document.
func scanTOMLOutsideStrings(raw string, start, end int, visit func(int, byte) bool) error {
const (
outside = iota
basic
literal
multilineBasic
multilineLiteral
)
state, escaped := outside, false
for i := start; i < end; {
ch := raw[i]
switch state {
case basic:
if escaped {
escaped = false
i++
continue
}
switch ch {
case '\\':
escaped = true
case '"':
state = outside
}
i++
case literal:
if ch == '\'' {
state = outside
}
i++
case multilineBasic:
if escaped {
escaped = false
i++
continue
}
if ch == '\\' {
escaped = true
i++
continue
}
if strings.HasPrefix(raw[i:], `"""`) {
state = outside
i += 3
continue
}
i++
case multilineLiteral:
if strings.HasPrefix(raw[i:], "'''") {
state = outside
i += 3
continue
}
i++
default:
if ch == '#' {
for i < end && raw[i] != '\n' {
i++
}
continue
}
if ch == '"' {
run := 1
for i+run < end && raw[i+run] == '"' {
run++
}
if run >= 3 {
state = multilineBasic
i += 3
} else {
state = basic
i++
}
continue
}
if ch == '\'' {
run := 1
for i+run < end && raw[i+run] == '\'' {
run++
}
if run >= 3 {
state = multilineLiteral
i += 3
} else {
state = literal
i++
}
continue
}
if visit != nil && !visit(i, ch) {
return nil
}
i++
}
}
if state != outside {
return fmt.Errorf("unterminated TOML string")
}
return nil
}
func trimTOMLWhitespace(raw string, start, end int) (int, int) {
for start < end && strings.ContainsRune(" \t\r\n", rune(raw[start])) {
start++
}
for end > start && strings.ContainsRune(" \t\r\n", rune(raw[end-1])) {
end--
}
return start, end
}
func findTOMLAssignmentEquals(raw string, start, end int) (int, error) {
var found = -1
depth := 0
err := scanTOMLOutsideStrings(raw, start, end, func(pos int, ch byte) bool {
switch ch {
case '[', '{':
depth++
case ']', '}':
depth--
case '=':
if depth == 0 {
found = pos
return false
}
}
return true
})
return found, err
}
func applyTOMLReplacements(raw string, replacements []tomlReplacement) string {
sort.Slice(replacements, func(i, j int) bool { return replacements[i].start < replacements[j].start })
for _, r := range slices.Backward(replacements) {
raw = raw[:r.start] + r.value + raw[r.end:]
}
return raw
}
func isProviderArrayTableHeader(line string) bool {
trimmed := strings.TrimSpace(line)
if comment := tomlInlineCommentIndex(trimmed); comment >= 0 {
trimmed = strings.TrimSpace(trimmed[:comment])
}
if !strings.HasPrefix(trimmed, "[[") || !strings.HasSuffix(trimmed, "]]") {
return false
}
key := strings.TrimSpace(trimmed[2 : len(trimmed)-2])
switch {
case key == "providers", key == "'providers'":
return true
case len(key) >= 2 && key[0] == '"' && key[len(key)-1] == '"':
decoded, err := strconv.Unquote(key)
return err == nil && decoded == "providers"
default:
return false
}
}
func rewriteDeepSeekProviderBlock(lines []string, block providerTOMLBlock) error {
kindLine, baseURLLine := -1, -1
state := tomlOutside
for i := block.start + 1; i < block.end; i++ {
if state != tomlOutside {
state = advanceTOMLStringState(state, lines[i])
continue
}
nextState := advanceTOMLStringState(tomlOutside, lines[i])
if nextState != tomlOutside {
state = nextState
continue
}
switch {
case isTOMLKeyAssignment(lines[i], "kind"):
kindLine = i
case isTOMLKeyAssignment(lines[i], "base_url"):
baseURLLine = i
}
state = nextState
}
if kindLine < 0 || baseURLLine < 0 {
return fmt.Errorf("upgrade DeepSeek protocol: provider table is missing kind or base_url")
}
lines[kindLine] = replaceTOMLStringAssignment(lines[kindLine], "anthropic")
lines[baseURLLine] = replaceTOMLStringAssignment(lines[baseURLLine], deepSeekAnthropicBaseURL)
return nil
}
func replaceTOMLStringAssignment(line, value string) string {
carriageReturn := strings.HasSuffix(line, "\r")
line = strings.TrimSuffix(line, "\r")
equals, err := findTOMLAssignmentEquals(line, 0, len(line))
if err != nil {
equals = strings.IndexByte(line, '=')
}
if equals < 0 {
return line
}
rhs := line[equals+1:]
leadingLen := len(rhs) - len(strings.TrimLeft(rhs, " \t"))
leading := rhs[:leadingLen]
suffix := ""
if comment := tomlInlineCommentIndex(rhs); comment >= 0 {
spaceStart := comment
for spaceStart > 0 && (rhs[spaceStart-1] == ' ' || rhs[spaceStart-1] == '\t') {
spaceStart--
}
suffix = rhs[spaceStart:]
}
next := line[:equals+1] + leading + strconv.Quote(value) + suffix
if carriageReturn {
next += "\r"
}
return next
}
func tomlInlineCommentIndex(value string) int {
inBasic, inLiteral, escaped := false, false, false
for i := range len(value) {
ch := value[i]
if inBasic {
if escaped {
escaped = false
continue
}
switch ch {
case '\\':
escaped = true
case '"':
inBasic = false
}
continue
}
if inLiteral {
if ch == '\'' {
inLiteral = false
}
continue
}
switch ch {
case '"':
inBasic = true
case '\'':
inLiteral = true
case '#':
return i
}
}
return -1
}
@@ -0,0 +1,577 @@
package config
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"github.com/BurntSushi/toml"
fileencoding "reasonix/internal/fileutil/encoding"
)
func TestMigrateLegacyDeepSeekProtocolUserConfigPreservesTOMLAndIsIdempotent(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
path := filepath.Join(home, "config.toml")
raw := `# keep this user comment
config_version = 4
default_model = "deepseek-flash/deepseek-v4-flash"
future_top_level = "preserve-me"
[[providers]]
name = "deepseek-flash"
kind = "openai" # legacy wire
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
balance_url = "https://api.deepseek.com/user/balance"
context_window = 1000000
[[providers]]
name = "deepseek-pro"
kind = "openai"
base_url = "https://api.deepseek.com/"
model = "deepseek-v4-pro"
api_key_env = "DEEPSEEK_API_KEY"
[[providers]]
name = "other"
kind = "openai"
base_url = "https://gateway.example/v1"
model = "other-model"
api_key_env = "OTHER_KEY"
future_provider_field = "untouched"
`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
changed, err := MigrateLegacyDeepSeekProtocolUserConfig()
if err != nil {
t.Fatalf("MigrateLegacyDeepSeekProtocolUserConfig: %v", err)
}
if !changed {
t.Fatal("legacy official providers were not migrated")
}
updatedBytes, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
updated := string(updatedBytes)
if strings.Count(updated, `kind = "anthropic"`) != 2 ||
strings.Count(updated, `base_url = "https://api.deepseek.com/anthropic"`) != 2 {
t.Fatalf("migrated provider protocol mismatch:\n%s", updated)
}
for _, preserved := range []string{
"# keep this user comment",
`future_top_level = "preserve-me"`,
`future_provider_field = "untouched"`,
`base_url = "https://gateway.example/v1"`,
`kind = "anthropic" # legacy wire`,
} {
if !strings.Contains(updated, preserved) {
t.Errorf("migration dropped %q:\n%s", preserved, updated)
}
}
cfg, err := LoadForEditReadOnlyStrict(path)
if err != nil {
t.Fatalf("load migrated config: %v", err)
}
for _, name := range []string{"deepseek-flash", "deepseek-pro"} {
entry, ok := cfg.Provider(name)
if !ok {
t.Fatalf("migrated provider %q missing", name)
}
if entry.Kind != "anthropic" || entry.BaseURL != deepSeekAnthropicBaseURL ||
entry.Thinking != "enabled" || !EffectiveWebSearch(entry) ||
len(entry.SupportedEfforts) == 0 || entry.DefaultEffort != "high" {
t.Errorf("migrated provider %q capabilities = %+v", name, entry)
}
}
beforeSecondRun := string(updatedBytes)
changed, err = MigrateLegacyDeepSeekProtocolUserConfig()
if err != nil {
t.Fatalf("second migration: %v", err)
}
if changed {
t.Fatal("second migration unexpectedly reported a change")
}
afterSecondRun, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(afterSecondRun) != beforeSecondRun {
t.Fatal("idempotent migration rewrote the config on its second run")
}
}
func TestUpgradeDeepSeekProviderProtocolWritesThroughSymlinkAndPreservesMode(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "shared-config.toml")
link := filepath.Join(dir, "config.toml")
raw := `[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
`
if err := os.WriteFile(target, []byte(raw), 0o640); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlinks are unavailable: %v", err)
}
changed, err := UpgradeDeepSeekProviderProtocol(link, "deepseek")
if err != nil {
t.Fatalf("UpgradeDeepSeekProviderProtocol: %v", err)
}
if !changed {
t.Fatal("symlinked DeepSeek provider was not upgraded")
}
if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("logical config link was replaced: info=%v err=%v", info, err)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(target)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o640 {
t.Fatalf("migrated target mode = %04o, want 0640", got)
}
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got), `kind = "anthropic"`) ||
!strings.Contains(string(got), `base_url = "https://api.deepseek.com/anthropic"`) {
t.Fatalf("symlink target was not upgraded:\n%s", got)
}
}
func TestMigrateLegacyDeepSeekProtocolUserConfigSerializesConcurrentUpgrades(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
path := filepath.Join(home, "config.toml")
raw := `[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
const workers = 12
start := make(chan struct{})
errs := make(chan error, workers)
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
<-start
_, err := MigrateLegacyDeepSeekProtocolUserConfig()
errs <- err
})
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("concurrent migration: %v", err)
}
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Count(string(got), `kind = "anthropic"`) != 1 ||
strings.Count(string(got), `base_url = "https://api.deepseek.com/anthropic"`) != 1 {
t.Fatalf("concurrent migration produced a corrupt or partial config:\n%s", got)
}
if _, err := LoadForEditReadOnlyStrict(path); err != nil {
t.Fatalf("concurrently migrated config is invalid: %v", err)
}
}
func TestDeepSeekProtocolUpgradeAvailabilityUsesUserConfigSource(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
path := filepath.Join(home, "config.toml")
if err := os.WriteFile(path, []byte("# unrelated user settings\n"), 0o600); err != nil {
t.Fatal(err)
}
if CanUpgradeDeepSeekProviderProtocolUserConfig("deepseek") {
t.Fatal("an unrelated user config must not expose an upgrade for a project-only provider")
}
raw := `[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
if !CanUpgradeDeepSeekProviderProtocolUserConfig("deepseek") {
t.Fatal("eligible user-global provider did not expose the grouped upgrade")
}
if CanUpgradeDeepSeekProviderProtocolUserConfig("unrelated") {
t.Fatal("an unrelated provider target unexpectedly exposed the DeepSeek upgrade")
}
}
func TestMigrateLegacyDeepSeekProtocolPreservesConfigEncoding(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
path := filepath.Join(home, "config.toml")
raw := `# preserve UTF-16 configuration
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
`
encoded := fileencoding.Encode(raw, fileencoding.UTF16LE)
if err := os.WriteFile(path, encoded, 0o600); err != nil {
t.Fatal(err)
}
changed, err := MigrateLegacyDeepSeekProtocolUserConfig()
if err != nil || !changed {
t.Fatalf("MigrateLegacyDeepSeekProtocolUserConfig: changed=%v err=%v", changed, err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !bytes.HasPrefix(got, []byte{0xff, 0xfe}) {
t.Fatalf("migrated config lost its UTF-16LE BOM: %x", got[:min(len(got), 8)])
}
decoded := string(fileencoding.DecodeToUTF8(got))
if !strings.Contains(decoded, `kind = "anthropic"`) ||
!strings.Contains(decoded, `base_url = "https://api.deepseek.com/anthropic"`) ||
!strings.Contains(decoded, "# preserve UTF-16 configuration") {
t.Fatalf("migrated UTF-16 config = %q", decoded)
}
}
func TestDeepSeekProtocolMigrationSupportsInlineProviderArrays(t *testing.T) {
raw := `config_version = 4
future_prompt = """
[not-a-section]
providers = [{ name = "quoted-example", kind = "openai" }]
"""
providers = [{ name = "deepseek-flash", kind = "openai", base_url = "https://api.deepseek.com", model = "deepseek-v4-flash", api_key_env = "DEEPSEEK_API_KEY" }, { name = "other", kind = "openai", base_url = "https://gateway.example/v1", model = "other-model", api_key_env = "OTHER_KEY", headers = { X-Trace = "keep,=#value" } }, { name = "local", kind = "ollama", model = "local-model" }]
`
next, changed, err := rewriteLegacyDeepSeekProtocol(raw, "", true)
if err != nil {
t.Fatalf("rewrite inline providers: %v", err)
}
if !changed {
t.Fatal("eligible inline DeepSeek provider was not migrated")
}
for _, want := range []string{
`kind = "anthropic"`,
`base_url = "https://api.deepseek.com/anthropic"`,
`headers = { X-Trace = "keep,=#value" }`,
`base_url = "https://gateway.example/v1"`,
`{ name = "local", kind = "ollama", model = "local-model" }`,
`providers = [{ name = "quoted-example", kind = "openai" }]`,
} {
if !strings.Contains(next, want) {
t.Errorf("inline migration dropped %q:\n%s", want, next)
}
}
var decoded Config
if _, err := toml.Decode(next, &decoded); err != nil {
t.Fatalf("migrated inline TOML is invalid: %v\n%s", err, next)
}
if len(decoded.Providers) != 3 || decoded.Providers[0].Kind != "anthropic" || decoded.Providers[1].Kind != "openai" {
t.Fatalf("migrated inline providers = %+v", decoded.Providers)
}
again, changed, err := rewriteLegacyDeepSeekProtocol(next, "", true)
if err != nil {
t.Fatalf("second inline migration: %v", err)
}
if changed || again != next {
t.Fatal("inline provider migration is not idempotent")
}
}
func TestManualDeepSeekProtocolUpgradeSupportsMultilineInlineArray(t *testing.T) {
raw := `providers = [
{ name = "deepseek-pro", kind = 'openai', base_url = 'https://api.deepseek.com/v1', model = "deepseek-v4-pro", api_key_env = "CUSTOM_KEY", headers = { X-Route = "keep,=#route" }, future_capability = true },
]
`
next, changed, err := rewriteLegacyDeepSeekProtocol(raw, "deepseek", false)
if err != nil {
t.Fatalf("manual inline upgrade: %v", err)
}
if !changed || !strings.Contains(next, `kind = "anthropic"`) ||
!strings.Contains(next, `base_url = "https://api.deepseek.com/anthropic"`) {
t.Fatalf("manual inline upgrade mismatch:\n%s", next)
}
for _, want := range []string{
`api_key_env = "CUSTOM_KEY"`,
`headers = { X-Route = "keep,=#route" }`,
`future_capability = true`,
} {
if !strings.Contains(next, want) {
t.Errorf("manual inline upgrade dropped %q:\n%s", want, next)
}
}
}
func TestAutomaticDeepSeekProtocolMigrationKeepsCustomizedProviders(t *testing.T) {
tests := []struct {
name string
extra string
}{
{name: "proxy endpoint", extra: `base_url = "https://proxy.example/v1"`},
{name: "custom headers", extra: `headers = { X-Route = "custom" }`},
{name: "explicit model list", extra: `models = ["deepseek-v4-flash"]`},
{name: "vision override", extra: `vision = true`},
{name: "reasoning override", extra: `reasoning_protocol = "none"`},
{name: "effort override", extra: `supported_efforts = ["high"]`},
{name: "custom key", extra: `api_key_env = "MY_DEEPSEEK_KEY"`},
{name: "unknown future field", extra: `future_capability = true`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
baseURL := `base_url = "https://api.deepseek.com"`
apiKey := `api_key_env = "DEEPSEEK_API_KEY"`
model := `model = "deepseek-v4-flash"`
switch {
case strings.HasPrefix(tt.extra, "base_url"):
baseURL = tt.extra
case strings.HasPrefix(tt.extra, "api_key_env"):
apiKey = tt.extra
case strings.HasPrefix(tt.extra, "models"):
model = tt.extra
}
extra := tt.extra
if tt.extra == baseURL || tt.extra == apiKey || tt.extra == model {
extra = ""
}
raw := `[[providers]]
name = "deepseek-flash"
kind = "openai"
` + baseURL + "\n" + model + "\n" + apiKey + "\n" + extra + "\n"
next, changed, err := rewriteLegacyDeepSeekProtocol(raw, "", true)
if err != nil {
t.Fatalf("rewriteLegacyDeepSeekProtocol: %v", err)
}
if changed || next != raw {
t.Fatalf("customized provider was automatically migrated:\n%s", next)
}
})
}
}
func TestManualDeepSeekProtocolUpgradePreservesCapabilitiesAndUnknownFields(t *testing.T) {
raw := `[[providers]]
name = "deepseek-flash"
kind = 'openai'
base_url = 'https://api.deepseek.com'
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
vision = true
future_capability = "keep"
[[providers]]
name = "deepseek-pro"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-pro"
api_key_env = "DEEPSEEK_API_KEY"
reasoning_protocol = "none"
`
next, changed, err := rewriteLegacyDeepSeekProtocol(raw, "deepseek", false)
if err != nil {
t.Fatalf("rewriteLegacyDeepSeekProtocol: %v", err)
}
if !changed || strings.Count(next, `kind = "anthropic"`) != 2 ||
strings.Count(next, `base_url = "https://api.deepseek.com/anthropic"`) != 2 {
t.Fatalf("manual family upgrade mismatch:\n%s", next)
}
for _, preserved := range []string{
`vision = true`,
`future_capability = "keep"`,
`reasoning_protocol = "none"`,
} {
if !strings.Contains(next, preserved) {
t.Errorf("manual upgrade dropped %q:\n%s", preserved, next)
}
}
}
func TestDeepSeekProtocolMigrationSupportsQuotedKeys(t *testing.T) {
raw := `[[providers]]
"name" = "deepseek-flash"
'kind' = 'openai'
"base_url" = 'https://api.deepseek.com'
'model' = "deepseek-v4-flash"
"api_key_env" = "DEEPSEEK_API_KEY"
`
next, changed, err := rewriteLegacyDeepSeekProtocol(raw, "", true)
if err != nil {
t.Fatalf("rewrite quoted-key provider: %v", err)
}
if !changed {
t.Fatal("quoted-key provider was not migrated")
}
for _, want := range []string{
`'kind' = "anthropic"`,
`"base_url" = "https://api.deepseek.com/anthropic"`,
} {
if !strings.Contains(next, want) {
t.Errorf("migration changed or dropped %q:\n%s", want, next)
}
}
var decoded Config
if _, err := toml.Decode(next, &decoded); err != nil {
t.Fatalf("migrated quoted-key TOML is invalid: %v\n%s", err, next)
}
if len(decoded.Providers) != 1 || decoded.Providers[0].Kind != "anthropic" || decoded.Providers[0].BaseURL != deepSeekAnthropicBaseURL {
t.Fatalf("migrated quoted-key provider = %+v", decoded.Providers)
}
}
func TestDeepSeekProtocolMigrationSupportsQuotedProviderTableHeaders(t *testing.T) {
raw := `[["providers"]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
[['providers']]
name = "deepseek-pro"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-pro"
api_key_env = "DEEPSEEK_API_KEY"
`
next, changed, err := rewriteLegacyDeepSeekProtocol(raw, "", true)
if err != nil {
t.Fatalf("rewrite quoted provider table headers: %v", err)
}
if !changed || strings.Count(next, `kind = "anthropic"`) != 2 ||
strings.Count(next, `base_url = "https://api.deepseek.com/anthropic"`) != 2 {
t.Fatalf("quoted provider table headers were not migrated:\n%s", next)
}
for _, header := range []string{`[["providers"]]`, `[['providers']]`} {
if !strings.Contains(next, header) {
t.Errorf("migration changed provider table header %q:\n%s", header, next)
}
}
var decoded Config
if _, err := toml.Decode(next, &decoded); err != nil {
t.Fatalf("migrated quoted-header TOML is invalid: %v\n%s", err, next)
}
if len(decoded.Providers) != 2 || decoded.Providers[0].Kind != "anthropic" || decoded.Providers[1].Kind != "anthropic" {
t.Fatalf("migrated quoted-header providers = %+v", decoded.Providers)
}
}
func TestManualDeepSeekProtocolUpgradeSkipsMultilineProviderText(t *testing.T) {
raw := `[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
description = '''
kind = "example"
base_url = "https://example.invalid"
'''
`
next, changed, err := rewriteLegacyDeepSeekProtocol(raw, "deepseek", false)
if err != nil {
t.Fatalf("manual multiline upgrade: %v", err)
}
if !changed || !strings.Contains(next, `kind = "anthropic"`) || !strings.Contains(next, `base_url = "https://api.deepseek.com/anthropic"`) {
t.Fatalf("manual multiline upgrade mismatch:\n%s", next)
}
for _, want := range []string{
`kind = "example"`,
`base_url = "https://example.invalid"`,
} {
if !strings.Contains(next, want) {
t.Errorf("multiline provider text changed or dropped %q:\n%s", want, next)
}
}
var decoded Config
if _, err := toml.Decode(next, &decoded); err != nil {
t.Fatalf("migrated multiline TOML is invalid: %v\n%s", err, next)
}
if len(decoded.Providers) != 1 || decoded.Providers[0].Kind != "anthropic" {
t.Fatalf("migrated multiline provider = %+v", decoded.Providers)
}
}
func TestCanUpgradeDeepSeekProviderProtocolRejectsProxyButAllowsExplicitUpgradeOfCustomization(t *testing.T) {
base := ProviderEntry{
Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com",
Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
}
if !CanUpgradeDeepSeekProviderProtocol(&base) {
t.Fatal("standard official provider should offer manual upgrade")
}
proxy := base
proxy.BaseURL = "https://deepseek.example/v1"
if CanUpgradeDeepSeekProviderProtocol(&proxy) {
t.Fatal("proxy endpoint should not offer manual upgrade")
}
headers := base
headers.Headers = map[string]string{"X-Route": "custom"}
if !CanUpgradeDeepSeekProviderProtocol(&headers) {
t.Fatal("custom headers should block automatic migration, not the explicit upgrade action")
}
versioned := base
versioned.BaseURL = "https://api.deepseek.com/v1"
if !CanUpgradeDeepSeekProviderProtocol(&versioned) {
t.Fatal("the official /v1 compatibility address should offer the explicit upgrade action")
}
customKey := base
customKey.APIKeyEnv = "MY_DEEPSEEK_KEY"
if !CanUpgradeDeepSeekProviderProtocol(&customKey) {
t.Fatal("an official provider with a custom key env should offer the explicit upgrade action")
}
}
func TestNormalizeOfficialDeepSeekModelsDoesNotAddProToResponses(t *testing.T) {
c := &Config{Providers: []ProviderEntry{{
Name: "deepseek", Kind: "responses", BaseURL: "https://api.deepseek.com",
Model: "deepseek-v4-flash",
}}}
normalizeOfficialDeepSeekModels(c)
p, ok := c.Provider("deepseek")
if !ok {
t.Fatal("DeepSeek provider missing after normalization")
}
if !p.HasModel("deepseek-v4-flash") {
t.Fatal("Responses provider lost its Flash model")
}
if p.HasModel("deepseek-v4-pro") {
t.Fatalf("Responses normalization added unsupported Pro model: %+v", p.ModelList())
}
}
+16
View File
@@ -14,6 +14,22 @@ func TestDefaultReasoningLanguageAuto(t *testing.T) {
}
}
func TestDefaultDeepSeekUsesAnthropicWebSearch(t *testing.T) {
cfg := Default()
for _, name := range []string{"deepseek-flash", "deepseek-pro"} {
provider, ok := cfg.Provider(name)
if !ok {
t.Fatalf("default provider %q is missing", name)
}
if provider.Kind != "anthropic" || provider.BaseURL != deepSeekAnthropicBaseURL {
t.Fatalf("default provider %q = kind %q base_url %q, want anthropic %q", name, provider.Kind, provider.BaseURL, deepSeekAnthropicBaseURL)
}
if !EffectiveWebSearch(provider) {
t.Fatalf("default provider %q web_search = false, want true", name)
}
}
}
func TestDefaultDesktopAppearanceAutoGraphite(t *testing.T) {
cfg := Default()
if got := cfg.DesktopTheme(); got != "auto" {
+21 -7
View File
@@ -761,7 +761,7 @@ func TestEffectiveVisionDoesNotInferCustomMimoProxy(t *testing.T) {
}
}
func TestEffectiveVisionDefaultsOfficialDeepSeekToTextOnlyButAllowsExplicitModels(t *testing.T) {
func TestEffectiveVisionRejectsOfficialDeepSeekOverridesButPreservesCustomGateways(t *testing.T) {
for _, endpoint := range []struct {
kind string
baseURL string
@@ -771,19 +771,28 @@ func TestEffectiveVisionDefaultsOfficialDeepSeekToTextOnlyButAllowsExplicitModel
{kind: "openai", baseURL: "https://eu.deepseek.com/v1"},
{kind: "anthropic", baseURL: "https://api.deepseek.com/anthropic"},
} {
visionOn := true
official := &ProviderEntry{
Name: "deepseek",
Kind: endpoint.kind,
BaseURL: endpoint.baseURL,
Model: "deepseek-v4-pro",
Vision: true,
VisionModels: []string{"deepseek-v4-pro"},
visionOverride: &visionOn,
ReasoningProtocol: ReasoningProtocolDeepSeek,
}
if CanConfigureVision(official) {
t.Fatalf("official DeepSeek endpoint %q must not allow vision configuration", endpoint.baseURL)
}
if EffectiveVision(official) {
t.Fatalf("official DeepSeek endpoint %q must remain text-only", endpoint.baseURL)
}
if ExplicitModelVision(official) {
t.Fatalf("provider-wide vision must not count as an explicit model capability for %q", endpoint.baseURL)
t.Fatalf("official DeepSeek endpoint %q must not expose ignored vision metadata as usable", endpoint.baseURL)
}
if !official.HasVisionModel("deepseek-v4-pro") {
t.Fatalf("official DeepSeek endpoint %q lost persisted vision metadata instead of ignoring it", endpoint.baseURL)
}
}
@@ -794,8 +803,8 @@ func TestEffectiveVisionDefaultsOfficialDeepSeekToTextOnlyButAllowsExplicitModel
Model: "deepseek-v5-vision",
VisionModels: []string{"deepseek-v5-vision"},
}
if !EffectiveVision(future) || !ExplicitModelVision(future) {
t.Fatal("model listed in vision_models must opt in on the official DeepSeek endpoint")
if EffectiveVision(future) {
t.Fatal("a future model name must not bypass the official DeepSeek wire constraint")
}
visionOn := true
@@ -812,8 +821,8 @@ func TestEffectiveVisionDefaultsOfficialDeepSeekToTextOnlyButAllowsExplicitModel
if !ok {
t.Fatal("ResolveModel did not find explicit future DeepSeek model")
}
if !EffectiveVision(overridden) || !ExplicitModelVision(overridden) {
t.Fatal("model_overrides vision=true must opt in on the official DeepSeek endpoint")
if EffectiveVision(overridden) {
t.Fatal("model_overrides vision=true must not bypass the official DeepSeek wire constraint")
}
custom := &ProviderEntry{
@@ -824,9 +833,14 @@ func TestEffectiveVisionDefaultsOfficialDeepSeekToTextOnlyButAllowsExplicitModel
Vision: true,
ReasoningProtocol: ReasoningProtocolDeepSeek,
}
if !EffectiveVision(custom) {
if !CanConfigureVision(custom) || !EffectiveVision(custom) {
t.Fatal("explicit vision=true must remain available for custom DeepSeek gateways")
}
custom.Vision = false
custom.VisionModels = []string{"deepseek-v4-pro"}
if !ExplicitModelVision(custom) {
t.Fatal("custom DeepSeek gateway must expose positive model-scoped vision metadata")
}
}
func TestEffectiveVisionUsesPerModelVisionList(t *testing.T) {
+453 -36
View File
@@ -7,6 +7,7 @@ import (
"net/url"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
@@ -797,6 +798,7 @@ func normalizeConfigForEdit(cfg *Config) bool {
changed = normalizeLegacyMimoCustomProviders(cfg) || changed
normalizeLegacyProviderModels(cfg)
normalizeDesktopOfficialProviderAccess(cfg)
normalizeOfficialDeepSeekModels(cfg)
applyDeepSeekOfficialDefaultPricing(cfg)
backfillDeepSeekOfficialPrices(cfg)
normalizeEffortConfig(cfg)
@@ -1218,15 +1220,6 @@ func tomlSectionHeader(line string) string {
return "other"
}
func isTOMLKeyAssignment(line, key string) bool {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || !strings.HasPrefix(trimmed, key) {
return false
}
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))
return strings.HasPrefix(rest, "=")
}
// normalizeLegacyProviderModels repairs provider entries written by older
// desktop builds that carried the official provider name/endpoint but omitted the
// model field. The repair is intentionally narrow: valid user-provided model
@@ -1565,12 +1558,58 @@ func normalizeOfficialDeepSeekModels(c *Config) {
}
switch strings.TrimSpace(p.Name) {
case "deepseek":
ensureProviderModels(p, []string{"deepseek-v4-flash", "deepseek-v4-pro"}, "deepseek-v4-flash")
required := []string{"deepseek-v4-flash", "deepseek-v4-pro"}
if strings.EqualFold(strings.TrimSpace(p.Kind), "responses") {
required = required[:1]
}
ensureProviderModels(p, required, "deepseek-v4-flash")
case "deepseek-flash":
ensureProviderModels(p, []string{"deepseek-v4-flash"}, "deepseek-v4-flash")
case "deepseek-pro":
ensureProviderModels(p, []string{"deepseek-v4-pro"}, "deepseek-v4-pro")
}
backfillDeepSeekAnthropicCapabilities(p)
}
}
func backfillDeepSeekAnthropicCapabilities(p *ProviderEntry) {
if p == nil || !strings.EqualFold(strings.TrimSpace(p.Kind), "anthropic") ||
!IsOfficialDeepSeekWebSearchEndpoint(p) {
return
}
if strings.TrimSpace(p.Thinking) == "" {
p.Thinking = "enabled"
}
capabilities := map[string]ProviderModelOverride{
"deepseek-v4-flash": {SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high"},
"deepseek-v4-pro": {SupportedEfforts: []string{"disabled", "high", "max"}, DefaultEffort: "high"},
}
if model := strings.TrimSpace(p.Model); model != "" && len(p.Models) == 0 {
defaults, ok := capabilities[model]
if !ok || len(p.SupportedEfforts) > 0 {
return
}
p.SupportedEfforts = append([]string(nil), defaults.SupportedEfforts...)
if strings.TrimSpace(p.DefaultEffort) == "" {
p.DefaultEffort = defaults.DefaultEffort
}
return
}
if p.ModelOverrides == nil {
p.ModelOverrides = map[string]ProviderModelOverride{}
}
for model, defaults := range capabilities {
if !p.HasModel(model) {
continue
}
override := p.ModelOverrides[model]
if len(override.SupportedEfforts) == 0 {
override.SupportedEfforts = append([]string(nil), defaults.SupportedEfforts...)
if strings.TrimSpace(override.DefaultEffort) == "" {
override.DefaultEffort = defaults.DefaultEffort
}
}
p.ModelOverrides[model] = override
}
}
@@ -1760,10 +1799,25 @@ func normalizeDesktopOfficialProviderAccess(c *Config) {
if c == nil || len(c.Desktop.ProviderAccess) == 0 {
return
}
canCanonicalizeDeepSeek := canCanonicalizeLegacyDeepSeekProviders(c)
_, hasCanonicalDeepSeek := c.Provider("deepseek")
legacyDeepSeek := officialLegacyDeepSeekProviders(c)
seen := desktopProviderAccessMap(nil)
next := make([]string, 0, len(c.Desktop.ProviderAccess))
for _, name := range c.Desktop.ProviderAccess {
name = desktopProviderAccessNameForConfig(c, name)
name = strings.TrimSpace(name)
if name == "deepseek" && !canCanonicalizeDeepSeek && !hasCanonicalDeepSeek && len(legacyDeepSeek) > 0 {
for _, legacy := range legacyDeepSeek {
if !seen[legacy.Name] {
seen[legacy.Name] = true
next = append(next, legacy.Name)
}
}
continue
}
if CanonicalDesktopOfficialProviderName(name) != "deepseek" || name == "deepseek" || canCanonicalizeDeepSeek {
name = desktopProviderAccessNameForConfig(c, name)
}
if name == "" || seen[name] {
continue
}
@@ -1775,7 +1829,11 @@ func normalizeDesktopOfficialProviderAccess(c *Config) {
ensureDeepSeekOfficialProvider(c)
}
normalizeLegacyMimoProviderCatalogs(c)
retargetDesktopOfficialRefs(c, seen)
retargetAccess := maps.Clone(seen)
if p, ok := c.Provider("deepseek"); !canCanonicalizeDeepSeek || !ok || officialProviderKind(p) != "deepseek" {
delete(retargetAccess, "deepseek")
}
retargetDesktopOfficialRefs(c, retargetAccess)
}
// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list
@@ -1864,7 +1922,7 @@ func providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical str
}
switch canonical {
case "deepseek":
return officialProviderKind(p) == "deepseek"
return isCanonicalizableLegacyDeepSeekProvider(p)
default:
return false
}
@@ -1894,27 +1952,40 @@ func ensureDeepSeekOfficialProvider(c *Config) {
}
return
}
if !canCanonicalizeLegacyDeepSeekProviders(c) {
return
}
entry := ProviderEntry{
Name: "deepseek",
Kind: "openai",
BaseURL: "https://api.deepseek.com",
Kind: "anthropic",
BaseURL: deepSeekAnthropicBaseURL,
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
Default: "deepseek-v4-flash",
APIKeyEnv: "DEEPSEEK_API_KEY",
BalanceURL: "https://api.deepseek.com/user/balance",
Thinking: "enabled",
WebSearch: boolPointer(true),
ContextWindow: 1_000_000,
Prices: deepSeekV4PricesForConfig(c),
ModelOverrides: map[string]ProviderModelOverride{
"deepseek-v4-flash": {SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high"},
"deepseek-v4-pro": {SupportedEfforts: []string{"disabled", "high", "max"}, DefaultEffort: "high"},
},
}
if old, ok := c.Provider("deepseek-flash"); ok {
entry = officialProviderFromLegacy(entry, old)
legacyProviders := officialLegacyDeepSeekProviders(c)
if len(legacyProviders) > 0 {
entry = officialProviderFromLegacy(entry, legacyProviders[0])
currency := c.DeepSeekOfficialPricingCurrency()
if c.DesktopCurrency() == "" && old.persistedOfficialCurrency != "" {
currency = old.persistedOfficialCurrency
if c.DesktopCurrency() == "" && legacyProviders[0].persistedOfficialCurrency != "" {
currency = legacyProviders[0].persistedOfficialCurrency
entry.persistedOfficialCurrency = currency
}
entry.Prices = DeepSeekV4PricesForCurrency(currency)
entry.Models = mergeModelLists([]string{"deepseek-v4-flash", "deepseek-v4-pro"}, old.ModelList())
entry.Default = firstKnownModel(entry.Default, entry.Models, "deepseek-v4-flash")
for _, old := range legacyProviders {
entry.Models = mergeModelLists(entry.Models, old.ModelList())
mergeLegacyDeepSeekModelConfiguration(&entry, old)
}
entry.Default = preferredLegacyDeepSeekDefault(legacyProviders, entry.Models, entry.Default)
}
backfillOfficialContextWindow(&entry, 1_000_000)
c.Providers = append(c.Providers, entry)
@@ -1945,21 +2016,367 @@ func backfillOfficialContextWindow(e *ProviderEntry, fallback int) {
}
func officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {
entry.Kind = old.Kind
entry.BaseURL = old.BaseURL
entry.ModelsURL = old.ModelsURL
entry.APIKeyEnv = old.APIKeyEnv
entry.BalanceURL = old.BalanceURL
entry.ContextWindow = old.ContextWindow
entry.Price = old.Price
entry.Thinking = old.Thinking
entry.Effort = old.Effort
entry.ReasoningProtocol = old.ReasoningProtocol
entry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)
entry.DefaultEffort = old.DefaultEffort
entry.NoProxy = old.NoProxy
entry.persistedOfficialCurrency = old.persistedOfficialCurrency
return entry
if old == nil {
return entry
}
// Start from the legacy entry so current and future transport fields are not
// silently dropped from the effective canonical provider. Identity, catalog,
// pricing and capability fields are merged model by model below.
legacy := cloneProviderEntry(*old)
legacy.Name = entry.Name
legacy.Model = ""
legacy.Models = append([]string(nil), entry.Models...)
legacy.Default = entry.Default
legacy.ContextWindow = entry.ContextWindow
legacy.MaxOutputTokens = entry.MaxOutputTokens
legacy.Price = nil
legacy.Prices = clonePricingMap(entry.Prices)
legacy.ReasoningProtocol = entry.ReasoningProtocol
legacy.SupportedEfforts = append([]string(nil), entry.SupportedEfforts...)
legacy.DefaultEffort = entry.DefaultEffort
legacy.Vision = entry.Vision
legacy.VisionModels = append([]string(nil), entry.VisionModels...)
legacy.ModelOverrides = cloneModelOverrideMap(entry.ModelOverrides)
return legacy
}
func officialLegacyDeepSeekProviders(c *Config) []*ProviderEntry {
if c == nil {
return nil
}
out := make([]*ProviderEntry, 0, 2)
for _, name := range []string{"deepseek-flash", "deepseek-pro"} {
if p, ok := c.Provider(name); ok && isCanonicalizableLegacyDeepSeekProvider(p) {
out = append(out, p)
}
}
return out
}
func isCanonicalizableLegacyDeepSeekProvider(p *ProviderEntry) bool {
if p == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(p.Kind)) {
case "openai":
return isOfficialDeepSeekOpenAIEndpoint(p.BaseURL)
case "anthropic":
return IsOfficialDeepSeekWebSearchEndpoint(p)
default:
return false
}
}
func canCanonicalizeLegacyDeepSeekProviders(c *Config) bool {
if c == nil {
return true
}
legacy := officialLegacyDeepSeekProviders(c)
if canonical, ok := c.Provider("deepseek"); ok {
if officialProviderKind(canonical) != "deepseek" {
return false
}
for _, old := range legacy {
if !legacyDeepSeekProviderWideFieldsEqual(canonical, old) ||
!legacyDeepSeekModelFieldsCompatibleIgnoringDefault(canonical, old) {
return false
}
}
}
for i := 1; i < len(legacy); i++ {
if !legacyDeepSeekProviderWideFieldsEqual(legacy[0], legacy[i]) ||
!legacyDeepSeekModelFieldsCompatible(legacy[0], legacy[i]) {
return false
}
}
return true
}
func legacyDeepSeekModelFieldsCompatibleIgnoringDefault(a, b *ProviderEntry) bool {
if a == nil || b == nil {
return a == b
}
left := cloneProviderEntry(*a)
right := cloneProviderEntry(*b)
left.Default = ""
right.Default = ""
return legacyDeepSeekModelFieldsCompatible(&left, &right)
}
func legacyDeepSeekProviderWideFieldsEqual(a, b *ProviderEntry) bool {
if a == nil || b == nil {
return a == b
}
left := legacyDeepSeekProviderWideProjection(a)
right := legacyDeepSeekProviderWideProjection(b)
return reflect.DeepEqual(left, right)
}
func legacyDeepSeekProviderWideProjection(entry *ProviderEntry) ProviderEntry {
out := cloneProviderEntry(*entry)
out.Name = ""
out.Kind = strings.ToLower(strings.TrimSpace(out.Kind))
out.BaseURL = normalizedBaseURLForMigration(out.BaseURL)
out.ChatURL = strings.TrimSpace(out.ChatURL)
out.ModelsURL = strings.TrimSpace(out.ModelsURL)
out.APIKeyEnv = strings.TrimSpace(out.APIKeyEnv)
out.BalanceURL = normalizedDeepSeekBalanceURL(out.BalanceURL)
out.ResponsesMode = strings.TrimSpace(out.ResponsesMode)
out.Thinking = strings.TrimSpace(out.Thinking)
out.Effort = strings.TrimSpace(out.Effort)
out.VisionDetail = strings.TrimSpace(out.VisionDetail)
// These fields can be represented independently for every model in the
// canonical provider. They are compared by legacyDeepSeekModelFieldsCompatible.
out.Model = ""
out.Models = nil
out.Default = ""
out.ContextWindow = 0
out.MaxOutputTokens = 0
out.Price = nil
out.Prices = nil
out.ReasoningProtocol = ""
out.SupportedEfforts = nil
out.DefaultEffort = ""
out.Vision = false
out.VisionModels = nil
out.ModelOverrides = nil
out.visionOverride = nil
out.resolvedAPIKey = ""
out.resolvedSource = CredentialSource{}
return out
}
func normalizedDeepSeekBalanceURL(raw string) string {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
return deepSeekOfficialBalanceURL
}
return raw
}
type legacyDeepSeekModelFields struct {
contextWindowSet bool
contextWindow int
maxOutputTokensSet bool
maxOutputTokens int
priceSet bool
price *provider.Pricing
reasoningProtocolSet bool
reasoningProtocol string
supportedEffortsSet bool
supportedEfforts []string
defaultEffortSet bool
defaultEffort string
visionSet bool
vision bool
}
func legacyDeepSeekModelFieldsCompatible(a, b *ProviderEntry) bool {
if a == nil || b == nil {
return a == b
}
if left, right := strings.TrimSpace(a.Default), strings.TrimSpace(b.Default); left != "" && right != "" && left != right {
return false
}
models := map[string]string{}
add := func(model string) {
model = strings.TrimSpace(model)
if model != "" {
models[strings.ToLower(model)] = model
}
}
for _, entry := range []*ProviderEntry{a, b} {
for _, model := range entry.ModelList() {
add(model)
}
for _, model := range entry.VisionModels {
add(model)
}
for model := range entry.Prices {
add(model)
}
for model := range entry.ModelOverrides {
add(model)
}
}
for _, model := range models {
left, leftSet := legacyDeepSeekModelFieldProjection(a, model)
right, rightSet := legacyDeepSeekModelFieldProjection(b, model)
if leftSet && rightSet && !legacyDeepSeekModelFieldProjectionsCompatible(left, right) {
return false
}
}
return true
}
func legacyDeepSeekModelFieldProjection(entry *ProviderEntry, model string) (legacyDeepSeekModelFields, bool) {
var out legacyDeepSeekModelFields
if entry == nil {
return out, false
}
listed := entry.HasModel(model)
if listed {
out.contextWindowSet = true
out.contextWindow = entry.ContextWindow
if out.contextWindow <= 0 {
out.contextWindow = 1_000_000
}
out.maxOutputTokensSet = true
out.maxOutputTokens = entry.MaxOutputTokens
out.reasoningProtocolSet = true
out.reasoningProtocol = strings.TrimSpace(entry.ReasoningProtocol)
out.supportedEffortsSet = true
out.supportedEfforts = append([]string(nil), entry.SupportedEfforts...)
out.defaultEffortSet = true
out.defaultEffort = strings.TrimSpace(entry.DefaultEffort)
out.visionSet = true
out.vision = entry.Vision || entry.HasVisionModel(model)
if price := entry.PriceForModel(model); price != nil {
out.priceSet = true
out.price = price
}
}
if price, ok := pricingForModelKey(entry.Prices, model); ok {
out.priceSet = true
out.price = clonePricing(price)
}
if override, ok := entry.modelOverrideForModel(model); ok {
if override.ContextWindow > 0 {
out.contextWindowSet = true
out.contextWindow = override.ContextWindow
}
if override.MaxOutputTokens != 0 {
out.maxOutputTokensSet = true
out.maxOutputTokens = override.MaxOutputTokens
}
if strings.TrimSpace(override.ReasoningProtocol) != "" {
out.reasoningProtocolSet = true
out.reasoningProtocol = strings.TrimSpace(override.ReasoningProtocol)
}
if override.SupportedEfforts != nil {
out.supportedEffortsSet = true
out.supportedEfforts = append([]string(nil), override.SupportedEfforts...)
out.defaultEffortSet = true
out.defaultEffort = strings.TrimSpace(override.DefaultEffort)
}
if override.Vision != nil {
out.visionSet = true
out.vision = *override.Vision
}
}
return out, listed || out.contextWindowSet || out.maxOutputTokensSet || out.priceSet ||
out.reasoningProtocolSet || out.supportedEffortsSet || out.defaultEffortSet || out.visionSet
}
func pricingForModelKey(prices map[string]*provider.Pricing, model string) (*provider.Pricing, bool) {
for key, price := range prices {
if strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(model)) {
return price, true
}
}
return nil, false
}
func legacyDeepSeekModelFieldProjectionsCompatible(a, b legacyDeepSeekModelFields) bool {
return (!a.contextWindowSet || !b.contextWindowSet || a.contextWindow == b.contextWindow) &&
(!a.maxOutputTokensSet || !b.maxOutputTokensSet || a.maxOutputTokens == b.maxOutputTokens) &&
(!a.priceSet || !b.priceSet || reflect.DeepEqual(a.price, b.price)) &&
(!a.reasoningProtocolSet || !b.reasoningProtocolSet || a.reasoningProtocol == b.reasoningProtocol) &&
(!a.supportedEffortsSet || !b.supportedEffortsSet || slices.Equal(a.supportedEfforts, b.supportedEfforts)) &&
(!a.defaultEffortSet || !b.defaultEffortSet || a.defaultEffort == b.defaultEffort) &&
(!a.visionSet || !b.visionSet || a.vision == b.vision)
}
func preferredLegacyDeepSeekDefault(entries []*ProviderEntry, models []string, fallback string) string {
for _, entry := range entries {
if entry == nil {
continue
}
candidate := strings.TrimSpace(entry.Default)
if candidate != "" && slices.Contains(models, candidate) {
return candidate
}
}
return firstKnownModel(fallback, models, "deepseek-v4-flash")
}
func mergeLegacyDeepSeekModelConfiguration(entry, old *ProviderEntry) {
if entry == nil || old == nil {
return
}
if entry.Prices == nil {
entry.Prices = map[string]*provider.Pricing{}
}
if entry.ModelOverrides == nil {
entry.ModelOverrides = map[string]ProviderModelOverride{}
}
entry.VisionModels = mergeModelLists(entry.VisionModels, old.VisionModels)
for model, price := range old.Prices {
entry.Prices[model] = clonePricing(price)
}
for _, model := range old.ModelList() {
model = strings.TrimSpace(model)
if model == "" {
continue
}
if price := old.PriceForModel(model); price != nil {
entry.Prices[model] = price
}
override := entry.ModelOverrides[model]
if old.ContextWindow > 0 && old.ContextWindow != entry.ContextWindow {
override.ContextWindow = old.ContextWindow
}
if old.MaxOutputTokens != entry.MaxOutputTokens {
override.MaxOutputTokens = old.MaxOutputTokens
}
if protocol := strings.TrimSpace(old.ReasoningProtocol); protocol != "" {
override.ReasoningProtocol = protocol
}
if len(old.SupportedEfforts) > 0 {
override.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)
override.DefaultEffort = old.DefaultEffort
}
if old.Vision || old.HasVisionModel(model) {
vision := true
override.Vision = &vision
}
if explicit, ok := old.modelOverrideForModel(model); ok {
mergeProviderModelOverride(&override, explicit)
}
entry.ModelOverrides[model] = override
}
for model, override := range old.ModelOverrides {
if old.HasModel(model) {
continue
}
current := entry.ModelOverrides[model]
mergeProviderModelOverride(&current, override)
entry.ModelOverrides[model] = current
}
}
func mergeProviderModelOverride(dst *ProviderModelOverride, src ProviderModelOverride) {
if dst == nil {
return
}
if strings.TrimSpace(src.ReasoningProtocol) != "" {
dst.ReasoningProtocol = src.ReasoningProtocol
}
if len(src.SupportedEfforts) > 0 {
dst.SupportedEfforts = append([]string(nil), src.SupportedEfforts...)
dst.DefaultEffort = src.DefaultEffort
}
if src.Vision != nil {
vision := *src.Vision
dst.Vision = &vision
}
if src.ContextWindow > 0 {
dst.ContextWindow = src.ContextWindow
}
if src.MaxOutputTokens != 0 {
dst.MaxOutputTokens = src.MaxOutputTokens
}
}
func mergeModelLists(primary, extra []string) []string {
+22 -2
View File
@@ -584,10 +584,30 @@ func migrateLegacyBaseURL(cfg *Config, baseURL string) {
if cfg == nil || baseURL == "" {
return
}
officialDeepSeek := isOfficialDeepSeekOpenAIEndpoint(baseURL)
for i := range cfg.Providers {
if cfg.Providers[i].APIKeyEnv == "DEEPSEEK_API_KEY" {
cfg.Providers[i].BaseURL = baseURL
p := &cfg.Providers[i]
if p.APIKeyEnv != "DEEPSEEK_API_KEY" {
continue
}
if officialDeepSeek {
// v0.x stored the official OpenAI-compatible root (or /v1). The
// current built-in provider is Anthropic, whose documented endpoint
// has a distinct /anthropic prefix.
p.Kind = "anthropic"
p.BaseURL = deepSeekAnthropicBaseURL
continue
}
// A non-official v0.x base URL was an OpenAI-compatible endpoint. Keep
// that wire protocol instead of applying the new Anthropic defaults to a
// custom gateway that may not implement Messages API.
p.Kind = "openai"
p.BaseURL = baseURL
p.Thinking = ""
p.WebSearch = nil
p.SupportedEfforts = nil
p.DefaultEffort = ""
p.ModelOverrides = nil
}
}
+28 -1
View File
@@ -971,12 +971,39 @@ func TestMigrateCustomBaseURLWarns(t *testing.T) {
}
for _, name := range []string{"deepseek-flash", "deepseek-pro"} {
p, ok := cfg.Provider(name)
if !ok || p.BaseURL != "https://my-proxy.example/v1" {
if !ok || p.Kind != "openai" || p.BaseURL != "https://my-proxy.example/v1" ||
p.Thinking != "" || p.WebSearch != nil || len(p.SupportedEfforts) != 0 || p.DefaultEffort != "" {
t.Fatalf("%s base_url was not migrated: %+v", name, p)
}
}
}
func TestMigrateOfficialLegacyBaseURLUsesAnthropicEndpoint(t *testing.T) {
for _, baseURL := range []string{
"https://api.deepseek.com",
"https://api.deepseek.com/v1/",
} {
t.Run(baseURL, func(t *testing.T) {
src, _, _ := legacyHome(t)
writeLegacy(t, src, `{"apiKey":"sk-official","baseUrl":"`+baseURL+`"}`)
if _, err := MigrateLegacyIfNeeded(); err != nil {
t.Fatalf("migrate: %v", err)
}
cfg, err := Load()
if err != nil {
t.Fatalf("load migrated config: %v", err)
}
for _, name := range []string{"deepseek-flash", "deepseek-pro"} {
p, ok := cfg.Provider(name)
if !ok || p.Kind != "anthropic" || p.BaseURL != deepSeekAnthropicBaseURL || !EffectiveWebSearch(p) {
t.Fatalf("%s official migration = %+v, want Anthropic endpoint with web search", name, p)
}
}
})
}
}
func TestMigrateSupportData(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping since legacyOSSupportDir equals current reasonixHomeDir on Windows")
@@ -233,6 +233,50 @@ func TestOfficialDeepSeekProviderStillGetsItsDefaults(t *testing.T) {
}
}
func TestMissingCanonicalDeepSeekUsesAnthropicDefault(t *testing.T) {
cfg := Default()
ensureDeepSeekOfficialProvider(cfg)
p, ok := cfg.Provider("deepseek")
if !ok {
t.Fatal("canonical DeepSeek provider missing after load")
}
if p.Kind != "anthropic" || p.BaseURL != deepSeekAnthropicBaseURL || !EffectiveWebSearch(p) {
t.Fatalf("canonical DeepSeek provider = kind:%q base_url:%q web_search:%t, want Anthropic-compatible with web search", p.Kind, p.BaseURL, EffectiveWebSearch(p))
}
}
func TestExplicitOpenAIDeepSeekProviderIsNotMigrated(t *testing.T) {
home := t.TempDir()
ws := t.TempDir()
t.Setenv("REASONIX_HOME", home)
path := filepath.Join(home, "config.toml")
raw := `config_version = 1
default_model = "deepseek-flash/deepseek-v4-flash"
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadForRootReadOnly(ws)
if err != nil {
t.Fatal(err)
}
p, ok := cfg.Provider("deepseek-flash")
if !ok {
t.Fatal("explicit DeepSeek provider missing after load")
}
if p.Kind != "openai" || p.BaseURL != "https://api.deepseek.com" {
t.Fatalf("explicit DeepSeek provider migrated to kind:%q base_url:%q", p.Kind, p.BaseURL)
}
}
// TestOfficialDeepSeekBackfillRespectsDeclaredValues keeps the backfill from
// overriding a user who deliberately narrowed the window or disabled the
// balance readout for the official endpoint.
+26 -16
View File
@@ -31,7 +31,15 @@ const (
// intentionally editable after installation; they reduce setup friction without
// turning fast-moving third-party catalogs into hard runtime dependencies.
func CuratedProviderPresets() []ProviderPreset {
presets := cloneProviderPresets(curatedProviderPresets)
presets := make([]ProviderPreset, 0, len(curatedProviderPresets))
for _, preset := range curatedProviderPresets {
// Keep the old direct lookup available for installed configurations, but
// do not offer the redundant Anthropic preset in new-provider surfaces.
if preset.ID == "deepseek-anthropic" {
continue
}
presets = append(presets, cloneProviderPreset(preset))
}
sort.SliceStable(presets, func(i, j int) bool {
return providerPresetDisplayRank(presets[i].ID) < providerPresetDisplayRank(presets[j].ID)
})
@@ -52,9 +60,7 @@ func CuratedProviderPreset(id string) (ProviderPreset, bool) {
func providerPresetDisplayRank(id string) int {
switch {
case id == "deepseek-responses":
return -1
case id == "deepseek-anthropic":
return 0
return -2
case id == "glm-cn" || id == "zai-global" || strings.HasPrefix(id, "glm-coding-plan-") || strings.HasPrefix(id, "zai-coding-plan-"):
return 0
case strings.HasPrefix(id, "longcat-"):
@@ -185,8 +191,8 @@ func kimiK3DirectOverride() ProviderModelOverride {
var curatedProviderPresets = []ProviderPreset{
{
ID: "deepseek-anthropic",
Label: "DeepSeek Anthropic",
Description: "Official DeepSeek Anthropic-compatible endpoint for Flash and Pro with server-side web search; search may increase token usage.",
Label: "DeepSeek Official Anthropic",
Description: "Separate official DeepSeek Anthropic-compatible entry for Flash and Pro.",
KeyEnv: "DEEPSEEK_API_KEY",
Entries: []ProviderEntry{{
Name: "deepseek-anthropic",
@@ -539,8 +545,8 @@ var curatedProviderPresets = []ProviderPreset{
},
{
ID: "deepseek-responses",
Label: "DeepSeek Responses API",
Description: "DeepSeek official stateless Responses API for deepseek-v4-flash with server-side web search; search may increase token usage.",
Label: "DeepSeek Official Responses API",
Description: "Official stateless DeepSeek Responses API for Flash with server-side web search.",
KeyEnv: "DEEPSEEK_API_KEY",
Entries: []ProviderEntry{{
Name: "deepseek-responses",
@@ -981,14 +987,6 @@ func boolPointer(value bool) *bool {
return &value
}
func cloneProviderPresets(in []ProviderPreset) []ProviderPreset {
out := make([]ProviderPreset, 0, len(in))
for _, p := range in {
out = append(out, cloneProviderPreset(p))
}
return out
}
func cloneProviderPreset(p ProviderPreset) ProviderPreset {
p.Entries = cloneProviderEntries(p.Entries)
for i := range p.Entries {
@@ -1011,6 +1009,14 @@ func cloneProviderEntry(e ProviderEntry) ProviderEntry {
value := *e.WebSearch
e.WebSearch = &value
}
if e.ResponsesStateful != nil {
value := *e.ResponsesStateful
e.ResponsesStateful = &value
}
if e.visionOverride != nil {
value := *e.visionOverride
e.visionOverride = &value
}
e.Models = append([]string(nil), e.Models...)
e.VisionModels = append([]string(nil), e.VisionModels...)
e.SupportedEfforts = append([]string(nil), e.SupportedEfforts...)
@@ -1066,6 +1072,10 @@ func cloneModelOverrideMap(in map[string]ProviderModelOverride) map[string]Provi
out := make(map[string]ProviderModelOverride, len(in))
for k, v := range in {
v.SupportedEfforts = append([]string(nil), v.SupportedEfforts...)
if v.Vision != nil {
vision := *v.Vision
v.Vision = &vision
}
out[k] = v
}
return out
+11 -2
View File
@@ -4,7 +4,6 @@ import "testing"
func TestCuratedProviderPresetsCoverRequestedProviders(t *testing.T) {
wantIDs := []string{
"deepseek-anthropic",
"longcat-openai",
"longcat-anthropic",
"token-rhythm",
@@ -133,7 +132,6 @@ func TestDeepSeekResponsesPresetMatchesOfficialSupport(t *testing.T) {
func TestCuratedProviderPresetsDisplayOrder(t *testing.T) {
wantPrefix := []string{
"deepseek-responses",
"deepseek-anthropic",
"glm-cn",
"zai-global",
"glm-coding-plan-cn",
@@ -162,6 +160,17 @@ func TestCuratedProviderPresetsDisplayOrder(t *testing.T) {
}
}
func TestCuratedProviderPresetsHideRedundantDeepSeekAnthropicPreset(t *testing.T) {
for _, preset := range CuratedProviderPresets() {
if preset.ID == "deepseek-anthropic" {
t.Fatal("redundant DeepSeek Anthropic preset should not be shown in the curated list")
}
}
if _, ok := CuratedProviderPreset("deepseek-anthropic"); !ok {
t.Fatal("legacy DeepSeek Anthropic preset must remain available for compatibility")
}
}
func TestTokenRhythmPresetMatchesPublicAPIIntegration(t *testing.T) {
preset, ok := CuratedProviderPreset("token-rhythm")
if !ok || len(preset.Entries) != 1 {
+78
View File
@@ -0,0 +1,78 @@
package config
import (
"strconv"
"strings"
)
func isTOMLKeyAssignment(line, key string) bool {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") {
return false
}
got, ok := tomlAssignmentKey(trimmed)
return ok && got == key
}
// tomlAssignmentKey returns the simple key on a single TOML assignment line.
// It accepts bare keys and both TOML quoted-key forms while leaving dotted
// keys, malformed lines, and values containing an equals sign to the parser.
func tomlAssignmentKey(line string) (string, bool) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
return "", false
}
var key string
switch line[0] {
case '"':
end := 1
escaped := false
for end < len(line) {
if escaped {
escaped = false
end++
continue
}
switch line[end] {
case '\\':
escaped = true
case '"':
decoded, err := strconv.Unquote(line[:end+1])
if err != nil {
return "", false
}
key = decoded
line = line[end+1:]
goto assignment
}
end++
}
return "", false
case '\'':
end := strings.IndexByte(line[1:], '\'')
if end < 0 {
return "", false
}
end++
key = line[1:end]
line = line[end+1:]
default:
end := 0
for end < len(line) && line[end] != '=' && line[end] != ' ' && line[end] != '\t' {
end++
}
if end == 0 {
return "", false
}
key = line[:end]
line = line[end:]
}
assignment:
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "=") || strings.Contains(key, ".") {
return "", false
}
return key, true
}
+18 -13
View File
@@ -59,36 +59,41 @@ func modelTokenSeparator(r rune) bool {
return r == '-' || r == '_' || r == '.' || r == '/' || r == ':'
}
// CanConfigureVision reports whether user-supplied vision metadata may enable
// image input for this endpoint. DeepSeek's official APIs currently accept text
// message content only, so their protocol constraint is authoritative over
// persisted provider-wide flags, vision_models, and model overrides. Custom
// DeepSeek-compatible gateways remain configurable because they may implement
// their own multimodal translation layer.
func CanConfigureVision(e *ProviderEntry) bool {
return e != nil && !openai.IsDeepSeek(e.BaseURL)
}
// EffectiveVision resolves whether the selected model accepts image input.
// Explicit provider vision still wins for custom vision-capable gateways; the
// MiMo endpoint heuristic is deliberately limited to known MiMo endpoints so
// arbitrary OpenAI-compatible proxies do not get image payloads unexpectedly.
func EffectiveVision(e *ProviderEntry) bool {
if e == nil {
if !CanConfigureVision(e) {
return false
}
if enabled, explicit := explicitModelVision(e); explicit {
return enabled
}
// DeepSeek's official APIs currently accept text message content only. Treat
// current official models as text-only when only the legacy provider-wide
// vision flag is set, so stale configs cannot make requests 400. A concrete
// model can still opt in through vision_models or a model override when
// DeepSeek documents a future multimodal request shape.
if openai.IsDeepSeek(e.BaseURL) {
return false
}
if e.Vision {
return true
}
return isOfficialMimoVisionEntry(e)
}
// ExplicitModelVision reports whether the selected model has a positive,
// model-scoped image capability declaration. Provider assembly uses this
// provenance to distinguish a deliberate future-model opt-in from a stale
// provider-wide vision=true setting.
// ExplicitModelVision reports whether the selected model has an explicit,
// positive image capability declaration that the endpoint is allowed to use.
// Keep this query separate from EffectiveVision so callers can distinguish a
// model-scoped capability from provider-wide or endpoint-inferred support.
func ExplicitModelVision(e *ProviderEntry) bool {
if !CanConfigureVision(e) {
return false
}
enabled, explicit := explicitModelVision(e)
return explicit && enabled
}
+4 -4
View File
@@ -1861,9 +1861,9 @@ func (c *Controller) runRefTurnWithResolverSync(ctx context.Context, input, refL
sent = "Referenced context:\n\n" + block + "\n\n" + input
}
if strings.TrimSpace(original) != "" {
return c.runEditedGoalLoopWithRawDisplay(ctx, sent, input, display, original)
return c.runEditedGoalLoopWithImageRefsRawDisplay(ctx, sent, input, refLine, display, original)
}
return c.runGoalLoopWithRawDisplay(ctx, sent, input, display)
return c.runGoalLoopWithImageRefsRawDisplay(ctx, sent, input, refLine, display)
}
// notice emits an informational Notice event.
@@ -1889,8 +1889,8 @@ func (c *Controller) Run(ctx context.Context, input string) (err error) {
parentSession := c.parentSessionID()
ctx = agent.WithParentSession(ctx, parentSession)
ctx = jobs.WithSession(ctx, parentSession)
ctx = agent.WithUserImages(ctx, c.inputImages(input))
rawInput := input
ctx = c.withTurnImages(ctx, rawInput)
ctx = agent.WithRawUserInput(ctx, rawInput)
input = c.Compose(input)
// input.receive: same interception seam as the orchestrated turn — the
@@ -1960,7 +1960,7 @@ func (c *Controller) RunSubagentProfile(ctx context.Context, name, task string,
parentSession := c.parentSessionID()
ctx = agent.WithParentSession(ctx, parentSession)
ctx = jobs.WithSession(ctx, parentSession)
ctx = agent.WithUserImages(ctx, c.inputImages(task))
ctx = c.withTurnImages(ctx, task)
ctx = agent.WithResponseLanguagePreference(ctx, c.responseLanguage)
ctx = agent.WithReasoningLanguagePreference(ctx, c.reasoningLanguage)
ctx = agent.WithSubagentDepth(ctx, 0)
+85
View File
@@ -1,11 +1,13 @@
package control
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/agent"
"reasonix/internal/config"
)
@@ -129,6 +131,89 @@ func TestControllerInputImagesSkipsModelImagesWhenSelectedModelIsTextOnly(t *tes
}
}
func TestControllerResolvesSubagentImageCandidatesForTextParent(t *testing.T) {
workspace := t.TempDir()
cfg := config.Default()
cfg.Providers = []config.ProviderEntry{{
Name: "custom",
Kind: "openai",
BaseURL: "https://example.invalid/v1",
Models: []string{"text-only", "vision-pro"},
VisionModels: []string{"vision-pro"},
}}
if err := cfg.SaveTo(filepath.Join(workspace, "reasonix.toml")); err != nil {
t.Fatalf("save workspace config: %v", err)
}
path := filepath.Join(workspace, "diagram.png")
if err := os.WriteFile(path, mustBase64(t, tinyPNG), 0o644); err != nil {
t.Fatal(err)
}
c := &Controller{workspaceRoot: workspace, modelRef: "custom/text-only"}
if urls := c.inputImages("look at @diagram.png"); len(urls) != 0 {
t.Fatalf("text-only parent should suppress its own image payload, got %v", urls)
}
if urls := c.resolveInputImageCandidates("look at @diagram.png"); len(urls) != 1 {
t.Fatalf("subagent image candidates = %v, want one image for a vision child", urls)
}
}
func TestControllerResolveTurnImagesReusesCandidatesForVisionParent(t *testing.T) {
workspace := t.TempDir()
writeVisionTestConfig(t, workspace)
path := filepath.Join(workspace, "diagram.png")
if err := os.WriteFile(path, mustBase64(t, tinyPNG), 0o644); err != nil {
t.Fatal(err)
}
c := &Controller{workspaceRoot: workspace, modelRef: "custom/vision-pro"}
userImages, candidates := c.resolveTurnImages("inspect @diagram.png")
if len(userImages) != 1 || len(candidates) != 1 {
t.Fatalf("turn images = %v, candidates = %v; want one image in both paths", userImages, candidates)
}
if &userImages[0] != &candidates[0] || userImages[0] != candidates[0] {
t.Fatal("vision parent and subagent candidates should reuse the same resolved image slice")
}
c.modelRef = "custom/text-only"
userImages, candidates = c.resolveTurnImages("inspect @diagram.png")
if len(userImages) != 0 || len(candidates) != 1 {
t.Fatalf("text parent turn images = %v, candidates = %v; want candidates only", userImages, candidates)
}
}
func TestGoalContinuationKeepsCurrentTurnImageCandidatesWithoutCrossTurnLeak(t *testing.T) {
workspace := t.TempDir()
writeVisionTestConfig(t, workspace)
path := filepath.Join(workspace, "diagram.png")
if err := os.WriteFile(path, mustBase64(t, tinyPNG), 0o644); err != nil {
t.Fatal(err)
}
c := &Controller{workspaceRoot: workspace, modelRef: "custom/text-only"}
initial := c.prepareOrchestratedTurnImages(orchestratedTurn{
raw: "inspect the diagnostic",
imageRefs: "@diagram.png",
})
if len(initial.userImages) != 0 || len(initial.imageCandidates) != 1 {
t.Fatalf("initial turn images = %v, candidates = %v; want child-only candidate", initial.userImages, initial.imageCandidates)
}
ctx := agent.WithSubagentImageCandidates(context.Background(), initial.imageCandidates)
continuation := orchestratedTurn{goalContinuation: &goalContinuationSnapshot{}, synthetic: true, raw: goalContinueTurn}
userImages, candidates := c.imagesForOrchestratedTurn(ctx, continuation)
if len(userImages) != 0 || len(candidates) != 1 || candidates[0] != initial.imageCandidates[0] {
t.Fatalf("Goal continuation images = %v, candidates = %v; want original child candidate only", userImages, candidates)
}
next := c.prepareOrchestratedTurnImages(orchestratedTurn{raw: "plain next user turn"})
ctx = agent.WithSubagentImageCandidates(ctx, next.imageCandidates)
userImages, candidates = c.imagesForOrchestratedTurn(ctx, continuation)
if len(userImages) != 0 || len(candidates) != 0 {
t.Fatalf("next user turn leaked prior image: images = %v, candidates = %v", userImages, candidates)
}
}
func TestControllerImageInputEnabledDoesNotFallbackFromUnknownRef(t *testing.T) {
workspace := t.TempDir()
writeVisionTestConfig(t, workspace)
+7
View File
@@ -556,6 +556,13 @@ func (c *Controller) inputImages(line string) []string {
if !c.imageInputEnabled() {
return nil
}
return c.resolveInputImageCandidates(line)
}
// resolveInputImageCandidates resolves authorized image references without
// consulting the active model capability. The parent controller uses this only
// to hand candidates to a child; the child decides whether to embed them.
func (c *Controller) resolveInputImageCandidates(line string) []string {
var urls []string
for _, r := range c.detectRefs(line) {
baseDir := c.workspaceRoot
+59
View File
@@ -0,0 +1,59 @@
package control
import (
"context"
"strings"
"reasonix/internal/agent"
)
// resolveTurnImages resolves each user attachment once. Text-only parents keep
// the data only as candidates for a vision-capable child; vision-capable
// parents reuse the same data URLs for their own provider request.
func (c *Controller) resolveTurnImages(line string) (userImages, imageCandidates []string) {
imageCandidates = c.resolveInputImageCandidates(line)
if c.imageInputEnabled() {
userImages = imageCandidates
}
return userImages, imageCandidates
}
func (c *Controller) prepareOrchestratedTurnImages(turn orchestratedTurn) orchestratedTurn {
turn.userImages, turn.imageCandidates = c.resolveTurnImages(turn.imageReferenceInput())
turn.imagesResolved = true
return turn
}
func (c *Controller) imagesForOrchestratedTurn(ctx context.Context, turn orchestratedTurn) (userImages, imageCandidates []string) {
if turn.imagesResolved {
return turn.userImages, turn.imageCandidates
}
if turn.goalContinuation != nil {
// A Goal continuation belongs to the same visible user turn, so keep its
// child-only image candidates. Do not add them to the synthetic parent
// message: a vision parent already has the image in its earlier history.
return nil, agent.SubagentImageCandidates(ctx)
}
return c.resolveTurnImages(turn.imageReferenceInput())
}
func (c *Controller) withTurnImages(ctx context.Context, line string) context.Context {
userImages, imageCandidates := c.resolveTurnImages(line)
ctx = agent.WithUserImages(ctx, userImages)
return agent.WithSubagentImageCandidates(ctx, imageCandidates)
}
func (turn orchestratedTurn) imageReferenceInput() string {
if strings.TrimSpace(turn.imageRefs) != "" {
return turn.imageRefs
}
return turn.raw
}
func (c *Controller) runGoalLoopWithImageRefsRawDisplay(ctx context.Context, input, raw, imageRefs, display string) error {
return newTurnOrchestrator(c).runGoalLoopWithImageRefsRawDisplay(ctx, input, raw, imageRefs, display)
}
func (c *Controller) runEditedGoalLoopWithImageRefsRawDisplay(ctx context.Context, input, raw, imageRefs, display, original string) error {
return newTurnOrchestrator(c).runEditedGoalLoopWithImageRefsRawDisplay(ctx, input, raw, imageRefs, display, original)
}
+29 -8
View File
@@ -25,6 +25,10 @@ type turnOrchestrator struct {
type orchestratedTurn struct {
input string
raw string
imageRefs string
userImages []string
imageCandidates []string
imagesResolved bool
display string
editedOriginal string
synthetic bool
@@ -39,8 +43,8 @@ func (o *turnOrchestrator) runTurnWithRawDisplay(ctx context.Context, input, raw
return o.runOrchestratedTurn(ctx, orchestratedTurn{input: input, raw: raw, display: display})
}
func (o *turnOrchestrator) runEditedTurnWithRawDisplay(ctx context.Context, input, raw, display, original string) error {
return o.runOrchestratedTurn(ctx, orchestratedTurn{input: input, raw: raw, display: display, editedOriginal: original})
func (o *turnOrchestrator) runTurnWithImageRefsRawDisplay(ctx context.Context, input, raw, imageRefs, display string) error {
return o.runOrchestratedTurn(ctx, orchestratedTurn{input: input, raw: raw, imageRefs: imageRefs, display: display})
}
func (o *turnOrchestrator) runSyntheticTurnWithRawDisplay(ctx context.Context, input, raw, display string) error {
@@ -82,6 +86,8 @@ func (o *turnOrchestrator) runSubagentSkillGoalLoop(ctx context.Context, sk skil
func (o *turnOrchestrator) runSubagentSkillTurnsGoalLoop(ctx context.Context, skills []skill.Skill, task, raw, display string, runner skill.SubagentRunner, planMode bool) error {
expectedContinuationEpoch := o.c.goals.continuationToken()
userImages, imageCandidates := o.c.resolveTurnImages(raw)
ctx = agent.WithSubagentImageCandidates(ctx, imageCandidates)
// The skill turn's model requests count against the active goal's token
// budget, so bind a recorder for the span even though the sub-agent cannot
// call update_goal itself.
@@ -89,7 +95,7 @@ func (o *turnOrchestrator) runSubagentSkillTurnsGoalLoop(ctx context.Context, sk
recorder := o.c.goals.newTurnRecorder(scopeID, o.c.goals.continuationToken())
o.c.goalUsageTee.setActiveRecorder(recorder)
}
if err := o.runSubagentSkillTurns(ctx, skills, task, raw, display, runner, planMode); err != nil {
if err := o.runSubagentSkillTurns(ctx, skills, task, raw, display, runner, planMode, userImages, imageCandidates); err != nil {
if ctx.Err() != nil {
o.c.goalUsageTee.setActiveRecorder(nil)
o.c.stopGoal(GoalStatusStopped)
@@ -104,14 +110,14 @@ func (o *turnOrchestrator) runSubagentSkillTurnsGoalLoop(ctx context.Context, sk
// answers only. Child reasoning and tool chatter stay out of the
// provider-visible parent context while their UI events nest under synthetic
// top-level run_skill cards.
func (o *turnOrchestrator) runSubagentSkillTurns(ctx context.Context, skills []skill.Skill, task, raw, display string, runner skill.SubagentRunner, planMode bool) (err error) {
func (o *turnOrchestrator) runSubagentSkillTurns(ctx context.Context, skills []skill.Skill, task, raw, display string, runner skill.SubagentRunner, planMode bool, images, imageCandidates []string) (err error) {
c := o.c
c.maybeSessionStart(ctx)
parentSession := c.parentSessionID()
images := c.inputImages(raw)
ctx = agent.WithParentSession(ctx, parentSession)
ctx = jobs.WithSession(ctx, parentSession)
ctx = agent.WithUserImages(ctx, images)
ctx = agent.WithSubagentImageCandidates(ctx, imageCandidates)
ctx = agent.WithResponseLanguagePreference(ctx, c.responseLanguage)
ctx = agent.WithReasoningLanguagePreference(ctx, c.reasoningLanguage)
@@ -186,8 +192,9 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
parentSession := c.parentSessionID()
ctx = agent.WithParentSession(ctx, parentSession)
ctx = jobs.WithSession(ctx, parentSession)
userImages := c.inputImages(turn.input)
userImages, imageCandidates := c.imagesForOrchestratedTurn(ctx, turn)
ctx = agent.WithUserImages(ctx, userImages)
ctx = agent.WithSubagentImageCandidates(ctx, imageCandidates)
ctx = agent.WithRawUserInput(ctx, turn.raw)
continuation := turn.goalContinuation
var input string
@@ -364,8 +371,14 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
}
func (o *turnOrchestrator) runGoalLoopWithRawDisplay(ctx context.Context, input, raw, display string) error {
return o.runGoalLoopWithImageRefsRawDisplay(ctx, input, raw, "", display)
}
func (o *turnOrchestrator) runGoalLoopWithImageRefsRawDisplay(ctx context.Context, input, raw, imageRefs, display string) error {
expectedContinuationEpoch := o.c.goals.continuationToken()
err := o.runTurnWithRawDisplay(ctx, input, raw, display)
turn := o.c.prepareOrchestratedTurnImages(orchestratedTurn{input: input, raw: raw, imageRefs: imageRefs, display: display})
ctx = agent.WithSubagentImageCandidates(ctx, turn.imageCandidates)
err := o.runOrchestratedTurn(ctx, turn)
if err != nil {
if ctx.Err() != nil {
o.c.goalUsageTee.setActiveRecorder(nil)
@@ -388,8 +401,16 @@ func (o *turnOrchestrator) runGoalLoopWithRawDisplay(ctx context.Context, input,
}
func (o *turnOrchestrator) runEditedGoalLoopWithRawDisplay(ctx context.Context, input, raw, display, original string) error {
return o.runEditedGoalLoopWithImageRefsRawDisplay(ctx, input, raw, "", display, original)
}
func (o *turnOrchestrator) runEditedGoalLoopWithImageRefsRawDisplay(ctx context.Context, input, raw, imageRefs, display, original string) error {
expectedContinuationEpoch := o.c.goals.continuationToken()
err := o.runEditedTurnWithRawDisplay(ctx, input, raw, display, original)
turn := o.c.prepareOrchestratedTurnImages(orchestratedTurn{
input: input, raw: raw, imageRefs: imageRefs, display: display, editedOriginal: original,
})
ctx = agent.WithSubagentImageCandidates(ctx, turn.imageCandidates)
err := o.runOrchestratedTurn(ctx, turn)
if err != nil {
if ctx.Err() != nil {
o.c.goalUsageTee.setActiveRecorder(nil)
+2 -2
View File
@@ -1011,12 +1011,12 @@ func TestTurnOrchestratorCancelBeforeRunnerAddsUserPreservesVisiblePrompt(t *tes
c.canceling = true
c.mu.Unlock()
err := newTurnOrchestrator(c).runTurnWithRawDisplay(context.Background(), "inspect @diagram.png", "inspect @diagram.png", "")
err := newTurnOrchestrator(c).runTurnWithImageRefsRawDisplay(context.Background(), "Referenced context:\n\n<image path=\"diagram.png\">\[email protected]\n</image>\n\ninspect the diagnostic", "inspect the diagnostic", "@diagram.png", "")
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
msgs := sess.Snapshot()
if len(msgs) != 3 || msgs[1].Role != provider.RoleUser || msgs[1].Content != "inspect @diagram.png" || !msgs[2].LocalOnly {
if len(msgs) != 3 || msgs[1].Role != provider.RoleUser || !strings.Contains(msgs[1].Content, "inspect the diagnostic") || !msgs[2].LocalOnly {
t.Fatalf("session after pre-executor cancel = %+v, want user plus recovery marker", msgs)
}
if len(msgs[1].Images) != 1 || !strings.HasPrefix(msgs[1].Images[0], "data:image/png;base64,") {
+4
View File
@@ -101,6 +101,10 @@ func New(cfg provider.Config) (provider.Provider, error) {
effort, _ := cfg.Extra["effort"].(string)
effort = strings.ToLower(strings.TrimSpace(effort))
vision, _ := cfg.Extra["vision"].(bool)
// DeepSeek's official Anthropic-compatible endpoint is text-only. Enforce
// that wire constraint here as defense in depth, independent of config or
// extension capability metadata.
vision = vision && !officialDeepSeek
webSearch, _ := cfg.Extra["web_search"].(bool)
headers, _ := cfg.Extra["headers"].(map[string]string)
authHeader, _ := cfg.Extra["auth_header"].(bool)
+65
View File
@@ -1,8 +1,10 @@
package anthropic
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"reasonix/internal/provider"
@@ -38,6 +40,69 @@ func TestBuildRequestSkipsImageBlockWithoutVision(t *testing.T) {
}
}
func TestOfficialDeepSeekIgnoresVisionMetadata(t *testing.T) {
p, err := New(provider.Config{
Name: "deepseek-anthropic",
BaseURL: "https://api.deepseek.com/anthropic",
Model: "deepseek-v4-pro",
Extra: map[string]any{"vision": true},
})
if err != nil {
t.Fatalf("New: %v", err)
}
c := p.(*client)
if c.vision {
t.Fatal("official DeepSeek Anthropic endpoint must ignore vision metadata")
}
req := c.buildRequest(context.Background(), provider.Request{Messages: append(
[]provider.Message{{
Role: provider.RoleUser, Content: "describe",
Images: []string{"data:image/jpeg;base64,ZZZZ"},
}},
toolMessages([]string{"data:image/png;base64,QUFB"})...,
)})
body, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
if strings.Contains(string(body), `"type":"image"`) || strings.Contains(string(body), "ZZZZ") || strings.Contains(string(body), "QUFB") {
t.Fatalf("official DeepSeek Anthropic request leaked image payload: %s", body)
}
}
func TestOfficialDeepSeekImageMetadataMatchesTextOnlyWireBytes(t *testing.T) {
p, err := New(provider.Config{
Name: "deepseek-anthropic",
BaseURL: "https://api.deepseek.com/anthropic",
Model: "deepseek-v4-pro",
Extra: map[string]any{"vision": true},
})
if err != nil {
t.Fatalf("New: %v", err)
}
c := p.(*client)
plain := []provider.Message{
{Role: provider.RoleUser, Content: "inspect"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "shot", Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", Content: "vision result"},
}
withImages := append([]provider.Message(nil), plain...)
withImages[0].Images = []string{"data:image/png;base64," + strings.Repeat("QUFB", 20_000)}
withImages[2].Images = []string{"data:image/png;base64,VE9PTA=="}
plainBody, err := json.Marshal(c.buildRequest(context.Background(), provider.Request{Messages: plain}))
if err != nil {
t.Fatalf("marshal plain request: %v", err)
}
imageBody, err := json.Marshal(c.buildRequest(context.Background(), provider.Request{Messages: withImages}))
if err != nil {
t.Fatalf("marshal image request: %v", err)
}
if !bytes.Equal(imageBody, plainBody) {
t.Fatalf("official DeepSeek Anthropic image metadata changed provider-visible bytes:\nplain: %s\nimage: %s", plainBody, imageBody)
}
}
// toolMessages is a paired history whose tool result carries an image: the
// shape parseToolResult produces for an MCP screenshot tool.
func toolMessages(images []string) []provider.Message {
@@ -78,6 +78,79 @@ func TestRealDeepSeekAnthropicToolLoop(t *testing.T) {
len(second.text), len(second.reasoning), second.promptTokens, second.cacheHitTokens)
}
// TestRealDeepSeekAnthropicWebSearch verifies that the official Anthropic
// compatibility endpoint accepts the server-side web_search tool and returns a
// normal assistant completion. It is intentionally separate from the tool-loop
// test so a provider-side search regression is visible on its own.
func TestRealDeepSeekAnthropicWebSearch(t *testing.T) {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
t.Skip("DEEPSEEK_API_KEY not set — skipping live probe")
}
p, err := New(provider.Config{
Name: "deepseek-anthropic",
BaseURL: "https://api.deepseek.com/anthropic",
Model: "deepseek-v4-flash",
APIKey: key,
Extra: map[string]any{
"api_key_env": "DEEPSEEK_API_KEY",
"thinking": "disabled",
"web_search": true,
},
})
if err != nil {
t.Fatalf("New: %v", err)
}
turn := collectLiveDeepSeekTurn(t, p, provider.Request{
Messages: []provider.Message{{
Role: provider.RoleUser,
Content: "Search the web for the latest DeepSeek API documentation update and reply with one source URL.",
}},
MaxTokens: 256,
})
if strings.TrimSpace(turn.text) == "" {
t.Fatalf("web_search returned no assistant text (reasoning=%d)", len(turn.reasoning))
}
t.Logf("web_search: text=%d prompt=%d cache_hit=%d", len(turn.text), turn.promptTokens, turn.cacheHitTokens)
}
// TestRealDeepSeekAnthropicIgnoresImages confirms the text-only official
// endpoint still completes when stale vision metadata accompanies a user turn.
// The provider-boundary filter is what prevents the historical image_url 400.
func TestRealDeepSeekAnthropicIgnoresImages(t *testing.T) {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
t.Skip("DEEPSEEK_API_KEY not set — skipping live probe")
}
p, err := New(provider.Config{
Name: "deepseek-anthropic",
BaseURL: "https://api.deepseek.com/anthropic",
Model: "deepseek-v4-flash",
APIKey: key,
Extra: map[string]any{
"api_key_env": "DEEPSEEK_API_KEY",
"vision": true,
"thinking": "disabled",
},
})
if err != nil {
t.Fatalf("New: %v", err)
}
turn := collectLiveDeepSeekTurn(t, p, provider.Request{Messages: []provider.Message{{
Role: provider.RoleUser,
Content: "Reply with the single word: ok.",
Images: []string{"data:image/png;base64,AAAA"},
}}, MaxTokens: 64})
if strings.TrimSpace(turn.text) == "" {
t.Fatalf("text-only image-filter smoke returned no assistant text")
}
t.Logf("image-filter: text=%d prompt=%d cache_hit=%d", len(turn.text), turn.promptTokens, turn.cacheHitTokens)
}
type liveDeepSeekTurn struct {
text, reasoning string
calls []provider.ToolCall
+35 -12
View File
@@ -79,7 +79,7 @@ func TestOfficialDeepSeekProviderWideVisionInputMatchesTextOnlyRequest(t *testin
}
}
func TestOfficialDeepSeekExplicitFutureVisionInputUsesImageParts(t *testing.T) {
func TestOfficialDeepSeekExplicitModelVisionInputMatchesTextOnlyRequest(t *testing.T) {
p, err := New(provider.Config{
Name: "deepseek",
BaseURL: "https://api.deepseek.com",
@@ -93,17 +93,27 @@ func TestOfficialDeepSeekExplicitFutureVisionInputUsesImageParts(t *testing.T) {
t.Fatalf("New: %v", err)
}
c := p.(*client)
if !c.vision {
t.Fatal("explicit model-scoped vision must remain enabled on the official DeepSeek endpoint")
if c.vision {
t.Fatal("explicit model-scoped vision must not bypass the official DeepSeek endpoint guard")
}
req := c.buildRequest(provider.Request{Messages: []provider.Message{{
textOnly := provider.Request{Messages: []provider.Message{{
Role: provider.RoleUser, Content: "describe",
}}}
withImage := provider.Request{Messages: []provider.Message{{
Role: provider.RoleUser, Content: "describe",
Images: []string{"data:image/png;base64,AAAA"},
}}})
parts, ok := req.Messages[0].Content.([]chatContentPart)
if !ok || len(parts) != 2 || parts[1].ImageURL == nil {
t.Fatalf("explicit future DeepSeek content = %#v, want [text, image_url]", req.Messages[0].Content)
}}}
textBody, err := json.Marshal(c.buildRequest(textOnly))
if err != nil {
t.Fatalf("marshal text request: %v", err)
}
imageBody, err := json.Marshal(c.buildRequest(withImage))
if err != nil {
t.Fatalf("marshal image request: %v", err)
}
if !bytes.Equal(imageBody, textBody) {
t.Fatalf("explicit official DeepSeek image request changed provider-visible bytes:\ntext: %s\nimage: %s", textBody, imageBody)
}
}
@@ -112,21 +122,27 @@ func TestOfficialDeepSeekDoesNotInjectToolResultImages(t *testing.T) {
Name: "deepseek",
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-v4-pro",
Extra: map[string]any{"vision": true},
Extra: map[string]any{
"vision": true,
"vision_model_explicit": true,
},
})
if err != nil {
t.Fatalf("New: %v", err)
}
req := p.(*client).buildRequest(provider.Request{Messages: []provider.Message{
plainMessages := []provider.Message{
{Role: provider.RoleUser, Content: "take a screenshot"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
ID: "c1", Name: "shot", Arguments: "{}",
}}},
{
Role: provider.RoleTool, ToolCallID: "c1", Name: "shot",
Content: "[image: image/png]", Images: []string{"data:image/png;base64,AAAA"},
Content: "[image: image/png]",
},
}})
}
imageMessages := append([]provider.Message(nil), plainMessages...)
imageMessages[2].Images = []string{"data:image/png;base64,AAAA"}
req := p.(*client).buildRequest(provider.Request{Messages: imageMessages})
if len(req.Messages) != 3 {
t.Fatalf("messages = %d, want 3 without an injected image message", len(req.Messages))
}
@@ -137,6 +153,13 @@ func TestOfficialDeepSeekDoesNotInjectToolResultImages(t *testing.T) {
if strings.Contains(string(body), "image_url") || strings.Contains(string(body), "base64,AAAA") {
t.Fatalf("official DeepSeek request leaked tool image payload: %s", body)
}
plainBody, err := json.Marshal(p.(*client).buildRequest(provider.Request{Messages: plainMessages}))
if err != nil {
t.Fatalf("marshal plain request: %v", err)
}
if !bytes.Equal(body, plainBody) {
t.Fatalf("official DeepSeek tool image metadata changed provider-visible bytes:\nplain: %s\nimage: %s", plainBody, body)
}
}
func TestCustomDeepSeekProtocolGatewayPreservesExplicitVision(t *testing.T) {
+4 -5
View File
@@ -90,14 +90,13 @@ func New(cfg provider.Config) (provider.Provider, error) {
headers, _ := cfg.Extra["headers"].(map[string]string)
extraBody, _ := cfg.Extra["extra_body"].(map[string]any)
vision, _ := cfg.Extra["vision"].(bool)
explicitModelVision, _ := cfg.Extra["vision_model_explicit"].(bool)
officialDeepSeek := IsDeepSeek(cfg.BaseURL)
// DeepSeek's official chat API accepts string message content only. Keep
// this provider-boundary guard even though config capability resolution
// normally prevents image attachments from reaching this layer. A positive
// model-scoped capability can opt in without letting stale provider-wide
// vision=true settings affect current text-only models.
vision = vision && (!officialDeepSeek || explicitModelVision)
// normally prevents image attachments from reaching this layer. No persisted
// or extension-supplied capability flag may override the endpoint's current
// wire contract; future native vision support needs an explicit serializer.
vision = vision && !officialDeepSeek
visionDetail, _ := cfg.Extra["vision_detail"].(string)
visionDetail = strings.ToLower(strings.TrimSpace(visionDetail))
if visionDetail != "low" && visionDetail != "high" {
+39
View File
@@ -0,0 +1,39 @@
package responses
import (
"bytes"
"encoding/json"
"strings"
"testing"
"reasonix/internal/provider"
)
func TestOfficialDeepSeekResponsesImageMetadataMatchesTextOnlyWireBytes(t *testing.T) {
c := New(Config{
Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash",
Extra: map[string]any{"vision": true},
}).(*client)
plain := []provider.Message{
{Role: provider.RoleUser, Content: "inspect"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "shot", Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", Content: "vision result"},
}
withImages := append([]provider.Message(nil), plain...)
withImages[0].Images = []string{"data:image/png;base64," + strings.Repeat("QUFB", 20_000)}
withImages[2].Images = []string{"data:image/png;base64,VE9PTA=="}
plainRequest, _, _ := c.buildRequestBody(provider.Request{Messages: plain})
imageRequest, _, _ := c.buildRequestBody(provider.Request{Messages: withImages})
plainBody, err := json.Marshal(plainRequest)
if err != nil {
t.Fatalf("marshal plain request: %v", err)
}
imageBody, err := json.Marshal(imageRequest)
if err != nil {
t.Fatalf("marshal image request: %v", err)
}
if !bytes.Equal(imageBody, plainBody) {
t.Fatalf("official DeepSeek Responses image metadata changed provider-visible bytes:\nplain: %s\nimage: %s", plainBody, imageBody)
}
}
@@ -0,0 +1,46 @@
//go:build live
package responses
import (
"os"
"strings"
"testing"
"reasonix/internal/provider"
)
// TestRealDeepSeekResponsesWebSearch exercises the official stateless
// Responses endpoint with its provider-executed web_search tool. It is
// credential-gated and build-tagged so ordinary CI remains deterministic.
func TestRealDeepSeekResponsesWebSearch(t *testing.T) {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
t.Skip("DEEPSEEK_API_KEY not set — skipping live probe")
}
p := New(Config{
Name: "deepseek-responses",
BaseURL: "https://api.deepseek.com",
Model: "deepseek-v4-flash",
APIKey: key,
Effort: "disabled",
WebSearch: true,
MaxOutputTokens: 256,
KeyEnv: "DEEPSEEK_API_KEY",
})
chunks := collect(t, p, provider.Request{Messages: []provider.Message{{
Role: provider.RoleUser,
Content: "Search the web for the latest DeepSeek API documentation update and reply with one source URL.",
}}, MaxTokens: 256})
var text strings.Builder
for _, chunk := range chunks {
if chunk.Type == provider.ChunkText {
text.WriteString(chunk.Text)
}
}
if strings.TrimSpace(text.String()) == "" {
t.Fatalf("web_search returned no assistant text")
}
t.Logf("web_search: text=%d chunks=%d", len(text.String()), len(chunks))
}
+4
View File
@@ -140,6 +140,10 @@ func New(cfg Config) provider.Provider {
sessionCache = *cfg.SessionCache
}
vision, _ := cfg.Extra["vision"].(bool)
// DeepSeek's official Responses endpoint is currently text-only. Keep this
// provider-boundary guard so stale config or extension metadata cannot emit
// unsupported input_image items.
vision = vision && vendor != "deepseek"
httpClient := &http.Client{Timeout: 300 * time.Second}
if built, err := netclient.NewHTTPClient(cfg.Proxy, netclient.TransportOptions{
DialTimeout: 30 * time.Second, KeepAlive: 30 * time.Second,
+28 -2
View File
@@ -1,6 +1,7 @@
package responses
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -920,7 +921,7 @@ func TestMessagesToInputTextOnlyStaysStringShape(t *testing.T) {
}
func TestMessagesToInputEmbedsImagesAsInputImageParts(t *testing.T) {
c := New(Config{Name: "test", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Extra: map[string]any{"vision": true}}).(*client)
c := New(Config{Name: "mimo", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5", Extra: map[string]any{"vision": true}}).(*client)
body, _, _ := c.buildRequestBody(provider.Request{Messages: []provider.Message{
{Role: provider.RoleUser, Content: "what is this", Images: []string{"data:image/png;base64,AAAA", "data:image/jpeg;base64,BBBB"}},
}})
@@ -944,7 +945,7 @@ func TestMessagesToInputEmbedsImagesAsInputImageParts(t *testing.T) {
}
}
// Vision disabled: images are ignored, content stays a string.
plain := New(Config{Name: "test", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash"}).(*client)
plain := New(Config{Name: "mimo", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5"}).(*client)
body2, _, _ := plain.buildRequestBody(provider.Request{Messages: []provider.Message{
{Role: provider.RoleUser, Content: "what is this", Images: []string{"data:image/png;base64,AAAA"}},
}})
@@ -954,6 +955,31 @@ func TestMessagesToInputEmbedsImagesAsInputImageParts(t *testing.T) {
}
}
func TestOfficialDeepSeekResponsesIgnoresVisionMetadata(t *testing.T) {
c := New(Config{
Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash",
Extra: map[string]any{"vision": true},
}).(*client)
if c.vision {
t.Fatal("official DeepSeek Responses endpoint must ignore vision metadata")
}
body, _, _ := c.buildRequestBody(provider.Request{Messages: []provider.Message{{
Role: provider.RoleUser, Content: "what is this",
Images: []string{"data:image/png;base64,AAAA"},
}}})
items := body["input"].([]map[string]any)
if got, ok := items[0]["content"].(string); !ok || got != "what is this" {
t.Fatalf("official DeepSeek content = %#v, want plain text", items[0]["content"])
}
encoded, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request body: %v", err)
}
if bytes.Contains(encoded, []byte("input_image")) || bytes.Contains(encoded, []byte("base64,AAAA")) {
t.Fatalf("official DeepSeek Responses request leaked image payload: %s", encoded)
}
}
func TestResponseFormatJSONObjectOnWire(t *testing.T) {
// text.format.type=json_object must be serialized when requested, and
// the request body must stay byte-identical without it (cache stability).
+46 -37
View File
@@ -2,14 +2,14 @@
"limits": {
"banner": 15,
"commented-code": 0,
"complexity": 2083,
"essay": 4130,
"file-size": 110760,
"function-size": 9177,
"complexity": 2081,
"essay": 4132,
"file-size": 111435,
"function-size": 9175,
"layering": 1,
"marker": 0,
"narrative": 64,
"test-file-size": 68678
"test-file-size": 70180
},
"files": {
"cmd/e2ebench/main.go": {
@@ -36,7 +36,7 @@
},
"desktop/app_test.go": {
"essay": 2,
"test-file-size": 10216
"test-file-size": 10257
},
"desktop/bot_bridge.go": {
"essay": 11
@@ -107,6 +107,9 @@
"desktop/frontend/src/__tests__/rich-composer-selection.test.tsx": {
"test-file-size": 352
},
"desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx": {
"test-file-size": 132
},
"desktop/frontend/src/__tests__/tab-switch-hydration.test.tsx": {
"test-file-size": 162
},
@@ -135,7 +138,7 @@
"file-size": 141
},
"desktop/frontend/src/components/SettingsPanel.tsx": {
"file-size": 6666
"file-size": 6961
},
"desktop/frontend/src/components/StatusBar.tsx": {
"file-size": 23
@@ -159,7 +162,7 @@
"file-size": 415
},
"desktop/frontend/src/lib/bridge.ts": {
"file-size": 4644
"file-size": 4671
},
"desktop/frontend/src/lib/crash.ts": {
"file-size": 225
@@ -168,7 +171,7 @@
"file-size": 307
},
"desktop/frontend/src/lib/types.ts": {
"file-size": 1419
"file-size": 1423
},
"desktop/frontend/src/lib/useController.ts": {
"file-size": 3916
@@ -221,6 +224,12 @@
"desktop/plugin_packages_app.go": {
"essay": 4
},
"desktop/provider_access_removal.go": {
"essay": 4
},
"desktop/provider_access_removal_test.go": {
"test-file-size": 1
},
"desktop/race_regression_test.go": {
"essay": 4
},
@@ -276,13 +285,13 @@
"test-file-size": 733
},
"desktop/settings_app.go": {
"complexity": 25,
"essay": 28,
"file-size": 3067,
"function-size": 50
"complexity": 22,
"essay": 26,
"file-size": 2910,
"function-size": 25
},
"desktop/settings_app_test.go": {
"test-file-size": 1028
"test-file-size": 1857
},
"desktop/shared_host.go": {
"essay": 1
@@ -639,12 +648,12 @@
"internal/agent/task.go": {
"complexity": 25,
"essay": 55,
"file-size": 1351,
"file-size": 1355,
"function-size": 114
},
"internal/agent/task_test.go": {
"essay": 4,
"test-file-size": 377
"test-file-size": 398
},
"internal/agent/testutil/mock_provider.go": {
"essay": 10
@@ -665,15 +674,15 @@
"essay": 1
},
"internal/boot/boot.go": {
"complexity": 293,
"complexity": 295,
"essay": 102,
"file-size": 2212,
"function-size": 1839,
"file-size": 2234,
"function-size": 1862,
"narrative": 3
},
"internal/boot/boot_test.go": {
"essay": 10,
"test-file-size": 4254
"test-file-size": 4510
},
"internal/boot/extension_dispatch_test.go": {
"essay": 3,
@@ -959,7 +968,7 @@
"essay": 6
},
"internal/config/backfill_test.go": {
"test-file-size": 944
"test-file-size": 1099
},
"internal/config/cache_policy.go": {
"essay": 9
@@ -968,8 +977,8 @@
"essay": 2
},
"internal/config/config.go": {
"essay": 58,
"file-size": 1519,
"essay": 59,
"file-size": 1538,
"narrative": 2
},
"internal/config/credentials.go": {
@@ -993,7 +1002,7 @@
"file-size": 1681
},
"internal/config/edit_test.go": {
"test-file-size": 2404
"test-file-size": 2418
},
"internal/config/effort.go": {
"complexity": 9,
@@ -1004,7 +1013,7 @@
},
"internal/config/load.go": {
"essay": 24,
"file-size": 1230,
"file-size": 1647,
"function-size": 53
},
"internal/config/main_test.go": {
@@ -1017,10 +1026,10 @@
"test-file-size": 499
},
"internal/config/migrate.go": {
"file-size": 7
"file-size": 27
},
"internal/config/migrate_test.go": {
"test-file-size": 331
"test-file-size": 358
},
"internal/config/mutate.go": {
"essay": 2
@@ -1038,7 +1047,7 @@
"essay": 3
},
"internal/config/provider_presets.go": {
"file-size": 272
"file-size": 282
},
"internal/config/render.go": {
"complexity": 249,
@@ -1050,7 +1059,7 @@
"test-file-size": 761
},
"internal/config/vision.go": {
"essay": 2
"essay": 1
},
"internal/control/approval.go": {
"essay": 19
@@ -1132,7 +1141,7 @@
},
"internal/control/refs.go": {
"essay": 5,
"file-size": 561
"file-size": 568
},
"internal/control/refs_test.go": {
"test-file-size": 141
@@ -1159,7 +1168,7 @@
"internal/control/turn_orchestrator.go": {
"complexity": 3,
"essay": 13,
"function-size": 61
"function-size": 62
},
"internal/control/turn_orchestrator_test.go": {
"essay": 1,
@@ -1520,7 +1529,7 @@
"internal/provider/anthropic/anthropic.go": {
"complexity": 45,
"essay": 36,
"file-size": 108,
"file-size": 112,
"function-size": 154
},
"internal/provider/anthropic/anthropic_test.go": {
@@ -1531,10 +1540,10 @@
"essay": 8
},
"internal/provider/openai/openai.go": {
"complexity": 61,
"complexity": 60,
"essay": 40,
"file-size": 531,
"function-size": 252
"file-size": 530,
"function-size": 251
},
"internal/provider/openai/openai_test.go": {
"essay": 5,
@@ -1550,12 +1559,12 @@
"internal/provider/responses/responses.go": {
"complexity": 54,
"essay": 25,
"file-size": 79,
"file-size": 83,
"function-size": 181
},
"internal/provider/responses/responses_test.go": {
"essay": 6,
"test-file-size": 351
"test-file-size": 377
},
"internal/provider/responses/vendor.go": {
"essay": 21