🐛 fix(ai/volcengine): 修复火山引擎兼容路径并拆分双预设

- OpenAI 兼容 URL 归一化改为保留已有 v3 和 v4 版本段,避免火山与智谱地址被错误补 /v1
- 对误填 /chat/completions 和 /models 的地址先回退到 base URL,再拼接目标端点
- 模型列表与连通性检测复用统一端点解析逻辑,修复火山 Coding Plan 等兼容服务请求
- AI 设置页拆分火山方舟与火山 Coding Plan 两个预设,并按完整路径精确匹配回显
- 修正模型下拉默认值行为,未选模型时保持占位态,避免误用动态列表首项
- 补充 provider 与 service 回归测试,并新增需求追踪文档
This commit is contained in:
Syngnat
2026-03-27 12:04:55 +08:00
parent 37ac13b94e
commit a5fdfefa2d
10 changed files with 206 additions and 22 deletions

View File

@@ -33,7 +33,8 @@ const PROVIDER_PRESETS: ProviderPreset[] = [
{ key: 'moonshot', label: 'Kimi', icon: <ExperimentOutlined />, desc: 'Kimi K2.5 (Anthropic 兼容)', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] },
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] },
{ key: 'gemini', label: 'Gemini', icon: <CloudOutlined />, desc: 'Gemini 3.1 / 2.5 系列', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: [] },
{ key: 'volcengine', label: '火山引擎', icon: <CloudOutlined />, desc: '火山方舟 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: 'ep-xxxxxx', models: [] },
{ key: 'volcengine-ark', label: '火山方舟', icon: <CloudOutlined />, desc: 'Ark 通用推理 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] },
{ key: 'volcengine-coding', label: '火山 Coding Plan', icon: <CloudOutlined />, desc: 'Ark Code / Coding Plan', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] },
{ key: 'minimax', label: 'MiniMax', icon: <ExperimentOutlined />, desc: 'M2.7 / M2.5 系列 (Anthropic 兼容)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M2.7', models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2'] },
{ key: 'ollama', label: 'Ollama', icon: <AppstoreOutlined />, desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] },
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] },
@@ -50,7 +51,28 @@ const getProviderHostname = (raw?: string): string => {
}
};
const getProviderFingerprint = (raw?: string): string => {
if (!raw) return '';
try {
const url = new URL(raw);
const normalizedPath = url.pathname.replace(/\/+$/, '').toLowerCase();
return `${url.hostname.toLowerCase()}${normalizedPath}`;
} catch {
return '';
}
};
const matchProviderPreset = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl'>): ProviderPreset => {
const fingerprint = getProviderFingerprint(provider.baseUrl);
const exactPreset = PROVIDER_PRESETS.find(pr =>
pr.backendType === provider.type
&& fingerprint !== ''
&& fingerprint === getProviderFingerprint(pr.defaultBaseUrl)
);
if (exactPreset) {
return exactPreset;
}
const host = getProviderHostname(provider.baseUrl);
if (host.endsWith('moonshot.cn')) {
return findPreset('moonshot');

View File

@@ -354,7 +354,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
<Select
size="small"
variant="filled"
value={activeProvider.model || (dynamicModels.length > 0 ? dynamicModels[0] : activeProvider.models?.[0])}
value={activeProvider.model || undefined}
onChange={onModelChange}
onDropdownVisibleChange={(open) => { if (open && dynamicModels.length === 0) onFetchModels(); }}
loading={loadingModels}

0
frontend/wailsjs/runtime/package.json Normal file → Executable file
View File

0
frontend/wailsjs/runtime/runtime.d.ts vendored Normal file → Executable file
View File

0
frontend/wailsjs/runtime/runtime.js Normal file → Executable file
View File

View File

@@ -2,9 +2,13 @@ package provider
import (
"fmt"
"net/url"
"regexp"
"strings"
)
var openAICompatibleVersionSuffixPattern = regexp.MustCompile(`(?i)(^|/)v\d+$`)
// ParseDataURI 解析前端传递的 Data URI返回 mimeType 和去掉前缀的 rawBase64
func ParseDataURI(dataURI string) (mimeType, rawBase64 string, err error) {
if !strings.HasPrefix(dataURI, "data:") {
@@ -24,3 +28,70 @@ func ParseDataURI(dataURI string) (mimeType, rawBase64 string, err error) {
rawBase64 = parts[1]
return mimeType, rawBase64, nil
}
// NormalizeOpenAICompatibleBaseURL 统一归一化 OpenAI 兼容服务的 base URL。
func NormalizeOpenAICompatibleBaseURL(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return defaultOpenAIBaseURL
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return normalizeOpenAICompatibleBaseURLString(trimmed)
}
parsed.RawQuery = ""
parsed.Fragment = ""
parsed.Path = normalizeOpenAICompatiblePath(parsed.Path)
return strings.TrimRight(parsed.String(), "/")
}
// ResolveOpenAICompatibleEndpoint 基于归一化 base URL 拼接 OpenAI 兼容接口路径。
func ResolveOpenAICompatibleEndpoint(baseURL string, endpoint string) string {
normalizedBaseURL := NormalizeOpenAICompatibleBaseURL(baseURL)
normalizedEndpoint := strings.TrimLeft(strings.TrimSpace(endpoint), "/")
if normalizedEndpoint == "" {
return normalizedBaseURL
}
return normalizedBaseURL + "/" + normalizedEndpoint
}
func normalizeOpenAICompatibleBaseURLString(raw string) string {
normalized := strings.TrimRight(strings.TrimSpace(raw), "/")
if normalized == "" {
return defaultOpenAIBaseURL
}
lower := strings.ToLower(normalized)
switch {
case strings.HasSuffix(lower, "/chat/completions"):
normalized = normalized[:len(normalized)-len("/chat/completions")]
case strings.HasSuffix(lower, "/models"):
normalized = normalized[:len(normalized)-len("/models")]
}
normalized = strings.TrimRight(normalized, "/")
if openAICompatibleVersionSuffixPattern.MatchString(normalized) {
return normalized
}
return normalized + "/v1"
}
func normalizeOpenAICompatiblePath(path string) string {
normalized := strings.TrimRight(strings.TrimSpace(path), "/")
lower := strings.ToLower(normalized)
switch {
case strings.HasSuffix(lower, "/chat/completions"):
normalized = normalized[:len(normalized)-len("/chat/completions")]
case strings.HasSuffix(lower, "/models"):
normalized = normalized[:len(normalized)-len("/models")]
}
normalized = strings.TrimRight(normalized, "/")
if openAICompatibleVersionSuffixPattern.MatchString(normalized) {
return normalized
}
if normalized == "" {
return "/v1"
}
return normalized + "/v1"
}

View File

@@ -30,14 +30,7 @@ type OpenAIProvider struct {
// NewOpenAIProvider 创建 OpenAI Provider 实例
func NewOpenAIProvider(config ai.ProviderConfig) (Provider, error) {
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
if baseURL == "" {
baseURL = defaultOpenAIBaseURL
}
// 确保 baseURL 包含 /v1 路径(兼容用户只填域名的情况,如 https://anyrouter.top
if !strings.HasSuffix(baseURL, "/v1") && !strings.Contains(baseURL, "/v1/") {
baseURL = baseURL + "/v1"
}
baseURL := NormalizeOpenAICompatibleBaseURL(config.BaseURL)
model := strings.TrimSpace(config.Model)
if model == "" {
return nil, fmt.Errorf("模型 ID 不能为空,请在设置中选择或输入模型")
@@ -315,7 +308,7 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cal
}
if len(chunk.Choices) > 0 {
choice := chunk.Choices[0]
// Handle ToolCalls delta
if len(choice.Delta.ToolCalls) > 0 {
receivedContent = true
@@ -383,10 +376,7 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, body interface{}) (io.Re
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
url := p.baseURL + "/chat/completions"
url := ResolveOpenAICompatibleEndpoint(p.baseURL, "chat/completions")
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err)

View File

@@ -5,6 +5,76 @@ import (
"testing"
)
func TestNormalizeOpenAICompatibleBaseURL(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{
name: "empty uses default openai base url",
raw: "",
want: "https://api.openai.com/v1",
},
{
name: "domain only appends v1",
raw: "https://api.openai.com",
want: "https://api.openai.com/v1",
},
{
name: "keeps existing v1 suffix",
raw: "https://api.deepseek.com/v1",
want: "https://api.deepseek.com/v1",
},
{
name: "keeps dashscope compatible mode path",
raw: "https://dashscope.aliyuncs.com/compatible-mode/v1",
want: "https://dashscope.aliyuncs.com/compatible-mode/v1",
},
{
name: "keeps zhipu v4 path",
raw: "https://open.bigmodel.cn/api/paas/v4",
want: "https://open.bigmodel.cn/api/paas/v4",
},
{
name: "keeps volcengine ark v3 path",
raw: "https://ark.cn-beijing.volces.com/api/v3",
want: "https://ark.cn-beijing.volces.com/api/v3",
},
{
name: "keeps volcengine coding plan v3 path",
raw: "https://ark.cn-beijing.volces.com/api/coding/v3",
want: "https://ark.cn-beijing.volces.com/api/coding/v3",
},
{
name: "strips chat completions suffix before normalizing",
raw: "https://api.openai.com/v1/chat/completions",
want: "https://api.openai.com/v1",
},
{
name: "strips models suffix before normalizing",
raw: "https://ark.cn-beijing.volces.com/api/coding/v3/models",
want: "https://ark.cn-beijing.volces.com/api/coding/v3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := NormalizeOpenAICompatibleBaseURL(tt.raw); got != tt.want {
t.Fatalf("expected normalized base url %q, got %q", tt.want, got)
}
})
}
}
func TestResolveOpenAICompatibleEndpoint(t *testing.T) {
got := ResolveOpenAICompatibleEndpoint("https://ark.cn-beijing.volces.com/api/coding/v3/models", "chat/completions")
want := "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions"
if got != want {
t.Fatalf("expected endpoint %q, got %q", want, got)
}
}
func TestOpenAIProvider_Validate_MissingAPIKey(t *testing.T) {
p, err := NewOpenAIProvider(ai.ProviderConfig{Type: "openai", Model: "gpt-4o"})
if err != nil {

View File

@@ -268,13 +268,7 @@ func resolveModelsURL(config ai.ProviderConfig) string {
case "openai":
fallthrough
default:
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
if !strings.HasSuffix(baseURL, "/v1") && !strings.Contains(baseURL, "/v1/") {
baseURL = baseURL + "/v1"
}
return baseURL + "/models"
return provider.ResolveOpenAICompatibleEndpoint(baseURL, "models")
}
}

View File

@@ -37,6 +37,43 @@ func TestResolveModelsURL_UsesOpenAIModelsEndpointForOpenAICompatibleProvider(t
}
}
func TestResolveModelsURL_UsesVersionedVolcengineCodingPlanPath(t *testing.T) {
url := resolveModelsURL(ai.ProviderConfig{
Type: "openai",
BaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3",
})
if url != "https://ark.cn-beijing.volces.com/api/coding/v3/models" {
t.Fatalf("expected volcengine coding plan models endpoint, got %q", url)
}
}
func TestResolveModelsURL_UsesVersionedZhipuPath(t *testing.T) {
url := resolveModelsURL(ai.ProviderConfig{
Type: "openai",
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
})
if url != "https://open.bigmodel.cn/api/paas/v4/models" {
t.Fatalf("expected zhipu models endpoint, got %q", url)
}
}
func TestNewModelsRequest_StripsChatCompletionsSuffixForOpenAICompatibleProvider(t *testing.T) {
req, err := newModelsRequest(ai.ProviderConfig{
Type: "openai",
BaseURL: "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
APIKey: "sk-test",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if req.URL.String() != "https://ark.cn-beijing.volces.com/api/v3/models" {
t.Fatalf("expected normalized models endpoint, got %q", req.URL.String())
}
if got := req.Header.Get("Authorization"); got != "Bearer sk-test" {
t.Fatalf("expected bearer auth header, got %q", got)
}
}
func TestDefaultStaticModelsForProvider_ReturnsMiniMaxAnthropicModels(t *testing.T) {
models := defaultStaticModelsForProvider(ai.ProviderConfig{
Type: "anthropic",