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 13912a61dd..b4713c6b47 100644 --- a/desktop/frontend/src/__tests__/provider-editor-model-picker.test.tsx +++ b/desktop/frontend/src/__tests__/provider-editor-model-picker.test.tsx @@ -210,7 +210,13 @@ const backendUnsupportedCustomProvider: ProviderView = { visionCapability: "unsupported", }; -function renderProviderEditor(initial?: ProviderView) { +const legacyChatURLProvider: ProviderView = { + ...backendUnsupportedCustomProvider, + name: "legacy-chat-url", + chatUrl: "https://legacy.example.com/chat/completions/", +}; + +function renderProviderEditor(initial?: ProviderView, onSave: (provider: ProviderView) => void | Promise = () => undefined) { return ( undefined} - onSave={() => undefined} + onSave={onSave} /> ); @@ -243,6 +249,14 @@ try { ok(!editorThrew, "provider editor can switch from built-in to custom without changing hook order"); ok(rootEl.textContent?.includes("OpenAI-compatible") === true, "provider editor renders the custom provider fields after the switch"); ok(rootEl.textContent?.includes("Kimi K3 reasoning (low / high / max)") === true, "custom provider editor exposes the explicit Kimi K3 reasoning protocol"); +const providerUrlInput = rootEl.querySelector(".provider-url-input"); +ok(rootEl.querySelectorAll('input[type="radio"]').length === 0, "custom provider editor exposes only one API address input"); +ok(providerUrlInput?.value === "", "new custom providers start with an empty exact request address"); +const providerUrlLabel = Array.from(rootEl.querySelectorAll("label")).find( + (label) => label.htmlFor === providerUrlInput?.id, +); +ok(Boolean(providerUrlLabel) && providerUrlInput?.getAttribute("aria-describedby") !== null, "provider URL input has a programmatic label and description"); +ok(rootEl.textContent?.includes("Reasonix uses it unchanged.") === true, "provider URL helper explains exact request behavior"); await act(async () => { root.render(
); @@ -272,6 +286,49 @@ ok( rootEl.textContent?.includes("No image input") === true, "provider editor honors backend vision capability for endpoints outside the legacy frontend heuristic", ); +const customProviderUrlInput = rootEl.querySelector(".provider-url-input"); +ok(rootEl.querySelectorAll('input[type="radio"]').length === 0, "existing custom providers no longer expose an address mode selector"); +ok(customProviderUrlInput?.value === "https://eu.deepseek.com/v1/chat/completions", "legacy base-only providers display their previously effective request URL"); + +let migratedProvider: ProviderView | undefined; +await act(async () => { + root.render(renderProviderEditor(legacyChatURLProvider, (provider) => { + migratedProvider = provider; + })); + await flushPromises(); +}); +const legacyProviderUrlInput = rootEl.querySelector(".provider-url-input"); +ok(legacyProviderUrlInput?.value === "https://legacy.example.com/chat/completions", "legacy OpenAI chat URLs display their historically normalized effective endpoint"); +const saveButton = Array.from(rootEl.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Save", +); +await act(async () => { + saveButton?.click(); + await flushPromises(); +}); +ok(migratedProvider?.requestUrl === "https://legacy.example.com/chat/completions" && migratedProvider?.baseUrl === legacyChatURLProvider.baseUrl, "saving migrates the legacy effective address without rewriting its baseUrl"); +ok(migratedProvider?.chatUrl === migratedProvider?.requestUrl, "saving mirrors the normalized legacy OpenAI endpoint for previous releases"); + +let exactProvider: ProviderView | undefined; +await act(async () => { + root.render(renderProviderEditor({ + ...legacyChatURLProvider, + name: "exact-request-url", + requestUrl: "https://exact.example.com/custom/?token=1", + }, (provider) => { + exactProvider = provider; + })); + await flushPromises(); +}); +const exactSaveButton = Array.from(rootEl.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Save", +); +await act(async () => { + exactSaveButton?.click(); + await flushPromises(); +}); +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.unmount(); diff --git a/desktop/frontend/src/__tests__/provider-endpoint.test.ts b/desktop/frontend/src/__tests__/provider-endpoint.test.ts new file mode 100644 index 0000000000..5d1ce92b47 --- /dev/null +++ b/desktop/frontend/src/__tests__/provider-endpoint.test.ts @@ -0,0 +1,31 @@ +import { + providerBaseURLForSave, + providerBaseURLFromRequestURL, + providerRequestURLFromConfig, +} from "../lib/providerEndpoint"; +let failed = 0; +function eq(actual: unknown, expected: unknown, label: string) { + if (actual === expected) { + process.stdout.write(` PASS ${label}\n`); + return; + } + process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`); + failed += 1; +} +console.log("\nprovider endpoint"); + +eq(providerRequestURLFromConfig("openai", "https://proxy.example.com/v1", ""), "https://proxy.example.com/v1/chat/completions", "legacy OpenAI base URLs expose their effective request URL"); +eq(providerRequestURLFromConfig("anthropic", "https://proxy.example.com/v1", ""), "https://proxy.example.com/v1/messages", "legacy Anthropic base URLs expose their effective request URL"); +eq(providerRequestURLFromConfig("responses", "https://proxy.example.com/v1", ""), "https://proxy.example.com/v1/responses", "legacy Responses base URLs expose their effective request URL"); +eq(providerRequestURLFromConfig("openai", "https://proxy.example.com/v1", "", "https://legacy.example.com/chat/completions/"), "https://legacy.example.com/chat/completions", "legacy OpenAI chat URLs preserve historical trailing-slash normalization"); +eq(providerRequestURLFromConfig("anthropic", "https://proxy.example.com/v1", "", "https://stale.example.com/chat/completions"), "https://proxy.example.com/v1/messages", "legacy Anthropic chat URLs remain ignored"); +eq(providerRequestURLFromConfig("openai", "", "https://proxy.example.com/custom/chat/?token=1", "https://legacy.example.com/chat/completions"), "https://proxy.example.com/custom/chat/?token=1", "explicit request URLs remain unchanged and take priority"); +eq(providerBaseURLFromRequestURL("openai", "https://proxy.example.com/v1/chat/completions?token=1"), "https://proxy.example.com/v1", "request URLs derive a query-free base URL for model discovery"); + +eq(providerBaseURLForSave({ kind: "openai", baseUrl: "https://api.deepseek.com/v1", chatUrl: "https://gateway.example/custom/chat/completions" }, "openai", "https://gateway.example/custom/chat/completions"), "https://api.deepseek.com/v1", "unchanged legacy providers preserve their independent base URL"); +eq(providerBaseURLForSave({ kind: "anthropic", baseUrl: "https://models.example/v1/", requestUrl: "https://gateway.example/custom/messages?token=1" }, "anthropic", "https://gateway.example/custom/messages?token=1"), "https://models.example/v1/", "unchanged explicit request URLs preserve their independent base URL exactly"); +eq(providerBaseURLForSave({ kind: "openai", baseUrl: "https://models.example/v1", requestUrl: "https://gateway.example/old/chat/completions" }, "openai", "https://gateway.example/new/chat/completions"), "https://gateway.example/new", "changing the request URL derives a new base URL"); +eq(providerBaseURLForSave({ kind: "openai", baseUrl: "https://models.example/v1", requestUrl: "https://gateway.example/v1/chat/completions" }, "anthropic", "https://gateway.example/v1/chat/completions"), "https://gateway.example/v1/chat/completions", "changing protocol derives a new base URL under the new protocol"); +eq(providerBaseURLForSave(undefined, "responses", "https://gateway.example/v1/responses"), "https://gateway.example/v1", "new providers derive their base URL from the exact request URL"); + +if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx b/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx index 57da98c74a..c2c7c5cdbd 100644 --- a/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx +++ b/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx @@ -9,8 +9,6 @@ import { formatProviderExtraBody, parseProviderExtraBody, providerExtraBodyParseError, - providerBaseURLFromChatURL, - providerChatURLPreview, providerEditorEffectiveKind, normalizeProviderView, } from "../components/SettingsPanel"; @@ -83,9 +81,6 @@ eq(legacyServerWebSearchProvider.serverWebSearchCapability, undefined, "older pr eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "anthropic", "new custom providers keep the selected Anthropic-compatible kind"); eq(providerEditorEffectiveKind(false, "anthropic", ["anthropic", "openai"]), "anthropic", "existing providers preserve their stored kind"); -eq(providerChatURLPreview("https://proxy.example.com/v1", "", false), "https://proxy.example.com/v1/chat/completions", "base URL mode previews chat completions URL"); -eq(providerChatURLPreview("", "https://proxy.example.com/custom/chat", true), "https://proxy.example.com/custom/chat", "full URL mode previews configured URL"); -eq(providerBaseURLFromChatURL("https://proxy.example.com/v1/chat/completions"), "https://proxy.example.com/v1", "chat URL derives base URL for model discovery"); eq(formatProviderExtraBody({ top_p: 0.7, enable_thinking: true }), "{\n \"enable_thinking\": true,\n \"top_p\": 0.7\n}", "extra body editor formats stable JSON"); eq(JSON.stringify(parseProviderExtraBody('{ "enable_thinking": true, "top_p": 0.7 }')), "{\"enable_thinking\":true,\"top_p\":0.7}", "extra body editor parses JSON object"); let extraBodyRejected = false; diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index 64ee01a721..bb460ba6ae 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -6,6 +6,7 @@ import { app, openExternal } from "../lib/bridge"; import { normalizeLangPref, useI18n, useT, type DictKey, type LangPref } from "../lib/i18n"; import { apiKeyEnvFromProviderName, createLatestRequestGate, inferredVisionModels, mergedFetchedProviderModels, mergeProviderModelContextWindows, providerApiKeyEnvForSave, providerDefaultModel, providerIsConfigured, providerModelCandidates, providerModelContextWindowDrafts, providerModelContextWindowIsSmall, providerRequiresKey } from "../lib/providerModels"; import { cachedFetchProviderModels, invalidateProviderCacheByAPIKeyEnv, shouldSkipAutoRefresh } from "../lib/providerModelCache"; +import { providerBaseURLForSave, providerRequestURLFromConfig, trimmedBaseURL } from "../lib/providerEndpoint"; import { opencodeGoPresetDescriptionKeys } from "../lib/providerPresetDescriptions"; import { useUpdater } from "../lib/useUpdater"; import { @@ -901,24 +902,6 @@ export function providerEditorEffectiveKind(isNewCustomProvider: boolean, kind: return selected || kinds[0] || "openai"; } -function trimmedURL(value: string): string { - return value.trim().replace(/\/+$/, ""); -} - -export function providerChatURLPreview(baseUrl: string, chatUrl: string, fullURL: boolean): string { - if (fullURL) return trimmedURL(chatUrl); - const base = trimmedURL(baseUrl); - return base ? `${base}/chat/completions` : ""; -} - -export function providerBaseURLFromChatURL(chatUrl: string): string { - const full = trimmedURL(chatUrl); - for (const suffix of ["/chat/completions", "/responses", "/response"]) { - if (full.endsWith(suffix)) return trimmedURL(full.slice(0, -suffix.length)); - } - return full; -} - function formatProviderHeaders(headers: Record | null | undefined): string { const entries = Object.entries(headers ?? {}) .map(([key, value]) => [key.trim(), String(value ?? "").trim()] as const) @@ -1353,6 +1336,7 @@ export function normalizeProviderView(p: ProviderView): ProviderView { builtIn: Boolean(p.builtIn), added: Boolean(p.added), chatUrl: p.chatUrl ?? "", + requestUrl: p.requestUrl ?? "", models: asArray(p.models), visionModels, visionModelsConfigured: Boolean(p.visionModelsConfigured ?? visionModels.length > 0), @@ -6215,9 +6199,14 @@ export function ProviderEditor({ const t = useT(); const [name, setName] = useState(initial?.name ?? ""); const [kind, setKind] = useState(initial?.kind ?? "openai"); - const [baseUrl, setBaseUrl] = useState(initial?.baseUrl ?? ""); - const [chatUrl, setChatUrl] = useState(initial?.chatUrl ?? ""); - const [fullChatUrl, setFullChatUrl] = useState(Boolean((initial?.chatUrl ?? "").trim())); + const [requestUrl, setRequestUrl] = useState(() => providerRequestURLFromConfig( + initial?.kind ?? "openai", + initial?.baseUrl ?? "", + initial?.requestUrl ?? "", + initial?.chatUrl ?? "", + )); + const providerUrlInputId = useId(); + const providerUrlHelpId = useId(); const [models, setModels] = useState((initial?.models ?? []).join(", ")); const [modelCandidates, setModelCandidates] = useState(initial?.models ?? []); const [visionModels, setVisionModels] = useState((initial?.visionModels ?? []).join(", ")); @@ -6253,15 +6242,14 @@ export function ProviderEditor({ return choices.length > 0 ? choices : ["openai"]; }, [kind, kinds]); const effectiveKind = providerEditorEffectiveKind(isNewCustomProvider, kind, providerKindChoices); - const effectiveBaseUrl = fullChatUrl ? providerBaseURLFromChatURL(chatUrl) : baseUrl.trim(); - const effectiveChatUrl = fullChatUrl ? trimmedURL(chatUrl) : ""; + const effectiveRequestUrl = requestUrl.trim(); + const effectiveBaseUrl = providerBaseURLForSave(initial, effectiveKind, effectiveRequestUrl); + const effectiveLegacyChatUrl = effectiveKind.toLowerCase() === "openai" ? effectiveRequestUrl : initial?.chatUrl ?? ""; const effectiveModelsUrl = modelsUrl.trim(); - const initialEffectiveBaseUrl = initial - ? ((initial.chatUrl ?? "").trim() ? providerBaseURLFromChatURL(initial.chatUrl ?? "") : trimmedURL(initial.baseUrl)) - : ""; + const initialEffectiveBaseUrl = initial ? trimmedBaseURL(initial.baseUrl) : ""; const retainedVisionCapability = initial && effectiveKind.trim().toLowerCase() === initial.kind.trim().toLowerCase() && - trimmedURL(effectiveBaseUrl) === initialEffectiveBaseUrl + trimmedBaseURL(effectiveBaseUrl) === initialEffectiveBaseUrl ? initial.visionCapability : undefined; const effectiveVisionCapability = providerVisionCapabilityForView({ @@ -6271,7 +6259,7 @@ export function ProviderEditor({ }); const retainedServerWebSearchCapability = initial && effectiveKind.trim().toLowerCase() === initial.kind.trim().toLowerCase() && - trimmedURL(effectiveBaseUrl) === initialEffectiveBaseUrl + trimmedBaseURL(effectiveBaseUrl) === initialEffectiveBaseUrl ? initial.serverWebSearchCapability : undefined; const effectiveServerWebSearchCapability = retainedServerWebSearchCapability ?? @@ -6286,7 +6274,6 @@ export function ProviderEditor({ }, [extraBodyDraft, t]); const effectiveExtraBody = extraBodyParse.value; const extraBodyInvalid = Boolean(extraBodyDraft.trim() && extraBodyParse.error); - const previewChatUrl = providerChatURLPreview(baseUrl, chatUrl, fullChatUrl); const modelNames = useMemo( () => parseProviderListInput(models), [models], @@ -6331,7 +6318,8 @@ export function ProviderEditor({ added: initial?.added ?? true, kind: effectiveKind, baseUrl: effectiveBaseUrl, - chatUrl: effectiveChatUrl, + chatUrl: effectiveLegacyChatUrl, + requestUrl: effectiveRequestUrl, modelsUrl: effectiveModelsUrl, models: [], visionModels: [], @@ -6387,7 +6375,8 @@ export function ProviderEditor({ added: initial?.added ?? true, kind: effectiveKind, baseUrl: effectiveBaseUrl, - chatUrl: effectiveChatUrl, + chatUrl: effectiveLegacyChatUrl, + requestUrl: effectiveRequestUrl, models: ms, visionModels: vms, visionModelsConfigured: visionModelsConfigured || vms.length > 0, @@ -6613,43 +6602,19 @@ export function ProviderEditor({ ))}
{providerKindHint(effectiveKind, t)}
-
- - -
+ { - const value = e.target.value; - if (fullChatUrl) { - setChatUrl(value); - setBaseUrl(providerBaseURLFromChatURL(value)); - } else { - setBaseUrl(value); - } - }} + id={providerUrlInputId} + className="mem-input provider-url-input" + aria-describedby={providerUrlHelpId} + placeholder={t("settings.providerChatUrlPlaceholder")} + value={requestUrl} + onChange={(e) => setRequestUrl(e.target.value)} /> -
- {previewChatUrl ? t("settings.providerRequestPreview", { url: previewChatUrl }) : t("settings.providerRequestPreviewEmpty")} +
+ {t("settings.providerRequestUrlHint")}
{!initial && ( <> diff --git a/desktop/frontend/src/lib/providerEndpoint.ts b/desktop/frontend/src/lib/providerEndpoint.ts new file mode 100644 index 0000000000..238e3766c9 --- /dev/null +++ b/desktop/frontend/src/lib/providerEndpoint.ts @@ -0,0 +1,80 @@ +export interface ProviderEndpointConfig { + kind: string; + baseUrl: string; + requestUrl?: string; + chatUrl?: string; +} + +export function trimmedBaseURL(value: string): string { + return value.trim().replace(/\/+$/, ""); +} + +export function providerRequestURLFromConfig( + kind: string, + baseUrl: string, + requestUrl: string, + legacyChatUrl = "", +): string { + const exactRequestURL = requestUrl.trim(); + if (exactRequestURL) return exactRequestURL; + if (kind.trim().toLowerCase() === "openai") { + const legacyOpenAIRequestURL = legacyChatUrl.trim().replace(/\/+$/, ""); + if (legacyOpenAIRequestURL) return legacyOpenAIRequestURL; + } + const base = trimmedBaseURL(baseUrl); + if (!base) return ""; + switch (kind.trim().toLowerCase()) { + case "anthropic": + return base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`; + case "responses": + case "dashscope-responses": + return `${base}/responses`; + default: + return `${base}/chat/completions`; + } +} + +export function providerBaseURLFromRequestURL(kind: string, requestUrl: string): string { + const exactRequestURL = requestUrl.trim(); + if (!exactRequestURL) return ""; + const normalizedKind = kind.trim().toLowerCase(); + const suffixes = normalizedKind === "anthropic" + ? ["/v1/messages"] + : normalizedKind === "responses" || normalizedKind === "dashscope-responses" + ? ["/responses"] + : ["/chat/completions"]; + try { + const parsed = new URL(exactRequestURL); + const pathname = parsed.pathname.replace(/\/+$/, ""); + const suffix = suffixes.find((candidate) => pathname.endsWith(candidate)); + parsed.pathname = suffix ? pathname.slice(0, -suffix.length) || "/" : pathname || "/"; + parsed.search = ""; + parsed.hash = ""; + return trimmedBaseURL(parsed.toString()); + } catch { + const suffix = suffixes.find((candidate) => exactRequestURL.endsWith(candidate)); + if (suffix) return trimmedBaseURL(exactRequestURL.slice(0, -suffix.length)); + } + return trimmedBaseURL(exactRequestURL); +} + +export function providerBaseURLForSave( + initial: ProviderEndpointConfig | undefined, + effectiveKind: string, + effectiveRequestUrl: string, +): string { + const requestUrl = effectiveRequestUrl.trim(); + if (initial) { + const initialRequestUrl = providerRequestURLFromConfig( + initial.kind, + initial.baseUrl, + initial.requestUrl ?? "", + initial.chatUrl ?? "", + ); + const kindUnchanged = initial.kind.trim().toLowerCase() === effectiveKind.trim().toLowerCase(); + if (kindUnchanged && initialRequestUrl === requestUrl) { + return initial.baseUrl.trim(); + } + } + return providerBaseURLFromRequestURL(effectiveKind, requestUrl); +} diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index 5d06594b75..2e51f1e311 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -1717,7 +1717,8 @@ export interface ProviderView { added: boolean; kind: string; baseUrl: string; - chatUrl?: string; // optional full chat completions URL; empty derives from baseUrl + chatUrl?: string; // legacy OpenAI chat endpoint override; preserved for old-config compatibility + requestUrl?: string; // exact provider request URL written by the current settings UI models: string[]; visionModels: string[]; // subset of models that accepts image input visionModelsConfigured: boolean; // true when an empty list is an explicit choice diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 1b7d03f980..dfb34f5992 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -2643,11 +2643,8 @@ export const en = { "settings.providerProtocolAnthropic": "Anthropic-compatible", "settings.providerProtocolAnthropicHint": "For Claude-style Messages API services such as some coding-plan endpoints.", "settings.providerBaseUrlLabel": "API address", - "settings.providerChatUrlLabel": "Chat request URL", "settings.providerChatUrlPlaceholder": "e.g. https://api.openai.com/v1/chat/completions", - "settings.providerUseFullChatUrl": "Full URL", - "settings.providerRequestPreview": "Request URL: {url}", - "settings.providerRequestPreviewEmpty": "Enter an API address to preview the request URL.", + "settings.providerRequestUrlHint": "Enter the full request address; Reasonix uses it unchanged.", "settings.providerApiKeyEnvHint": "Generated from the provider name by default; change it only when you need a fixed env var name.", "settings.providerKey": "Key", "settings.providerKeyOptional": "Key", diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index ba8baefb28..ea76017a44 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -1806,11 +1806,8 @@ export const zhTW: Record = { "settings.providerProtocolAnthropic": "Anthropic-compatible", "settings.providerProtocolAnthropicHint": "適用於 Claude Messages API 風格的服務,例如部分 coding plan 端點。", "settings.providerBaseUrlLabel": "API 位址", - "settings.providerChatUrlLabel": "完整請求 URL", "settings.providerChatUrlPlaceholder": "例如 https://api.openai.com/v1/chat/completions", - "settings.providerUseFullChatUrl": "完整 URL", - "settings.providerRequestPreview": "請求位址:{url}", - "settings.providerRequestPreviewEmpty": "填寫 API 位址後會顯示最終請求位址。", + "settings.providerRequestUrlHint": "填寫完整請求位址;系統將原樣使用。", "settings.providerApiKeyEnvHint": "預設會根據供應商名稱自動生成,只有需要固定環境變數名稱時再修改。", "settings.providerKey": "金鑰", "settings.providerKeyOptional": "金鑰", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index 9872a70455..24dab16148 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -2646,11 +2646,8 @@ export const zh: Record = { "settings.providerProtocolAnthropic": "Anthropic-compatible", "settings.providerProtocolAnthropicHint": "适用于 Claude Messages API 风格的服务,例如部分 coding plan 端点。", "settings.providerBaseUrlLabel": "API 地址", - "settings.providerChatUrlLabel": "完整请求 URL", "settings.providerChatUrlPlaceholder": "例如 https://api.openai.com/v1/chat/completions", - "settings.providerUseFullChatUrl": "完整 URL", - "settings.providerRequestPreview": "请求地址:{url}", - "settings.providerRequestPreviewEmpty": "填写 API 地址后会显示最终请求地址。", + "settings.providerRequestUrlHint": "填写完整请求地址;系统将原样使用。", "settings.providerApiKeyEnvHint": "默认会根据供应商名称自动生成,只有需要固定环境变量名时再修改。", "settings.providerKey": "密钥", "settings.providerKeyOptional": "密钥", diff --git a/desktop/settings_app.go b/desktop/settings_app.go index 9918eef6a3..39ffeebda9 100644 --- a/desktop/settings_app.go +++ b/desktop/settings_app.go @@ -46,6 +46,7 @@ type ProviderView struct { Kind string `json:"kind"` BaseURL string `json:"baseUrl"` ChatURL string `json:"chatUrl"` + RequestURL string `json:"requestUrl"` Models []string `json:"models"` VisionModels []string `json:"visionModels"` VisionModelsSet bool `json:"visionModelsConfigured"` @@ -639,7 +640,7 @@ func providerViewFromEntryForRootWithResolverAndCredentials(p config.ProviderEnt visionCapability = "unsupported" } return ProviderView{ - Name: p.Name, BuiltIn: builtIn, Added: added, Kind: p.Kind, BaseURL: p.BaseURL, ChatURL: p.ChatURL, + Name: p.Name, 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), @@ -840,6 +841,7 @@ func providerEntryCoreMatches(existing, preset config.ProviderEntry) bool { return strings.EqualFold(strings.TrimSpace(existing.Kind), strings.TrimSpace(preset.Kind)) && normalizeProviderURL(existing.BaseURL) == normalizeProviderURL(preset.BaseURL) && strings.TrimSpace(existing.ChatURL) == strings.TrimSpace(preset.ChatURL) && + strings.TrimSpace(existing.RequestURL) == strings.TrimSpace(preset.RequestURL) && strings.TrimSpace(existing.APIKeyEnv) == strings.TrimSpace(preset.APIKeyEnv) && existing.AuthHeader == preset.AuthHeader } @@ -2407,6 +2409,10 @@ func saveProviderConfig(c *config.Config, p ProviderView) error { e.Kind = p.Kind e.BaseURL = p.BaseURL e.ChatURL = strings.TrimSpace(p.ChatURL) + e.RequestURL = strings.TrimSpace(p.RequestURL) + if strings.EqualFold(strings.TrimSpace(e.Kind), "openai") && e.RequestURL != "" { + e.ChatURL = e.RequestURL + } e.ModelsURL = strings.TrimSpace(p.ModelsURL) e.APIKeyEnv = p.APIKeyEnv e.Headers = p.Headers diff --git a/desktop/settings_app_test.go b/desktop/settings_app_test.go index 55018f7f6f..62193e28be 100644 --- a/desktop/settings_app_test.go +++ b/desktop/settings_app_test.go @@ -853,19 +853,20 @@ func TestSaveProviderPersistsAuthHeader(t *testing.T) { } } -func TestSaveProviderPersistsCustomEndpointURLs(t *testing.T) { +func TestSaveProviderPersistsAndMirrorsCustomEndpointURLs(t *testing.T) { isolateDesktopUserDirs(t) app := NewApp() if err := app.SaveProvider(ProviderView{ - Name: "sub2api", - Kind: "openai", - BaseURL: "https://proxy.example.com/v1", - ChatURL: " https://proxy.example.com/custom/chat/completions ", - ModelsURL: " https://proxy.example.com/v1/models ", - Models: []string{"model-a"}, - Default: "model-a", - APIKeyEnv: "SUB2API_KEY", + Name: "sub2api", + Kind: "openai", + BaseURL: "https://proxy.example.com/v1", + ChatURL: " https://legacy.example.com/chat/completions/ ", + RequestURL: " https://proxy.example.com/custom/chat/completions/?token=1 ", + ModelsURL: " https://proxy.example.com/v1/models ", + Models: []string{"model-a"}, + Default: "model-a", + APIKeyEnv: "SUB2API_KEY", }); err != nil { t.Fatalf("SaveProvider: %v", err) } @@ -875,9 +876,12 @@ func TestSaveProviderPersistsCustomEndpointURLs(t *testing.T) { if !ok { t.Fatal("saved provider not found") } - if got.ChatURL != "https://proxy.example.com/custom/chat/completions" { + if got.ChatURL != "https://proxy.example.com/custom/chat/completions/?token=1" { t.Fatalf("saved chat_url = %q", got.ChatURL) } + if got.RequestURL != "https://proxy.example.com/custom/chat/completions/?token=1" { + t.Fatalf("saved request_url = %q", got.RequestURL) + } if got.ModelsURL != "https://proxy.example.com/v1/models" { t.Fatalf("saved models_url = %q", got.ModelsURL) } @@ -887,9 +891,12 @@ func TestSaveProviderPersistsCustomEndpointURLs(t *testing.T) { if provider.Name != "sub2api" { continue } - if provider.ChatURL != "https://proxy.example.com/custom/chat/completions" { + if provider.ChatURL != "https://proxy.example.com/custom/chat/completions/?token=1" { t.Fatalf("Settings chatUrl = %q", provider.ChatURL) } + if provider.RequestURL != "https://proxy.example.com/custom/chat/completions/?token=1" { + t.Fatalf("Settings requestUrl = %q", provider.RequestURL) + } if provider.ModelsURL != "https://proxy.example.com/v1/models" { t.Fatalf("Settings modelsUrl = %q", provider.ModelsURL) } diff --git a/docs/CONFIG_PATHS.md b/docs/CONFIG_PATHS.md index 0d27c1f7dd..5285974c8f 100644 --- a/docs/CONFIG_PATHS.md +++ b/docs/CONFIG_PATHS.md @@ -135,12 +135,16 @@ providers accidentally share `CUSTOM_API_KEY`, edit each provider's ### Custom provider endpoint URLs -Custom OpenAI-compatible providers normally store an API endpoint in `base_url`. -Reasonix sends chat requests to `base_url + "/chat/completions"` and probes model -discovery candidates such as `/models` and `/v1/models`. If a gateway gives you a -complete chat request URL, set `chat_url`; Reasonix will use it directly and will -not append `/chat/completions`. If model discovery needs a separate address, set -`models_url`. +The desktop custom-provider form treats its **API address** as the exact request +URL and stores it in `request_url`; Reasonix does not append or rewrite its path. +Existing TOML entries are not reinterpreted: legacy `chat_url` keeps its former +OpenAI-only behavior, while Anthropic and Responses continue deriving their path +from `base_url` until the provider is explicitly saved in the current desktop UI. +Saving an OpenAI-compatible provider mirrors the exact address into legacy +`chat_url`, so previous releases continue using the same target. Previous +releases cannot honor arbitrary Anthropic or Responses request paths. +If model discovery needs a separate address, set `models_url`; otherwise Reasonix +probes candidates derived from `base_url`. If a gateway requires vendor-specific top-level request body fields, set `extra_body`, for example `extra_body = { enable_thinking = true }`. These values diff --git a/docs/CONFIG_PATHS.zh-CN.md b/docs/CONFIG_PATHS.zh-CN.md index 5e48952438..736311852d 100644 --- a/docs/CONFIG_PATHS.zh-CN.md +++ b/docs/CONFIG_PATHS.zh-CN.md @@ -114,11 +114,14 @@ provider-name 规则。例如 `https://token.sensenova.cn/v1` 会生成 provider ### 自定义 provider 的端点 URL -自定义 OpenAI-compatible provider 通常只需要在 `base_url` 中填写 API 端点。 -Reasonix 会把聊天请求发送到 `base_url + "/chat/completions"`,并尝试 `/models` -和 `/v1/models` 等模型发现地址。如果网关给的是完整聊天请求 URL,可以设置 -`chat_url`;Reasonix 会直接使用这个地址,不再追加 `/chat/completions`。如果模型 -发现需要使用单独地址,可以设置 `models_url`。 +桌面端自定义 provider 表单会把「API 地址」作为完整请求地址写入 `request_url`, +Reasonix 不会追加或改写路径。已有 TOML 配置不会被重新解释:旧 `chat_url` 继续 +保持原来的 OpenAI 专用行为,Anthropic 和 Responses 仍会根据 `base_url` 推导请求 +路径;只有用户在新版桌面端明确保存该 provider 后,才会写入并启用 `request_url`。 +保存 OpenAI-compatible provider 时还会把完整地址同步到旧 `chat_url`,使旧版本 +继续使用同一请求目标。旧版本无法识别 Anthropic 或 Responses 的任意自定义请求路径。 +模型发现需要单独地址时可设置 `models_url`;否则 Reasonix 会继续从 `base_url` +推测模型发现地址。 ## 全局 `.env` diff --git a/docs/SPEC.md b/docs/SPEC.md index f22ce7c2c3..e62d454037 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -92,9 +92,10 @@ type Config struct { differing only in `base_url` / `model` / `api_key_env`. Adding another OpenAI- compatible model is a config edit, not a code change. - **A provider is a vendor endpoint** (one `base_url` + `api_key_env`) that offers - one or more models. OpenAI-compatible chat normally posts to - `base_url + "/chat/completions"`; set `chat_url` only for gateways that require a - full request URL. An entry declares either a single `model = "..."` or a + one or more models. `request_url`, when set, is the exact request target for + OpenAI-compatible, Anthropic-compatible, and Responses providers. Legacy + `chat_url` retains its historical OpenAI-only behavior; other legacy entries + derive the protocol path from `base_url`. An entry declares either a single `model = "..."` or a `models = ["...", "..."]` list (with an optional `default`); the list form lets one vendor expose several models without re-declaring the endpoint/key. A **model reference** (`default_model`, the `--model` flag, the desktop switcher) @@ -998,7 +999,7 @@ reasoning_language = "auto" # visible reasoning text: auto|zh|en name = "deepseek" kind = "anthropic" base_url = "https://api.deepseek.com/anthropic" -# chat_url = "https://proxy.example.com/v1/chat/completions" # optional full chat request URL +# request_url = "https://proxy.example.com/anthropic/v1/messages" # optional exact provider request URL # models_url = "https://proxy.example.com/v1/models" # optional model discovery URL models = ["deepseek-v4-flash", "deepseek-v4-pro"] default = "deepseek-v4-flash" # optional; defaults to models[0] diff --git a/docs/SPEC.zh-CN.md b/docs/SPEC.zh-CN.md index 3a7301662a..d698b939b9 100644 --- a/docs/SPEC.zh-CN.md +++ b/docs/SPEC.zh-CN.md @@ -71,7 +71,7 @@ func New(kind string, cfg Config) (Provider, error) - `openai` kind 实现 OpenAI-compatible `/chat/completions`。 - OpenAI-compatible vendor 只是 `kind = "openai"` 的不同配置实例,通过 `base_url`、`model`、`api_key_env` 区分;新增兼容模型通常只需改配置。 -- 一个 provider 表示一个 vendor endpoint,可通过 `models` 暴露多个模型,并以 `default` 指定默认项。`default_model`、`--model` 和桌面端模型选择器都经 `Config.ResolveModel` 解析,可接受 provider 名、裸模型名或 `provider/model`。 +- 一个 provider 表示一个 vendor endpoint,可通过 `models` 暴露多个模型,并以 `default` 指定默认项。设置 `request_url` 时,OpenAI-compatible、Anthropic-compatible 和 Responses provider 都会原样使用该完整请求地址;旧 `chat_url` 只保留 OpenAI 历史兼容语义。`default_model`、`--model` 和桌面端模型选择器都经 `Config.ResolveModel` 解析,可接受 provider 名、裸模型名或 `provider/model`。 - `context_window` 是 provider 级默认值;`model_overrides..context_window` 可覆盖单个模型。 - `max_output_tokens` 是独立的本轮输出上限,不由客户端 reasoning 字节上限换算,也不参与 `compact_ratio`。推荐 `0`(自动:DeepSeek 默认 high 约 64K);显式 `32768` 控费/普通编码,`65536` 重推理/长工具链,`131072` 仅在反复 `finish_reason=length` 时再考虑。正数为显式上限,负数为在协议允许时省略;混合网关可用 `model_overrides..max_output_tokens` 覆盖单个模型。Anthropic 因协议要求仍会提供 `max_tokens` 默认值。 - streaming tool-call delta 在 provider 内按 index 聚合,只向上层发出完整 `ToolCall`。 @@ -390,6 +390,7 @@ reasoning_language = "auto" name = "deepseek" kind = "anthropic" base_url = "https://api.deepseek.com/anthropic" +# request_url = "https://proxy.example.com/anthropic/v1/messages" # 可选:完整请求地址 models = ["deepseek-v4-flash", "deepseek-v4-pro"] default = "deepseek-v4-flash" api_key_env = "DEEPSEEK_API_KEY" diff --git a/internal/boot/boot.go b/internal/boot/boot.go index b324c4bc87..d25054c47f 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -2662,6 +2662,7 @@ func NewProviderWithProxy(e *config.ProviderEntry, proxy netclient.ProxySpec) (p "reasoning_protocol": config.ReasoningProtocolForEntry(e), "max_output_tokens": e.MaxOutputTokens, "chat_url": e.ChatURL, + "request_url": e.RequestURL, "headers": e.Headers, "extra_body": e.ExtraBody, "auth_header": e.AuthHeader, diff --git a/internal/boot/boot_test.go b/internal/boot/boot_test.go index fffb1587b2..b44f5f8097 100644 --- a/internal/boot/boot_test.go +++ b/internal/boot/boot_test.go @@ -1580,7 +1580,7 @@ func TestNewProviderPropagatesConfiguredMaxOutputTokens(t *testing.T) { p, err := NewProvider(&config.ProviderEntry{ Name: "openai", Kind: "openai", BaseURL: "https://api.openai.com/v1", - ChatURL: srv.URL, Model: "o3", MaxOutputTokens: 4096, + ChatURL: "https://legacy.invalid/chat/completions/", RequestURL: srv.URL, Model: "o3", MaxOutputTokens: 4096, }) if err != nil { t.Fatalf("NewProvider: %v", err) diff --git a/internal/config/config.go b/internal/config/config.go index 7a015a8fa9..e0b3ff4954 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1337,11 +1337,12 @@ type ProviderEntry struct { Name string `toml:"name"` Kind string `toml:"kind"` BaseURL string `toml:"base_url"` - ChatURL string `toml:"chat_url"` - Model string `toml:"model"` // a single model (back-compat) - Models []string `toml:"models"` // a vendor's model list (one base_url/key, many models) - ModelsURL string `toml:"models_url"` // auto-fetch models from this URL on startup - Default string `toml:"default"` // default model when Models is set (else Models[0]) + ChatURL string `toml:"chat_url"` // legacy OpenAI chat endpoint override; retained with its historical semantics + RequestURL string `toml:"request_url"` // exact provider request URL written by current settings UI + Model string `toml:"model"` // a single model (back-compat) + Models []string `toml:"models"` // a vendor's model list (one base_url/key, many models) + ModelsURL string `toml:"models_url"` // auto-fetch models from this URL on startup + Default string `toml:"default"` // default model when Models is set (else Models[0]) APIKeyEnv string `toml:"api_key_env"` PresetID string `toml:"preset_id"` // curated preset identity; UI-only metadata, not sent to model providers. PresetVersion int `toml:"preset_version"` // curated preset schema version for future migrations. diff --git a/internal/config/load.go b/internal/config/load.go index 74ccd53662..4a9d696f46 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -2201,6 +2201,7 @@ func legacyDeepSeekProviderWideProjection(entry *ProviderEntry) ProviderEntry { out.Kind = strings.ToLower(strings.TrimSpace(out.Kind)) out.BaseURL = normalizedBaseURLForMigration(out.BaseURL) out.ChatURL = strings.TrimSpace(out.ChatURL) + out.RequestURL = strings.TrimSpace(out.RequestURL) out.ModelsURL = strings.TrimSpace(out.ModelsURL) out.APIKeyEnv = strings.TrimSpace(out.APIKeyEnv) out.BalanceURL = normalizedDeepSeekBalanceURL(out.BalanceURL) diff --git a/internal/config/render.go b/internal/config/render.go index 1aab7cd8ec..63a5e640c4 100644 --- a/internal/config/render.go +++ b/internal/config/render.go @@ -306,7 +306,10 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string { fmt.Fprintf(&b, "kind = %q\n", p.Kind) fmt.Fprintf(&b, "base_url = %q\n", p.BaseURL) if p.ChatURL != "" { - fmt.Fprintf(&b, "chat_url = %q # optional full chat completions URL; disables automatic /chat/completions suffix\n", p.ChatURL) + fmt.Fprintf(&b, "chat_url = %q # legacy OpenAI chat endpoint override\n", p.ChatURL) + } + if p.RequestURL != "" { + fmt.Fprintf(&b, "request_url = %q # exact provider request URL; no path completion\n", p.RequestURL) } if len(p.Models) > 0 { fmt.Fprintf(&b, "models = %s\n", renderStringArray(p.Models)) @@ -1003,6 +1006,9 @@ func RenderTOMLProjectDelta(c *Config) string { if p.ChatURL != "" { fmt.Fprintf(&b, "chat_url = %q\n", p.ChatURL) } + if p.RequestURL != "" { + fmt.Fprintf(&b, "request_url = %q\n", p.RequestURL) + } if len(p.Models) > 0 { fmt.Fprintf(&b, "models = %s\n", renderStringArray(p.Models)) if p.Default != "" { diff --git a/internal/config/render_test.go b/internal/config/render_test.go index ae0f4b0a17..7473275490 100644 --- a/internal/config/render_test.go +++ b/internal/config/render_test.go @@ -308,6 +308,7 @@ func TestRenderTOMLRoundTrips(t *testing.T) { mm, _ := orig.Provider("mimo-pro") mm.BaseURL = "http://localhost:8000/v1" mm.ChatURL = "http://localhost:8000/v1/chat/completions" + mm.RequestURL = "http://localhost:8000/custom/chat/completions/?token=1" mm.ModelsURL = "http://localhost:8000/v1/models" mm.ReasoningProtocol = "openai" mm.PresetID = "mimo-api" @@ -481,7 +482,7 @@ func TestRenderTOMLRoundTrips(t *testing.T) { if got.Tools.Shell.Path != "/usr/local/bin/bash" { t.Errorf("tools.shell.path = %q, want /usr/local/bin/bash", got.Tools.Shell.Path) } - if g, _ := got.Provider("mimo-pro"); g == nil || g.BaseURL != "http://localhost:8000/v1" || g.ChatURL != "http://localhost:8000/v1/chat/completions" || g.ModelsURL != "http://localhost:8000/v1/models" || g.ReasoningProtocol != "openai" { + if g, _ := got.Provider("mimo-pro"); g == nil || g.BaseURL != "http://localhost:8000/v1" || g.ChatURL != "http://localhost:8000/v1/chat/completions" || g.RequestURL != "http://localhost:8000/custom/chat/completions/?token=1" || g.ModelsURL != "http://localhost:8000/v1/models" || g.ReasoningProtocol != "openai" { t.Errorf("mimo-pro endpoint fields not preserved: %+v", g) } if g, _ := got.Provider("mimo-pro"); g == nil || g.PresetID != "mimo-api" || g.PresetVersion != ProviderPresetVersion { @@ -912,6 +913,7 @@ func TestResponsesProviderModeRoundTripsInUserAndProjectRender(t *testing.T) { cfg := Default() cfg.Providers = append(cfg.Providers, ProviderEntry{ Name: "responses-test", Kind: "responses", BaseURL: "https://example.com/v1", + ChatURL: "https://legacy.example.com/chat/completions", RequestURL: "https://example.com/v1/custom/responses", Model: "model", APIKeyEnv: "RESPONSES_API_KEY", ResponsesMode: "stateful", ResponsesStateful: &legacyFalse, }) @@ -925,7 +927,7 @@ func TestResponsesProviderModeRoundTripsInUserAndProjectRender(t *testing.T) { t.Fatalf("decode responses config: %v\n%s", err, rendered) } entry, ok := decoded.Provider("responses-test") - if !ok || entry.ResponsesMode != "stateful" || entry.ResponsesStateful == nil || *entry.ResponsesStateful { + if !ok || entry.ChatURL != "https://legacy.example.com/chat/completions" || entry.RequestURL != "https://example.com/v1/custom/responses" || entry.ResponsesMode != "stateful" || entry.ResponsesStateful == nil || *entry.ResponsesStateful { t.Fatalf("responses settings did not round-trip: %+v, found=%v", entry, ok) } } diff --git a/internal/provider/anthropic/anthropic.go b/internal/provider/anthropic/anthropic.go index 0c93166f5b..7b786f9d99 100644 --- a/internal/provider/anthropic/anthropic.go +++ b/internal/provider/anthropic/anthropic.go @@ -89,6 +89,11 @@ func New(cfg provider.Config) (provider.Provider, error) { if root == "" { root = defaultBaseURL } + requestURL, _ := cfg.Extra["request_url"].(string) + requestURL = strings.TrimSpace(requestURL) + if requestURL == "" { + requestURL = root + "/v1/messages" + } officialDeepSeek := openai.IsDeepSeek(root) keyEnv, _ := cfg.Extra["api_key_env"].(string) // for actionable auth errors keySource, _ := cfg.Extra["api_key_source"].(string) @@ -133,6 +138,7 @@ func New(cfg provider.Config) (provider.Provider, error) { keyEnv: keyEnv, keySource: keySource, baseURL: root, + requestURL: requestURL, model: cfg.Model, nativeAnthropic: strings.EqualFold(root, defaultBaseURL), deepseek: officialDeepSeek, @@ -160,6 +166,7 @@ type client struct { keyEnv string // api_key_env name, surfaced in auth errors keySource string // source of keyEnv, surfaced in auth errors baseURL string + requestURL string model string nativeAnthropic bool // first-party endpoint: documented default-5m cache-write pricing applies deepseek bool // official DeepSeek Anthropic endpoint: unsigned reasoning replay + automatic cache @@ -224,7 +231,7 @@ func (c *client) MissingToolCallReasoningWarningIdentity() string { protocol = "deepseek-anthropic" } return strings.Join([]string{ - "anthropic", strings.TrimSpace(c.name), strings.TrimSpace(c.baseURL), + "anthropic", strings.TrimSpace(c.name), strings.TrimSpace(c.requestURL), strings.TrimSpace(c.model), protocol, strings.TrimSpace(c.thinking), strings.TrimSpace(c.effort), }, "\x00") } @@ -291,7 +298,7 @@ func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provi bufPool.Put(buf) newReq := func(ctx context.Context) (*http.Request, error) { - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/messages", bytes.NewReader(body)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.requestURL, bytes.NewReader(body)) if err != nil { return nil, err } diff --git a/internal/provider/anthropic/request_url_test.go b/internal/provider/anthropic/request_url_test.go new file mode 100644 index 0000000000..e8b5650686 --- /dev/null +++ b/internal/provider/anthropic/request_url_test.go @@ -0,0 +1,65 @@ +package anthropic + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "reasonix/internal/provider" +) + +func TestStreamUsesConfiguredRequestURLExactly(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RequestURI() != "/custom/messages/?token=1" { + t.Errorf("request URI = %q, want /custom/messages/?token=1", r.URL.RequestURI()) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, `event: message_start +data: {"type":"message_start","message":{"usage":{"input_tokens":2,"output_tokens":0}}} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}} + +event: message_stop +data: {"type":"message_stop"} + +`) + })) + defer srv.Close() + + p, err := New(provider.Config{ + Name: "custom-anthropic", + BaseURL: srv.URL + "/base", + Model: "model", + APIKey: "key", + Extra: map[string]any{"request_url": srv.URL + "/custom/messages/?token=1"}, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + stream, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}}) + if err != nil { + t.Fatalf("Stream: %v", err) + } + for chunk := range stream { + if chunk.Type == provider.ChunkError { + t.Fatalf("stream error: %v", chunk.Err) + } + } +} + +func TestLegacyChatURLRemainsIgnored(t *testing.T) { + p, err := New(provider.Config{ + BaseURL: "https://base.example.com/v1", + Model: "model", + Extra: map[string]any{"chat_url": "https://stale.example.com/chat/completions"}, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + if got := p.(*client).requestURL; got != "https://base.example.com/v1/messages" { + t.Fatalf("requestURL = %q, want legacy base-derived endpoint", got) + } +} diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go index a1cc552de5..86f4aa736d 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -84,8 +84,12 @@ func New(cfg provider.Config) (provider.Provider, error) { // A meaningful explicit list is the endpoint's declared effort vocabulary; // auto remains implicit and is therefore ignored here. supportedEfforts, hasExplicitEfforts := reasoningEffortVocabulary(kimiK3, supportedEfforts) - chatURL, _ := cfg.Extra["chat_url"].(string) - chatURL = normalizeChatURL(cfg.BaseURL, chatURL) + legacyChatURL, _ := cfg.Extra["chat_url"].(string) + chatURL, _ := cfg.Extra["request_url"].(string) + chatURL = strings.TrimSpace(chatURL) + if chatURL == "" { + chatURL = normalizeChatURL(cfg.BaseURL, legacyChatURL) + } prefixChatURL := deepSeekPrefixChatURL(chatURL) headers, _ := cfg.Extra["headers"].(map[string]string) extraBody, _ := cfg.Extra["extra_body"].(map[string]any) @@ -371,8 +375,8 @@ func normalizeReasoningProtocol(raw string) string { } func normalizeChatURL(baseURL, chatURL string) string { - if trimmed := strings.TrimRight(strings.TrimSpace(chatURL), "/"); trimmed != "" { - return trimmed + if legacy := strings.TrimRight(strings.TrimSpace(chatURL), "/"); legacy != "" { + return legacy } return strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/chat/completions" } diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index 0785b1320b..2a1fbcb98b 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -532,8 +532,8 @@ func TestStreamUsesConfiguredChatURL(t *testing.T) { var sawRequest bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sawRequest = true - if r.URL.Path != "/proxy/v1/chat/completions" { - t.Errorf("path = %s, want /proxy/v1/chat/completions", r.URL.Path) + if r.URL.RequestURI() != "/proxy/v1/chat/completions" { + t.Errorf("request URI = %s, want /proxy/v1/chat/completions", r.URL.RequestURI()) http.NotFound(w, r) return } @@ -551,7 +551,7 @@ func TestStreamUsesConfiguredChatURL(t *testing.T) { BaseURL: srv.URL + "/base", Model: "model-a", APIKey: "k", - Extra: map[string]any{"chat_url": srv.URL + "/proxy/v1/chat/completions"}, + Extra: map[string]any{"chat_url": srv.URL + "/proxy/v1/chat/completions/"}, }) if err != nil { t.Fatalf("New: %v", err) diff --git a/internal/provider/openai/request_url_test.go b/internal/provider/openai/request_url_test.go new file mode 100644 index 0000000000..56132ef1f4 --- /dev/null +++ b/internal/provider/openai/request_url_test.go @@ -0,0 +1,24 @@ +package openai + +import ( + "testing" + + "reasonix/internal/provider" +) + +func TestNewPrefersExactRequestURLOverLegacyChatURL(t *testing.T) { + p, err := New(provider.Config{ + BaseURL: "https://base.example.com/v1", + Model: "model-a", + Extra: map[string]any{ + "chat_url": "https://legacy.example.com/chat/completions/", + "request_url": "https://exact.example.com/custom/?token=1", + }, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + if got := p.(*client).chatURL; got != "https://exact.example.com/custom/?token=1" { + t.Fatalf("chatURL = %q, want exact request_url", got) + } +} diff --git a/internal/provider/responses/request_url_test.go b/internal/provider/responses/request_url_test.go new file mode 100644 index 0000000000..cc0389d574 --- /dev/null +++ b/internal/provider/responses/request_url_test.go @@ -0,0 +1,52 @@ +package responses + +import ( + "net/http" + "net/http/httptest" + "testing" + + "reasonix/internal/provider" +) + +func TestStreamUsesConfiguredRequestURLExactly(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RequestURI() != "/custom/responses/?token=1" { + t.Errorf("request URI = %q, want /custom/responses/?token=1", r.URL.RequestURI()) + } + writeEvents(w, `{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`) + })) + defer server.Close() + + collect(t, New(Config{ + Name: "custom-responses", APIKey: "key", BaseURL: server.URL + "/base", RequestURL: server.URL + "/custom/responses/?token=1", Model: "m", Mode: "stateless", + }), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}}) +} + +func TestFactoryUsesRequestURLAndIgnoresLegacyChatURL(t *testing.T) { + p, err := newFromConfig(provider.Config{ + BaseURL: "https://base.example.com/v1", + Model: "m", + Extra: map[string]any{ + "chat_url": "https://stale.example.com/chat/completions", + "request_url": "https://exact.example.com/custom/responses/?token=1", + }, + }) + if err != nil { + t.Fatalf("newFromConfig: %v", err) + } + if got := p.(*client).requestURL; got != "https://exact.example.com/custom/responses/?token=1" { + t.Fatalf("requestURL = %q, want exact request_url", got) + } + + legacy, err := newFromConfig(provider.Config{ + BaseURL: "https://base.example.com/v1", + Model: "m", + Extra: map[string]any{"chat_url": "https://stale.example.com/chat/completions"}, + }) + if err != nil { + t.Fatalf("newFromConfig legacy: %v", err) + } + if got := legacy.(*client).requestURL; got != "https://base.example.com/v1/responses" { + t.Fatalf("legacy requestURL = %q, want base-derived endpoint", got) + } +} diff --git a/internal/provider/responses/responses.go b/internal/provider/responses/responses.go index 7ddf33b3fa..4bcea9e70d 100644 --- a/internal/provider/responses/responses.go +++ b/internal/provider/responses/responses.go @@ -48,10 +48,11 @@ func newFromConfig(cfg provider.Config) (provider.Provider, error) { keyEnv, _ := cfg.Extra["api_key_env"].(string) keySource, _ := cfg.Extra["api_key_source"].(string) 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, Effort: effort, Mode: mode, Stateful: stateful, WebSearch: webSearch, Proxy: proxy, - KeyEnv: keyEnv, KeySource: keySource, MaxOutputTokens: maxOutputTokens, + KeyEnv: keyEnv, KeySource: keySource, MaxOutputTokens: maxOutputTokens, RequestURL: requestURL, // Extra 原样透传:vision 等能力开关由调用方(boot/CLI)写入 // cfg.Extra,factory 若丢弃则 New() 读不到(评审 #7234 第 3 点)。 Extra: cfg.Extra, @@ -60,17 +61,18 @@ func newFromConfig(cfg provider.Config) (provider.Provider, error) { // Config holds Responses API provider settings. type Config struct { - Name string - APIKey string - BaseURL string - Model string - 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 + Name string + APIKey string + BaseURL string + Model string + 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 enables Reasonix's // 32K reasoning safety default on official DeepSeek and otherwise omits the // field; thinking-disabled DeepSeek requests and negative values omit it. @@ -104,17 +106,17 @@ func (c Config) mode() string { // deepseek (incl. eu.deepseek.com) / mimo via exact-host matching. type client struct { - name, apiKey, keyEnv, keySource string - baseURL, model, effort string - vendor, mode string - caps vendorCapabilities - sessionCache bool - webSearch bool - maxOutputTokens int - vision bool // model accepts image input; embed Images as input_image parts - http *http.Client - idleTimeout time.Duration - authed atomic.Bool + name, apiKey, keyEnv, keySource string + baseURL, requestURL, model, effort string + vendor, mode string + caps vendorCapabilities + sessionCache bool + webSearch bool + maxOutputTokens int + vision bool // model accepts image input; embed Images as input_image parts + http *http.Client + idleTimeout time.Duration + authed atomic.Bool mu sync.Mutex lastResponseID string @@ -152,9 +154,14 @@ func New(cfg Config) provider.Provider { }); err == nil { httpClient = built } + baseURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") + requestURL := strings.TrimSpace(cfg.RequestURL) + if requestURL == "" { + requestURL = baseURL + "/responses" + } return &client{ name: cfg.Name, apiKey: cfg.APIKey, keyEnv: cfg.KeyEnv, keySource: cfg.KeySource, - baseURL: strings.TrimRight(cfg.BaseURL, "/"), model: cfg.Model, effort: cfg.Effort, + baseURL: baseURL, requestURL: requestURL, model: cfg.Model, effort: cfg.Effort, vendor: vendor, caps: cap, mode: cfg.mode(), sessionCache: sessionCache, webSearch: cfg.WebSearch, maxOutputTokens: maxOutputTokens, vision: vision, http: httpClient, idleTimeout: defaultStreamIdleTimeout, @@ -198,7 +205,7 @@ func (c *client) MissingToolCallReasoningWarningIdentity() string { return "" } return strings.Join([]string{ - "responses", strings.TrimSpace(c.name), strings.TrimSpace(c.baseURL), + "responses", strings.TrimSpace(c.name), strings.TrimSpace(c.requestURL), strings.TrimSpace(c.model), strings.TrimSpace(c.vendor), strings.TrimSpace(c.mode), strings.TrimSpace(c.effort), }, "\x00") } @@ -264,7 +271,7 @@ func (c *client) send(ctx context.Context, body map[string]any) (*http.Response, return nil, fmt.Errorf("responses: marshal request: %w", err) } newRequest := func(ctx context.Context) (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/responses", bytes.NewReader(payload)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.requestURL, bytes.NewReader(payload)) if err != nil { return nil, err } diff --git a/tools/repolint/baseline.json b/tools/repolint/baseline.json index 0b112d91e5..e2b64e49c1 100644 --- a/tools/repolint/baseline.json +++ b/tools/repolint/baseline.json @@ -2,14 +2,14 @@ "limits": { "banner": 15, "commented-code": 0, - "complexity": 2103, + "complexity": 2106, "essay": 4071, "file-size": 109568, - "function-size": 9153, + "function-size": 9163, "layering": 1, "marker": 0, "narrative": 64, - "test-file-size": 69935 + "test-file-size": 69939 }, "files": { "cmd/e2ebench/main.go": { @@ -108,7 +108,7 @@ "test-file-size": 352 }, "desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx": { - "test-file-size": 122 + "test-file-size": 117 }, "desktop/frontend/src/__tests__/tab-switch-hydration.test.tsx": { "test-file-size": 162 @@ -141,7 +141,7 @@ "file-size": 141 }, "desktop/frontend/src/components/SettingsPanel.tsx": { - "file-size": 6754 + "file-size": 6719 }, "desktop/frontend/src/components/StatusBar.tsx": { "file-size": 36 @@ -174,7 +174,7 @@ "file-size": 269 }, "desktop/frontend/src/lib/types.ts": { - "file-size": 1511 + "file-size": 1512 }, "desktop/frontend/src/lib/useController.ts": { "file-size": 3936 @@ -287,10 +287,10 @@ "desktop/settings_app.go": { "complexity": 22, "essay": 26, - "file-size": 2742 + "file-size": 2748 }, "desktop/settings_app_test.go": { - "test-file-size": 1856 + "test-file-size": 1863 }, "desktop/shared_host.go": { "essay": 1 @@ -678,7 +678,7 @@ "internal/boot/boot.go": { "complexity": 299, "essay": 105, - "file-size": 2251, + "file-size": 2252, "function-size": 1886, "narrative": 3 }, @@ -980,7 +980,7 @@ }, "internal/config/config.go": { "essay": 54, - "file-size": 1519, + "file-size": 1520, "narrative": 2 }, "internal/config/credentials.go": { @@ -1015,7 +1015,7 @@ }, "internal/config/load.go": { "essay": 24, - "file-size": 1730, + "file-size": 1731, "function-size": 57 }, "internal/config/main_test.go": { @@ -1055,13 +1055,13 @@ "file-size": 267 }, "internal/config/render.go": { - "complexity": 250, + "complexity": 252, "essay": 2, - "file-size": 1010, - "function-size": 961 + "file-size": 1016, + "function-size": 967 }, "internal/config/render_test.go": { - "test-file-size": 757 + "test-file-size": 759 }, "internal/config/vision.go": { "essay": 1 @@ -1535,7 +1535,7 @@ "internal/provider/anthropic/anthropic.go": { "complexity": 45, "essay": 36, - "file-size": 118, + "file-size": 125, "function-size": 154 }, "internal/provider/anthropic/anthropic_test.go": { @@ -1546,10 +1546,10 @@ "essay": 8 }, "internal/provider/openai/openai.go": { - "complexity": 67, + "complexity": 68, "essay": 41, - "file-size": 543, - "function-size": 263 + "file-size": 547, + "function-size": 267 }, "internal/provider/openai/openai_test.go": { "essay": 5, @@ -1565,7 +1565,7 @@ "internal/provider/responses/responses.go": { "complexity": 54, "essay": 25, - "file-size": 98, + "file-size": 105, "function-size": 181 }, "internal/provider/responses/responses_test.go": {