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:
woody
2026-05-20 13:48:35 +08:00
committed by GitHub
parent e1e631263c
commit e7651f3d3e
5 changed files with 600 additions and 31 deletions

View File

@@ -128,18 +128,20 @@ OpenAI 所对应的 `type` 为 `openai`。它特有的配置字段如下:
Azure OpenAI 所对应的 `type``azure`。它特有的配置字段如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
| ----------------- | -------- | -------- | ------ | -------------------------------------------------------- |
| `azureServiceUrl` | string | 必填 | - | Azure OpenAI 服务的 URL,须包含 `api-version` 查询参数。 |
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
| ----------------- | -------- | -------- | ------ | ------------------------------------------------------------------------------------------------------ |
| `azureServiceUrl` | string | 必填 | - | Azure OpenAI 服务的 URL`/openai/v1` 新版路径无需日期型 `api-version`legacy 路径或仅资源名称模式仍须包含。 |
**注意:**
1. Azure OpenAI 只支持配置一个 API Token。
2. `azureServiceUrl` 支持以下三种配置格式:
1. 完整路径格式,例如:`https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME/chat/completions?api-version=2024-02-15-preview`
2. `azureServiceUrl` 支持新版 `/openai/v1` 和 legacy 配置格式:
1. 新版 v1 格式,例如:`https://YOUR_RESOURCE_NAME.openai.azure.com/openai/v1`
- 插件会直接使用该 v1 base URL且不会自动追加日期型 `api-version`
2. Legacy 完整路径格式,例如:`https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME/chat/completions?api-version=2024-02-15-preview`
- 插件会直接将请求转发至该 URL不会参考实际的请求路径。
2. 部署名称格式,例如:`https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME?api-version=2024-02-15-preview`
3. Legacy 部署名称格式,例如:`https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME?api-version=2024-02-15-preview`
- 插件会根据实际的请求路径拼接后续路径。路径中的部署名称会保留不变,不会按照模型映射规则进行修改。同时支持 URL 中不包含部署名称的接口。
3. 资源名称格式,例如:`https://YOUR_RESOURCE_NAME.openai.azure.com?api-version=2024-02-15-preview`
4. Legacy 资源名称格式,例如:`https://YOUR_RESOURCE_NAME.openai.azure.com?api-version=2024-02-15-preview`
- 插件会根据实际的请求路径拼接后续路径。路径中的部署名称会根据请求中的模型名称结合模型映射规则进行填入。同时支持 URL 中不包含部署名称的接口。
#### 月之暗面Moonshot
@@ -400,7 +402,7 @@ provider:
type: azure
apiTokens:
- "YOUR_AZURE_OPENAI_API_TOKEN"
azureServiceUrl: "https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME/chat/completions?api-version=2024-02-15-preview",
azureServiceUrl: "https://YOUR_RESOURCE_NAME.openai.azure.com/openai/v1",
```
**请求示例**

View File

@@ -99,18 +99,20 @@ For OpenAI, the corresponding `type` is `openai`. Its unique configuration field
For Azure OpenAI, the corresponding `type` is `azure`. Its unique configuration field is:
| Name | Data Type | Filling Requirements | Default Value | Description |
|---------------------|-------------|----------------------|---------------|---------------------------------------------------------------------------------------------------------------|
| `azureServiceUrl` | string | Required | - | The URL of the Azure OpenAI service, must include the `api-version` query parameter. |
| Name | Data Type | Filling Requirements | Default Value | Description |
|---------------------|-------------|----------------------|---------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `azureServiceUrl` | string | Required | - | Azure OpenAI service URL. The `/openai/v1` path does not require a dated `api-version`; legacy paths or resource-only mode still require it. |
**Note:**
1. Azure OpenAI only supports configuring one API Token.
2. `azureServiceUrl` accepts three formats
1. Full URL. e.g. `https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME/chat/completions?api-version=2024-02-15-preview`
2. `azureServiceUrl` accepts the new `/openai/v1` format and legacy formats:
1. v1 URL. e.g. `https://YOUR_RESOURCE_NAME.openai.azure.com/openai/v1`
- The plugin uses this v1 base URL directly and does not append a dated `api-version`.
2. Legacy full URL. e.g. `https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME/chat/completions?api-version=2024-02-15-preview`
- Request will be forwarded to the given URL, no matter what original path the request uses.
2. Resource name + deployment namee.g. `https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME?api-version=2024-02-15-preview`
3. Legacy resource name + deployment name, e.g. `https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME?api-version=2024-02-15-preview`
- The path will be updated based on the actual request path, leaving the deployment name unchanged. APIs with no deployment name in the path are also support.
3. Resource name only.e.g.`https://YOUR_RESOURCE_NAME.openai.azure.com?api-version=2024-02-15-preview`
4. Legacy resource name only, e.g. `https://YOUR_RESOURCE_NAME.openai.azure.com?api-version=2024-02-15-preview`
- The path will be updated based on the actual request path. The deployment name will be filled based on the model name in the request and the configured model mapping rule. APIs with no deployment name in the path are also support.
#### Moonshot
@@ -337,7 +339,7 @@ provider:
type: azure
apiTokens:
- "YOUR_AZURE_OPENAI_API_TOKEN"
azureServiceUrl: "https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME/chat/completions?api-version=2024-02-15-preview",
azureServiceUrl: "https://YOUR_RESOURCE_NAME.openai.azure.com/openai/v1",
```
**Request Example**

View File

@@ -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 必须是合法 URLapiTokens 至少包含一个可用 token。
// 输出语义:返回 nil 表示插件可启动;返回 error 时会阻止当前 provider 配置生效。
// 边界场景:/openai/v1 新版路径不要求日期型 api-versionlegacy 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 完整路径不应追加空 querylegacy 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

View 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)])
})
}

View File

@@ -50,6 +50,40 @@ var azureFullPathConfig = func() json.RawMessage {
return data
}()
// 测试配置Azure OpenAI v1 完整路径配置(无需 api-version
var azureV1FullPathConfigWithoutApiVersion = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"provider": map[string]interface{}{
"type": "azure",
"apiTokens": []string{
"sk-azure-v1",
},
"azureServiceUrl": "https://v1-resource.openai.azure.com/openai/v1/chat/completions",
"modelMapping": map[string]string{
"*": "gpt-4.1",
},
},
})
return data
}()
// 测试配置Azure OpenAI v1 base_url 配置(无需 api-version
var azureV1BaseURLConfigWithoutApiVersion = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"provider": map[string]interface{}{
"type": "azure",
"apiTokens": []string{
"sk-azure-v1-base",
},
"azureServiceUrl": "https://v1-base-resource.openai.azure.com/openai/v1",
"modelMapping": map[string]string{
"*": "gpt-4.1",
},
},
})
return data
}()
// 测试配置Azure OpenAI仅部署配置
var azureDeploymentOnlyConfig = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
@@ -203,7 +237,7 @@ var azureInvalidConfigMissingUrl = func() json.RawMessage {
return data
}()
// 测试配置Azure OpenAI无效配置缺少api-version
// 测试配置Azure OpenAI legacy deployment 无效配置缺少api-version
var azureInvalidConfigMissingApiVersion = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"provider": map[string]interface{}{
@@ -220,6 +254,23 @@ var azureInvalidConfigMissingApiVersion = func() json.RawMessage {
return data
}()
// 测试配置Azure OpenAI legacy domain-only 无效配置缺少api-version
var azureInvalidDomainOnlyConfigMissingApiVersion = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"provider": map[string]interface{}{
"type": "azure",
"apiTokens": []string{
"sk-azure-invalid-domain",
},
"azureServiceUrl": "https://invalid-domain-resource.openai.azure.com",
"modelMapping": map[string]string{
"*": "gpt-3.5-turbo",
},
},
})
return data
}()
// 测试配置Azure OpenAI无效配置缺少apiToken
var azureInvalidConfigMissingToken = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
@@ -325,6 +376,28 @@ func RunAzureParseConfigTests(t *testing.T) {
require.NotNil(t, config)
})
// 测试Azure OpenAI v1完整路径配置解析缺少api-version也应通过
t.Run("azure v1 full path config without api version", func(t *testing.T) {
host, status := test.NewTestHost(azureV1FullPathConfigWithoutApiVersion)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
config, err := host.GetMatchConfig()
require.NoError(t, err)
require.NotNil(t, config)
})
// 测试Azure OpenAI v1 base_url配置解析缺少api-version也应通过
t.Run("azure v1 base url config without api version", func(t *testing.T) {
host, status := test.NewTestHost(azureV1BaseURLConfigWithoutApiVersion)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
config, err := host.GetMatchConfig()
require.NoError(t, err)
require.NotNil(t, config)
})
// 测试Azure OpenAI仅部署配置解析
t.Run("azure deployment only config", func(t *testing.T) {
host, status := test.NewTestHost(azureDeploymentOnlyConfig)
@@ -385,6 +458,14 @@ func RunAzureParseConfigTests(t *testing.T) {
require.Equal(t, types.OnPluginStartStatusFailed, status)
})
// 测试Azure OpenAI legacy domain-only无效配置缺少api-version
t.Run("azure invalid domain-only config missing api version", func(t *testing.T) {
host, status := test.NewTestHost(azureInvalidDomainOnlyConfigMissingApiVersion)
defer host.Reset()
// legacy domain-only 模式仍应失败因为缺少api-version
require.Equal(t, types.OnPluginStartStatusFailed, status)
})
// 测试Azure OpenAI无效配置缺少apiToken
t.Run("azure invalid config missing token", func(t *testing.T) {
host, status := test.NewTestHost(azureInvalidConfigMissingToken)
@@ -479,6 +560,60 @@ func RunAzureOnHttpRequestHeadersTests(t *testing.T) {
require.True(t, hasApiKey, "api-key header should exist")
require.Equal(t, "sk-azure-fullpath", apiKeyValue, "api-key should contain Azure API token")
})
// 测试Azure OpenAI v1完整路径配置不拼接空query
t.Run("azure v1 full path request headers without trailing query", func(t *testing.T) {
host, status := test.NewTestHost(azureV1FullPathConfigWithoutApiVersion)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/chat/completions"},
{":method", "POST"},
{"Content-Type", "application/json"},
})
require.Equal(t, types.HeaderStopIteration, action)
requestHeaders := host.GetRequestHeaders()
require.NotNil(t, requestHeaders)
hostValue, hasHost := test.GetHeaderValue(requestHeaders, ":authority")
require.True(t, hasHost, "Host header should exist")
require.Equal(t, "v1-resource.openai.azure.com", hostValue, "Host should be changed to Azure service domain")
pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path")
require.True(t, hasPath, "Path header should exist")
require.Equal(t, "/openai/v1/chat/completions", pathValue, "Path should not contain trailing empty query")
})
// 测试Azure OpenAI v1 base_url会继续拼接OpenAI capability path
t.Run("azure v1 base url request headers maps chat completions path", func(t *testing.T) {
host, status := test.NewTestHost(azureV1BaseURLConfigWithoutApiVersion)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/chat/completions"},
{":method", "POST"},
{"Content-Type", "application/json"},
})
require.Equal(t, types.HeaderStopIteration, action)
requestHeaders := host.GetRequestHeaders()
require.NotNil(t, requestHeaders)
hostValue, hasHost := test.GetHeaderValue(requestHeaders, ":authority")
require.True(t, hasHost, "Host header should exist")
require.Equal(t, "v1-base-resource.openai.azure.com", hostValue, "Host should be changed to Azure service domain")
pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path")
require.True(t, hasPath, "Path header should exist")
require.Equal(t, "/openai/v1/chat/completions", pathValue, "Path should map OpenAI request path onto Azure v1 base URL")
})
})
}
@@ -534,6 +669,39 @@ func RunAzureOnHttpRequestBodyTests(t *testing.T) {
require.Equal(t, pathValue, "/openai/deployments/test-deployment/chat/completions?api-version=2024-02-15-preview", "Path should contain Azure deployment path")
})
// 测试Azure OpenAI v1 base_url在Body阶段仍保持正确的capability path
t.Run("azure v1 base url request body maps chat completions path", func(t *testing.T) {
host, status := test.NewTestHost(azureV1BaseURLConfigWithoutApiVersion)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
action := host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/chat/completions"},
{":method", "POST"},
{"Content-Type", "application/json"},
})
require.Equal(t, types.HeaderStopIteration, action)
requestBody := `{
"model": "gpt-4.1",
"messages": [
{
"role": "user",
"content": "Hello from Azure v1"
}
]
}`
action = host.CallOnHttpRequestBody([]byte(requestBody))
require.Equal(t, types.ActionContinue, action)
requestHeaders := host.GetRequestHeaders()
pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path")
require.True(t, hasPath, "Path header should exist")
require.Equal(t, "/openai/v1/chat/completions", pathValue, "Path should keep Azure v1 capability path after body processing")
})
// 测试Azure OpenAI请求体处理不同模型
t.Run("azure different model request body", func(t *testing.T) {
host, status := test.NewTestHost(azureMultiModelConfig)