mirror of
https://github.com/alibaba/higress.git
synced 2026-05-26 05:37:25 +08:00
fix(ai-proxy): support azure openai v1 service urls (#3765)
Signed-off-by: wydream <yaodiwu618@gmail.com> Co-authored-by: EndlessSeeker <153817598+EndlessSeeker@users.noreply.github.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -19,6 +20,7 @@ type azureServiceUrlType int
|
||||
|
||||
const (
|
||||
pathAzurePrefix = "/openai"
|
||||
pathAzureOpenAIV1 = "/openai/v1"
|
||||
pathAzureModelPlaceholder = "{model}"
|
||||
pathAzureWithModelPrefix = "/openai/deployments/" + pathAzureModelPlaceholder
|
||||
queryAzureApiVersion = "api-version"
|
||||
@@ -28,6 +30,7 @@ const (
|
||||
azureServiceUrlTypeFull azureServiceUrlType = iota
|
||||
azureServiceUrlTypeWithDeployment
|
||||
azureServiceUrlTypeDomainOnly
|
||||
azureServiceUrlTypeOpenAIV1Base
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -67,14 +70,43 @@ func (m *azureProviderInitializer) DefaultCapabilities() map[string]string {
|
||||
return capabilities
|
||||
}
|
||||
|
||||
// DefaultOpenAIV1Capabilities 生成 Azure OpenAI v1 base_url 模式下的 capability path。
|
||||
// 输入约束:basePath 必须是 Azure v1 base path,通常为 /openai/v1,调用方应先完成 URL mode 判定。
|
||||
// 输出语义:把 OpenAI 标准 /v1/... 能力路径平移到 Azure /openai/v1/...,不引入 deployment 和 api-version。
|
||||
// 边界场景:若后续 Azure v1 base path 发生变化,只需要同步调用方传入的 basePath 与 spec 契约。
|
||||
func (m *azureProviderInitializer) DefaultOpenAIV1Capabilities(basePath string) map[string]string {
|
||||
return defaultOpenAIV1Capabilities(basePath, (&openaiProviderInitializer{}).DefaultCapabilities())
|
||||
}
|
||||
|
||||
// defaultOpenAIV1Capabilities 将 OpenAI capability map 转换为 Azure OpenAI v1 path map。
|
||||
// 输入约束:openAICapabilities 中的 path 应以 /v1 开头;异常 path 会被跳过,避免生成不可用上游路径。
|
||||
// 输出语义:返回的 path 统一位于 basePath 下,不包含 deployment 占位符和日期型 api-version。
|
||||
// 边界场景:保留异常 capability 跳过逻辑,防止未来 OpenAI capability 定义变更时污染 Azure v1 默认映射。
|
||||
func defaultOpenAIV1Capabilities(basePath string, openAICapabilities map[string]string) map[string]string {
|
||||
var capabilities = map[string]string{}
|
||||
for k, v := range openAICapabilities {
|
||||
if !strings.HasPrefix(v, PathOpenAIPrefix) {
|
||||
log.Warnf("azureProviderInitializer: capability %s has an unexpected path %s, skipping", k, v)
|
||||
continue
|
||||
}
|
||||
capabilities[k] = path.Join(basePath, strings.TrimPrefix(v, PathOpenAIPrefix))
|
||||
log.Debugf("azureProviderInitializer: v1 capability %s -> %s", k, capabilities[k])
|
||||
}
|
||||
return capabilities
|
||||
}
|
||||
|
||||
// ValidateConfig 校验 Azure OpenAI provider 配置是否满足启动条件。
|
||||
// 输入约束:azureServiceUrl 必须是合法 URL,apiTokens 至少包含一个可用 token。
|
||||
// 输出语义:返回 nil 表示插件可启动;返回 error 时会阻止当前 provider 配置生效。
|
||||
// 边界场景:/openai/v1 新版路径不要求日期型 api-version,legacy deployment 或 domain-only 模式仍要求非空 api-version。
|
||||
func (m *azureProviderInitializer) ValidateConfig(config *ProviderConfig) error {
|
||||
if config.azureServiceUrl == "" {
|
||||
return errors.New("missing azureServiceUrl in provider config")
|
||||
}
|
||||
if azureServiceUrl, err := url.Parse(config.azureServiceUrl); err != nil {
|
||||
return fmt.Errorf("invalid azureServiceUrl: %w", err)
|
||||
} else if !azureServiceUrl.Query().Has(queryAzureApiVersion) {
|
||||
return fmt.Errorf("missing %s query parameter in azureServiceUrl: %s", queryAzureApiVersion, config.azureServiceUrl)
|
||||
} else if err := validateAzureServiceURLAPIVersion(azureServiceUrl, config.azureServiceUrl); err != nil {
|
||||
return err
|
||||
}
|
||||
if config.apiTokens == nil || len(config.apiTokens) == 0 {
|
||||
return errors.New("no apiToken found in provider config")
|
||||
@@ -82,6 +114,64 @@ func (m *azureProviderInitializer) ValidateConfig(config *ProviderConfig) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAzureServiceURLAPIVersion 按 Azure OpenAI URL path mode 校验 api-version。
|
||||
// 输入约束:serviceURL 应来自 azureServiceUrl 的解析结果,rawServiceURL 仅用于错误信息回显。
|
||||
// 输出语义:v1 路径直接通过;legacy 路径只有携带非空 api-version 时通过。
|
||||
// 边界场景:/openai/v10、/openai/deployments/... 和仅域名 URL 都不属于 v1 模式。
|
||||
func validateAzureServiceURLAPIVersion(serviceURL *url.URL, rawServiceURL string) error {
|
||||
if isAzureOpenAIV1Path(serviceURL.Path) {
|
||||
// APIGO-CONTRACT: azure-service-url-api-version-optional
|
||||
// 新版 Azure OpenAI v1 API 以 /openai/v1 作为 base_url,产品机制不再要求日期型 api-version;
|
||||
// 若微软 v1 API 路径或版本机制再次变化,需要同步更新 apigo 控制面与 ai-proxy 插件判定。
|
||||
return nil
|
||||
}
|
||||
if serviceURL.Query().Get(queryAzureApiVersion) == "" {
|
||||
// APIGO-CONTRACT: azure-service-url-api-version-optional
|
||||
// legacy deployment/domain-only 代理模式仍依赖 api-version 生成旧版 Azure data-plane 请求;
|
||||
// 只有确认旧版 /openai/deployments 路径也取消版本参数要求时,才能放宽该分支。
|
||||
return fmt.Errorf("missing %s query parameter in azureServiceUrl: %s", queryAzureApiVersion, rawServiceURL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAzureOpenAIV1Path 判断 Azure OpenAI 服务 URL 是否使用新版 /openai/v1 path mode。
|
||||
// 输入约束:rawPath 可为空或带尾随斜杠,函数内部会用 path.Clean 规整。
|
||||
// 输出语义:仅 /openai/v1 及其子路径返回 true,避免把 /openai/v10 误判为 v1。
|
||||
// 边界场景:空路径会被规整为 ".",必须返回 false,让 domain-only legacy 模式继续要求 api-version。
|
||||
func isAzureOpenAIV1Path(rawPath string) bool {
|
||||
cleanPath := path.Clean(rawPath)
|
||||
return cleanPath == pathAzureOpenAIV1 || strings.HasPrefix(cleanPath, pathAzureOpenAIV1+"/")
|
||||
}
|
||||
|
||||
// isAzureOpenAIV1BasePath 判断 URL 是否是 Azure OpenAI v1 的 base_url,而不是具体接口完整路径。
|
||||
// 输入约束:rawPath 来自 azureServiceUrl.Path,可为空或带尾随斜杠。
|
||||
// 输出语义:仅精确 /openai/v1 返回 true;/openai/v1/chat/completions 仍保留 full path 语义。
|
||||
// 边界场景:/openai/v10 或 /openai/v1beta 都不能被误判为 v1 base_url。
|
||||
func isAzureOpenAIV1BasePath(rawPath string) bool {
|
||||
return path.Clean(rawPath) == pathAzureOpenAIV1
|
||||
}
|
||||
|
||||
// appendAzureServiceURLRawQuery 将 azureServiceUrl 中的原始 query 拼接到目标路径。
|
||||
// 输入约束:basePath 可以已经包含 query 或以 ? 结尾;rawQuery 为空时不得追加任何分隔符。
|
||||
// 输出语义:返回可直接写入 :path 的完整路径,并保持已有 query 与 azureServiceUrl query 的相对顺序。
|
||||
// 边界场景:Azure v1 URL 常常没有 query,此时必须避免生成尾随空问号。
|
||||
func appendAzureServiceURLRawQuery(basePath, rawQuery string) string {
|
||||
if rawQuery == "" {
|
||||
return basePath
|
||||
}
|
||||
if !strings.Contains(basePath, "?") {
|
||||
return basePath + "?" + rawQuery
|
||||
}
|
||||
if strings.HasSuffix(basePath, "?") {
|
||||
return basePath + rawQuery
|
||||
}
|
||||
return basePath + "&" + rawQuery
|
||||
}
|
||||
|
||||
// CreateProvider 基于已校验的 Azure OpenAI 配置创建运行时 provider。
|
||||
// 输入约束:config.azureServiceUrl 必须可解析;ValidateConfig 会在正常启动路径提前检查 api-version 与 token。
|
||||
// 输出语义:返回的 provider 会记录 URL 类型、默认模型、原始 query 和上下文缓存,用于请求阶段改写 Host/Path/Header。
|
||||
// 边界场景:/openai/deployments/{deployment} 可提取默认 deployment;/openai/v1 作为 v1 base_url 映射 OpenAI path,不补 api-version。
|
||||
func (m *azureProviderInitializer) CreateProvider(config ProviderConfig) (Provider, error) {
|
||||
var serviceUrl *url.URL
|
||||
if u, err := url.Parse(config.azureServiceUrl); err != nil {
|
||||
@@ -101,6 +191,12 @@ func (m *azureProviderInitializer) CreateProvider(config ProviderConfig) (Provid
|
||||
serviceUrlType = azureServiceUrlTypeWithDeployment
|
||||
}
|
||||
log.Debugf("azureProvider: found default model from serviceUrl: %s", defaultModel)
|
||||
} else if isAzureOpenAIV1BasePath(serviceUrl.Path) {
|
||||
// APIGO-CONTRACT: azure-service-url-api-version-optional
|
||||
// /openai/v1 是 Azure OpenAI v1 的 base_url,运行时需要继续拼接 OpenAI capability path;
|
||||
// 若将其当作 full path,会把 /v1/chat/completions 错误覆盖成 /openai/v1。
|
||||
serviceUrlType = azureServiceUrlTypeOpenAIV1Base
|
||||
log.Debugf("azureProvider: using Azure OpenAI v1 base path: %s", serviceUrl.Path)
|
||||
} else {
|
||||
// If path doesn't match the /openai/deployments pattern,
|
||||
// check if it's a custom full path or domain only
|
||||
@@ -114,14 +210,18 @@ func (m *azureProviderInitializer) CreateProvider(config ProviderConfig) (Provid
|
||||
}
|
||||
log.Debugf("azureProvider: serviceUrlType=%d", serviceUrlType)
|
||||
|
||||
config.setDefaultCapabilities(m.DefaultCapabilities())
|
||||
if serviceUrlType == azureServiceUrlTypeOpenAIV1Base {
|
||||
config.setDefaultCapabilities(m.DefaultOpenAIV1Capabilities(pathAzureOpenAIV1))
|
||||
} else {
|
||||
config.setDefaultCapabilities(m.DefaultCapabilities())
|
||||
}
|
||||
apiVersion := serviceUrl.Query().Get(queryAzureApiVersion)
|
||||
log.Debugf("azureProvider: using %s: %s", queryAzureApiVersion, apiVersion)
|
||||
return &azureProvider{
|
||||
config: config,
|
||||
serviceUrl: serviceUrl,
|
||||
serviceUrlType: serviceUrlType,
|
||||
serviceUrlFullPath: serviceUrl.Path + "?" + serviceUrl.RawQuery,
|
||||
serviceUrlFullPath: appendAzureServiceURLRawQuery(serviceUrl.Path, serviceUrl.RawQuery),
|
||||
apiVersion: apiVersion,
|
||||
defaultModel: defaultModel,
|
||||
contextCache: createContextCache(&config),
|
||||
@@ -201,6 +301,10 @@ func (m *azureProvider) TransformRequestBody(ctx wrapper.HttpContext, apiName Ap
|
||||
return
|
||||
}
|
||||
|
||||
// transformRequestPath 根据插件协议和 Azure service URL 类型生成最终上游请求路径。
|
||||
// 输入约束:ctx 中可能已经写入最终模型名;apiName 必须来自 ai-proxy 能识别的 API 枚举。
|
||||
// 输出语义:original 协议返回空字符串表示不覆盖 path;其他协议返回包含必要 Azure path/query 的上游路径。
|
||||
// 边界场景:v1 完整路径不应追加空 query,legacy domain-only 模式仍会把 api-version 追加到 capability 映射后的路径。
|
||||
func (m *azureProvider) transformRequestPath(ctx wrapper.HttpContext, apiName ApiName) string {
|
||||
// When using original protocol, don't overwrite the path.
|
||||
// This ensures basePathHandling works correctly even in TransformRequestBody stage.
|
||||
@@ -234,16 +338,7 @@ func (m *azureProvider) transformRequestPath(ctx wrapper.HttpContext, apiName Ap
|
||||
path = strings.ReplaceAll(path, pathAzureModelPlaceholder, model)
|
||||
log.Debugf("azureProvider: model replaced path: %s", path)
|
||||
}
|
||||
if !strings.Contains(path, "?") {
|
||||
// No query string yet
|
||||
path = path + "?" + m.serviceUrl.RawQuery
|
||||
} else if strings.HasSuffix(path, "?") {
|
||||
// Ends with "?" and has no query parameter
|
||||
path = path + m.serviceUrl.RawQuery
|
||||
} else {
|
||||
// Has other query parameters
|
||||
path = path + "&" + m.serviceUrl.RawQuery
|
||||
}
|
||||
path = appendAzureServiceURLRawQuery(path, m.serviceUrl.RawQuery)
|
||||
log.Debugf("azureProvider: final path: %s", path)
|
||||
|
||||
return path
|
||||
|
||||
302
plugins/wasm-go/extensions/ai-proxy/provider/azure_test.go
Normal file
302
plugins/wasm-go/extensions/ai-proxy/provider/azure_test.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAzureValidateServiceURLAPIVersionByPathMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serviceURL string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "v1 base path without api-version is accepted",
|
||||
serviceURL: "https://resource.openai.azure.com/openai/v1",
|
||||
},
|
||||
{
|
||||
name: "v1 base path with trailing slash without api-version is accepted",
|
||||
serviceURL: "https://resource.openai.azure.com/openai/v1/",
|
||||
},
|
||||
{
|
||||
name: "v1 child path without api-version is accepted",
|
||||
serviceURL: "https://resource.openai.azure.com/openai/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
name: "v1 child path with duplicate slash without api-version is accepted",
|
||||
serviceURL: "https://resource.openai.azure.com/openai/v1//chat/completions",
|
||||
},
|
||||
{
|
||||
name: "legacy deployment path with api-version is accepted",
|
||||
serviceURL: "https://resource.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-10-21",
|
||||
},
|
||||
{
|
||||
name: "legacy domain-only path with api-version is accepted",
|
||||
serviceURL: "https://resource.openai.azure.com?api-version=2024-10-21",
|
||||
},
|
||||
{
|
||||
name: "legacy deployment path without api-version is rejected",
|
||||
serviceURL: "https://resource.openai.azure.com/openai/deployments/gpt-4/chat/completions",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "legacy domain-only path without api-version is rejected",
|
||||
serviceURL: "https://resource.openai.azure.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "legacy path with empty api-version value is rejected",
|
||||
serviceURL: "https://resource.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "lookalike v10 path without api-version is rejected",
|
||||
serviceURL: "https://resource.openai.azure.com/openai/v10/chat/completions",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "lookalike v1beta path without api-version is rejected",
|
||||
serviceURL: "https://resource.openai.azure.com/openai/v1beta/chat/completions",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
u, err := url.Parse(tt.serviceURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = validateAzureServiceURLAPIVersion(u, tt.serviceURL)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), queryAzureApiVersion)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureOpenAIV1PathClassification(t *testing.T) {
|
||||
t.Run("v1 path mode accepts only exact v1 path and children", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{path: "/openai/v1", want: true},
|
||||
{path: "/openai/v1/", want: true},
|
||||
{path: "/openai//v1", want: true},
|
||||
{path: "/openai/v1/chat/completions", want: true},
|
||||
{path: "/openai/v1//chat/completions", want: true},
|
||||
{path: "", want: false},
|
||||
{path: "/", want: false},
|
||||
{path: "/openai", want: false},
|
||||
{path: "/openai/deployments/gpt-4/chat/completions", want: false},
|
||||
{path: "/openai/v10/chat/completions", want: false},
|
||||
{path: "/openai/v1beta/chat/completions", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, isAzureOpenAIV1Path(tt.path))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("v1 base url mode accepts only exact v1 base path", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{path: "/openai/v1", want: true},
|
||||
{path: "/openai/v1/", want: true},
|
||||
{path: "/openai//v1", want: true},
|
||||
{path: "/openai/v1/chat/completions", want: false},
|
||||
{path: "/openai/v10", want: false},
|
||||
{path: "/openai/v1beta", want: false},
|
||||
{path: "", want: false},
|
||||
{path: "/", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, isAzureOpenAIV1BasePath(tt.path))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppendAzureServiceURLRawQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
basePath string
|
||||
rawQuery string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty raw query keeps path unchanged",
|
||||
basePath: "/openai/v1/chat/completions",
|
||||
want: "/openai/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
name: "path without query gets question mark",
|
||||
basePath: "/openai/deployments/gpt-4/chat/completions",
|
||||
rawQuery: "api-version=2024-10-21",
|
||||
want: "/openai/deployments/gpt-4/chat/completions?api-version=2024-10-21",
|
||||
},
|
||||
{
|
||||
name: "path ending with question mark appends raw query directly",
|
||||
basePath: "/openai/files?",
|
||||
rawQuery: "api-version=2024-10-21",
|
||||
want: "/openai/files?api-version=2024-10-21",
|
||||
},
|
||||
{
|
||||
name: "path with existing query appends raw query with ampersand",
|
||||
basePath: "/openai/files?limit=10",
|
||||
rawQuery: "api-version=2024-10-21",
|
||||
want: "/openai/files?limit=10&api-version=2024-10-21",
|
||||
},
|
||||
{
|
||||
name: "raw query order is preserved",
|
||||
basePath: "/openai/v1/chat/completions",
|
||||
rawQuery: "foo=bar&api-version=preview",
|
||||
want: "/openai/v1/chat/completions?foo=bar&api-version=preview",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, appendAzureServiceURLRawQuery(tt.basePath, tt.rawQuery))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureDefaultOpenAIV1Capabilities(t *testing.T) {
|
||||
capabilities := (&azureProviderInitializer{}).DefaultOpenAIV1Capabilities(pathAzureOpenAIV1)
|
||||
|
||||
assert.Equal(t, "/openai/v1/chat/completions", capabilities[string(ApiNameChatCompletion)])
|
||||
assert.Equal(t, "/openai/v1/embeddings", capabilities[string(ApiNameEmbeddings)])
|
||||
assert.Equal(t, "/openai/v1/models", capabilities[string(ApiNameModels)])
|
||||
assert.Equal(t, "/openai/v1/responses", capabilities[string(ApiNameResponses)])
|
||||
for apiName, capabilityPath := range capabilities {
|
||||
assert.True(t, strings.HasPrefix(capabilityPath, pathAzureOpenAIV1+"/"), "capability %s should use Azure v1 base path", apiName)
|
||||
assert.NotContains(t, capabilityPath, pathAzureModelPlaceholder, "capability %s should not use deployment placeholder", apiName)
|
||||
assert.NotContains(t, capabilityPath, queryAzureApiVersion, "capability %s should not synthesize api-version", apiName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultOpenAIV1CapabilitiesSkipsUnexpectedPath(t *testing.T) {
|
||||
capabilities := defaultOpenAIV1Capabilities(pathAzureOpenAIV1, map[string]string{
|
||||
string(ApiNameChatCompletion): PathOpenAIChatCompletions,
|
||||
"bad-capability": "/bad/chat/completions",
|
||||
})
|
||||
|
||||
assert.Equal(t, "/openai/v1/chat/completions", capabilities[string(ApiNameChatCompletion)])
|
||||
assert.NotContains(t, capabilities, "bad-capability")
|
||||
}
|
||||
|
||||
func TestAzureValidateConfigCoversErrorBranches(t *testing.T) {
|
||||
initializer := &azureProviderInitializer{}
|
||||
|
||||
t.Run("missing azureServiceUrl is rejected before URL parsing", func(t *testing.T) {
|
||||
err := initializer.ValidateConfig(&ProviderConfig{
|
||||
apiTokens: []string{"sk-test"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "missing azureServiceUrl")
|
||||
})
|
||||
|
||||
t.Run("invalid azureServiceUrl is rejected", func(t *testing.T) {
|
||||
err := initializer.ValidateConfig(&ProviderConfig{
|
||||
azureServiceUrl: "https://[invalid-host",
|
||||
apiTokens: []string{"sk-test"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid azureServiceUrl")
|
||||
})
|
||||
|
||||
t.Run("legacy URL without api-version is rejected", func(t *testing.T) {
|
||||
err := initializer.ValidateConfig(&ProviderConfig{
|
||||
azureServiceUrl: "https://resource.openai.azure.com/openai/deployments/gpt-4/chat/completions",
|
||||
apiTokens: []string{"sk-test"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), queryAzureApiVersion)
|
||||
})
|
||||
|
||||
t.Run("v1 URL without api token is rejected after path mode validation", func(t *testing.T) {
|
||||
err := initializer.ValidateConfig(&ProviderConfig{
|
||||
azureServiceUrl: "https://resource.openai.azure.com/openai/v1",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no apiToken")
|
||||
})
|
||||
|
||||
t.Run("v1 URL with api token is accepted without api-version", func(t *testing.T) {
|
||||
err := initializer.ValidateConfig(&ProviderConfig{
|
||||
azureServiceUrl: "https://resource.openai.azure.com/openai/v1",
|
||||
apiTokens: []string{"sk-test"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAzureCreateProviderClassifiesV1ServiceURLMode(t *testing.T) {
|
||||
initializer := &azureProviderInitializer{}
|
||||
|
||||
t.Run("invalid URL returns create error", func(t *testing.T) {
|
||||
_, err := initializer.CreateProvider(ProviderConfig{
|
||||
azureServiceUrl: "https://[invalid-host",
|
||||
apiTokens: []string{"sk-test"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid azureServiceUrl")
|
||||
})
|
||||
|
||||
t.Run("v1 base url uses OpenAI capability mapping without api-version", func(t *testing.T) {
|
||||
p, err := initializer.CreateProvider(ProviderConfig{
|
||||
azureServiceUrl: "https://resource.openai.azure.com/openai/v1",
|
||||
apiTokens: []string{"sk-test"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
azureProvider, ok := p.(*azureProvider)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, azureServiceUrlTypeOpenAIV1Base, azureProvider.serviceUrlType)
|
||||
assert.Equal(t, "/openai/v1", azureProvider.serviceUrlFullPath)
|
||||
assert.Equal(t, "", azureProvider.apiVersion)
|
||||
assert.Equal(t, "/openai/v1/chat/completions", azureProvider.config.capabilities[string(ApiNameChatCompletion)])
|
||||
})
|
||||
|
||||
t.Run("v1 base url with trailing slash is still base mode", func(t *testing.T) {
|
||||
p, err := initializer.CreateProvider(ProviderConfig{
|
||||
azureServiceUrl: "https://resource.openai.azure.com/openai/v1/",
|
||||
apiTokens: []string{"sk-test"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
azureProvider, ok := p.(*azureProvider)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, azureServiceUrlTypeOpenAIV1Base, azureProvider.serviceUrlType)
|
||||
assert.Equal(t, "/openai/v1/", azureProvider.serviceUrlFullPath)
|
||||
})
|
||||
|
||||
t.Run("v1 full path remains full path mode", func(t *testing.T) {
|
||||
p, err := initializer.CreateProvider(ProviderConfig{
|
||||
azureServiceUrl: "https://resource.openai.azure.com/openai/v1/chat/completions",
|
||||
apiTokens: []string{"sk-test"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
azureProvider, ok := p.(*azureProvider)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, azureServiceUrlTypeFull, azureProvider.serviceUrlType)
|
||||
assert.Equal(t, "/openai/v1/chat/completions", azureProvider.serviceUrlFullPath)
|
||||
assert.Equal(t, "/openai/deployments/{model}/chat/completions", azureProvider.config.capabilities[string(ApiNameChatCompletion)])
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user