fix(provider): support full custom chat URLs

This commit is contained in:
SivanCola committed 2026-06-30 17:41:52 +08:00
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>
+1
View File
@@ -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
+7
View File
@@ -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",
+7
View File
@@ -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": "金鑰",
+7
View File
@@ -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": "密钥",
+5 -3
View File
@@ -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())
+45
View File
@@ -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)
+9
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -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
View File
@@ -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)
}
+1
View File
@@ -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
+6 -2
View File
@@ -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
}
+34
View File
@@ -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")
+6
View File
@@ -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 != "" {
+4 -2
View File
@@ -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)
+12 -1
View File
@@ -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
}
+52
View File
@@ -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,