mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #7945 from SivanCola/feature/deepseek-anthropic-provider
Recommend DeepSeek Anthropic access / 推荐 DeepSeek Anthropic 接入
This commit is contained in:
69 files changed
+7679
-1040
No files matched your search
+47
-36
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 "";
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "全選",
|
||||
|
||||
@@ -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": "基元律动",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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 的
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 默认值
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
@@ -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(¤t, 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,") {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user