Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -5102,9 +5102,34 @@ type HistoryMessage struct {
DecisionReceipt *provider.DecisionReceipt `json:"decisionReceipt,omitempty"`
Readiness *event.FinalReadiness `json:"readiness,omitempty"`
ProtocolRecovery *provider.ProtocolRecoveryAction `json:"protocolRecovery,omitempty"`
Diagnostic *provider.FailureDiagnostic `json:"diagnostic,omitempty"`
ServerSearch []provider.ServerSearchCall `json:"serverSearch,omitempty"`
}

func interruptedTurnHistoryNotice(recovery *provider.InterruptedTurnRecovery) HistoryMessage {
if recovery != nil && recovery.TerminalStatus == "failed" {
diagnostic := recovery.FailureDiagnostic
message := "The provider request failed. Check the connection settings and try again."
detail := provider.FailureDiagnosticDetail(diagnostic)
if diagnostic != nil {
if statusMessage := i18n.M.ProviderStatusMessage(diagnostic.Status); statusMessage != "" {
message = statusMessage
} else if diagnostic.Status > 0 {
message = fmt.Sprintf("Provider request failed (HTTP %d).", diagnostic.Status)
}
label := provider.ProviderDisplayLabel(diagnostic.ProviderID, diagnostic.ProviderDisplayName, diagnostic.Protocol)
if label != "" {
message = label + ": " + message
}
}
return HistoryMessage{Role: "notice", Level: "warn", Code: event.NoticeCodeProviderRequestFailed, Content: message, Detail: detail, Diagnostic: diagnostic}
}
return HistoryMessage{
Role: "notice", Level: "info", Code: event.NoticeCodeCancelledTurn,
Content: "This turn was interrupted. Partial output is kept for reference; only completed tool pairs and a bounded recovery summary enter the next model turn. Inspect the workspace before continuing or reverting changes.",
}
}

type HistoryToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Expand Down Expand Up @@ -5583,10 +5608,7 @@ func (state *historyMessageConvertState) convertHistoryMessage(
})
}
if m.LocalOnly && m.InterruptedTurn != nil {
out = append(out, HistoryMessage{
Role: "notice", Level: "info", Code: event.NoticeCodeCancelledTurn,
Content: "This turn was interrupted. Partial output is kept for reference; only completed tool pairs and a bounded recovery summary enter the next model turn. Inspect the workspace before continuing or reverting changes.",
})
out = append(out, interruptedTurnHistoryNotice(m.InterruptedTurn))
}
if m.Role == provider.RoleUser {
key := messageDisplayKey(agent.UserMessageText(m))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,25 @@ const legacyChatURLProvider: ProviderView = {
chatUrl: "https://legacy.example.com/chat/completions/",
};

const mismatchedDeepSeekProvider: ProviderView = {
...builtInProvider,
name: "deepseek-anthropic",
displayName: "Deepseek2",
builtIn: false,
presetId: "deepseek-anthropic",
kind: "openai",
baseUrl: "https://api.deepseek.com/anthropic/v1",
requestUrl: "https://api.deepseek.com/anthropic/v1/chat/completions",
catalog: {
brandId: "deepseek", brandLabel: "DeepSeek", region: "global", product: "api", format: "anthropic", baseUrl: "https://api.deepseek.com/anthropic",
protocols: {
openai: { baseUrl: "https://api.deepseek.com/v1", source: "fixture", checkedOn: "2026-09-08" },
responses: { baseUrl: "https://api.deepseek.com", source: "fixture", checkedOn: "2026-09-08" },
anthropic: { baseUrl: "https://api.deepseek.com/anthropic", source: "fixture", checkedOn: "2026-09-08" },
},
},
};

function renderProviderEditor(initial?: ProviderView, onSave: (provider: ProviderView) => void | Promise<void> = () => undefined) {
return (
<LocaleProvider>
Expand Down Expand Up @@ -404,6 +423,18 @@ await act(async () => {
ok(exactProvider?.requestUrl === "https://exact.example.com/custom/?token=1" && exactProvider?.baseUrl === legacyChatURLProvider.baseUrl, "saving preserves an explicit requestUrl and independent baseUrl exactly");
ok(exactProvider?.chatUrl === exactProvider?.requestUrl, "saving mirrors the exact OpenAI request URL for previous releases");

await act(async () => {
root.render(renderProviderEditor(mismatchedDeepSeekProvider));
await flushPromises();
});
const mismatchAlert = rootEl.querySelector<HTMLElement>('[role="alert"]');
const mismatchSave = Array.from(rootEl.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "Save changes");
const useRecommended = Array.from(rootEl.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "Apply" && button.title === "https://api.deepseek.com/v1/chat/completions");
ok(mismatchAlert?.textContent?.includes("does not match") === true && mismatchSave?.disabled === true, "protocol mismatch is a blocking editor error");
await act(async () => { useRecommended?.click(); await flushPromises(); });
ok(rootEl.querySelector<HTMLInputElement>(".provider-url-input")?.value === "https://api.deepseek.com/v1/chat/completions", "recommended action applies the catalog request URL");
ok(rootEl.querySelector<HTMLElement>('[role="alert"]') === null, "recommended route clears the mismatch gate");

await act(async () => {
root.render(renderProviderEditor({ ...legacyChatURLProvider, name: "save-failure" }, () => { throw new Error("storage unavailable"); }));
await flushPromises();
Expand Down
30 changes: 30 additions & 0 deletions desktop/frontend/src/__tests__/provider-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
providerBaseURLForSave,
providerRequestURLForFormatChange,
providerBaseURLFromRequestURL,
providerEndpointMismatchDetail,
providerRequestURLForCatalogFormatChange,
providerRequestURLFromConfig,
} from "../lib/providerEndpoint";
let failed = 0;
Expand Down Expand Up @@ -34,4 +36,32 @@ eq(providerRequestURLForFormatChange("anthropic", "openai", "https://gateway.exa
eq(providerRequestURLForFormatChange("openai", "responses", "https://gateway.example/custom?token=x"), "https://gateway.example/custom?token=x", "custom request paths and query values remain untouched");
eq(providerRequestURLForFormatChange("openai", "responses", "https://gateway.example/v1/chat/completions?version=1"), "https://gateway.example/v1/chat/completions?version=1", "query-bearing exact overrides are never rewritten");

const deepSeekCatalog = {
brandId: "deepseek", brandLabel: "DeepSeek", region: "global", product: "api", format: "anthropic", baseUrl: "https://api.deepseek.com/anthropic",
protocols: {
openai: { baseUrl: "https://api.deepseek.com/v1", source: "fixture", checkedOn: "2026-09-08" },
responses: { baseUrl: "https://api.deepseek.com", source: "fixture", checkedOn: "2026-09-08" },
anthropic: { baseUrl: "https://api.deepseek.com/anthropic", source: "fixture", checkedOn: "2026-09-08" },
},
};
const mimoCatalog = {
...deepSeekCatalog,
brandId: "mimo",
protocols: {
...deepSeekCatalog.protocols,
openai: { ...deepSeekCatalog.protocols.openai, baseUrl: "https://api.xiaomimimo.com/v1" },
responses: { ...deepSeekCatalog.protocols.responses, baseUrl: "https://api.xiaomimimo.com/v1" },
},
};
eq(providerRequestURLForCatalogFormatChange("anthropic", "openai", "https://api.deepseek.com/anthropic/v1/messages", deepSeekCatalog), "https://api.deepseek.com/v1/chat/completions", "official Anthropic route switches to the registered Chat route");
eq(providerRequestURLForCatalogFormatChange("anthropic", "responses", "https://api.deepseek.com/anthropic/v1/messages", deepSeekCatalog), "https://api.deepseek.com/responses", "official Anthropic route switches to the registered Responses route");
eq(providerRequestURLForCatalogFormatChange("anthropic", "openai", "https://gateway.example/custom/v1/messages", deepSeekCatalog), "https://gateway.example/custom/v1/messages", "custom gateway remains byte-for-byte unchanged");
eq(providerRequestURLForCatalogFormatChange("anthropic", "openai", "https://api.deepseek.com/anthropic/v1/messages?token=x", deepSeekCatalog), "https://api.deepseek.com/anthropic/v1/messages?token=x", "query-bearing official host override stays untouched");
eq(providerEndpointMismatchDetail("openai", "https://api.deepseek.com/anthropic/v1/chat/completions", deepSeekCatalog).mismatch, true, "mixed DeepSeek path is blocked");
eq(providerEndpointMismatchDetail("openai", "https://api.deepseek.com/anthropic/v1/chat/completions", deepSeekCatalog).recommendedUrl, "https://api.deepseek.com/v1/chat/completions", "mixed DeepSeek path recommends the catalog route");
eq(providerEndpointMismatchDetail("openai", "https://api.deepseek.com/chat/completions", deepSeekCatalog).mismatch, false, "working DeepSeek root alias remains valid");
eq(providerEndpointMismatchDetail("openai", "https://api.xiaomimimo.com/v1/chat/completions", mimoCatalog).mismatch, false, "shared Chat and Responses base remains valid");
eq(providerEndpointMismatchDetail("openai", "https://gateway.example/anthropic/v1/chat/completions", deepSeekCatalog).mismatch, false, "custom host is not catalog-rewritten");
eq(providerEndpointMismatchDetail("openai", "https://api.deepseek.com/anthropic/custom/chat/completions", deepSeekCatalog).mismatch, false, "unknown custom path on the official host stays user-owned");

if (failed > 0) process.exit(1);
27 changes: 27 additions & 0 deletions desktop/frontend/src/__tests__/provider-failure-meta.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Run: pnpm exec tsx src/__tests__/provider-failure-meta.test.ts
import assert from "node:assert/strict";

import { initialState, reducer } from "../lib/useController";

const started = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
const failed = reducer(started, {
type: "event",
e: {
kind: "turn_done",
err: "Deepseek2 · Chat Completions: Request endpoint not found (HTTP 404).",
detail: "Connection ID: deepseek-anthropic\nRequest path: /anthropic/v1/chat/completions",
diagnostic: { kind: "request", status: 404, providerId: "deepseek-anthropic", providerDisplayName: "Deepseek2", protocol: "openai", requestPath: "/anthropic/v1/chat/completions" },
},
});
const notice = failed.items.find((item) => item.kind === "notice" && item.level === "warn");

assert.equal(
notice?.kind === "notice" ? notice.text : "",
"Deepseek2 · Chat Completions: Request endpoint not found (HTTP 404).",
"provider display identity stays in the primary live error",
);
assert.equal(
notice?.kind === "notice" ? notice.detail : "",
"Connection ID: deepseek-anthropic\nRequest path: /anthropic/v1/chat/completions",
"stable provider id and sanitized path stay in live diagnostic details",
);
38 changes: 25 additions & 13 deletions desktop/frontend/src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { SettingsOptions } from "./SettingsOptions";
import { SettingsSelect } from "./SettingsSelect";
import { providerProtocolLabel, providerEndpointMismatch, providerProtocolChoices } from "../lib/providerProtocol";
import { providerProtocolLabel, providerProtocolChoices } from "../lib/providerProtocol";
import { providerSupportsServerWebSearch } from "../lib/providerSearch";
export { providerSupportsServerWebSearch } from "../lib/providerSearch";
import { ManagementPageShell } from "./ManagementPageShell";
Expand All @@ -21,7 +21,7 @@ import { app, COMPACT_RATIO_MAX_PERCENT, COMPACT_RATIO_MIN_PERCENT, openExternal
import { normalizeLangPref, useI18n, type DictKey, type LangPref } from "../lib/i18n";
import { createLatestRequestGate, mergedFetchedProviderModels, mergeProviderModelContextWindows, providerApiKeyEnvForSave, providerDefaultModel, providerIsConfigured, providerModelCandidates, providerModelContextWindowDrafts, providerRequiresKey } from "../lib/providerModels";
import { cachedFetchProviderModelCatalog, cachedFetchProviderModels, invalidateProviderCacheByAPIKeyEnv, shouldSkipAutoRefresh } from "../lib/providerModelCache";
import { providerBaseURLForSave, providerRequestURLForFormatChange, providerRequestURLFromConfig, trimmedBaseURL } from "../lib/providerEndpoint";
import { providerBaseURLForSave, providerEndpointMismatchDetail, providerRequestURLForCatalogFormatChange, providerRequestURLFromConfig, trimmedBaseURL } from "../lib/providerEndpoint";
import { providerModelVisionCapability, providerVisionModelsForView } from "../lib/providerVisionCapability";
import { useUpdater } from "../lib/useUpdater";
import {
Expand Down Expand Up @@ -6029,15 +6029,25 @@ export function ProviderEditor({
const [savedSnapshot, setSavedSnapshot] = useState(draftSnapshot);
const dirty = draftSnapshot !== savedSnapshot;
const isNewCustomProvider = !initial;
const providerKindChoices = useMemo(() => {
const editorCatalog = useMemo(() => {
if (initial?.catalog) return initial.catalog;
if (initial?.presetId) {
const preset = providerPresets.find(item => item.id === initial.presetId);
if (preset) return catalogForPreset(preset);
}
const catalogs = providerPresets.map(catalogForPreset);
const currentRoute = catalogs.find(c => c.format === kind && c.baseUrl && providerRequestURLFromConfig(kind, c.baseUrl, "") === requestUrl);
const registered = currentRoute ? catalogs.filter(c => c.brandId === currentRoute.brandId && c.region === currentRoute.region && c.product === currentRoute.product).map(c => c.format).filter(format => format !== "bundle") : kinds;
const choices = providerProtocolChoices(kind, initial?.kind, registered, Boolean(currentRoute));
return catalogs.find(catalog => Object.entries(catalog.protocols ?? {}).some(([routeKind, route]) =>
providerRequestURLFromConfig(routeKind, route.baseUrl, "") === requestUrl,
));
}, [initial?.catalog, initial?.presetId, providerPresets, requestUrl]);
const providerKindChoices = useMemo(() => {
const registered = editorCatalog ? Object.keys(editorCatalog.protocols ?? {}) : kinds;
const choices = providerProtocolChoices(kind, initial?.kind, registered, Boolean(editorCatalog));
return choices.length > 0 ? choices : ["openai"];
}, [kind, initial?.kind, kinds, providerPresets, requestUrl]);
}, [editorCatalog, kind, initial?.kind, kinds]);
const effectiveKind = providerEditorEffectiveKind(isNewCustomProvider, kind, providerKindChoices);
const effectiveRequestUrl = requestUrl.trim();
const endpointMismatch = providerEndpointMismatchDetail(effectiveKind, effectiveRequestUrl, editorCatalog);
const effectiveBaseUrl = providerBaseURLForSave(initial, effectiveKind, effectiveRequestUrl);
const effectiveLegacyChatUrl = effectiveKind.toLowerCase() === "openai" ? effectiveRequestUrl : initial?.chatUrl ?? "";
const effectiveModelsUrl = modelsUrl.trim();
Expand Down Expand Up @@ -6149,6 +6159,8 @@ export function ProviderEditor({
const provider: ProviderView = {
name: name.trim(),
...(hideConnectionName ? {} : { displayName: displayName.trim() }),
...(initial?.presetId ? { presetId: initial.presetId } : {}),
...(initial?.catalog ? { catalog: initial.catalog } : {}),
builtIn: initial?.builtIn ?? false,
added: initial?.added ?? true,
kind: effectiveKind,
Expand Down Expand Up @@ -6352,10 +6364,7 @@ export function ProviderEditor({
<SettingsSelect className="mem-select" aria-label={t("settings.providerProtocol")} title={providerKindHint(effectiveKind, t)} value={kind} disabled={busy || fetchingModels} onValueChange={(value) => {
const nextKind = value;
setRequestUrl(current => {
const catalogs = providerPresets.map(catalogForPreset);
const currentRoute = catalogs.find(c => c.format === kind && c.baseUrl && providerRequestURLFromConfig(kind, c.baseUrl, "") === current);
const nextRoute = currentRoute && catalogs.find(c => c.brandId === currentRoute.brandId && c.region === currentRoute.region && c.product === currentRoute.product && c.format === nextKind);
return nextRoute?.baseUrl ? providerRequestURLFromConfig(nextKind, nextRoute.baseUrl, "") : providerRequestURLForFormatChange(kind, nextKind, current);
return providerRequestURLForCatalogFormatChange(kind, nextKind, current, editorCatalog);
});
setKind(nextKind);
}}>
Expand All @@ -6365,7 +6374,10 @@ export function ProviderEditor({
</option>
))}
</SettingsSelect>
{providerEndpointMismatch(effectiveKind, effectiveRequestUrl) && <div role="alert" className="banner banner--warning">{t("settings.providerProtocolMismatch")}</div>}
{endpointMismatch.mismatch && <div role="alert" className="banner banner--warning">
<span>{t("settings.providerProtocolMismatch")}</span>
{endpointMismatch.recommendedUrl && <button type="button" className="btn btn--small" title={endpointMismatch.recommendedUrl} onClick={() => setRequestUrl(endpointMismatch.recommendedUrl)}>{t("settings.compactRatioApply")}</button>}
</div>}
</div>
<div className="provider-key-single">
<label htmlFor={`provider-key-${initial?.name ?? "new"}`}>API Key</label>
Expand Down Expand Up @@ -6422,7 +6434,7 @@ export function ProviderEditor({
<button className="btn btn--small" onClick={onCancel} disabled={busy}>
{t("common.cancel")}
</button>
<button className="btn btn--primary btn--small" onClick={() => void save()} disabled={busy || fetchingModels || (Boolean(initial) && !dirty) || !name.trim() || !effectiveBaseUrl || !models.trim() || extraBodyInvalid}>
<button className="btn btn--primary btn--small" onClick={() => void save()} disabled={busy || fetchingModels || (Boolean(initial) && !dirty) || !name.trim() || !effectiveBaseUrl || !models.trim() || extraBodyInvalid || endpointMismatch.mismatch}>
{t("settings.models.saveChanges")}
</button>
</div>
Expand Down
10 changes: 10 additions & 0 deletions desktop/frontend/src/lib/providerCatalog.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@
"format": "openai",
"baseUrl": "https://api.deepseek.com/v1",
"protocols": {
"anthropic": {
"baseUrl": "https://api.deepseek.com/anthropic",
"source": "https://api-docs.deepseek.com/guides/anthropic_api",
"checkedOn": "2026-09-08"
},
"openai": {
"baseUrl": "https://api.deepseek.com/v1",
"source": "https://api-docs.deepseek.com/",
Expand All @@ -94,6 +99,11 @@
"format": "responses",
"baseUrl": "https://api.deepseek.com",
"protocols": {
"anthropic": {
"baseUrl": "https://api.deepseek.com/anthropic",
"source": "https://api-docs.deepseek.com/guides/anthropic_api",
"checkedOn": "2026-09-08"
},
"openai": {
"baseUrl": "https://api.deepseek.com/v1",
"source": "https://api-docs.deepseek.com/",
Expand Down
Loading
Loading