mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(provider): support full custom chat URLs
This commit is contained in:
1 parent
40b32c421e
commit
0b3f501db0
20 files changed
+311
-30
No files matched your search
@@ -4,7 +4,12 @@ import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { SettingsPanel, providerEditorEffectiveKind } from "../components/SettingsPanel";
|
||||
import {
|
||||
SettingsPanel,
|
||||
providerBaseURLFromChatURL,
|
||||
providerChatURLPreview,
|
||||
providerEditorEffectiveKind,
|
||||
} from "../components/SettingsPanel";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { SettingsView } from "../lib/types";
|
||||
@@ -93,6 +98,9 @@ console.log("\nsettings refresh snapshot");
|
||||
|
||||
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "openai", "new custom providers ignore sorted providerKinds and default to OpenAI");
|
||||
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");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
|
||||
@@ -654,6 +654,24 @@ export function providerEditorEffectiveKind(isNewCustomProvider: boolean, kind:
|
||||
return isNewCustomProvider ? "openai" : (kind.trim() || 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 normalizeReasoningLanguage(lang: string | undefined): string {
|
||||
const v = String(lang ?? "").trim().toLowerCase();
|
||||
return v === "zh" || v === "en" ? v : "auto";
|
||||
@@ -786,6 +804,7 @@ function normalizeProviderView(p: ProviderView): ProviderView {
|
||||
...p,
|
||||
builtIn: Boolean(p.builtIn),
|
||||
added: Boolean(p.added),
|
||||
chatUrl: p.chatUrl ?? "",
|
||||
models: asArray(p.models),
|
||||
visionModels,
|
||||
visionModelsConfigured: Boolean(p.visionModelsConfigured ?? visionModels.length > 0),
|
||||
@@ -4293,12 +4312,14 @@ function ProviderEditor({
|
||||
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 [models, setModels] = useState((initial?.models ?? []).join(", "));
|
||||
const [visionModels, setVisionModels] = useState((initial?.visionModels ?? []).join(", "));
|
||||
const [visionModelsConfigured, setVisionModelsConfigured] = useState(
|
||||
Boolean(initial?.visionModelsConfigured ?? ((initial?.visionModels ?? []).length > 0)),
|
||||
);
|
||||
const [modelsUrl] = useState(initial?.modelsUrl ?? "");
|
||||
const [modelsUrl, setModelsUrl] = useState(initial?.modelsUrl ?? "");
|
||||
const [apiKeyEnv, setApiKeyEnv] = useState(initial?.apiKeyEnv ?? "");
|
||||
const [keyDraft, setKeyDraft] = useState("");
|
||||
const [balanceUrl, setBalanceUrl] = useState(initial?.balanceUrl ?? "");
|
||||
@@ -4316,6 +4337,10 @@ function ProviderEditor({
|
||||
const builtIn = initial?.builtIn ?? false;
|
||||
const isNewCustomProvider = !initial;
|
||||
const effectiveKind = providerEditorEffectiveKind(isNewCustomProvider, kind, kinds);
|
||||
const effectiveBaseUrl = fullChatUrl ? providerBaseURLFromChatURL(chatUrl) : baseUrl.trim();
|
||||
const effectiveChatUrl = fullChatUrl ? trimmedURL(chatUrl) : "";
|
||||
const effectiveModelsUrl = modelsUrl.trim();
|
||||
const previewChatUrl = providerChatURLPreview(baseUrl, chatUrl, fullChatUrl);
|
||||
|
||||
// Offer the kinds the kernel actually registered; if the stored kind is a
|
||||
// legacy/unknown one, keep it as an option so editing doesn't silently change it.
|
||||
@@ -4363,8 +4388,9 @@ function ProviderEditor({
|
||||
builtIn: initial?.builtIn ?? false,
|
||||
added: initial?.added ?? true,
|
||||
kind: effectiveKind,
|
||||
baseUrl: baseUrl.trim(),
|
||||
modelsUrl,
|
||||
baseUrl: effectiveBaseUrl,
|
||||
chatUrl: effectiveChatUrl,
|
||||
modelsUrl: effectiveModelsUrl,
|
||||
models: [],
|
||||
visionModels: [],
|
||||
visionModelsConfigured: false,
|
||||
@@ -4404,13 +4430,14 @@ function ProviderEditor({
|
||||
builtIn: initial?.builtIn ?? false,
|
||||
added: initial?.added ?? true,
|
||||
kind: effectiveKind,
|
||||
baseUrl: baseUrl.trim(),
|
||||
baseUrl: effectiveBaseUrl,
|
||||
chatUrl: effectiveChatUrl,
|
||||
models: ms,
|
||||
visionModels: vms,
|
||||
visionModelsConfigured: visionModelsConfigured || vms.length > 0,
|
||||
default: ms[0] ?? "",
|
||||
apiKeyEnv: effectiveApiKeyEnv,
|
||||
modelsUrl,
|
||||
modelsUrl: effectiveModelsUrl,
|
||||
keySet: Boolean(keyDraft.trim()) || (initial?.keySet ?? false),
|
||||
balanceUrl: balanceUrl.trim(),
|
||||
contextWindow: Number(ctx) || 0,
|
||||
@@ -4460,7 +4487,7 @@ function ProviderEditor({
|
||||
() => models.split(",").map((m) => m.trim()).filter(Boolean),
|
||||
[models],
|
||||
);
|
||||
const canFetch = Boolean(name.trim() && baseUrl.trim());
|
||||
const canFetch = Boolean(name.trim() && effectiveBaseUrl);
|
||||
|
||||
const protocolField = initial ? (
|
||||
<select className="mem-select" value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||
@@ -4489,6 +4516,14 @@ function ProviderEditor({
|
||||
onChange={(e) => setApiKeyEnv(e.target.value)}
|
||||
/>
|
||||
<div className="mem-hint">{t("settings.providerApiKeyEnvHint")}</div>
|
||||
<label className="set-label">{t("settings.providerModelsUrl")}</label>
|
||||
<input
|
||||
className="mem-input"
|
||||
placeholder={t("settings.providerModelsUrlPlaceholder")}
|
||||
value={modelsUrl}
|
||||
onChange={(e) => setModelsUrl(e.target.value)}
|
||||
/>
|
||||
<div className="mem-hint">{t("settings.providerModelsUrlHint")}</div>
|
||||
<label className="set-label">{t("settings.providerBalanceUrl")}</label>
|
||||
<input className="mem-input" placeholder={t("settings.balanceUrlPlaceholder")} value={balanceUrl} onChange={(e) => setBalanceUrl(e.target.value)} />
|
||||
<div className="mem-hint">{t("settings.balanceUrlHint")}</div>
|
||||
@@ -4600,8 +4635,44 @@ function ProviderEditor({
|
||||
<input className="mem-input" placeholder={t("settings.customProviderNamePlaceholder")} value={name} onChange={(e) => setName(e.target.value)} disabled={!!initial} />
|
||||
<label className="set-label">{t("settings.providerProtocol")}</label>
|
||||
{protocolField}
|
||||
<label className="set-label">{t("settings.providerBaseUrlLabel")}</label>
|
||||
<input className="mem-input" placeholder={t("settings.providerBaseUrl")} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} />
|
||||
<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>
|
||||
<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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="mem-hint">
|
||||
{previewChatUrl ? t("settings.providerRequestPreview", { url: previewChatUrl }) : t("settings.providerRequestPreviewEmpty")}
|
||||
</div>
|
||||
{!initial && (
|
||||
<>
|
||||
<label className="set-label">{t("settings.providerKey")}</label>
|
||||
@@ -4657,7 +4728,7 @@ function ProviderEditor({
|
||||
<button className="btn btn--small" onClick={onCancel} disabled={busy}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button className="btn btn--primary btn--small" onClick={() => void save()} disabled={busy || !name.trim() || !baseUrl.trim() || !models.trim()}>
|
||||
<button className="btn btn--primary btn--small" onClick={() => void save()} disabled={busy || !name.trim() || !effectiveBaseUrl || !models.trim()}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -746,6 +746,7 @@ export interface ProviderView {
|
||||
added: boolean;
|
||||
kind: string;
|
||||
baseUrl: string;
|
||||
chatUrl?: string; // optional full chat completions URL; empty derives from baseUrl
|
||||
models: string[];
|
||||
visionModels: string[]; // subset of models that accepts image input
|
||||
visionModelsConfigured: boolean; // true when an empty list is an explicit choice
|
||||
|
||||
@@ -1003,6 +1003,8 @@ export const en = {
|
||||
"settings.providerKind": "Kind",
|
||||
"settings.providerBaseUrl": "base_url (https://…)",
|
||||
"settings.providerModelsUrl": "models_url (optional)",
|
||||
"settings.providerModelsUrlPlaceholder": "e.g. https://api.openai.com/v1/models",
|
||||
"settings.providerModelsUrlHint": "Optional. Use when model discovery needs a different URL than the chat endpoint.",
|
||||
"settings.providerModels": "models (comma-separated)",
|
||||
"settings.providerApiKeyEnv": "api_key_env (e.g. DEEPSEEK_API_KEY)",
|
||||
"settings.providerBalanceUrl": "Balance URL (optional)",
|
||||
@@ -1355,6 +1357,11 @@ export const en = {
|
||||
"settings.providerProtocolOpenAI": "OpenAI-compatible",
|
||||
"settings.providerProtocolOpenAIHint": "For proxies, aggregators, and self-hosted services compatible with the OpenAI API.",
|
||||
"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.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",
|
||||
|
||||
@@ -735,6 +735,8 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.providerKind": "類型",
|
||||
"settings.providerBaseUrl": "base_url(https://…)",
|
||||
"settings.providerModelsUrl": "models_url(可選)",
|
||||
"settings.providerModelsUrlPlaceholder": "例如 https://api.openai.com/v1/models",
|
||||
"settings.providerModelsUrlHint": "可選。模型發現需要使用不同於聊天請求的位址時填寫。",
|
||||
"settings.providerModels": "模型(逗號分隔)",
|
||||
"settings.providerApiKeyEnv": "api_key_env(如 DEEPSEEK_API_KEY)",
|
||||
"settings.providerBalanceUrl": "餘額查詢 URL(可選)",
|
||||
@@ -860,6 +862,11 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.providerProtocolOpenAI": "OpenAI-compatible",
|
||||
"settings.providerProtocolOpenAIHint": "適用於相容 OpenAI API 的代理、聚合平台和自建服務。",
|
||||
"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.providerApiKeyEnvHint": "預設會根據供應商名稱自動生成,只有需要固定環境變數名稱時再修改。",
|
||||
"settings.providerKey": "金鑰",
|
||||
"settings.providerKeyOptional": "金鑰",
|
||||
|
||||
@@ -1005,6 +1005,8 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.providerKind": "类型",
|
||||
"settings.providerBaseUrl": "base_url(https://…)",
|
||||
"settings.providerModelsUrl": "models_url(可选)",
|
||||
"settings.providerModelsUrlPlaceholder": "例如 https://api.openai.com/v1/models",
|
||||
"settings.providerModelsUrlHint": "可选。模型发现需要使用不同于聊天请求的地址时填写。",
|
||||
"settings.providerModels": "模型(逗号分隔)",
|
||||
"settings.providerApiKeyEnv": "api_key_env(如 DEEPSEEK_API_KEY)",
|
||||
"settings.providerBalanceUrl": "余额查询 URL(可选)",
|
||||
@@ -1357,6 +1359,11 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.providerProtocolOpenAI": "OpenAI-compatible",
|
||||
"settings.providerProtocolOpenAIHint": "适用于兼容 OpenAI API 的代理、聚合平台和自建服务。",
|
||||
"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.providerApiKeyEnvHint": "默认会根据供应商名称自动生成,只有需要固定环境变量名时再修改。",
|
||||
"settings.providerKey": "密钥",
|
||||
"settings.providerKeyOptional": "密钥",
|
||||
|
||||
@@ -32,6 +32,7 @@ type ProviderView struct {
|
||||
Added bool `json:"added"`
|
||||
Kind string `json:"kind"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
ChatURL string `json:"chatUrl"`
|
||||
Models []string `json:"models"`
|
||||
VisionModels []string `json:"visionModels"`
|
||||
VisionModelsSet bool `json:"visionModelsConfigured"`
|
||||
@@ -306,7 +307,7 @@ func providerViewFromEntryForRootWithResolver(p config.ProviderEntry, builtIn, a
|
||||
key := resolver.ResolveGlobalFirst(p.APIKeyEnv)
|
||||
requiresKey := p.RequiresAPIKey()
|
||||
return ProviderView{
|
||||
Name: p.Name, BuiltIn: builtIn, Added: added, Kind: p.Kind, BaseURL: p.BaseURL,
|
||||
Name: p.Name, BuiltIn: builtIn, Added: added, Kind: p.Kind, BaseURL: p.BaseURL, ChatURL: p.ChatURL,
|
||||
Models: nonNil(models), VisionModels: nonNil(providerVisionModels(models, visionModels)), VisionModelsSet: visionModelsSet, ModelsURL: p.ModelsURL, Default: p.DefaultModel(),
|
||||
APIKeyEnv: p.APIKeyEnv,
|
||||
KeySet: key.Set,
|
||||
@@ -1203,7 +1204,8 @@ func (a *App) SaveProvider(p ProviderView) error {
|
||||
e.Name = p.Name
|
||||
e.Kind = p.Kind
|
||||
e.BaseURL = p.BaseURL
|
||||
e.ModelsURL = p.ModelsURL
|
||||
e.ChatURL = strings.TrimSpace(p.ChatURL)
|
||||
e.ModelsURL = strings.TrimSpace(p.ModelsURL)
|
||||
e.APIKeyEnv = p.APIKeyEnv
|
||||
e.BalanceURL = strings.TrimSpace(p.BalanceURL)
|
||||
e.ContextWindow = p.ContextWindow
|
||||
@@ -1283,7 +1285,7 @@ func (a *App) FetchProviderModels(p ProviderView) ([]string, error) {
|
||||
e := config.ProviderEntry{
|
||||
Name: p.Name,
|
||||
BaseURL: p.BaseURL,
|
||||
ModelsURL: p.ModelsURL,
|
||||
ModelsURL: strings.TrimSpace(p.ModelsURL),
|
||||
APIKeyEnv: p.APIKeyEnv,
|
||||
}
|
||||
e.ResolveAPIKeyForRoot(a.activeWorkspaceRoot())
|
||||
|
||||
@@ -382,6 +382,51 @@ func TestSaveProviderFiltersNonChatModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProviderPersistsCustomEndpointURLs(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",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProvider: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
got, ok := cfg.Provider("sub2api")
|
||||
if !ok {
|
||||
t.Fatal("saved provider not found")
|
||||
}
|
||||
if got.ChatURL != "https://proxy.example.com/custom/chat/completions" {
|
||||
t.Fatalf("saved chat_url = %q", got.ChatURL)
|
||||
}
|
||||
if got.ModelsURL != "https://proxy.example.com/v1/models" {
|
||||
t.Fatalf("saved models_url = %q", got.ModelsURL)
|
||||
}
|
||||
|
||||
view := app.Settings()
|
||||
for _, provider := range view.Providers {
|
||||
if provider.Name != "sub2api" {
|
||||
continue
|
||||
}
|
||||
if provider.ChatURL != "https://proxy.example.com/custom/chat/completions" {
|
||||
t.Fatalf("Settings chatUrl = %q", provider.ChatURL)
|
||||
}
|
||||
if provider.ModelsURL != "https://proxy.example.com/v1/models" {
|
||||
t.Fatalf("Settings modelsUrl = %q", provider.ModelsURL)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("Settings providers missing sub2api: %+v", view.Providers)
|
||||
}
|
||||
|
||||
func TestSaveProviderClearsProviderWideVisionForPerModelSelection(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
|
||||
@@ -108,6 +108,15 @@ uses `CUSTOM_API_KEY`, it will keep working with that key. If several old custom
|
||||
providers accidentally share `CUSTOM_API_KEY`, edit each provider's
|
||||
`api_key_env` to a distinct name and save the corresponding API key again.
|
||||
|
||||
### 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`.
|
||||
|
||||
## Global `.env`
|
||||
|
||||
`<Reasonix home>/.env` is the single runtime source for provider API keys saved
|
||||
|
||||
@@ -95,6 +95,14 @@ provider-name 规则。例如 `https://token.sensenova.cn/v1` 会生成 provider
|
||||
读取这个 key。若多个旧自定义 provider 已经意外共用了 `CUSTOM_API_KEY`,需要手动把各自的
|
||||
`api_key_env` 改成不同名称,并重新保存对应的 API key。
|
||||
|
||||
### 自定义 provider 的端点 URL
|
||||
|
||||
自定义 OpenAI-compatible provider 通常只需要在 `base_url` 中填写 API 端点。
|
||||
Reasonix 会把聊天请求发送到 `base_url + "/chat/completions"`,并尝试 `/models`
|
||||
和 `/v1/models` 等模型发现地址。如果网关给的是完整聊天请求 URL,可以设置
|
||||
`chat_url`;Reasonix 会直接使用这个地址,不再追加 `/chat/completions`。如果模型
|
||||
发现需要使用单独地址,可以设置 `models_url`。
|
||||
|
||||
## 全局 `.env`
|
||||
|
||||
`<Reasonix home>/.env` 是 Reasonix 保存的 provider API key 的唯一运行时来源。
|
||||
|
||||
+11
-7
@@ -84,14 +84,16 @@ 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. An entry declares either a single `model = "..."` or a
|
||||
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
|
||||
`models = ["...", "..."]` list (with an optional `default`); the list form lets
|
||||
one vendor expose several models without re-declaring the endpoint/key — picking
|
||||
a model reuses the same connection. A **model reference** (`default_model`, the
|
||||
`--model` flag, the desktop switcher) resolves via `Config.ResolveModel`, which
|
||||
accepts a provider name (→ its default model), a bare model name, or an explicit
|
||||
`provider/model`. `context_window` / `price` are per-provider, so models that
|
||||
need distinct values stay separate single-`model` entries.
|
||||
one vendor expose several models without re-declaring the endpoint/key. A
|
||||
**model reference** (`default_model`, the `--model` flag, the desktop switcher)
|
||||
resolves via `Config.ResolveModel`, which accepts a provider name (→ its default
|
||||
model), a bare model name, or an explicit `provider/model`. `context_window` /
|
||||
`price` are per-provider, so models that need distinct values stay separate
|
||||
single-`model` entries.
|
||||
- Streaming tool-call deltas are accumulated by index inside the provider; only
|
||||
complete `ToolCall`s are emitted.
|
||||
|
||||
@@ -527,6 +529,8 @@ reasoning_language = "auto" # visible reasoning text: auto|zh|en
|
||||
name = "deepseek"
|
||||
kind = "openai"
|
||||
base_url = "https://api.deepseek.com"
|
||||
# chat_url = "https://proxy.example.com/v1/chat/completions" # optional full chat 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]
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
|
||||
@@ -1357,6 +1357,7 @@ func NewProviderWithProxy(e *config.ProviderEntry, proxy netclient.ProxySpec) (p
|
||||
"thinking": e.Thinking,
|
||||
"effort": config.EffectiveEffort(e),
|
||||
"reasoning_protocol": config.ReasoningProtocolForEntry(e),
|
||||
"chat_url": e.ChatURL,
|
||||
"proxy_spec": proxy,
|
||||
"vision": config.EffectiveVision(e),
|
||||
"vision_detail": e.VisionDetail,
|
||||
|
||||
+6
-5
@@ -1143,19 +1143,20 @@ func fetchModelListCompat(ctx context.Context, baseURL, apiKey string) ([]string
|
||||
return nil, err
|
||||
}
|
||||
var lastErr error
|
||||
var firstHardErr error
|
||||
for _, u := range candidates {
|
||||
models, err := openai.FetchModels(ctx, u, apiKey)
|
||||
if err == nil {
|
||||
return models, nil
|
||||
}
|
||||
lastErr = err
|
||||
// An endpoint-miss is not a hard error — try the next candidate.
|
||||
// Anything else (auth, 5xx, bad TLS) bubbles up immediately because
|
||||
// retrying it on a sibling URL won't help.
|
||||
if !openai.IsModelFetchEndpointMiss(err) {
|
||||
return nil, err
|
||||
if !openai.IsModelFetchEndpointMiss(err) && firstHardErr == nil {
|
||||
firstHardErr = err
|
||||
}
|
||||
}
|
||||
if firstHardErr != nil {
|
||||
return nil, firstHardErr
|
||||
}
|
||||
if lastErr != nil {
|
||||
slog.Debug("model-list probe: all candidates missed", "base_url", baseURL, "err", lastErr)
|
||||
}
|
||||
|
||||
@@ -936,6 +936,7 @@ 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
|
||||
|
||||
@@ -36,16 +36,20 @@ func (e *ProviderEntry) FetchModels(ctx context.Context) ([]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
var lastErr error
|
||||
var firstHardErr error
|
||||
for _, u := range candidates {
|
||||
models, err := openai.FetchModels(ctx, u, key)
|
||||
if err == nil {
|
||||
return models, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !openai.IsModelFetchEndpointMiss(err) {
|
||||
break
|
||||
if !openai.IsModelFetchEndpointMiss(err) && firstHardErr == nil {
|
||||
firstHardErr = err
|
||||
}
|
||||
}
|
||||
if firstHardErr != nil {
|
||||
return nil, firstHardErr
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,40 @@ func TestProviderFetchModelsFallsBackToV1Models(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderFetchModelsContinuesAfterRootAuthFailure(t *testing.T) {
|
||||
var paths []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.URL.Path)
|
||||
switch r.URL.Path {
|
||||
case "/models":
|
||||
http.Error(w, `{"error":"wrong endpoint"}`, http.StatusUnauthorized)
|
||||
case "/v1/models":
|
||||
if r.Header.Get("Authorization") != "Bearer test-key" {
|
||||
http.Error(w, "bad key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": []map[string]string{{"id": "model-a"}},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := ProviderEntry{Name: "test", BaseURL: srv.URL, APIKeyEnv: "FETCH_MODELS_TEST_KEY", resolvedAPIKey: "test-key"}
|
||||
got, err := p.FetchModels(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchModels: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "model-a" {
|
||||
t.Fatalf("got %v, want [model-a]", got)
|
||||
}
|
||||
if len(paths) != 2 || paths[0] != "/models" || paths[1] != "/v1/models" {
|
||||
t.Fatalf("paths = %v, want [/models /v1/models]", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderFetchModelsUsesSetupProbeEnv(t *testing.T) {
|
||||
const key = "FETCH_MODELS_PROBE_KEY"
|
||||
t.Setenv(key, "probe-key")
|
||||
|
||||
@@ -276,6 +276,9 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
|
||||
fmt.Fprintf(&b, "name = %q\n", p.Name)
|
||||
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)
|
||||
}
|
||||
if len(p.Models) > 0 {
|
||||
fmt.Fprintf(&b, "models = %s\n", renderStringArray(p.Models))
|
||||
if p.Default != "" {
|
||||
@@ -751,6 +754,9 @@ func RenderTOMLProjectDelta(c *Config) string {
|
||||
fmt.Fprintf(&b, "name = %q\n", p.Name)
|
||||
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\n", p.ChatURL)
|
||||
}
|
||||
if len(p.Models) > 0 {
|
||||
fmt.Fprintf(&b, "models = %s\n", renderStringArray(p.Models))
|
||||
if p.Default != "" {
|
||||
|
||||
@@ -223,6 +223,8 @@ 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.ModelsURL = "http://localhost:8000/v1/models"
|
||||
mm.ReasoningProtocol = "openai"
|
||||
ds, _ := orig.Provider("deepseek-flash")
|
||||
ds.Effort = "max"
|
||||
@@ -376,8 +378,8 @@ 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.ReasoningProtocol != "openai" {
|
||||
t.Errorf("mimo-pro base_url not preserved: %+v", g)
|
||||
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" {
|
||||
t.Errorf("mimo-pro endpoint fields not preserved: %+v", g)
|
||||
}
|
||||
if g, _ := got.Provider("deepseek-flash"); g == nil || g.Effort != "max" {
|
||||
t.Errorf("deepseek-flash effort not preserved: %+v", g)
|
||||
|
||||
@@ -62,6 +62,8 @@ func New(cfg provider.Config) (provider.Provider, error) {
|
||||
}
|
||||
protocol, _ := cfg.Extra["reasoning_protocol"].(string)
|
||||
protocol = normalizeReasoningProtocol(protocol)
|
||||
chatURL, _ := cfg.Extra["chat_url"].(string)
|
||||
chatURL = normalizeChatURL(cfg.BaseURL, chatURL)
|
||||
vision, _ := cfg.Extra["vision"].(bool)
|
||||
visionDetail, _ := cfg.Extra["vision_detail"].(string)
|
||||
visionDetail = strings.ToLower(strings.TrimSpace(visionDetail))
|
||||
@@ -116,6 +118,7 @@ func New(cfg provider.Config) (provider.Provider, error) {
|
||||
keyEnv: keyEnv,
|
||||
keySource: keySource,
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
chatURL: chatURL,
|
||||
model: cfg.Model,
|
||||
deepseek: deepseek,
|
||||
minimax: minimax,
|
||||
@@ -143,6 +146,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
|
||||
chatURL string
|
||||
model string
|
||||
http *http.Client
|
||||
deepseek bool
|
||||
@@ -175,6 +179,13 @@ func normalizeReasoningProtocol(raw string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeChatURL(baseURL, chatURL string) string {
|
||||
if trimmed := strings.TrimRight(strings.TrimSpace(chatURL), "/"); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/chat/completions"
|
||||
}
|
||||
|
||||
// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn
|
||||
// allocates a buffer, marshals the request, and sends it — pooling avoids the
|
||||
// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is
|
||||
@@ -195,7 +206,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+"/chat/completions", bytes.NewReader(body))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.chatURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -125,6 +125,58 @@ func TestStreamAuthError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer k" {
|
||||
http.Error(w, "bad key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(provider.Config{
|
||||
Name: "custom",
|
||||
BaseURL: srv.URL + "/base",
|
||||
Model: "model-a",
|
||||
APIKey: "k",
|
||||
Extra: map[string]any{"chat_url": srv.URL + "/proxy/v1/chat/completions"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
ch, err := p.Stream(context.Background(), provider.Request{
|
||||
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream: %v", err)
|
||||
}
|
||||
var got strings.Builder
|
||||
for chunk := range ch {
|
||||
if chunk.Type == provider.ChunkError {
|
||||
t.Fatalf("stream error: %v", chunk.Err)
|
||||
}
|
||||
if chunk.Type == provider.ChunkText {
|
||||
got.WriteString(chunk.Text)
|
||||
}
|
||||
}
|
||||
if !sawRequest {
|
||||
t.Fatal("server did not receive request")
|
||||
}
|
||||
if got.String() != "ok" {
|
||||
t.Fatalf("streamed text = %q, want ok", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:
|
||||
// DeepSeek rejects a message missing the `content` field, so every message must
|
||||
// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,
|
||||
|
||||
Reference in new issue
Block a user