diff --git a/desktop/app.go b/desktop/app.go index ba7d531daf..8c593d8d4e 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -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"` @@ -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)) diff --git a/desktop/frontend/src/__tests__/provider-editor-model-picker.test.tsx b/desktop/frontend/src/__tests__/provider-editor-model-picker.test.tsx index cada3fb286..0512d1b18a 100644 --- a/desktop/frontend/src/__tests__/provider-editor-model-picker.test.tsx +++ b/desktop/frontend/src/__tests__/provider-editor-model-picker.test.tsx @@ -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 = () => undefined) { return ( @@ -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('[role="alert"]'); +const mismatchSave = Array.from(rootEl.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Save changes"); +const useRecommended = Array.from(rootEl.querySelectorAll("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(".provider-url-input")?.value === "https://api.deepseek.com/v1/chat/completions", "recommended action applies the catalog request URL"); +ok(rootEl.querySelector('[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(); diff --git a/desktop/frontend/src/__tests__/provider-endpoint.test.ts b/desktop/frontend/src/__tests__/provider-endpoint.test.ts index e68a8d32ed..a65ab4e2b7 100644 --- a/desktop/frontend/src/__tests__/provider-endpoint.test.ts +++ b/desktop/frontend/src/__tests__/provider-endpoint.test.ts @@ -2,6 +2,8 @@ import { providerBaseURLForSave, providerRequestURLForFormatChange, providerBaseURLFromRequestURL, + providerEndpointMismatchDetail, + providerRequestURLForCatalogFormatChange, providerRequestURLFromConfig, } from "../lib/providerEndpoint"; let failed = 0; @@ -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); diff --git a/desktop/frontend/src/__tests__/provider-failure-meta.test.ts b/desktop/frontend/src/__tests__/provider-failure-meta.test.ts new file mode 100644 index 0000000000..4b9b050d2a --- /dev/null +++ b/desktop/frontend/src/__tests__/provider-failure-meta.test.ts @@ -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", +); diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index 9d248581be..441cec77ca 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -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"; @@ -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 { @@ -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(); @@ -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, @@ -6352,10 +6364,7 @@ export function ProviderEditor({ { 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); }}> @@ -6365,7 +6374,10 @@ export function ProviderEditor({ ))} - {providerEndpointMismatch(effectiveKind, effectiveRequestUrl) &&
{t("settings.providerProtocolMismatch")}
} + {endpointMismatch.mismatch &&
+ {t("settings.providerProtocolMismatch")} + {endpointMismatch.recommendedUrl && } +
}
@@ -6422,7 +6434,7 @@ export function ProviderEditor({ -
diff --git a/desktop/frontend/src/lib/providerCatalog.generated.json b/desktop/frontend/src/lib/providerCatalog.generated.json index fa45fe1bc0..912a73b419 100644 --- a/desktop/frontend/src/lib/providerCatalog.generated.json +++ b/desktop/frontend/src/lib/providerCatalog.generated.json @@ -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/", @@ -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/", diff --git a/desktop/frontend/src/lib/providerEndpoint.ts b/desktop/frontend/src/lib/providerEndpoint.ts index 84b5b05a1e..689252be64 100644 --- a/desktop/frontend/src/lib/providerEndpoint.ts +++ b/desktop/frontend/src/lib/providerEndpoint.ts @@ -1,3 +1,5 @@ +import type { ProviderCatalog } from "./providerCatalogTypes"; + export interface ProviderEndpointConfig { kind: string; baseUrl: string; @@ -96,3 +98,99 @@ export function providerRequestURLForFormatChange(previousKind: string, nextKind return url.toString(); } catch { return requestUrl; } } + +function normalizedProtocol(kind: string): string { + const normalized = kind.trim().toLowerCase(); + return normalized === "dashscope-responses" ? "responses" : normalized; +} + +function protocolSuffix(kind: string): string { + switch (normalizedProtocol(kind)) { + case "anthropic": return "/messages"; + case "responses": return "/responses"; + case "openai": return "/chat/completions"; + default: return ""; + } +} + +export function providerCatalogRequestURL(catalog: ProviderCatalog | undefined, kind: string): string { + const route = catalog?.protocols?.[normalizedProtocol(kind)]; + return route?.baseUrl ? providerRequestURLFromConfig(kind, route.baseUrl, "") : ""; +} + +function normalizedExactURL(value: string): string { + try { + const parsed = new URL(value.trim()); + if (parsed.search || parsed.hash || parsed.username || parsed.password) return ""; + parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/"; + return parsed.toString(); + } catch { + return ""; + } +} + +// Switch to a catalog route only when the current URL is one of that catalog's +// registered official routes. Custom gateways and query-bearing routes keep the +// conservative suffix-only behavior. +export function providerRequestURLForCatalogFormatChange( + previousKind: string, + nextKind: string, + requestUrl: string, + catalog?: ProviderCatalog, +): string { + const current = normalizedExactURL(requestUrl); + if (current && catalog?.protocols) { + const matchesOfficialRoute = Object.entries(catalog.protocols).some(([kind, route]) => + normalizedExactURL(providerRequestURLFromConfig(kind, route.baseUrl, "")) === current, + ); + if (matchesOfficialRoute) { + const next = providerCatalogRequestURL(catalog, nextKind); + if (next) return next; + } + } + if (catalog?.protocols) return requestUrl; + return providerRequestURLForFormatChange(previousKind, nextKind, requestUrl); +} + +export interface ProviderEndpointMismatchDetail { + mismatch: boolean; + recommendedUrl: string; +} + +export function providerEndpointMismatchDetail( + kind: string, + address: string, + catalog?: ProviderCatalog, +): ProviderEndpointMismatchDetail { + const recommendedUrl = providerCatalogRequestURL(catalog, kind); + let parsed: URL; + try { parsed = new URL(address); } catch { return { mismatch: false, recommendedUrl }; } + if (parsed.search || parsed.hash || parsed.username || parsed.password) return { mismatch: false, recommendedUrl }; + const path = parsed.pathname.replace(/\/+$/, ""); + const expected = protocolSuffix(kind); + if (!expected) return { mismatch: false, recommendedUrl }; + if (recommendedUrl && normalizedExactURL(address) === normalizedExactURL(recommendedUrl)) return { mismatch: false, recommendedUrl }; + const knownSuffix = ["/messages", "/chat/completions", "/responses"].find(suffix => path.endsWith(suffix)); + if (knownSuffix && !path.endsWith(expected)) return { mismatch: true, recommendedUrl }; + if (!catalog?.protocols || !path.endsWith(expected)) return { mismatch: false, recommendedUrl }; + + const selectedRoute = catalog.protocols[normalizedProtocol(kind)]; + if (!selectedRoute) return { mismatch: false, recommendedUrl }; + let selectedBase: URL; + try { selectedBase = new URL(selectedRoute.baseUrl); } catch { return { mismatch: false, recommendedUrl }; } + if (selectedBase.host.toLowerCase() !== parsed.host.toLowerCase()) return { mismatch: false, recommendedUrl }; + for (const [otherKind, route] of Object.entries(catalog.protocols)) { + if (normalizedProtocol(otherKind) === normalizedProtocol(kind)) continue; + let foreignBase: URL; + try { foreignBase = new URL(providerRequestURLFromConfig(otherKind, route.baseUrl, "")); } catch { continue; } + if (foreignBase.host.toLowerCase() !== parsed.host.toLowerCase()) continue; + const otherSuffix = protocolSuffix(otherKind); + const foreignRequestPath = foreignBase.pathname.replace(/\/+$/, ""); + const foreignRoot = foreignRequestPath.slice(0, -otherSuffix.length); + if (!foreignRoot || foreignRoot === "/") continue; + if (path === `${foreignRoot}${expected}`) { + return { mismatch: true, recommendedUrl }; + } + } + return { mismatch: false, recommendedUrl }; +} diff --git a/desktop/frontend/src/lib/providerProtocol.ts b/desktop/frontend/src/lib/providerProtocol.ts index e4d1b810b5..4a959137ed 100644 --- a/desktop/frontend/src/lib/providerProtocol.ts +++ b/desktop/frontend/src/lib/providerProtocol.ts @@ -1,4 +1,7 @@ // Presentation and conservative endpoint checks shared by provider settings. +import type { ProviderCatalog } from "./providerCatalogTypes"; +import { providerEndpointMismatchDetail } from "./providerEndpoint"; + export function providerProtocolLabel(kind: string): string { switch (kind.trim().toLowerCase()) { case "anthropic": return "Anthropic Messages (/v1/messages)"; @@ -9,15 +12,8 @@ export function providerProtocolLabel(kind: string): string { } } -export function providerEndpointMismatch(kind: string, address: string): boolean { - let path: string; - try { path = new URL(address).pathname.replace(/\/+$/, ""); } catch { return false; } - const protocol = kind.trim().toLowerCase(); - const expected = protocol === "anthropic" ? "/messages" - : protocol === "openai" ? "/chat/completions" - : protocol === "responses" || protocol === "dashscope-responses" ? "/responses" : ""; - // Base URLs and custom gateway paths cannot be inferred safely. - return Boolean(expected) && ["/messages", "/chat/completions", "/responses"].some(suffix => path.endsWith(suffix)) && !path.endsWith(expected); +export function providerEndpointMismatch(kind: string, address: string, catalog?: ProviderCatalog): boolean { + return providerEndpointMismatchDetail(kind, address, catalog).mismatch; } // Registered adapters are not all user-facing protocols. Keep saved/custom diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index 9254fe1cec..28c068887c 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -1,4 +1,4 @@ -import type { ProviderPresetView } from "./providerCatalogTypes"; +import type { ProviderCatalog, ProviderPresetView } from "./providerCatalogTypes"; export type { ProviderProtocolEndpoint, ProviderCatalog, ProviderPresetView } from "./providerCatalogTypes"; import type { RecoveryEventFields } from "./recoveryStatus"; // Wire contract — mirrors desktop/wire.go (itself mirroring internal/serve/wire.go). @@ -415,7 +415,7 @@ export interface WireEvent extends RecoveryEventFields { outcome?: "completed" | "partial" | "blocked" | "final_readiness" | "recovery_paused" | "completion_uncertain"; readiness?: WireFinalReadiness; protocolRecovery?: { id: string }; - diagnostic?: { kind: string; status?: number; traceId?: string }; + diagnostic?: { kind: string; status?: number; traceId?: string; providerId?: string; providerDisplayName?: string; protocol?: string; requestPath?: string }; /** Optional: "headers" | "stream". Older clients ignore unknown fields. */ retryScope?: "headers" | "stream" | "protocol"; streamAttempt?: WireStreamAttempt; @@ -822,7 +822,7 @@ export interface HistoryMessage { decisionReceipt?: WireDecisionReceipt; readiness?: WireFinalReadiness; protocolRecovery?: { id: string }; - diagnostic?: { kind: string; status?: number; traceId?: string }; + diagnostic?: { kind: string; status?: number; traceId?: string; providerId?: string; providerDisplayName?: string; protocol?: string; requestPath?: string }; serverSearch?: HistoryServerSearch[]; } @@ -1749,6 +1749,8 @@ export interface CapabilityIssue { export interface ProviderView { displayName?: string; name: string; + presetId?: string; // stable curated identity; read-only in the connection editor + catalog?: ProviderCatalog; // protocol routes for this installed connection, including hidden legacy presets builtIn: boolean; added: boolean; kind: string; diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index b9157b1980..63c40e584b 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -1989,7 +1989,7 @@ function applyEvent(s: State, e: WireEvent, preserveToolPayloads = false): State } items = [...finalized, ...interruptItems]; } else if (e.err && !s.streamInterruptNoticeShown) { - items = [...finalized, { kind: "notice", id: `e${s.seq}`, level: "warn", text: e.err }]; + items = [...finalized, { kind: "notice", id: `e${s.seq}`, level: "warn", text: e.err, detail: e.detail }]; } if (e.protocolRecovery?.id && e.status !== "interrupted" && !s.cancelRequested) { items = items.map(item => item.kind==="notice" && item.action==="recover_context" ? {...item,action:undefined} : item); diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 5c01fc214c..f6a05333e2 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -6,7 +6,7 @@ export const en = { // shared verbs / chrome "common.close": "Close", - "settings.providerProtocolMismatch": "The endpoint path does not match the selected API format. Check it before saving.", + "settings.providerProtocolMismatch": "The endpoint does not match the selected API format.", "settings.imageInputLabel": "Image input", "settings.imageInputModeAria": "Image input mode for {model}", "settings.imageInputAuto": "Auto", diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index 05b22e0da2..1a4bde06e1 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -5,7 +5,7 @@ import type { DictKey } from "./en"; export const zhTW: Record = { - "settings.providerProtocolMismatch": "API 位址的請求路徑與所選協定不一致,請檢查後再儲存。", + "settings.providerProtocolMismatch": "API 位址與所選協定不一致。", "settings.imageInputLabel": "圖片輸入", "settings.imageInputModeAria": "{model} 的圖片輸入模式", "settings.imageInputAuto": "自動", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index d98be1b511..ca29289af0 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -7,7 +7,7 @@ import type { DictKey } from "./en"; export const zh: Record = { // 通用动词 / 框架 "common.close": "关闭", - "settings.providerProtocolMismatch": "API 地址的请求路径与所选协议不一致,请检查后再保存。", + "settings.providerProtocolMismatch": "API 地址与所选协议不一致。", "settings.imageInputLabel": "图片输入", "settings.imageInputModeAria": "{model} 的图片输入模式", "settings.imageInputAuto": "自动", diff --git a/desktop/history_provider_failure_test.go b/desktop/history_provider_failure_test.go new file mode 100644 index 0000000000..a223050c78 --- /dev/null +++ b/desktop/history_provider_failure_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "strings" + "testing" + + "reasonix/internal/event" + "reasonix/internal/provider" +) + +func TestHistoryRestoresProviderFailureInsteadOfInterruptedNotice(t *testing.T) { + message := provider.Message{ + Role: provider.RoleTool, LocalOnly: true, + InterruptedTurn: &provider.InterruptedTurnRecovery{ + Pending: true, TerminalStatus: "failed", + FailureDiagnostic: &provider.FailureDiagnostic{Kind: "request", Status: 404, ProviderID: "deepseek-anthropic", ProviderDisplayName: "Deepseek2", Protocol: "openai", RequestPath: "/anthropic/v1/chat/completions"}, + }, + } + history := historyMessages([]provider.Message{message}, func(value string) string { return value }) + if len(history) != 1 { + t.Fatalf("history = %+v", history) + } + got := history[0] + if got.Code != event.NoticeCodeProviderRequestFailed || got.Level != "warn" || !strings.Contains(got.Content, "Deepseek2 · Chat Completions") || !strings.Contains(got.Content, "HTTP 404") { + t.Fatalf("failure history = %+v", got) + } + if got.Detail != "Connection ID: deepseek-anthropic\nRequest path: /anthropic/v1/chat/completions" || got.Diagnostic == nil || got.Diagnostic.ProviderID != "deepseek-anthropic" { + t.Fatalf("failure detail = %+v", got) + } +} + +func TestHistoryKeepsLegacyAndExplicitInterruptionsCompatible(t *testing.T) { + for _, recovery := range []*provider.InterruptedTurnRecovery{{Pending: true}, {Pending: true, TerminalStatus: "interrupted"}} { + history := historyMessages([]provider.Message{{Role: provider.RoleTool, LocalOnly: true, InterruptedTurn: recovery}}, func(value string) string { return value }) + if len(history) != 1 || history[0].Code != event.NoticeCodeCancelledTurn { + t.Fatalf("interrupted history = %+v", history) + } + } +} diff --git a/desktop/settings_app.go b/desktop/settings_app.go index d363ad9f25..ecafde5eaa 100644 --- a/desktop/settings_app.go +++ b/desktop/settings_app.go @@ -44,6 +44,8 @@ import ( type ProviderView struct { DisplayName *string `json:"displayName,omitempty"` Name string `json:"name"` + PresetID string `json:"presetId,omitempty"` + Catalog *config.ProviderCatalog `json:"catalog,omitempty"` BuiltIn bool `json:"builtIn"` Added bool `json:"added"` Kind string `json:"kind"` @@ -686,8 +688,13 @@ func providerViewFromEntryForRootWithResolverAndCredentials(p config.ProviderEnt visionCapability = "unsupported" } modelCapabilities := providerModelCapabilitiesForView(p, models) + presetID, catalog, hasCatalog := config.CatalogForProviderEntry(&p) + var catalogView *config.ProviderCatalog + if hasCatalog { + catalogView = &catalog + } return ProviderView{ - DisplayName: &p.DisplayName, Name: p.Name, BuiltIn: builtIn, Added: added, Kind: p.Kind, BaseURL: p.BaseURL, ChatURL: p.ChatURL, RequestURL: p.RequestURL, + DisplayName: &p.DisplayName, Name: p.Name, PresetID: presetID, Catalog: catalogView, BuiltIn: builtIn, Added: added, Kind: p.Kind, BaseURL: p.BaseURL, ChatURL: p.ChatURL, RequestURL: p.RequestURL, 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), @@ -2579,6 +2586,9 @@ func saveProviderConfig(c *config.Config, p ProviderView) error { e.VisionModels = nil e.ModelOverrides = nil } + if err := config.ValidateProviderEndpoint(&e); err != nil { + return err + } if err := c.UpsertProvider(e); err != nil { return err } diff --git a/desktop/settings_provider_endpoint_contract_test.go b/desktop/settings_provider_endpoint_contract_test.go new file mode 100644 index 0000000000..d735507f1a --- /dev/null +++ b/desktop/settings_provider_endpoint_contract_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "strings" + "testing" + + "reasonix/internal/config" +) + +func TestProviderViewCarriesHiddenCatalogIdentity(t *testing.T) { + view := providerViewFromEntry(config.ProviderEntry{ + Name: "deepseek-anthropic", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic", Model: "deepseek-v4-flash", + }, false, true) + if view.PresetID != "deepseek-anthropic" || view.Catalog == nil { + t.Fatalf("catalog identity missing: %+v", view) + } + if view.Catalog.BrandID != "deepseek" || view.Catalog.Region != "global" || view.Catalog.Product != "api" { + t.Fatalf("catalog identity = %+v", view.Catalog) + } + if got := view.Catalog.Protocols["anthropic"].BaseURL; got != "https://api.deepseek.com/anthropic" { + t.Fatalf("Anthropic route = %q", got) + } +} + +func TestSaveProviderRejectsProtocolEndpointMismatch(t *testing.T) { + cfg := config.Default() + cfg.Providers = []config.ProviderEntry{{ + Name: "deepseek-anthropic", PresetID: "deepseek-anthropic", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic", Model: "deepseek-v4-flash", + }} + view := providerViewFromEntry(cfg.Providers[0], false, true) + view.Kind = "openai" + view.RequestURL = "https://api.deepseek.com/anthropic/v1/chat/completions" + view.ChatURL = view.RequestURL + if err := saveProviderConfig(cfg, view); err == nil || !strings.Contains(err.Error(), "https://api.deepseek.com/v1/chat/completions") { + t.Fatalf("save mismatch error = %v", err) + } + if got := cfg.Providers[0].Kind; got != "anthropic" { + t.Fatalf("rejected save mutated provider kind to %q", got) + } +} + +func TestProtocolSwitchKeepsStableProviderAndModelReferences(t *testing.T) { + cfg := config.Default() + cfg.DefaultModel = "deepseek-anthropic/deepseek-v4-flash" + cfg.Providers = []config.ProviderEntry{{ + Name: "deepseek-anthropic", DisplayName: "Deepseek2", PresetID: "deepseek-anthropic", Kind: "anthropic", + BaseURL: "https://api.deepseek.com/anthropic", RequestURL: "https://api.deepseek.com/anthropic/v1/messages", + Model: "deepseek-v4-flash", Models: []string{"deepseek-v4-flash"}, + }} + view := providerViewFromEntry(cfg.Providers[0], false, true) + view.Kind = "openai" + view.BaseURL = "https://api.deepseek.com/v1" + view.RequestURL = "https://api.deepseek.com/v1/chat/completions" + view.ChatURL = view.RequestURL + if err := saveProviderConfig(cfg, view); err != nil { + t.Fatalf("save protocol switch: %v", err) + } + got := cfg.Providers[0] + if got.Name != "deepseek-anthropic" || got.PresetID != "deepseek-anthropic" || cfg.DefaultModel != "deepseek-anthropic/deepseek-v4-flash" { + t.Fatalf("stable references changed: provider=%+v default=%q", got, cfg.DefaultModel) + } + if got.DisplayName != "Deepseek2" || got.Kind != "openai" || got.RequestURL != "https://api.deepseek.com/v1/chat/completions" { + t.Fatalf("switched connection = %+v", got) + } +} diff --git a/docs/PROVIDER_PROTOCOL_ENDPOINTS.md b/docs/PROVIDER_PROTOCOL_ENDPOINTS.md index 76c642cd2e..172545d947 100644 --- a/docs/PROVIDER_PROTOCOL_ENDPOINTS.md +++ b/docs/PROVIDER_PROTOCOL_ENDPOINTS.md @@ -12,7 +12,7 @@ Base URL excludes the request suffix: Chat = /chat/completions; Responses = /res | anthropic|global|api | — | — | [https://api.anthropic.com](https://platform.claude.com/docs/en/api/messages/create) | | baidu|cn|api | [https://qianfan.baidubce.com/v2](https://cloud.baidu.com/doc/qianfan/s/Smoghsq3g) | — | [https://qianfan.baidubce.com/anthropic](https://cloud.baidu.com/doc/qianfan/s/Smoghsq3g) | | cerebras|global|api | [https://api.cerebras.ai/v1](https://inference-docs.cerebras.ai/resources/openai) | — | — | -| deepseek|global|api | [https://api.deepseek.com/v1](https://api-docs.deepseek.com/) | [https://api.deepseek.com](https://api-docs.deepseek.com/) | — | +| deepseek|global|api | [https://api.deepseek.com/v1](https://api-docs.deepseek.com/) | [https://api.deepseek.com](https://api-docs.deepseek.com/) | [https://api.deepseek.com/anthropic](https://api-docs.deepseek.com/guides/anthropic_api) | | doubao|cn|api | [https://ark.cn-beijing.volces.com/api/v3](https://www.volcengine.com/docs/82379/1795150) | [https://ark.cn-beijing.volces.com/api/v3](https://www.volcengine.com/docs/82379/1795150) | — | | fireworks|global|api | [https://api.fireworks.ai/inference/v1](https://docs.fireworks.ai/getting-started/quickstart) | [https://api.fireworks.ai/inference/v1](https://docs.fireworks.ai/guides/response-api) | [https://api.fireworks.ai/inference](https://docs.fireworks.ai/getting-started/quickstart) | | gemini|global|api | [https://generativelanguage.googleapis.com/v1beta/openai](https://ai.google.dev/gemini-api/docs/openai) | — | — | diff --git a/docs/PROVIDER_PROTOCOL_ENDPOINTS_ZH.md b/docs/PROVIDER_PROTOCOL_ENDPOINTS_ZH.md index 7beb5daffc..a1f71b5e38 100644 --- a/docs/PROVIDER_PROTOCOL_ENDPOINTS_ZH.md +++ b/docs/PROVIDER_PROTOCOL_ENDPOINTS_ZH.md @@ -12,7 +12,7 @@ Base URL excludes the request suffix: Chat = /chat/completions; Responses = /res | anthropic|global|api | — | — | [https://api.anthropic.com](https://platform.claude.com/docs/en/api/messages/create) | | baidu|cn|api | [https://qianfan.baidubce.com/v2](https://cloud.baidu.com/doc/qianfan/s/Smoghsq3g) | — | [https://qianfan.baidubce.com/anthropic](https://cloud.baidu.com/doc/qianfan/s/Smoghsq3g) | | cerebras|global|api | [https://api.cerebras.ai/v1](https://inference-docs.cerebras.ai/resources/openai) | — | — | -| deepseek|global|api | [https://api.deepseek.com/v1](https://api-docs.deepseek.com/) | [https://api.deepseek.com](https://api-docs.deepseek.com/) | — | +| deepseek|global|api | [https://api.deepseek.com/v1](https://api-docs.deepseek.com/) | [https://api.deepseek.com](https://api-docs.deepseek.com/) | [https://api.deepseek.com/anthropic](https://api-docs.deepseek.com/guides/anthropic_api) | | doubao|cn|api | [https://ark.cn-beijing.volces.com/api/v3](https://www.volcengine.com/docs/82379/1795150) | [https://ark.cn-beijing.volces.com/api/v3](https://www.volcengine.com/docs/82379/1795150) | — | | fireworks|global|api | [https://api.fireworks.ai/inference/v1](https://docs.fireworks.ai/getting-started/quickstart) | [https://api.fireworks.ai/inference/v1](https://docs.fireworks.ai/guides/response-api) | [https://api.fireworks.ai/inference](https://docs.fireworks.ai/getting-started/quickstart) | | gemini|global|api | [https://generativelanguage.googleapis.com/v1beta/openai](https://ai.google.dev/gemini-api/docs/openai) | — | — | diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 2bc323e7d6..5d57ff8795 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -2010,7 +2010,7 @@ func upsertPartialToolCall(calls []provider.ToolCall, call provider.ToolCall) [] return append(calls, call) } -func (a *Agent) recordInterruptedDisplay(text, reasoning string, calls []provider.ToolCall, pending bool, workDurationMs int64) { +func (a *Agent) recordInterruptedDisplay(text, reasoning string, calls []provider.ToolCall, pending bool, terminalErr error, workDurationMs int64) { displayCalls := make([]provider.ToolCall, 0, len(calls)) interrupted := make([]string, 0, len(calls)) notStarted := make([]provider.InterruptedToolSummary, 0, len(calls)) @@ -2028,6 +2028,12 @@ func (a *Agent) recordInterruptedDisplay(text, reasoning string, calls []provide notStarted = append(notStarted, provider.InterruptedToolSummary{ID: call.ID, Name: name}) } } + terminalStatus := "interrupted" + var failureDiagnostic *provider.FailureDiagnostic + if terminalErr != nil && !errors.Is(terminalErr, context.Canceled) { + terminalStatus = "failed" + failureDiagnostic = provider.DiagnoseFailure(terminalErr) + } a.sess.conversation.Add(provider.Message{ Role: provider.RoleTool, Content: text, @@ -2038,6 +2044,8 @@ func (a *Agent) recordInterruptedDisplay(text, reasoning string, calls []provide WorkDurationMs: workDurationMs, LocalOnly: true, InterruptedTurn: &provider.InterruptedTurnRecovery{ + TerminalStatus: terminalStatus, + FailureDiagnostic: failureDiagnostic, Pending: pending, InterruptedTools: interrupted, NotStartedTools: notStarted, diff --git a/internal/agent/incomplete_read_runtime.go b/internal/agent/incomplete_read_runtime.go index 9231b79824..f1c7c67cb1 100644 --- a/internal/agent/incomplete_read_runtime.go +++ b/internal/agent/incomplete_read_runtime.go @@ -42,7 +42,7 @@ func (a *Agent) resolveIncompleteReadToolRoundBoundary(ctx context.Context, stat a.emitIncompleteReadNotice(event.NoticeCodeReadContinuationRequired, i18n.M.ReadContinuationRequired, "read continuation instruction appended") } if ctx.Err() != nil { - a.recordInterruptedDisplay("", "", nil, true, state.workDurationMs()) + a.recordInterruptedDisplay("", "", nil, true, ctx.Err(), state.workDurationMs()) return false, ctx.Err(), true } if instruction == "" { diff --git a/internal/agent/interrupted_recovery.go b/internal/agent/interrupted_recovery.go index 5416c087e4..fe238ceb24 100644 --- a/internal/agent/interrupted_recovery.go +++ b/internal/agent/interrupted_recovery.go @@ -29,6 +29,10 @@ func (a *Agent) pendingInterruptedRecovery() *provider.InterruptedTurnRecovery { m := v if m.LocalOnly && m.InterruptedTurn != nil && m.InterruptedTurn.Pending { copy := *m.InterruptedTurn + if copy.FailureDiagnostic != nil { + diagnostic := *copy.FailureDiagnostic + copy.FailureDiagnostic = &diagnostic + } copy.WriteChecks = append([]provider.WriteRecoveryCheck(nil), copy.WriteChecks...) copy.SatisfiedWrites = append([]provider.InterruptedToolSummary(nil), copy.SatisfiedWrites...) copy.CompletedTools = append([]provider.InterruptedToolSummary(nil), copy.CompletedTools...) diff --git a/internal/agent/provider_failure_recovery_test.go b/internal/agent/provider_failure_recovery_test.go new file mode 100644 index 0000000000..2f05b18779 --- /dev/null +++ b/internal/agent/provider_failure_recovery_test.go @@ -0,0 +1,47 @@ +package agent + +import ( + "context" + "errors" + "testing" + + "reasonix/internal/agent/testutil" + "reasonix/internal/event" + "reasonix/internal/provider" +) + +func TestProviderFailurePersistsStructuredFailedTerminal(t *testing.T) { + apiErr := &provider.APIError{Provider: "deepseek-anthropic", ProviderDisplayName: "Deepseek2", Protocol: "openai", Status: 404} + mp := testutil.NewMock("m", testutil.ErrorTurn(apiErr)) + a := New(mp, echoRegistry(), NewSession(""), Options{}, event.Discard) + if err := a.Run(withNoClosedLoop(context.Background()), "check bug"); !errors.Is(err, apiErr) { + t.Fatalf("Run error = %v", err) + } + last := a.Session().Messages[len(a.Session().Messages)-1] + recovery := last.InterruptedTurn + if !last.LocalOnly || recovery == nil || recovery.TerminalStatus != "failed" { + t.Fatalf("failed recovery = %+v", last) + } + if d := recovery.FailureDiagnostic; d == nil || d.ProviderID != "deepseek-anthropic" || d.ProviderDisplayName != "Deepseek2" || d.Protocol != "openai" || d.Status != 404 { + t.Fatalf("failure diagnostic = %+v", d) + } +} + +func TestInterruptedRecoveryDisplayMetadataDoesNotChangeProviderBlock(t *testing.T) { + base := &provider.InterruptedTurnRecovery{Pending: true, InterruptedTools: []string{"bash"}, DroppedPartialText: true} + enriched := *base + enriched.TerminalStatus = "failed" + enriched.FailureDiagnostic = &provider.FailureDiagnostic{Kind: "request", Status: 404, ProviderID: "deepseek-anthropic", ProviderDisplayName: "Deepseek2", Protocol: "openai"} + if before, after := interruptedRecoveryBlock(base), interruptedRecoveryBlock(&enriched); before != after { + t.Fatalf("display metadata changed provider-visible recovery\nbefore=%q\nafter=%q", before, after) + } +} + +func TestCancelledTurnPersistsInterruptedTerminal(t *testing.T) { + a := New(testutil.NewMock("m"), echoRegistry(), NewSession(""), Options{}, event.Discard) + a.recordInterruptedDisplay("partial", "", nil, true, context.Canceled, 0) + last := a.Session().Messages[len(a.Session().Messages)-1] + if last.InterruptedTurn == nil || last.InterruptedTurn.TerminalStatus != "interrupted" || last.InterruptedTurn.FailureDiagnostic != nil { + t.Fatalf("cancelled recovery = %+v", last.InterruptedTurn) + } +} diff --git a/internal/agent/run_loop.go b/internal/agent/run_loop.go index 24194a6242..39738072dc 100644 --- a/internal/agent/run_loop.go +++ b/internal/agent/run_loop.go @@ -220,7 +220,7 @@ func (a *Agent) runToolLoop(ctx context.Context, state *turnRuntime) (runErr err // Exhausted stream retries (or a non-retryable error): persist one // bounded LocalOnly recovery record for the next real user message. // Intermediate failed attempts never wrote session state. - a.recordInterruptedDisplay(text, reasoning, partialCalls, true, state.workDurationMs()) + a.recordInterruptedDisplay(text, reasoning, partialCalls, true, err, state.workDurationMs()) // A broken provider stream can otherwise look like a silent hang // followed only by the generic interrupted-turn notice (#9560). if code, msg := streamInterruptNotice(err); msg != "" { diff --git a/internal/boot/boot.go b/internal/boot/boot.go index 7ba04ecadc..cc05bfbc25 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -2512,6 +2512,9 @@ func NewProviderWithProxyAndModelInfo(e *config.ProviderEntry, proxy netclient.P // clientSearch suppresses new native searches while retaining the adapter's // ability to read and replay existing native search history. func newProviderWithSearchMode(e *config.ProviderEntry, proxy netclient.ProxySpec, modelInfo *provider.ModelInfo, clientSearch bool) (provider.Provider, error) { + if err := config.ValidateProviderEndpoint(e); err != nil { + return nil, err + } if err := config.ReasoningCapabilityForEntry(e).Validate(e.Model, config.EffectiveEffort(e)); err != nil { return nil, err } @@ -2520,11 +2523,8 @@ func newProviderWithSearchMode(e *config.ProviderEntry, proxy netclient.ProxySpe modelInfo = &resolved.ModelInfo } return provider.New(e.Kind, provider.Config{ - Name: e.Name, - BaseURL: e.BaseURL, - Model: e.Model, - APIKey: e.APIKey(), - ModelInfo: modelInfo, + Name: e.Name, DisplayName: e.DisplayName, Protocol: e.Kind, + BaseURL: e.BaseURL, Model: e.Model, APIKey: e.APIKey(), ModelInfo: modelInfo, // Pass the key's env var so auth failures can name where to fix it, plus // provider-kind-specific knobs. EffectiveEffort applies a configured // default_effort when the user has not explicitly selected /effort. diff --git a/internal/boot/provider_endpoint_contract_test.go b/internal/boot/provider_endpoint_contract_test.go new file mode 100644 index 0000000000..5fdec6518f --- /dev/null +++ b/internal/boot/provider_endpoint_contract_test.go @@ -0,0 +1,21 @@ +package boot + +import ( + "strings" + "testing" + + "reasonix/internal/config" + "reasonix/internal/netclient" +) + +func TestRuntimeRejectsProtocolEndpointMismatch(t *testing.T) { + entry := &config.ProviderEntry{ + Name: "deepseek-anthropic", PresetID: "deepseek-anthropic", Kind: "openai", + BaseURL: "https://api.deepseek.com/anthropic/v1", RequestURL: "https://api.deepseek.com/anthropic/v1/chat/completions", + Model: "deepseek-v4-flash", + } + _, err := NewProviderWithProxyAndModelInfo(entry, netclient.ProxySpec{}, nil) + if err == nil || !strings.Contains(err.Error(), "https://api.deepseek.com/v1/chat/completions") { + t.Fatalf("runtime mismatch error = %v", err) + } +} diff --git a/internal/config/provider_endpoint_contract.go b/internal/config/provider_endpoint_contract.go new file mode 100644 index 0000000000..f7e23643dc --- /dev/null +++ b/internal/config/provider_endpoint_contract.go @@ -0,0 +1,185 @@ +package config + +import ( + "fmt" + "net/url" + "strings" +) + +// ProviderEndpointMismatch describes a high-confidence conflict between a +// selected protocol and an exact request URL. It is intentionally conservative: +// custom gateways and query-bearing routes remain user-owned. +type ProviderEndpointMismatch struct { + Protocol string + RequestURL string + Recommended string +} + +func (e *ProviderEndpointMismatch) Error() string { + if e == nil { + return "" + } + if e.Recommended != "" { + return fmt.Sprintf("provider endpoint %q does not match protocol %q; use %s", e.RequestURL, e.Protocol, e.Recommended) + } + return fmt.Sprintf("provider endpoint %q does not match protocol %q", e.RequestURL, e.Protocol) +} + +func normalizedProviderProtocol(kind string) string { + kind = strings.ToLower(strings.TrimSpace(kind)) + if kind == "dashscope-responses" { + return "responses" + } + return kind +} + +func providerProtocolSuffix(kind string) string { + switch normalizedProviderProtocol(kind) { + case "anthropic": + return "/messages" + case "responses": + return "/responses" + case "openai": + return "/chat/completions" + default: + return "" + } +} + +// ProviderRequestURL builds the complete request URL represented by one SDK +// base URL in the protocol registry. +func ProviderRequestURL(kind, baseURL string) string { + base := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if base == "" { + return "" + } + switch normalizedProviderProtocol(kind) { + case "anthropic": + if strings.HasSuffix(base, "/v1") { + return base + "/messages" + } + return base + "/v1/messages" + case "responses": + return base + "/responses" + case "openai": + return base + "/chat/completions" + default: + return base + } +} + +// ProviderEffectiveRequestURL resolves current and legacy endpoint fields with +// the same precedence used by the runtime adapters. +func ProviderEffectiveRequestURL(e *ProviderEntry) string { + if e == nil { + return "" + } + if requestURL := strings.TrimSpace(e.RequestURL); requestURL != "" { + return requestURL + } + if normalizedProviderProtocol(e.Kind) == "openai" { + if chatURL := strings.TrimRight(strings.TrimSpace(e.ChatURL), "/"); chatURL != "" { + return chatURL + } + } + return ProviderRequestURL(e.Kind, e.BaseURL) +} + +// CatalogForProviderEntry resolves metadata for installed connections. Falling +// back to Name keeps hidden legacy presets useful without listing them for new +// connections. +func CatalogForProviderEntry(e *ProviderEntry) (string, ProviderCatalog, bool) { + if e == nil { + return "", ProviderCatalog{}, false + } + ids := []string{strings.TrimSpace(e.PresetID), strings.TrimSpace(e.Name)} + for _, id := range ids { + if id == "" { + continue + } + if preset, ok := CuratedProviderPreset(id); ok { + return preset.ID, CatalogForProviderPreset(preset), true + } + } + return "", ProviderCatalog{}, false +} + +func recommendedProviderRequestURL(kind string, catalog ProviderCatalog) string { + route, ok := catalog.Protocols[normalizedProviderProtocol(kind)] + if !ok { + return "" + } + return ProviderRequestURL(kind, route.BaseURL) +} + +// ProviderEndpointMismatchForEntry validates only explicit, recognizable +// conflicts. Unknown paths, hosts, query strings and fragments are preserved. +func ProviderEndpointMismatchForEntry(e *ProviderEntry) *ProviderEndpointMismatch { + if e == nil { + return nil + } + kind := normalizedProviderProtocol(e.Kind) + expectedSuffix := providerProtocolSuffix(kind) + requestURL := ProviderEffectiveRequestURL(e) + if expectedSuffix == "" || requestURL == "" { + return nil + } + u, err := url.Parse(requestURL) + if err != nil || u.Scheme == "" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return nil + } + path := strings.TrimRight(u.EscapedPath(), "/") + recommended := "" + _, catalog, hasCatalog := CatalogForProviderEntry(e) + if hasCatalog { + recommended = recommendedProviderRequestURL(kind, catalog) + if recommendedURL, parseErr := url.Parse(recommended); parseErr == nil && + strings.EqualFold(recommendedURL.Scheme, u.Scheme) && + strings.EqualFold(recommendedURL.Host, u.Host) && + strings.TrimRight(recommendedURL.EscapedPath(), "/") == path { + return nil + } + } + for _, suffix := range []string{"/v1/messages", "/messages", "/chat/completions", "/responses"} { + if strings.HasSuffix(path, suffix) && !strings.HasSuffix(path, expectedSuffix) { + return &ProviderEndpointMismatch{Protocol: kind, RequestURL: requestURL, Recommended: recommended} + } + } + if !hasCatalog || !strings.HasSuffix(path, expectedSuffix) { + return nil + } + selectedRoute, selected := catalog.Protocols[kind] + if !selected { + return nil + } + selectedBase, err := url.Parse(selectedRoute.BaseURL) + if err != nil || !strings.EqualFold(selectedBase.Host, u.Host) { + return nil + } + for otherKind, route := range catalog.Protocols { + if normalizedProviderProtocol(otherKind) == kind { + continue + } + otherBase, parseErr := url.Parse(ProviderRequestURL(otherKind, route.BaseURL)) + if parseErr != nil || !strings.EqualFold(otherBase.Host, u.Host) { + continue + } + otherSuffix := providerProtocolSuffix(otherKind) + foreignRequestPath := strings.TrimRight(otherBase.EscapedPath(), "/") + foreignRoot := strings.TrimSuffix(foreignRequestPath, otherSuffix) + if foreignRoot == "" || foreignRoot == "/" { + continue + } + if path == foreignRoot+expectedSuffix { + return &ProviderEndpointMismatch{Protocol: kind, RequestURL: requestURL, Recommended: recommended} + } + } + return nil +} + +func ValidateProviderEndpoint(e *ProviderEntry) error { + if mismatch := ProviderEndpointMismatchForEntry(e); mismatch != nil { + return mismatch + } + return nil +} diff --git a/internal/config/provider_endpoint_contract_test.go b/internal/config/provider_endpoint_contract_test.go new file mode 100644 index 0000000000..dc2dc67d93 --- /dev/null +++ b/internal/config/provider_endpoint_contract_test.go @@ -0,0 +1,61 @@ +package config + +import "testing" + +func TestDeepSeekEndpointContract(t *testing.T) { + preset, ok := CuratedProviderPreset("deepseek-anthropic") + if !ok { + t.Fatal("hidden DeepSeek preset missing") + } + catalog := CatalogForProviderPreset(preset) + want := map[string]string{ + "openai": "https://api.deepseek.com/v1/chat/completions", + "responses": "https://api.deepseek.com/responses", + "anthropic": "https://api.deepseek.com/anthropic/v1/messages", + } + for kind, requestURL := range want { + route, exists := catalog.Protocols[kind] + if !exists { + t.Fatalf("DeepSeek %s route missing", kind) + } + if got := ProviderRequestURL(kind, route.BaseURL); got != requestURL { + t.Fatalf("DeepSeek %s request URL = %q, want %q", kind, got, requestURL) + } + } +} + +func TestProviderEndpointMismatchIsConservative(t *testing.T) { + cases := []struct { + name string + entry ProviderEntry + mismatch bool + }{ + {"foreign standard suffix", ProviderEntry{Kind: "openai", RequestURL: "https://gateway.test/v1/messages"}, true}, + {"deepseek hybrid path", ProviderEntry{Name: "deepseek-anthropic", Kind: "openai", RequestURL: "https://api.deepseek.com/anthropic/v1/chat/completions"}, true}, + {"deepseek official chat", ProviderEntry{Name: "deepseek-anthropic", Kind: "openai", RequestURL: "https://api.deepseek.com/v1/chat/completions"}, false}, + {"mimo shared openai and responses root", ProviderEntry{Name: "mimo-api", Kind: "openai", BaseURL: "https://api.xiaomimimo.com/v1"}, false}, + {"deepseek accepted root alias", ProviderEntry{Name: "deepseek-anthropic", Kind: "openai", RequestURL: "https://api.deepseek.com/chat/completions"}, false}, + {"custom host", ProviderEntry{Name: "deepseek-anthropic", Kind: "openai", RequestURL: "https://gateway.test/anthropic/v1/chat/completions"}, false}, + {"query override", ProviderEntry{Name: "deepseek-anthropic", Kind: "openai", RequestURL: "https://api.deepseek.com/anthropic/v1/chat/completions?token=x"}, false}, + {"unknown path", ProviderEntry{Name: "deepseek-anthropic", Kind: "openai", RequestURL: "https://api.deepseek.com/custom/route"}, false}, + {"custom path in foreign namespace", ProviderEntry{Name: "deepseek-anthropic", Kind: "openai", RequestURL: "https://api.deepseek.com/anthropic/custom/chat/completions"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ProviderEndpointMismatchForEntry(&tc.entry) + if (got != nil) != tc.mismatch { + t.Fatalf("mismatch = %+v, want %v", got, tc.mismatch) + } + if got != nil && tc.entry.Name == "deepseek-anthropic" && got.Recommended != "https://api.deepseek.com/v1/chat/completions" { + t.Fatalf("recommendation = %q", got.Recommended) + } + }) + } +} + +func TestCatalogForProviderEntryUsesHiddenLegacyPreset(t *testing.T) { + id, catalog, ok := CatalogForProviderEntry(&ProviderEntry{Name: "deepseek-anthropic"}) + if !ok || id != "deepseek-anthropic" || catalog.BrandID != "deepseek" || catalog.Region != "global" || catalog.Product != "api" { + t.Fatalf("catalog identity = %q %+v %v", id, catalog, ok) + } +} diff --git a/internal/config/provider_protocol_endpoints.json b/internal/config/provider_protocol_endpoints.json index 5c8a194e16..0e49aaa8f6 100644 --- a/internal/config/provider_protocol_endpoints.json +++ b/internal/config/provider_protocol_endpoints.json @@ -34,6 +34,11 @@ } }, "deepseek|global|api": { + "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/", diff --git a/internal/config/provider_protocol_endpoints_test.go b/internal/config/provider_protocol_endpoints_test.go index 31fe9dbe7d..39dad28a2e 100644 --- a/internal/config/provider_protocol_endpoints_test.go +++ b/internal/config/provider_protocol_endpoints_test.go @@ -18,6 +18,7 @@ func TestDocumentedProtocolEndpoints(t *testing.T) { } } cases := []struct{ id, kind, want string }{ + {"deepseek-anthropic", "anthropic", "https://api.deepseek.com/anthropic"}, {"kimi-coding-plan", "openai", "https://api.kimi.com/coding/v1"}, {"siliconflow", "anthropic", "https://api.siliconflow.cn"}, {"ppio", "openai", "https://api.ppio.com/openai"}, diff --git a/internal/control/controller.go b/internal/control/controller.go index 7916c9c3cf..d38b98d79d 100644 --- a/internal/control/controller.go +++ b/internal/control/controller.go @@ -1112,6 +1112,7 @@ func (c *Controller) finishGuardedTurn(err error, completion *guardedTurnComplet } done = c.applyTurnDoneProtocol(done, cancelRequested) done.Diagnostic = provider.DiagnoseFailure(err) + done.Detail = provider.FailureDiagnosticDetail(done.Diagnostic) if !cancelRequested { done.ProtocolRecovery = c.executor.PendingProtocolRecovery() } diff --git a/internal/control/errmsg.go b/internal/control/errmsg.go index 14cfc7f6bf..adb26a2519 100644 --- a/internal/control/errmsg.go +++ b/internal/control/errmsg.go @@ -33,48 +33,55 @@ func explainError(err error) error { return &explainedError{msg: explainRecoveryWait(wait), cause: err} } if provider.IsStreamInterrupted(err) { - return fmt.Errorf("model stream interrupted after recovery attempts: %s. The partial response was kept; retry or ask Reasonix to continue", err.Error()) + return &explainedError{msg: fmt.Sprintf("model stream interrupted after recovery attempts: %s. The partial response was kept; retry or ask Reasonix to continue", err.Error()), cause: err} } if provider.IsConnReset(err) { - return fmt.Errorf("model stream disconnected before completion after retry attempts: %s. Check the provider/proxy connection, then retry or ask Reasonix to continue", err.Error()) + return &explainedError{msg: fmt.Sprintf("model stream disconnected before completion after retry attempts: %s. Check the provider/proxy connection, then retry or ask Reasonix to continue", err.Error()), cause: err} } // An overflow without token numbers has nothing to quote; the generic 400 // branch below keeps the provider's own reason instead of zeros. if limit := provider.AsContextLimitError(err); limit != nil && limit.WindowTokens > 0 { msg := fmt.Sprintf(i18n.M.ProviderErrContextOverflowFmt, limit.PromptTokens, limit.CompletionTokens, limit.RequestedTokens, limit.WindowTokens) if reason := apiErrorReason(limit.APIError); reason != "" { - return fmt.Errorf("%s\n%s", msg, reason) + msg = fmt.Sprintf("%s\n%s", msg, reason) } - return errors.New(msg) + label := "" + if limit.APIError != nil { + label = provider.ProviderDisplayLabel(limit.APIError.Provider, limit.APIError.ProviderDisplayName, limit.APIError.Protocol) + } + return &explainedError{msg: providerFailureMessage(label, limit.APIError, msg), cause: err} } if quota := provider.AsQuotaError(err); quota != nil { - return fmt.Errorf(i18n.M.ProviderErrQuotaExhaustedFmt, quota.Provider, quota.Status) + label := provider.ProviderDisplayLabel(quota.Provider, quota.ProviderDisplayName, quota.Protocol) + return &explainedError{msg: fmt.Sprintf(i18n.M.ProviderErrQuotaExhaustedFmt, label, quota.Status), cause: err} } var apiErr *provider.APIError if errors.As(err, &apiErr) { + label := provider.ProviderDisplayLabel(apiErr.Provider, apiErr.ProviderDisplayName, apiErr.Protocol) if provider.IsOpaqueBadRequest(err) { if trace := provider.DiagnoseFailure(err).TraceID; trace != "" { - return fmt.Errorf("%s\nTrace ID: %s", i18n.M.ProviderErrReasonMissing, trace) + return &explainedError{msg: providerFailureMessage(label, apiErr, fmt.Sprintf("%s\nTrace ID: %s", i18n.M.ProviderErrReasonMissing, trace)), cause: err} } - return errors.New(i18n.M.ProviderErrReasonMissing) + return &explainedError{msg: providerFailureMessage(label, apiErr, i18n.M.ProviderErrReasonMissing), cause: err} } if msg := providerContentSafetyMessage(apiErr); msg != "" { if reason := apiErrorReason(apiErr); reason != "" { - return fmt.Errorf("%s\n%s", msg, reason) + msg = fmt.Sprintf("%s\n%s", msg, reason) } - return errors.New(msg) + return &explainedError{msg: providerFailureMessage(label, apiErr, msg), cause: err} } msg := i18n.M.ProviderStatusMessage(apiErr.Status) if msg == "" { return err } if reason := apiErrorReason(apiErr); reason != "" { - return fmt.Errorf("%s\n%s", msg, reason) + msg = fmt.Sprintf("%s\n%s", msg, reason) } - return errors.New(msg) + return &explainedError{msg: providerFailureMessage(label, apiErr, msg), cause: err} } var authErr *provider.AuthError if errors.As(err, &authErr) { + label := provider.ProviderDisplayLabel(authErr.Provider, authErr.ProviderDisplayName, authErr.Protocol) reason := redactAuthReason(providerBodyReason(authErr.Body)) if modelFormatMismatchReason(reason) { details := []string{i18n.M.ProviderErrModelFormatMismatch} @@ -86,7 +93,7 @@ func explainError(err error) error { if reason != "" { details = append(details, reason) } - return errors.New(strings.Join(details, "\n")) + return &explainedError{msg: providerFailureMessage(label, authErr, strings.Join(details, "\n")), cause: err} } msg := i18n.M.ProviderErrAuth if authErr.HasKey { @@ -102,13 +109,27 @@ func explainError(err error) error { // not entitled to the model) — as diagnostic here as on APIError, but // auth bodies also echo credentials, so scrub key material first. if reason != "" { - return fmt.Errorf("%s\n%s", msg, reason) + msg = fmt.Sprintf("%s\n%s", msg, reason) } - return errors.New(msg) + return &explainedError{msg: providerFailureMessage(label, authErr, msg), cause: err} } return err } +func providerFailureMessage(label string, source any, message string) string { + hasDisplayIdentity := false + switch value := source.(type) { + case *provider.APIError: + hasDisplayIdentity = value != nil && (strings.TrimSpace(value.ProviderDisplayName) != "" || strings.TrimSpace(value.Protocol) != "") + case *provider.AuthError: + hasDisplayIdentity = value != nil && (strings.TrimSpace(value.ProviderDisplayName) != "" || strings.TrimSpace(value.Protocol) != "") + } + if !hasDisplayIdentity || strings.TrimSpace(label) == "" { + return message + } + return label + ": " + message +} + // explainedError shows the localized message while keeping the typed cause // reachable, so DiagnoseFailure on the TurnDone error still classifies it. type explainedError struct { diff --git a/internal/control/errmsg_test.go b/internal/control/errmsg_test.go index 334e2a537f..359e713f93 100644 --- a/internal/control/errmsg_test.go +++ b/internal/control/errmsg_test.go @@ -79,6 +79,17 @@ func TestExplainError(t *testing.T) { } } + notFoundCause := &provider.APIError{Provider: "deepseek-anthropic", ProviderDisplayName: "Deepseek2", Protocol: "openai", Status: 404} + notFound := explainError(notFoundCause) + for _, want := range []string{"Deepseek2 · Chat Completions", i18n.M.ProviderErrNotFound} { + if !strings.Contains(notFound.Error(), want) { + t.Errorf("404 = %q, want %q", notFound.Error(), want) + } + } + if d := provider.DiagnoseFailure(notFound); d.ProviderID != "deepseek-anthropic" || d.ProviderDisplayName != "Deepseek2" || d.Protocol != "openai" || d.Status != 404 { + t.Fatalf("explained 404 diagnostic = %+v", d) + } + jsonBody := explainError(&provider.APIError{Provider: "deepseek", Status: 400, Body: `{"error":{"message":"This model's maximum context length is 65536 tokens.","type":"invalid_request_error"}}`}) if !strings.Contains(jsonBody.Error(), i18n.M.ProviderErrBadRequest) || !strings.Contains(jsonBody.Error(), "maximum context length") { t.Errorf("400 should append the provider reason from a JSON body, got %q", jsonBody.Error()) diff --git a/internal/event/notice_codes.go b/internal/event/notice_codes.go index b9231fb65a..82ab0d38f9 100644 --- a/internal/event/notice_codes.go +++ b/internal/event/notice_codes.go @@ -18,6 +18,7 @@ const ( NoticeCodeWorkspaceLease = "workspace_lease" NoticeCodeBackgroundJobFinished = "background_job_finished" NoticeCodeCancelledTurn = "cancelled_turn_display" + NoticeCodeProviderRequestFailed = "provider_request_failed" NoticeCodeStreamInterruptedIdleTimeout = "stream_interrupted_idle_timeout" NoticeCodeStreamInterruptedPrematureEOF = "stream_interrupted_premature_eof" NoticeCodeStreamInterruptedConnectionReset = "stream_interrupted_connection_reset" diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index dcff7ff626..3d0342cca5 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -577,6 +577,7 @@ type Messages struct { SearchSourcesNotProvided string ProtocolRecoveryLabel string ProviderErrInsufficientBalance string // 402 + ProviderErrNotFound string // 404 ProviderErrUnprocessable string // 422 ProviderErrInputSensitive string // MiniMax 1026 ProviderErrOutputSensitive string // MiniMax 1027 @@ -656,6 +657,8 @@ func (m Messages) ProviderStatusMessage(status int) string { return m.ProviderErrAuth case 402: return m.ProviderErrInsufficientBalance + case 404: + return m.ProviderErrNotFound case 422: return m.ProviderErrUnprocessable case 429: diff --git a/internal/i18n/messages_en.go b/internal/i18n/messages_en.go index 4c49e246b2..30307fc5e1 100644 --- a/internal/i18n/messages_en.go +++ b/internal/i18n/messages_en.go @@ -537,6 +537,7 @@ var English = Messages{ SearchSourcesNotProvided: "Search completed; the provider did not supply usable structured sources.", ProtocolRecoveryLabel: "Recover from valid history", ProviderErrInsufficientBalance: "Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.", + ProviderErrNotFound: "Request endpoint not found (HTTP 404). Check the API format and request address.", ProviderErrUnprocessable: "Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.", ProviderErrInputSensitive: "MiniMax rejected the input during content review (error 1026). The review may include conversation history and tool results; adjust the relevant content or start a new session with only the necessary context. Repeating the same request is unlikely to help.", ProviderErrOutputSensitive: "MiniMax rejected the generated output during content review (error 1027). Adjust the request and try again, or use another provider if the rejection persists.", diff --git a/internal/i18n/messages_zh.go b/internal/i18n/messages_zh.go index ff99b034d5..d6e0975f65 100644 --- a/internal/i18n/messages_zh.go +++ b/internal/i18n/messages_zh.go @@ -538,6 +538,7 @@ var Chinese = Messages{ SearchSourcesNotProvided: "搜索已完成,供应商未提供可用的结构化来源。", ProtocolRecoveryLabel: "从有效历史恢复", ProviderErrInsufficientBalance: "余额不足 (HTTP 402):账户余额不足,请前往充值后重试。", + ProviderErrNotFound: "请求地址不存在(HTTP 404)。请检查 API 格式和请求地址。", ProviderErrUnprocessable: "参数错误 (HTTP 422):某个请求参数被拒绝,通常是程序缺陷。若持续出现请反馈。", ProviderErrInputSensitive: "输入被 MiniMax 内容审查拒绝(错误码 1026)。审查对象可能包含会话历史和工具结果;请调整相关内容,或新建会话仅保留必要上下文。原样重试通常无效。", ProviderErrOutputSensitive: "MiniMax 生成的内容被内容审查拒绝(错误码 1027)。请调整请求内容后重试;若持续出现,可改用其他服务商。", diff --git a/internal/i18n/messages_zh_tw.go b/internal/i18n/messages_zh_tw.go index 6fbc7c73b1..d7c847a2be 100644 --- a/internal/i18n/messages_zh_tw.go +++ b/internal/i18n/messages_zh_tw.go @@ -510,6 +510,7 @@ var ChineseTraditional = Messages{ SearchSourcesNotProvided: "搜尋已完成,供應商未提供可用的結構化來源。", ProtocolRecoveryLabel: "從有效歷史恢復", ProviderErrInsufficientBalance: "餘額不足 (HTTP 402):帳戶餘額不足,請前往儲值後重試。", + ProviderErrNotFound: "請求位址不存在(HTTP 404)。請檢查 API 格式和請求位址。", ProviderErrUnprocessable: "參數錯誤 (HTTP 422):某個請求參數被拒絕,通常是程式缺陷。若持續出現請回報。", ProviderErrInputSensitive: "輸入被 MiniMax 內容審查拒絕(錯誤碼 1026)。審查對象可能包含對話歷史和工具結果;請調整相關內容,或建立新對話只保留必要上下文。原樣重試通常無效。", ProviderErrOutputSensitive: "MiniMax 產生的內容被內容審查拒絕(錯誤碼 1027)。請調整請求內容後重試;若持續出現,可改用其他服務商。", diff --git a/internal/provider/anthropic/anthropic.go b/internal/provider/anthropic/anthropic.go index f648dc100c..e3235f3b62 100644 --- a/internal/provider/anthropic/anthropic.go +++ b/internal/provider/anthropic/anthropic.go @@ -157,6 +157,7 @@ func New(cfg provider.Config) (provider.Provider, error) { identityHeaders: provider.NewClientIdentityHeaders(), reasoning: ReasoningForConfig(cfg), name: name, + identity: provider.RequestIdentity{Provider: name, DisplayName: cfg.DisplayName, Protocol: cfg.Protocol}, apiKey: cfg.APIKey, keyEnv: keyEnv, keySource: keySource, @@ -193,6 +194,7 @@ type client struct { identityHeaders http.Header reasoning provider.ReasoningCapability name string + identity provider.RequestIdentity apiKey string keyEnv string // api_key_env name, surfaced in auth errors keySource string // source of keyEnv, surfaced in auth errors @@ -266,11 +268,13 @@ func (c *client) MissingToolCallReasoningWarningIdentity() string { func (c *client) sendOpts() provider.SendOptions { return provider.SendOptions{ - Provider: c.name, - KeyEnv: c.keyEnv, - KeySource: c.keySource, - KeyPresent: c.apiKey != "", - RetryAuth: c.authed.Load(), + Provider: c.name, + ProviderDisplayName: c.identity.DisplayName, + Protocol: c.identity.Protocol, + KeyEnv: c.keyEnv, + KeySource: c.keySource, + KeyPresent: c.apiKey != "", + RetryAuth: c.authed.Load(), } } diff --git a/internal/provider/failure_diagnostic.go b/internal/provider/failure_diagnostic.go index d793fb2a33..83bb9516cc 100644 --- a/internal/provider/failure_diagnostic.go +++ b/internal/provider/failure_diagnostic.go @@ -4,14 +4,91 @@ import ( "context" "encoding/json" "errors" + "fmt" "strings" ) +// RequestIdentity keeps the stable connection key separate from the +// user-editable label and the selected wire protocol. +type RequestIdentity struct { + Provider string + DisplayName string + Protocol string +} + +// RequestFailure preserves connection identity for failures that happen before +// an HTTP status exists, while retaining the original error for classification. +type RequestFailure struct { + Identity RequestIdentity + Operation string + Err error +} + +func (e *RequestFailure) Error() string { + return fmt.Sprintf("%s: %s: %v", ProviderDisplayLabel(e.Identity.Provider, e.Identity.DisplayName, e.Identity.Protocol), e.Operation, e.Err) +} + +func (e *RequestFailure) Unwrap() error { return e.Err } + +func ProtocolDisplayName(kind string) string { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "openai": + return "Chat Completions" + case "anthropic": + return "Anthropic Messages" + case "responses": + return "Responses" + case "dashscope-responses": + return "DashScope Responses" + default: + return strings.TrimSpace(kind) + } +} + +func ProviderDisplayLabel(providerID, displayName, protocol string) string { + name := strings.TrimSpace(displayName) + if name == "" { + name = strings.TrimSpace(providerID) + } + protocolName := ProtocolDisplayName(protocol) + if name == "" { + return protocolName + } + if protocolName == "" { + return name + } + return name + " · " + protocolName +} + // FailureDiagnostic contains safe classification only, never response bodies. type FailureDiagnostic struct { - Kind string `json:"kind"` - Status int `json:"status,omitempty"` - TraceID string `json:"traceId,omitempty"` + Kind string `json:"kind"` + Status int `json:"status,omitempty"` + TraceID string `json:"traceId,omitempty"` + ProviderID string `json:"providerId,omitempty"` + ProviderDisplayName string `json:"providerDisplayName,omitempty"` + Protocol string `json:"protocol,omitempty"` + RequestPath string `json:"requestPath,omitempty"` +} + +// FailureDiagnosticDetail renders the safe operator fields shared by live and +// persisted failure notices. It intentionally excludes display identity and +// any request query or credentials. +func FailureDiagnosticDetail(d *FailureDiagnostic) string { + if d == nil { + return "" + } + detail := "" + if d.ProviderID != "" { + detail = "Connection ID: " + d.ProviderID + } + if d.RequestPath != "" { + if detail != "" { + detail += "\n" + } + detail += "Request path: " + d.RequestPath + } + return detail } func DiagnoseFailure(err error) *FailureDiagnostic { @@ -19,9 +96,27 @@ func DiagnoseFailure(err error) *FailureDiagnostic { return nil } d := &FailureDiagnostic{Kind: "unknown"} + var request *RequestFailure + if errors.As(err, &request) { + d.ProviderID = request.Identity.Provider + d.ProviderDisplayName = request.Identity.DisplayName + d.Protocol = request.Identity.Protocol + } + var quota *QuotaError + if errors.As(err, "a) { + d.ProviderID = quota.Provider + d.ProviderDisplayName = quota.ProviderDisplayName + d.Protocol = quota.Protocol + } var api *APIError if errors.As(err, &api) { d.Status = api.Status + d.ProviderID = api.Provider + d.ProviderDisplayName = api.ProviderDisplayName + d.Protocol = api.Protocol + if len(api.RequestPath) <= 512 && strings.HasPrefix(api.RequestPath, "/") { + d.RequestPath = api.RequestPath + } // Trace identifiers are opaque tokens, not arbitrary header text. if len(api.TraceID) <= 128 && strings.IndexFunc(api.TraceID, func(r rune) bool { return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("-_.:", r)) @@ -42,6 +137,9 @@ func DiagnoseFailure(err error) *FailureDiagnostic { var auth *AuthError if errors.As(err, &auth) { d.Status = auth.Status + d.ProviderID = auth.Provider + d.ProviderDisplayName = auth.ProviderDisplayName + d.Protocol = auth.Protocol } case AsContextLimitError(err) != nil || AsOutputLimitError(err) != nil: d.Kind = "limit" diff --git a/internal/provider/failure_diagnostic_test.go b/internal/provider/failure_diagnostic_test.go index f049dd0973..02b4f7c43b 100644 --- a/internal/provider/failure_diagnostic_test.go +++ b/internal/provider/failure_diagnostic_test.go @@ -2,6 +2,7 @@ package provider import ( "encoding/json" + "errors" "strings" "testing" ) @@ -23,6 +24,75 @@ func TestFailureDiagnosticOpaqueAndSafe(t *testing.T) { t.Fatal("nil error diagnostic") } } + +func TestFailureDiagnosticKeepsDisplayAndStableIdentitySeparate(t *testing.T) { + err := &APIError{Provider: "deepseek-anthropic", ProviderDisplayName: "Deepseek2", Protocol: "openai", Status: 404, RequestPath: "/anthropic/v1/chat/completions"} + d := DiagnoseFailure(err) + if d.ProviderID != "deepseek-anthropic" || d.ProviderDisplayName != "Deepseek2" || d.Protocol != "openai" || d.Status != 404 || d.RequestPath != "/anthropic/v1/chat/completions" { + t.Fatalf("diagnostic = %+v", d) + } + if got := err.Error(); got != "Deepseek2 · Chat Completions: status 404" { + t.Fatalf("display error = %q", got) + } +} + +func TestRequestFailureKeepsDisplayAndStableIdentitySeparate(t *testing.T) { + cause := errors.New("invalid request URL") + err := &RequestFailure{ + Identity: RequestIdentity{Provider: "deepseek-anthropic", DisplayName: "Deepseek2", Protocol: "openai"}, + Operation: "build request", + Err: cause, + } + d := DiagnoseFailure(err) + if d.ProviderID != "deepseek-anthropic" || d.ProviderDisplayName != "Deepseek2" || d.Protocol != "openai" { + t.Fatalf("diagnostic identity = %+v", d) + } + if !errors.Is(err, cause) || strings.Contains(err.Error(), "deepseek-anthropic") || !strings.Contains(err.Error(), "Deepseek2 · Chat Completions") { + t.Fatalf("request error did not preserve cause and display identity: %v", err) + } +} + +func TestFailureDiagnosticDetailUsesOnlySafeOperatorFields(t *testing.T) { + diagnostic := &FailureDiagnostic{ + ProviderID: "deepseek-anthropic", + ProviderDisplayName: "Deepseek2", + Protocol: "openai", + RequestPath: "/anthropic/v1/chat/completions", + TraceID: "trace-secret", + } + if got, want := FailureDiagnosticDetail(diagnostic), "Connection ID: deepseek-anthropic\nRequest path: /anthropic/v1/chat/completions"; got != want { + t.Fatalf("FailureDiagnosticDetail() = %q, want %q", got, want) + } +} + +func TestInterruptedTurnRecoveryOptionalFieldsRemainBackwardCompatible(t *testing.T) { + type legacyRecovery struct { + Pending bool `json:"pending,omitempty"` + InterruptedTools []string `json:"interrupted_tools,omitempty"` + } + current := InterruptedTurnRecovery{ + TerminalStatus: "failed", FailureDiagnostic: &FailureDiagnostic{Kind: "request", Status: 404}, + Pending: true, InterruptedTools: []string{"bash"}, + } + raw, err := json.Marshal(current) + if err != nil { + t.Fatal(err) + } + var legacy legacyRecovery + if err := json.Unmarshal(raw, &legacy); err != nil { + t.Fatalf("legacy reader rejected optional fields: %v", err) + } + if !legacy.Pending || len(legacy.InterruptedTools) != 1 || legacy.InterruptedTools[0] != "bash" { + t.Fatalf("legacy fields lost: %+v", legacy) + } + var old InterruptedTurnRecovery + if err := json.Unmarshal([]byte(`{"pending":true,"interrupted_tools":["bash"]}`), &old); err != nil { + t.Fatalf("current reader rejected legacy record: %v", err) + } + if old.TerminalStatus != "" || old.FailureDiagnostic != nil || !old.Pending { + t.Fatalf("legacy defaults changed: %+v", old) + } +} func TestSearchStatusStaysOutsideReplay(t *testing.T) { raw := json.RawMessage(`{"type":"web_search_call","id":"s","status":"completed","opaque":"proof"}`) call := ServerSearchCall{ID: "s", Raw: raw} diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go index ebc9f265ff..54a657ec80 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -238,6 +238,7 @@ func New(cfg provider.Config) (provider.Provider, error) { identityHeaders: provider.NewClientIdentityHeaders(), reasoningState: reasoningState{ollamaCloud: ollamaCloud, thinkingLocked: configuredThinkingType(cfg) == "disabled", reasoning: ReasoningForConfig(cfg)}, name: name, + identity: provider.RequestIdentity{Provider: name, DisplayName: cfg.DisplayName, Protocol: cfg.Protocol}, apiKey: cfg.APIKey, keyEnv: keyEnv, keySource: keySource, @@ -278,6 +279,7 @@ type client struct { identityHeaders http.Header reasoningState name string + identity provider.RequestIdentity apiKey string keyEnv string // api_key_env name, surfaced in auth errors keySource string // source of keyEnv, surfaced in auth errors @@ -379,11 +381,13 @@ func (c *client) MissingToolCallReasoningWarningIdentity() string { func (c *client) sendOpts() provider.SendOptions { return provider.SendOptions{ - Provider: c.name, - KeyEnv: c.keyEnv, - KeySource: c.keySource, - KeyPresent: c.apiKey != "", - RetryAuth: c.authed.Load(), + Provider: c.name, + ProviderDisplayName: c.identity.DisplayName, + Protocol: c.identity.Protocol, + KeyEnv: c.keyEnv, + KeySource: c.keySource, + KeyPresent: c.apiKey != "", + RetryAuth: c.authed.Load(), } } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index b503929285..e69ae308b5 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -1022,11 +1022,13 @@ func MissingToolCallReasoningWarningFingerprint(p Provider) string { // Config is a resolved provider instance configuration. type Config struct { - Name string // instance name, e.g. "deepseek" - BaseURL string // OpenAI-compatible endpoint - Model string // model id - APIKey string // resolved from api_key_env - Extra map[string]any // kind-specific options + Name string // stable instance id, e.g. "deepseek-anthropic" + DisplayName string // user-editable label; empty falls back to Name + Protocol string // configured wire adapter id + BaseURL string // OpenAI-compatible endpoint + Model string // model id + APIKey string // resolved from api_key_env + Extra map[string]any // kind-specific options // ModelInfo is adapter-owned metadata for the exact model instance. It is // optional so existing third-party factories remain source-compatible. ModelInfo *ModelInfo @@ -1042,12 +1044,14 @@ type Config struct { // Body and extract it themselves. Providersshould return this (rather than a generic status error) // forauthfailures. type AuthError struct { - Provider string // the provider instance name, e.g. "deepseek" - KeyEnv string // the api_key_env the key is read from, when known - KeySource string // human-readable source of KeyEnv, when known - Status int // the HTTP status (401 or 403) - HasKey bool // a non-empty key was sent — the server rejected it, vs. no key configured at all - Body string // trimmed response-body snippet, the server's verbatim reason when it gave one + Provider string // stable provider instance id, e.g. "deepseek" + ProviderDisplayName string // user-editable display label + Protocol string // configured wire adapter id + KeyEnv string // the api_key_env the key is read from, when known + KeySource string // human-readable source of KeyEnv, when known + Status int // the HTTP status (401 or 403) + HasKey bool // a non-empty key was sent — the server rejected it, vs. no key configured at all + Body string // trimmed response-body snippet, the server's verbatim reason when it gave one } func (e *AuthError) Error() string { @@ -1059,7 +1063,7 @@ func (e *AuthError) Error() string { key += " from " + e.KeySource } return fmt.Sprintf("authentication failed for provider %q (HTTP %d): %s is invalid or expired — update it (in .env or your environment) and retry, or run `reasonix setup`", - e.Provider, e.Status, key) + ProviderDisplayLabel(e.Provider, e.ProviderDisplayName, e.Protocol), e.Status, key) } // Factory builds a Provider from a resolved Config. diff --git a/internal/provider/quota_error.go b/internal/provider/quota_error.go index 3dbee20b17..3f113e31b2 100644 --- a/internal/provider/quota_error.go +++ b/internal/provider/quota_error.go @@ -11,22 +11,32 @@ import ( // including gateways which encode billing failures as HTTP 401 or 429. Raw // bodies may contain private billing URLs or credentials and are not retained. type QuotaError struct { - Provider string - Status int - Code string + Provider string + ProviderDisplayName string + Protocol string + Status int + Code string } func (e *QuotaError) Error() string { - return fmt.Sprintf("provider %q credits or subscription quota exhausted (HTTP %d); check account allowance before continuing", e.Provider, e.Status) + return fmt.Sprintf("provider %q credits or subscription quota exhausted (HTTP %d); check account allowance before continuing", ProviderDisplayLabel(e.Provider, e.ProviderDisplayName, e.Protocol), e.Status) } // Unwrap preserves status-based APIError consumers without exposing the raw // billing response or making a quota rejection look like an AuthError. func (e *QuotaError) Unwrap() error { - return &APIError{Provider: e.Provider, Status: e.Status} + return &APIError{Provider: e.Provider, ProviderDisplayName: e.ProviderDisplayName, Protocol: e.Protocol, Status: e.Status} } func QuotaErrorFromResponse(name string, status int, body string) *QuotaError { + return quotaErrorFromResponse(name, "", "", status, body) +} + +func QuotaErrorFromResponseWithIdentity(name, displayName, protocol string, status int, body string) *QuotaError { + return quotaErrorFromResponse(name, displayName, protocol, status, body) +} + +func quotaErrorFromResponse(name, displayName, protocol string, status int, body string) *QuotaError { var v struct { Error struct { Code string `json:"code"` @@ -42,11 +52,11 @@ func QuotaErrorFromResponse(name string, status int, body string) *QuotaError { lower := strings.ToLower(body) for _, marker := range []string{"insufficient_quota", "insufficient balance", "insufficient token quota", "out of budget", "quota exceeded", "freeusagelimiterror", "gousagelimiterror", "creditserror", "monthly usage limit reached", "available balance"} { if strings.Contains(lower, marker) { - return &QuotaError{Provider: name, Status: status, Code: code} + return &QuotaError{Provider: name, ProviderDisplayName: displayName, Protocol: protocol, Status: status, Code: code} } } if status == 402 { - return &QuotaError{Provider: name, Status: status, Code: code} + return &QuotaError{Provider: name, ProviderDisplayName: displayName, Protocol: protocol, Status: status, Code: code} } return nil } @@ -59,11 +69,11 @@ func AsQuotaError(err error) *QuotaError { } var api *APIError if errors.As(err, &api) { - return QuotaErrorFromResponse(api.Provider, api.Status, api.Body) + return QuotaErrorFromResponseWithIdentity(api.Provider, api.ProviderDisplayName, api.Protocol, api.Status, api.Body) } var auth *AuthError if errors.As(err, &auth) { - return QuotaErrorFromResponse(auth.Provider, auth.Status, auth.Body) + return QuotaErrorFromResponseWithIdentity(auth.Provider, auth.ProviderDisplayName, auth.Protocol, auth.Status, auth.Body) } return nil } diff --git a/internal/provider/responses/factory.go b/internal/provider/responses/factory.go index 93f5648bf2..ba41162377 100644 --- a/internal/provider/responses/factory.go +++ b/internal/provider/responses/factory.go @@ -22,7 +22,7 @@ func newFromConfig(cfg provider.Config) (provider.Provider, error) { maxOutputTokens, _ := cfg.Extra["max_output_tokens"].(int) requestURL, _ := cfg.Extra["request_url"].(string) return New(Config{ - Name: cfg.Name, APIKey: cfg.APIKey, BaseURL: cfg.BaseURL, Model: cfg.Model, + Name: cfg.Name, DisplayName: cfg.DisplayName, Protocol: cfg.Protocol, APIKey: cfg.APIKey, BaseURL: cfg.BaseURL, Model: cfg.Model, ModelInfo: cfg.ModelInfo, Effort: effort, Mode: mode, Stateful: stateful, WebSearch: webSearch, Proxy: proxy, KeyEnv: keyEnv, KeySource: keySource, MaxOutputTokens: maxOutputTokens, RequestURL: requestURL, diff --git a/internal/provider/responses/responses.go b/internal/provider/responses/responses.go index 8be3ea85be..655aa28224 100644 --- a/internal/provider/responses/responses.go +++ b/internal/provider/responses/responses.go @@ -38,19 +38,21 @@ func init() { // Config holds Responses API provider settings. type Config struct { - Name string - APIKey string - BaseURL string - Model string - ModelInfo *provider.ModelInfo - Effort string - Mode string // stateful | stateless; empty uses vendor detection. - Stateful *bool // legacy form of Mode; nil preserves vendor detection. - WebSearch bool // expose the provider-executed web_search tool. - Proxy netclient.ProxySpec - KeyEnv string - KeySource string - RequestURL string // optional exact Responses request URL; empty derives from BaseURL + Name string + DisplayName string + Protocol string + APIKey string + BaseURL string + Model string + ModelInfo *provider.ModelInfo + Effort string + Mode string // stateful | stateless; empty uses vendor detection. + Stateful *bool // legacy form of Mode; nil preserves vendor detection. + WebSearch bool // expose the provider-executed web_search tool. + Proxy netclient.ProxySpec + KeyEnv string + KeySource string + RequestURL string // optional exact Responses request URL; empty derives from BaseURL // MaxOutputTokens is the total provider output budget. Zero omits the field // on official DeepSeek (server 384K ceiling) and unknown endpoints; MiMo // still applies its 16K/32K ladder. Negative values omit it. @@ -86,7 +88,9 @@ func (c Config) mode() string { type client struct { identityHeaders http.Header reasoning provider.ReasoningCapability - name, apiKey, keyEnv, keySource string + name string + identity provider.RequestIdentity + apiKey, keyEnv, keySource string baseURL, requestURL, model, effort string vendor, mode string caps vendorCapabilities @@ -160,7 +164,9 @@ func New(cfg Config) provider.Provider { } return &client{ identityHeaders: provider.NewClientIdentityHeaders(), - name: cfg.Name, apiKey: cfg.APIKey, keyEnv: cfg.KeyEnv, keySource: cfg.KeySource, + name: cfg.Name, + identity: provider.RequestIdentity{Provider: cfg.Name, DisplayName: cfg.DisplayName, Protocol: cfg.Protocol}, + apiKey: cfg.APIKey, keyEnv: cfg.KeyEnv, keySource: cfg.KeySource, reasoning: ReasoningForConfig(provider.Config{BaseURL: cfg.BaseURL, Model: cfg.Model, Extra: cfg.Extra}), baseURL: baseURL, requestURL: requestURL, model: cfg.Model, effort: cfg.Effort, vendor: vendor, caps: cap, mode: cfg.mode(), sessionCache: sessionCache, search: provider.SearchPolicy{NativeEnabled: cfg.WebSearch, ClientEnabled: clientWebSearch}, maxOutputTokens: maxOutputTokens, @@ -213,7 +219,7 @@ func nativeToolSearchModel(model string) bool { } func (c *client) sendOpts() provider.SendOptions { - return provider.SendOptions{Provider: c.name, KeyEnv: c.keyEnv, KeySource: c.keySource, KeyPresent: c.apiKey != "", RetryAuth: c.authed.Load()} + return provider.SendOptions{Provider: c.name, ProviderDisplayName: c.identity.DisplayName, Protocol: c.identity.Protocol, KeyEnv: c.keyEnv, KeySource: c.keySource, KeyPresent: c.apiKey != "", RetryAuth: c.authed.Load()} } // ResetContext drops stateful continuation metadata. Full-input stateless mode @@ -732,7 +738,7 @@ func authErrorFromResponse(c *client, responseError *sseError) error { if strings.Contains(value, "forbidden") || strings.Contains(value, "permission") { status = http.StatusForbidden } - return &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, KeySource: c.keySource, Status: status, HasKey: c.apiKey != "", Body: responseError.Message} + return &provider.AuthError{Provider: c.name, ProviderDisplayName: c.identity.DisplayName, Protocol: c.identity.Protocol, KeyEnv: c.keyEnv, KeySource: c.keySource, Status: status, HasKey: c.apiKey != "", Body: responseError.Message} } type sseEvent struct { diff --git a/internal/provider/retry.go b/internal/provider/retry.go index a08eed6440..f0013642f7 100644 --- a/internal/provider/retry.go +++ b/internal/provider/retry.go @@ -45,11 +45,13 @@ const maxAuthRetries = 2 // SendOptions carries the per-request context SendWithRetry needs to label // errors and decide whether a 401 is worth retrying. type SendOptions struct { - Provider string // provider instance name, surfaced in errors - KeyEnv string // api_key_env the key is read from, when known - KeySource string // human-readable source of KeyEnv, when known - KeyPresent bool // a non-empty key is being sent — separates "rejected" from "missing" - RetryAuth bool // the key has authenticated before — retry transient 401s instead of failing fast + Provider string // stable provider instance id + ProviderDisplayName string // user-editable display label + Protocol string // configured wire adapter id + KeyEnv string // api_key_env the key is read from, when known + KeySource string // human-readable source of KeyEnv, when known + KeyPresent bool // a non-empty key is being sent — separates "rejected" from "missing" + RetryAuth bool // the key has authenticated before — retry transient 401s instead of failing fast } // RetryInfo describes a backoff about to happen: Attempt is the 1-based retry @@ -166,21 +168,25 @@ func recordRequestAttempt(ctx context.Context) { // carries the code so the display layer can map it to an actionable, localized // message; Body is a trimmed snippet of the response. type APIError struct { - RetryAfter time.Duration // uncapped server delay for managed recovery - ShouldRetry string // explicit provider retry hint - Provider string - Status int - Body string - TraceID string // provider trace identifier from the response headers, when present - ToolContext string // resolved Reasonix/MCP identity for provider-indexed tool schema errors + RetryAfter time.Duration // uncapped server delay for managed recovery + ShouldRetry string // explicit provider retry hint + Provider string // stable provider instance id + ProviderDisplayName string + Protocol string + Status int + Body string + TraceID string // provider trace identifier from the response headers, when present + RequestPath string // path only; query and URL userinfo are never retained + ToolContext string // resolved Reasonix/MCP identity for provider-indexed tool schema errors } func (e *APIError) Error() string { + label := ProviderDisplayLabel(e.Provider, e.ProviderDisplayName, e.Protocol) var base string if e.Body == "" { - base = fmt.Sprintf("%s: status %d", e.Provider, e.Status) + base = fmt.Sprintf("%s: status %d", label, e.Status) } else { - base = fmt.Sprintf("%s: status %d: %s", e.Provider, e.Status, e.Body) + base = fmt.Sprintf("%s: status %d: %s", label, e.Status, e.Body) } if e.ToolContext != "" { return base + "\n" + e.ToolContext @@ -286,6 +292,7 @@ func readErrorBody(resp *http.Response) []byte { // failures are not retried (the model has already emitted tokens). func SendWithRetry(ctx context.Context, httpClient *http.Client, opts SendOptions, newReq func(context.Context) (*http.Request, error)) (*http.Response, error) { notify := retryNotifyFromContext(ctx) + identity := RequestIdentity{Provider: opts.Provider, DisplayName: opts.ProviderDisplayName, Protocol: opts.Protocol} var lastErr error var retryAfter time.Duration authRetries := 0 @@ -310,15 +317,15 @@ func SendWithRetry(ctx context.Context, httpClient *http.Client, opts SendOption req, err := newReq(ctx) if err != nil { - return nil, fmt.Errorf("%s: build request: %w", opts.Provider, err) + return nil, &RequestFailure{Identity: identity, Operation: "build request", Err: err} } recordRequestAttempt(ctx) resp, err := httpClient.Do(req) if err != nil { if !transientErr(err) { - return nil, fmt.Errorf("%s: request failed: %w", opts.Provider, err) + return nil, &RequestFailure{Identity: identity, Operation: "request failed", Err: err} } - lastErr = fmt.Errorf("%s: request failed: %w", opts.Provider, err) + lastErr = &RequestFailure{Identity: identity, Operation: "request failed", Err: err} continue } if resp.StatusCode == http.StatusOK { @@ -327,12 +334,12 @@ func SendWithRetry(ctx context.Context, httpClient *http.Client, opts SendOption msg := readErrorBody(resp) retryAfter = parseRetryAfter(resp) - if quota := QuotaErrorFromResponse(opts.Provider, resp.StatusCode, string(msg)); quota != nil { + if quota := QuotaErrorFromResponseWithIdentity(opts.Provider, opts.ProviderDisplayName, opts.Protocol, resp.StatusCode, string(msg)); quota != nil { return nil, quota } if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - authErr := &AuthError{Provider: opts.Provider, KeyEnv: opts.KeyEnv, KeySource: opts.KeySource, Status: resp.StatusCode, HasKey: opts.KeyPresent, Body: strings.TrimSpace(string(msg))} + authErr := &AuthError{Provider: opts.Provider, ProviderDisplayName: opts.ProviderDisplayName, Protocol: opts.Protocol, KeyEnv: opts.KeyEnv, KeySource: opts.KeySource, Status: resp.StatusCode, HasKey: opts.KeyPresent, Body: strings.TrimSpace(string(msg))} if !ManagedRecovery(ctx) && opts.RetryAuth && authRetries < maxAuthRetries { authRetries++ lastErr = authErr @@ -341,12 +348,15 @@ func SendWithRetry(ctx context.Context, httpClient *http.Client, opts SendOption return nil, authErr } apiErr := &APIError{ - RetryAfter: retryAfter, - ShouldRetry: resp.Header.Get("x-should-retry"), - Provider: opts.Provider, - Status: resp.StatusCode, - Body: strings.TrimSpace(string(msg)), - TraceID: responseTraceID(resp.Header), + RetryAfter: retryAfter, + ShouldRetry: resp.Header.Get("x-should-retry"), + Provider: opts.Provider, + ProviderDisplayName: opts.ProviderDisplayName, + Protocol: opts.Protocol, + Status: resp.StatusCode, + Body: strings.TrimSpace(string(msg)), + TraceID: responseTraceID(resp.Header), + RequestPath: responseRequestPath(resp), } if !RetryableStatus(resp.StatusCode) { if limitErr := ParseOutputLimitError(apiErr); limitErr != nil { @@ -365,6 +375,17 @@ func SendWithRetry(ctx context.Context, httpClient *http.Client, opts SendOption return nil, lastErr } +func responseRequestPath(resp *http.Response) string { + if resp == nil || resp.Request == nil || resp.Request.URL == nil { + return "" + } + path := resp.Request.URL.EscapedPath() + if len(path) > 512 { + return path[:512] + } + return path +} + func responseTraceID(header http.Header) string { for _, name := range []string{"trace_id", "trace-id", "x-trace-id"} { if value := strings.TrimSpace(header.Get(name)); value != "" { diff --git a/internal/provider/retry_test.go b/internal/provider/retry_test.go index 57b9726eb9..43d2b647ac 100644 --- a/internal/provider/retry_test.go +++ b/internal/provider/retry_test.go @@ -7,6 +7,7 @@ import ( "io" "net" "net/http" + "net/http/httptest" "strings" "sync" "syscall" @@ -43,6 +44,28 @@ func TestRetryableStatus(t *testing.T) { } } +func TestSendWithRetryCarriesDisplayIdentityAndSanitizedRequestPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer server.Close() + _, err := SendWithRetry(context.Background(), server.Client(), SendOptions{ + Provider: "deepseek-anthropic", ProviderDisplayName: "Deepseek2", Protocol: "openai", + }, func(ctx context.Context) (*http.Request, error) { + return http.NewRequestWithContext(ctx, http.MethodPost, server.URL+"/anthropic/v1/chat/completions?token=secret", nil) + }) + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error = %T %v", err, err) + } + if apiErr.Provider != "deepseek-anthropic" || apiErr.ProviderDisplayName != "Deepseek2" || apiErr.Protocol != "openai" || apiErr.RequestPath != "/anthropic/v1/chat/completions" { + t.Fatalf("API error identity = %+v", apiErr) + } + if strings.Contains(apiErr.RequestPath, "secret") { + t.Fatalf("query leaked into request path: %q", apiErr.RequestPath) + } +} + func TestTransientErr(t *testing.T) { if transientErr(nil) { t.Error("nil should not be transient") diff --git a/internal/provider/tool_recovery.go b/internal/provider/tool_recovery.go index d3c1a96828..824c82befb 100644 --- a/internal/provider/tool_recovery.go +++ b/internal/provider/tool_recovery.go @@ -55,6 +55,8 @@ func RecordToolRecovery(r *InterruptedTurnRecovery, call InterruptedToolSummary, // provider-excluded handoff for an unfinished turn. It contains bounded facts; // raw partial reasoning remains local for display. type InterruptedTurnRecovery struct { + TerminalStatus string `json:"terminalStatus,omitempty"` // failed | interrupted; absent preserves legacy display + FailureDiagnostic *FailureDiagnostic `json:"failureDiagnostic,omitempty"` WriteChecks []WriteRecoveryCheck `json:"write_checks,omitempty"` SatisfiedWrites []InterruptedToolSummary `json:"satisfied_writes,omitempty"` Pending bool `json:"pending,omitempty"`