mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-06 18:41:42 +08:00
🐛 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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user