1
0
Files
nex/backend/internal/conversion/openai/adapter_unified_test.go
lanyuanxiaoyao b7e205f4b6 refactor: 优化 URL 路径拼接,修复 /v1 重复问题
## 主要变更

**核心修改**:
- 路由定义:/:protocol/v1/*path → /:protocol/*path
- proxy_handler:nativePath 直接使用 path 参数,不添加 /v1 前缀
- OpenAI 适配器:DetectInterfaceType 和 BuildUrl 去掉 /v1 前缀
- Anthropic 适配器:保持 /v1 前缀(Claude Code 兼容)

**URL 格式变化**:
- OpenAI: /openai/v1/chat/completions → /openai/chat/completions
- Anthropic: /anthropic/v1/messages (保持不变)

**base_url 配置**:
- OpenAI: 配置到版本路径,如 https://api.openai.com/v1
- Anthropic: 不配置版本路径,如 https://api.anthropic.com

## 测试验证

- 所有单元测试通过
- 所有集成测试通过
- 真实 API 测试验证成功
- 跨协议转换正常工作

## 文档更新

- 更新 backend/README.md URL 格式说明
- 同步 OpenSpec 规范文件
2026-04-21 20:21:17 +08:00

361 lines
12 KiB
Go

package openai
import (
"encoding/json"
"testing"
"nex/backend/internal/conversion"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// ExtractUnifiedModelID
// ---------------------------------------------------------------------------
func TestExtractUnifiedModelID(t *testing.T) {
a := NewAdapter()
t.Run("standard_path", func(t *testing.T) {
id, err := a.ExtractUnifiedModelID("/models/openai/gpt-4")
require.NoError(t, err)
assert.Equal(t, "openai/gpt-4", id)
})
t.Run("multi_segment_path", func(t *testing.T) {
id, err := a.ExtractUnifiedModelID("/models/azure/accounts/org/models/gpt-4")
require.NoError(t, err)
assert.Equal(t, "azure/accounts/org/models/gpt-4", id)
})
t.Run("single_segment", func(t *testing.T) {
id, err := a.ExtractUnifiedModelID("/models/gpt-4")
require.NoError(t, err)
assert.Equal(t, "gpt-4", id)
})
t.Run("non_model_path", func(t *testing.T) {
_, err := a.ExtractUnifiedModelID("/chat/completions")
require.Error(t, err)
})
t.Run("empty_suffix", func(t *testing.T) {
_, err := a.ExtractUnifiedModelID("/models/")
require.Error(t, err)
})
t.Run("models_list_no_slash", func(t *testing.T) {
_, err := a.ExtractUnifiedModelID("/models")
require.Error(t, err)
})
t.Run("unrelated_path", func(t *testing.T) {
_, err := a.ExtractUnifiedModelID("/other")
require.Error(t, err)
})
}
// ---------------------------------------------------------------------------
// ExtractModelName
// ---------------------------------------------------------------------------
func TestExtractModelName(t *testing.T) {
a := NewAdapter()
t.Run("chat", func(t *testing.T) {
body := []byte(`{"model":"openai/gpt-4","messages":[]}`)
model, err := a.ExtractModelName(body, conversion.InterfaceTypeChat)
require.NoError(t, err)
assert.Equal(t, "openai/gpt-4", model)
})
t.Run("embedding", func(t *testing.T) {
body := []byte(`{"model":"openai/text-embedding","input":"hello"}`)
model, err := a.ExtractModelName(body, conversion.InterfaceTypeEmbeddings)
require.NoError(t, err)
assert.Equal(t, "openai/text-embedding", model)
})
t.Run("rerank", func(t *testing.T) {
body := []byte(`{"model":"openai/rerank","query":"test"}`)
model, err := a.ExtractModelName(body, conversion.InterfaceTypeRerank)
require.NoError(t, err)
assert.Equal(t, "openai/rerank", model)
})
t.Run("no_model_field", func(t *testing.T) {
body := []byte(`{"messages":[]}`)
_, err := a.ExtractModelName(body, conversion.InterfaceTypeChat)
require.Error(t, err)
})
t.Run("invalid_json", func(t *testing.T) {
body := []byte(`{invalid}`)
_, err := a.ExtractModelName(body, conversion.InterfaceTypeChat)
require.Error(t, err)
})
t.Run("unsupported_interface_type", func(t *testing.T) {
body := []byte(`{"model":"openai/gpt-4"}`)
_, err := a.ExtractModelName(body, conversion.InterfaceTypePassthrough)
require.Error(t, err)
})
}
// ---------------------------------------------------------------------------
// RewriteRequestModelName
// ---------------------------------------------------------------------------
func TestRewriteRequestModelName(t *testing.T) {
a := NewAdapter()
t.Run("chat", func(t *testing.T) {
body := []byte(`{"model":"openai/gpt-4","messages":[]}`)
rewritten, err := a.RewriteRequestModelName(body, "gpt-4", conversion.InterfaceTypeChat)
require.NoError(t, err)
var m map[string]interface{}
require.NoError(t, json.Unmarshal(rewritten, &m))
assert.Equal(t, "gpt-4", m["model"])
// messages field preserved
msgs, ok := m["messages"]
require.True(t, ok)
msgsArr, ok := msgs.([]interface{})
require.True(t, ok)
assert.Len(t, msgsArr, 0)
})
t.Run("preserves_unknown_fields", func(t *testing.T) {
body := []byte(`{"model":"openai/gpt-4","temperature":0.7}`)
rewritten, err := a.RewriteRequestModelName(body, "gpt-4", conversion.InterfaceTypeChat)
require.NoError(t, err)
var m map[string]interface{}
require.NoError(t, json.Unmarshal(rewritten, &m))
assert.Equal(t, "gpt-4", m["model"])
assert.Equal(t, 0.7, m["temperature"])
})
t.Run("embedding", func(t *testing.T) {
body := []byte(`{"model":"openai/text-embedding","input":"hello"}`)
rewritten, err := a.RewriteRequestModelName(body, "text-embedding", conversion.InterfaceTypeEmbeddings)
require.NoError(t, err)
var m map[string]interface{}
require.NoError(t, json.Unmarshal(rewritten, &m))
assert.Equal(t, "text-embedding", m["model"])
assert.Equal(t, "hello", m["input"])
})
t.Run("rerank", func(t *testing.T) {
body := []byte(`{"model":"openai/rerank","query":"test"}`)
rewritten, err := a.RewriteRequestModelName(body, "rerank", conversion.InterfaceTypeRerank)
require.NoError(t, err)
var m map[string]interface{}
require.NoError(t, json.Unmarshal(rewritten, &m))
assert.Equal(t, "rerank", m["model"])
assert.Equal(t, "test", m["query"])
})
t.Run("no_model_field", func(t *testing.T) {
body := []byte(`{"messages":[]}`)
_, err := a.RewriteRequestModelName(body, "gpt-4", conversion.InterfaceTypeChat)
require.Error(t, err)
})
t.Run("invalid_json", func(t *testing.T) {
body := []byte(`{invalid}`)
_, err := a.RewriteRequestModelName(body, "gpt-4", conversion.InterfaceTypeChat)
require.Error(t, err)
})
t.Run("unsupported_interface_type", func(t *testing.T) {
body := []byte(`{"model":"openai/gpt-4"}`)
_, err := a.RewriteRequestModelName(body, "gpt-4", conversion.InterfaceTypePassthrough)
require.Error(t, err)
})
}
// ---------------------------------------------------------------------------
// RewriteResponseModelName
// ---------------------------------------------------------------------------
func TestRewriteResponseModelName(t *testing.T) {
a := NewAdapter()
t.Run("chat_existing_model", func(t *testing.T) {
body := []byte(`{"model":"gpt-4","choices":[]}`)
rewritten, err := a.RewriteResponseModelName(body, "openai/gpt-4", conversion.InterfaceTypeChat)
require.NoError(t, err)
var m map[string]interface{}
require.NoError(t, json.Unmarshal(rewritten, &m))
assert.Equal(t, "openai/gpt-4", m["model"])
choices, ok := m["choices"]
require.True(t, ok)
choicesArr, ok := choices.([]interface{})
require.True(t, ok)
assert.Len(t, choicesArr, 0)
})
t.Run("chat_without_model_field", func(t *testing.T) {
body := []byte(`{"choices":[]}`)
rewritten, err := a.RewriteResponseModelName(body, "openai/gpt-4", conversion.InterfaceTypeChat)
require.NoError(t, err)
var m map[string]interface{}
require.NoError(t, json.Unmarshal(rewritten, &m))
assert.Equal(t, "openai/gpt-4", m["model"])
choices, ok := m["choices"]
require.True(t, ok)
choicesArr, ok := choices.([]interface{})
require.True(t, ok)
assert.Len(t, choicesArr, 0)
})
t.Run("rerank_existing_model", func(t *testing.T) {
body := []byte(`{"model":"rerank","results":[]}`)
rewritten, err := a.RewriteResponseModelName(body, "openai/rerank", conversion.InterfaceTypeRerank)
require.NoError(t, err)
var m map[string]interface{}
require.NoError(t, json.Unmarshal(rewritten, &m))
assert.Equal(t, "openai/rerank", m["model"])
})
t.Run("rerank_without_model_field_should_not_add", func(t *testing.T) {
body := []byte(`{"results":[]}`)
rewritten, err := a.RewriteResponseModelName(body, "openai/rerank", conversion.InterfaceTypeRerank)
require.NoError(t, err)
var m map[string]interface{}
require.NoError(t, json.Unmarshal(rewritten, &m))
_, hasModel := m["model"]
assert.False(t, hasModel, "rerank response without model field should not have one added")
})
t.Run("embedding_existing_model", func(t *testing.T) {
body := []byte(`{"model":"text-embedding","data":[]}`)
rewritten, err := a.RewriteResponseModelName(body, "openai/text-embedding", conversion.InterfaceTypeEmbeddings)
require.NoError(t, err)
var m map[string]interface{}
require.NoError(t, json.Unmarshal(rewritten, &m))
assert.Equal(t, "openai/text-embedding", m["model"])
})
t.Run("embedding_without_model_field_adds", func(t *testing.T) {
body := []byte(`{"data":[]}`)
rewritten, err := a.RewriteResponseModelName(body, "openai/text-embedding", conversion.InterfaceTypeEmbeddings)
require.NoError(t, err)
var m map[string]interface{}
require.NoError(t, json.Unmarshal(rewritten, &m))
assert.Equal(t, "openai/text-embedding", m["model"])
})
t.Run("passthrough_returns_body_unchanged", func(t *testing.T) {
body := []byte(`{"model":"gpt-4"}`)
rewritten, err := a.RewriteResponseModelName(body, "openai/gpt-4", conversion.InterfaceTypePassthrough)
require.NoError(t, err)
assert.Equal(t, string(body), string(rewritten))
})
t.Run("invalid_json", func(t *testing.T) {
body := []byte(`{invalid}`)
_, err := a.RewriteResponseModelName(body, "openai/gpt-4", conversion.InterfaceTypeChat)
require.Error(t, err)
})
}
// ---------------------------------------------------------------------------
// ExtractModelName and RewriteRequest consistency
// ---------------------------------------------------------------------------
func TestExtractModelNameAndRewriteRequestConsistency(t *testing.T) {
a := NewAdapter()
t.Run("chat_round_trip", func(t *testing.T) {
original := []byte(`{"model":"openai/gpt-4","messages":[],"temperature":0.7}`)
// Extract the unified model ID from the body
extracted, err := a.ExtractModelName(original, conversion.InterfaceTypeChat)
require.NoError(t, err)
assert.Equal(t, "openai/gpt-4", extracted)
// Rewrite to the native model name
rewritten, err := a.RewriteRequestModelName(original, "gpt-4", conversion.InterfaceTypeChat)
require.NoError(t, err)
// Extract again from the rewritten body to verify the same location was targeted
afterRewrite, err := a.ExtractModelName(rewritten, conversion.InterfaceTypeChat)
require.NoError(t, err)
assert.Equal(t, "gpt-4", afterRewrite)
// Verify other fields are preserved
var m map[string]interface{}
require.NoError(t, json.Unmarshal(rewritten, &m))
assert.Equal(t, 0.7, m["temperature"])
})
t.Run("embedding_round_trip", func(t *testing.T) {
original := []byte(`{"model":"openai/text-embedding","input":"hello"}`)
extracted, err := a.ExtractModelName(original, conversion.InterfaceTypeEmbeddings)
require.NoError(t, err)
assert.Equal(t, "openai/text-embedding", extracted)
rewritten, err := a.RewriteRequestModelName(original, "text-embedding", conversion.InterfaceTypeEmbeddings)
require.NoError(t, err)
afterRewrite, err := a.ExtractModelName(rewritten, conversion.InterfaceTypeEmbeddings)
require.NoError(t, err)
assert.Equal(t, "text-embedding", afterRewrite)
})
t.Run("rerank_round_trip", func(t *testing.T) {
original := []byte(`{"model":"openai/rerank","query":"test"}`)
extracted, err := a.ExtractModelName(original, conversion.InterfaceTypeRerank)
require.NoError(t, err)
assert.Equal(t, "openai/rerank", extracted)
rewritten, err := a.RewriteRequestModelName(original, "rerank", conversion.InterfaceTypeRerank)
require.NoError(t, err)
afterRewrite, err := a.ExtractModelName(rewritten, conversion.InterfaceTypeRerank)
require.NoError(t, err)
assert.Equal(t, "rerank", afterRewrite)
})
}
// ---------------------------------------------------------------------------
// isModelInfoPath (additional unified model ID cases)
// ---------------------------------------------------------------------------
func TestIsModelInfoPath_UnifiedModelID(t *testing.T) {
tests := []struct {
name string
path string
expected bool
}{
{"simple_model_id", "/models/gpt-4", true},
{"unified_model_id_with_slash", "/models/openai/gpt-4", true},
{"models_list", "/models", false},
{"models_list_trailing_slash", "/models/", false},
{"chat_completions", "/chat/completions", false},
{"deeply_nested", "/models/azure/eastus/deployments/my-dept/models/gpt-4", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, isModelInfoPath(tt.path))
})
}
}