mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "金鑰",
|
||||
|
||||
@@ -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": "密钥",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user