From 1453afba8a31ca45bb95dd23655c7d95d728c9ab Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:01:47 +0800 Subject: [PATCH] fix(provider): enforce connection protocol contracts Problem: protocol changes could combine a provider-specific base path with the wrong request suffix, surface opaque internal IDs, and reload provider failures as generic interruptions. Root cause: provider identity, protocol routes, endpoint validation, and terminal display metadata were owned by separate layers without one shared contract. Fix: attach catalog identity to saved connections, resolve official protocol routes from the documented registry, validate mismatches in the editor, save path, and runtime, carry safe display diagnostics, and persist failed versus interrupted terminal status without changing provider-visible recovery bytes. Verification: root and Desktop Go suites, focused frontend tests, TypeScript and lint checks, production bundle budgets, compatibility guards, and git diff checks pass. --- desktop/app.go | 30 ++- .../provider-editor-model-picker.test.tsx | 31 +++ .../src/__tests__/provider-endpoint.test.ts | 30 +++ .../__tests__/provider-failure-meta.test.ts | 27 +++ .../frontend/src/components/SettingsPanel.tsx | 38 ++-- .../src/lib/providerCatalog.generated.json | 10 + desktop/frontend/src/lib/providerEndpoint.ts | 98 ++++++++++ desktop/frontend/src/lib/providerProtocol.ts | 14 +- desktop/frontend/src/lib/types.ts | 8 +- desktop/frontend/src/lib/useController.ts | 2 +- desktop/frontend/src/locales/en.ts | 2 +- desktop/frontend/src/locales/zh-TW.ts | 2 +- desktop/frontend/src/locales/zh.ts | 2 +- desktop/history_provider_failure_test.go | 39 ++++ desktop/settings_app.go | 12 +- ...ettings_provider_endpoint_contract_test.go | 65 ++++++ docs/PROVIDER_PROTOCOL_ENDPOINTS.md | 2 +- docs/PROVIDER_PROTOCOL_ENDPOINTS_ZH.md | 2 +- internal/agent/agent.go | 10 +- internal/agent/incomplete_read_runtime.go | 2 +- internal/agent/interrupted_recovery.go | 4 + .../agent/provider_failure_recovery_test.go | 47 +++++ internal/agent/run_loop.go | 2 +- internal/boot/boot.go | 10 +- .../boot/provider_endpoint_contract_test.go | 21 ++ internal/config/provider_endpoint_contract.go | 185 ++++++++++++++++++ .../config/provider_endpoint_contract_test.go | 61 ++++++ .../config/provider_protocol_endpoints.json | 5 + .../provider_protocol_endpoints_test.go | 1 + internal/control/controller.go | 1 + internal/control/errmsg.go | 49 +++-- internal/control/errmsg_test.go | 11 ++ internal/event/notice_codes.go | 1 + internal/i18n/i18n.go | 3 + internal/i18n/messages_en.go | 1 + internal/i18n/messages_zh.go | 1 + internal/i18n/messages_zh_tw.go | 1 + internal/provider/anthropic/anthropic.go | 14 +- internal/provider/failure_diagnostic.go | 104 +++++++++- internal/provider/failure_diagnostic_test.go | 70 +++++++ internal/provider/openai/openai.go | 14 +- internal/provider/provider.go | 28 +-- internal/provider/quota_error.go | 28 ++- internal/provider/responses/factory.go | 2 +- internal/provider/responses/responses.go | 40 ++-- internal/provider/retry.go | 71 ++++--- internal/provider/retry_test.go | 23 +++ internal/provider/tool_recovery.go | 2 + 48 files changed, 1091 insertions(+), 135 deletions(-) create mode 100644 desktop/frontend/src/__tests__/provider-failure-meta.test.ts create mode 100644 desktop/history_provider_failure_test.go create mode 100644 desktop/settings_provider_endpoint_contract_test.go create mode 100644 internal/agent/provider_failure_recovery_test.go create mode 100644 internal/boot/provider_endpoint_contract_test.go create mode 100644 internal/config/provider_endpoint_contract.go create mode 100644 internal/config/provider_endpoint_contract_test.go 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"`