feat(provider): exact custom request URLs / 自定义供应商使用精确请求地址 (#8386)

* feat(provider): add exact custom request URLs

Problem:
Custom provider setup mixed base URLs and full request URLs, while legacy chat_url semantics differed across protocols.

Root cause:
The desktop editor reused chat_url for both legacy compatibility behavior and new exact endpoint intent.

Fix:
- add request_url as an explicit exact endpoint field
- preserve legacy chat_url behavior for existing OpenAI, Anthropic, and Responses configs
- simplify the desktop editor to one API address field and migrate only on save
- document and test global and project config round trips

Verification:
- frontend production build and focused editor tests
- focused provider, config, boot, and desktop tests
- desktop go test ./...

* fix(provider): preserve base URL on unchanged endpoint

Problem:
Saving an existing custom provider could rewrite base_url from request_url, changing model discovery and endpoint-derived capabilities even when the user had not changed the API address.

Root cause:
ProviderEditor always derived base_url from the effective request URL instead of preserving the independent stored value for unchanged providers.

Fix:
Preserve the stored base_url when provider kind and effective request URL are unchanged, and derive a new base only for new providers or intentional endpoint/protocol changes. Extract endpoint compatibility logic and provider request URL tests into focused owners, with exact repolint ratchets for the cross-layer contract.

Verification:
- pnpm test:all && pnpm build
- go test ./...
- cd desktop && go test ./...
- go test -race ./internal/provider/openai ./internal/provider/anthropic ./internal/provider/responses
- go vet focused root packages and desktop ./...
- go run ./tools/repolint

* fix(provider): mirror exact URL for legacy OpenAI

Problem:
Older clients ignore request_url and could keep using a stale chat_url after a custom OpenAI-compatible provider was saved with a new exact endpoint.

Root cause:
The desktop editor and save boundary preserved chat_url independently from the authoritative request_url.

Fix:
Mirror the exact OpenAI-compatible request URL into legacy chat_url in both the editor payload and desktop persistence boundary, retain non-OpenAI legacy behavior, and document the downgrade limitation. Add focused downgrade-compatibility coverage and complete the Chinese specification.

Verification:
- pnpm test:all
- pnpm build
- go test ./...
- (cd desktop && go test ./... && go vet ./...)
- go test -race ./internal/provider/openai ./internal/provider/anthropic ./internal/provider/responses
- go vet ./internal/provider/openai ./internal/provider/anthropic ./internal/provider/responses ./internal/config ./internal/boot
- go run ./tools/repolint
- git diff --cached --check

* test(provider): align exact URL copy assertion

Problem:
The provider editor test still asserted the previous English helper copy after the production hint was shortened without changing its meaning.

Root cause:
The final copy-size adjustment and its standalone TSX assertion were not updated together.

Fix:
Assert the current unchanged-URL helper text so the focused editor test covers the shipped copy again.

Verification:
- pnpm exec tsx src/__tests__/provider-editor-model-picker.test.tsx
- pnpm build
- go run ./tools/repolint
- git diff --cached --check
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-11 20:06:17 +08:00
1 parent 6d262926e0
commit 64f70f3fad
29 files changed
+490 -178

No files matched your search

@@ -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<void> = () => undefined) {
return (
<LocaleProvider>
<ProviderEditor
@@ -219,7 +225,7 @@ function renderProviderEditor(initial?: ProviderView) {
kinds={["openai"]}
busy={false}
onCancel={() => undefined}
onSave={() => undefined}
onSave={onSave}
/>
</LocaleProvider>
);
@@ -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<HTMLInputElement>(".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<HTMLLabelElement>("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(<div />);
@@ -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<HTMLInputElement>(".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<HTMLInputElement>(".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<HTMLButtonElement>("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<HTMLButtonElement>("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();
@@ -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);
@@ -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;
@@ -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<string, string> | 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<string[]>(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({
))}
</select>
<div className="mem-hint">{providerKindHint(effectiveKind, t)}</div>
<div className="set-row">
<label className="set-label set-grow">
{t(fullChatUrl ? "settings.providerChatUrlLabel" : "settings.providerBaseUrlLabel")}
</label>
<label className="set-check">
<input
type="checkbox"
checked={fullChatUrl}
onChange={(e) => {
const checked = e.target.checked;
setFullChatUrl(checked);
if (checked && !chatUrl.trim()) {
setChatUrl(providerChatURLPreview(baseUrl, "", false));
} else if (!checked && !baseUrl.trim()) {
setBaseUrl(providerBaseURLFromChatURL(chatUrl));
}
}}
/>
{t("settings.providerUseFullChatUrl")}
</label>
</div>
<label className="set-label" htmlFor={providerUrlInputId}>
{t("settings.providerBaseUrlLabel")}
</label>
<input
className="mem-input"
placeholder={t(fullChatUrl ? "settings.providerChatUrlPlaceholder" : "settings.providerBaseUrl")}
value={fullChatUrl ? chatUrl : baseUrl}
onChange={(e) => {
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)}
/>
<div className="mem-hint">
{previewChatUrl ? t("settings.providerRequestPreview", { url: previewChatUrl }) : t("settings.providerRequestPreviewEmpty")}
<div id={providerUrlHelpId} className="mem-hint">
{t("settings.providerRequestUrlHint")}
</div>
{!initial && (
<>
@@ -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);
}
+2 -1
View File
@@ -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
+1 -4
View File
@@ -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",
+1 -4
View File
@@ -1806,11 +1806,8 @@ export const zhTW: Record<DictKey, string> = {
"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": "金鑰",
+1 -4
View File
@@ -2646,11 +2646,8 @@ export const zh: Record<DictKey, string> = {
"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": "密钥",
+7 -1
View File
@@ -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
+18 -11
View File
@@ -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)
}
+10 -6
View File
@@ -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
+8 -5
View File
@@ -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`
+5 -4
View File
@@ -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]
+2 -1
View File
@@ -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.<model>.context_window` 可覆盖单个模型。
- `max_output_tokens` 是独立的本轮输出上限,不由客户端 reasoning 字节上限换算,也不参与 `compact_ratio`。推荐 `0`(自动:DeepSeek 默认 high 约 64K);显式 `32768` 控费/普通编码,`65536` 重推理/长工具链,`131072` 仅在反复 `finish_reason=length` 时再考虑。正数为显式上限,负数为在协议允许时省略;混合网关可用 `model_overrides.<model>.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"
+1
View File
@@ -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,
+1 -1
View File
@@ -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)
+6 -5
View File
@@ -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.
+1
View File
@@ -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)
+7 -1
View File
@@ -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 != "" {
+4 -2
View File
@@ -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)
}
}
+9 -2
View File
@@ -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
}
@@ -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)
}
}
+8 -4
View File
@@ -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"
}
+3 -3
View File
@@ -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)
@@ -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)
}
}
@@ -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)
}
}
+33 -26
View File
@@ -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
}
+20 -20
View File
@@ -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": {