mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a7739acdf | ||
|
|
14e0a49e17 | ||
|
|
b439dddfda | ||
|
|
e094a3f40b | ||
|
|
57387445f3 | ||
|
|
2fc24d8879 | ||
|
|
cc91155fe4 | ||
|
|
b7dba62678 |
@@ -1 +1 @@
|
||||
0.2.1
|
||||
0.2.2
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const groupModelAllowlistRepairMigration = "236_group_model_allowlist_repair.sql"
|
||||
|
||||
// 236 是可重放的修复迁移:235 的重命名一旦被记账就不会重跑,数据库若回到旧结构
|
||||
// (手工改回列名、按旧结构部分恢复)应用仍能启动,但所有关联 groups 的查询都会
|
||||
// 报 column groups.model_allowlist does not exist(issue #6780)。
|
||||
func TestMigration236RenamesLegacyModelsListConfigColumn(t *testing.T) {
|
||||
tx := testTx(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := tx.ExecContext(ctx, "ALTER TABLE groups RENAME COLUMN model_allowlist TO models_list_config")
|
||||
require.NoError(t, err)
|
||||
|
||||
var groupID int64
|
||||
require.NoError(t, tx.QueryRowContext(ctx, `
|
||||
INSERT INTO groups (name, platform, rate_multiplier, status, models_list_config)
|
||||
VALUES ('migration-236-rename', 'anthropic', 1, 'active', '{"enabled":true,"models":["claude-sonnet-5"]}'::jsonb)
|
||||
RETURNING id
|
||||
`).Scan(&groupID))
|
||||
|
||||
applyGroupModelAllowlistRepair(ctx, t, tx)
|
||||
|
||||
// 重命名保留原数据,且新列恢复 NOT NULL DEFAULT '{}' 的形状。
|
||||
var allowlist string
|
||||
require.NoError(t, tx.QueryRowContext(ctx,
|
||||
"SELECT model_allowlist::text FROM groups WHERE id = $1", groupID).Scan(&allowlist))
|
||||
require.JSONEq(t, `{"enabled":true,"models":["claude-sonnet-5"]}`, allowlist)
|
||||
requireModelAllowlistColumnShape(ctx, t, tx)
|
||||
|
||||
// 可重放:重复执行不报错也不改变结果。
|
||||
applyGroupModelAllowlistRepair(ctx, t, tx)
|
||||
require.NoError(t, tx.QueryRowContext(ctx,
|
||||
"SELECT model_allowlist::text FROM groups WHERE id = $1", groupID).Scan(&allowlist))
|
||||
require.JSONEq(t, `{"enabled":true,"models":["claude-sonnet-5"]}`, allowlist)
|
||||
}
|
||||
|
||||
func TestMigration236BackfillsWhenBothColumnsExist(t *testing.T) {
|
||||
tx := testTx(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := tx.ExecContext(ctx,
|
||||
"ALTER TABLE groups ADD COLUMN models_list_config JSONB NOT NULL DEFAULT '{}'::jsonb")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 新列仍是默认空值:旧列里的配置应该被补回来。
|
||||
var staleID int64
|
||||
require.NoError(t, tx.QueryRowContext(ctx, `
|
||||
INSERT INTO groups (name, platform, rate_multiplier, status, model_allowlist, models_list_config)
|
||||
VALUES ('migration-236-backfill', 'anthropic', 1, 'active', '{}'::jsonb, '{"enabled":true,"models":["legacy-model"]}'::jsonb)
|
||||
RETURNING id
|
||||
`).Scan(&staleID))
|
||||
|
||||
// 新列已有配置:不能被旧列覆盖。
|
||||
var currentID int64
|
||||
require.NoError(t, tx.QueryRowContext(ctx, `
|
||||
INSERT INTO groups (name, platform, rate_multiplier, status, model_allowlist, models_list_config)
|
||||
VALUES ('migration-236-keep', 'anthropic', 1, 'active', '{"enabled":true,"models":["current-model"]}'::jsonb, '{"enabled":true,"models":["legacy-model"]}'::jsonb)
|
||||
RETURNING id
|
||||
`).Scan(¤tID))
|
||||
|
||||
applyGroupModelAllowlistRepair(ctx, t, tx)
|
||||
|
||||
var backfilled, kept string
|
||||
require.NoError(t, tx.QueryRowContext(ctx,
|
||||
"SELECT model_allowlist::text FROM groups WHERE id = $1", staleID).Scan(&backfilled))
|
||||
require.JSONEq(t, `{"enabled":true,"models":["legacy-model"]}`, backfilled)
|
||||
require.NoError(t, tx.QueryRowContext(ctx,
|
||||
"SELECT model_allowlist::text FROM groups WHERE id = $1", currentID).Scan(&kept))
|
||||
require.JSONEq(t, `{"enabled":true,"models":["current-model"]}`, kept)
|
||||
}
|
||||
|
||||
func TestMigration236RecreatesMissingModelAllowlistColumn(t *testing.T) {
|
||||
tx := testTx(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := tx.ExecContext(ctx, "ALTER TABLE groups DROP COLUMN model_allowlist")
|
||||
require.NoError(t, err)
|
||||
|
||||
var groupID int64
|
||||
require.NoError(t, tx.QueryRowContext(ctx, `
|
||||
INSERT INTO groups (name, platform, rate_multiplier, status)
|
||||
VALUES ('migration-236-recreate', 'anthropic', 1, 'active')
|
||||
RETURNING id
|
||||
`).Scan(&groupID))
|
||||
|
||||
applyGroupModelAllowlistRepair(ctx, t, tx)
|
||||
|
||||
var allowlist string
|
||||
require.NoError(t, tx.QueryRowContext(ctx,
|
||||
"SELECT model_allowlist::text FROM groups WHERE id = $1", groupID).Scan(&allowlist))
|
||||
require.JSONEq(t, `{}`, allowlist)
|
||||
requireModelAllowlistColumnShape(ctx, t, tx)
|
||||
}
|
||||
|
||||
func applyGroupModelAllowlistRepair(ctx context.Context, t *testing.T, tx *sql.Tx) {
|
||||
t.Helper()
|
||||
|
||||
migrationSQL, err := dbmigrations.FS.ReadFile(groupModelAllowlistRepairMigration)
|
||||
require.NoError(t, err)
|
||||
_, err = tx.ExecContext(ctx, string(migrationSQL))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func requireModelAllowlistColumnShape(ctx context.Context, t *testing.T, tx *sql.Tx) {
|
||||
t.Helper()
|
||||
|
||||
var isNullable, columnDefault string
|
||||
require.NoError(t, tx.QueryRowContext(ctx, `
|
||||
SELECT is_nullable, COALESCE(column_default, '')
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'groups' AND column_name = 'model_allowlist'
|
||||
`).Scan(&isNullable, &columnDefault))
|
||||
require.Equal(t, "NO", isNullable)
|
||||
require.Contains(t, columnDefault, "'{}'::jsonb")
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFetchOpenAIAccountModelsOAuthPopulatesPickerFields(t *testing.T) {
|
||||
_, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"new-oauth-model"},{"slug":"gpt-6-astra"}]}`)
|
||||
gateway := &OpenAIGatewayService{}
|
||||
svc := &AccountTestService{openaiGatewayService: gateway}
|
||||
account := newCodexModelsTestAccount()
|
||||
ctx := context.Background()
|
||||
|
||||
before, err := gateway.FetchOpenAIModelsList(ctx, account)
|
||||
require.NoError(t, err)
|
||||
models, err := svc.FetchOpenAIAccountModels(ctx, account)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, models, 2)
|
||||
for i, id := range []string{"new-oauth-model", "gpt-6-astra"} {
|
||||
require.Equal(t, id, models[i].ID)
|
||||
require.Equal(t, id, models[i].DisplayName)
|
||||
require.Equal(t, "model", models[i].Type)
|
||||
}
|
||||
after, err := gateway.FetchOpenAIModelsList(ctx, account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, before.Body, after.Body, "picker fields must not change the shared catalog")
|
||||
require.NotContains(t, string(after.Body), "display_name")
|
||||
require.EqualValues(t, 1, calls.Load(), "picker must reuse the shared discovery cache")
|
||||
}
|
||||
|
||||
func TestFetchOpenAIAccountModelsAPIKeyPopulatesPickerFields(t *testing.T) {
|
||||
gateway := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
return ordinaryModelsUpstreamResponse(`{"data":[
|
||||
{"id":"new-api-model","owned_by":"provider","created":123},
|
||||
{"id":"blank-label","display_name":" ","type":""},
|
||||
{"id":"named-model","display_name":"Provider Model","type":"model"}
|
||||
]}`), nil
|
||||
}})
|
||||
svc := &AccountTestService{openaiGatewayService: gateway}
|
||||
models, err := svc.FetchOpenAIAccountModels(context.Background(), newCodexModelsAPIKeyTestAccount("https://models.example/v1"))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, models, 3)
|
||||
for i, name := range []string{"new-api-model", "blank-label", "Provider Model"} {
|
||||
require.Equal(t, name, models[i].DisplayName)
|
||||
require.Equal(t, "model", models[i].Type)
|
||||
}
|
||||
require.Equal(t, "provider", models[0].OwnedBy)
|
||||
require.EqualValues(t, 123, models[0].Created)
|
||||
require.Equal(t, "named-model", models[2].ID)
|
||||
}
|
||||
|
||||
func TestFetchOpenAIAccountModelsPreservesEmptyCatalog(t *testing.T) {
|
||||
gateway := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
return ordinaryModelsUpstreamResponse(`{"data":[]}`), nil
|
||||
}})
|
||||
svc := &AccountTestService{openaiGatewayService: gateway}
|
||||
models, err := svc.FetchOpenAIAccountModels(context.Background(), newCodexModelsAPIKeyTestAccount("https://models.example/v1"))
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, models, "an empty upstream catalog must not become a static model list")
|
||||
}
|
||||
@@ -192,6 +192,17 @@ func (s *AccountTestService) FetchOpenAIAccountModels(ctx context.Context, accou
|
||||
if err := json.Unmarshal(response.Body, &payload); err != nil {
|
||||
return nil, fmt.Errorf("decode OpenAI account models: %w", err)
|
||||
}
|
||||
// Standard model catalogs do not require the fields used by the admin picker.
|
||||
// Populate them here without changing the shared discovery response or cache.
|
||||
for i := range payload.Data {
|
||||
model := &payload.Data[i]
|
||||
if strings.TrimSpace(model.DisplayName) == "" {
|
||||
model.DisplayName = model.ID
|
||||
}
|
||||
if strings.TrimSpace(model.Type) == "" {
|
||||
model.Type = "model"
|
||||
}
|
||||
}
|
||||
return payload.Data, nil
|
||||
}
|
||||
|
||||
@@ -463,7 +474,9 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||
} else {
|
||||
req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader)
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken)
|
||||
// Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,
|
||||
// 其余保持 extra/default 行为。
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetBaseURL())
|
||||
}
|
||||
|
||||
// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
|
||||
|
||||
@@ -81,7 +81,9 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader)
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken)
|
||||
// Ollama Cloud Anthropic 兼容端点按 adaptive 实际选用的 Anthropic
|
||||
// base_url 强制 Bearer,其余保持 extra/default 行为。
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetCNProtocolBaseURL(APIProtocolAnthropic))
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
|
||||
resp, err := s.doCNProviderAdaptiveRequest(req, account)
|
||||
@@ -260,7 +262,9 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a
|
||||
for key, value := range claude.DefaultHeaders {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken)
|
||||
// Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持
|
||||
// extra/default 行为。
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetAnthropicProtocolBaseURL())
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
|
||||
resp, err := s.doCNProviderAdaptiveRequest(req, account)
|
||||
|
||||
@@ -33,7 +33,26 @@ func (a *Account) GetAnthropicAPIKeyAuthScheme() string {
|
||||
}
|
||||
}
|
||||
|
||||
func setAnthropicAPIKeyAuthHeader(header http.Header, account *Account, token string) {
|
||||
// isOllamaCloudAnthropicAuthBaseURL 判定本次实际选用的 Anthropic 上游 base_url
|
||||
// 是否指向 Ollama Cloud。与真实出站 URL 组装同源:先 TrimSpace + TrimRight "/"
|
||||
// (urlvalidator 与 nativeAnthropicTargetURL 同款归一化,'https://ollama.com/'
|
||||
// 与 '.../v1' 均合理),再复用既有严格 host helper isOllamaCloudBaseURL(精确
|
||||
// host、https、无 query/fragment、路径仅空或 /v1),不按 substring 匹配。
|
||||
func isOllamaCloudAnthropicAuthBaseURL(baseURL string) bool {
|
||||
return isOllamaCloudBaseURL(strings.TrimRight(strings.TrimSpace(baseURL), "/"))
|
||||
}
|
||||
|
||||
// setAnthropicAPIKeyAuthHeader 写入上游认证头。除 extra 覆写外,Ollama Cloud
|
||||
// 官方 Anthropic 兼容端点只认 Authorization: Bearer:判定依据是本次实际选用的
|
||||
// 上游 base_url(与出站 URL 组装同源,而非 account.Platform——Ollama Cloud key
|
||||
// 可挂在多个平台分组下),即使 extra 缺失或显式 x_api_key 也强制 Bearer;
|
||||
// 其它上游保持历史 extra/default 行为。baseURL 为该请求实际选用的 Anthropic
|
||||
// 上游 base(GetBaseURL / GetAnthropicProtocolBaseURL 等),默认官方端点时传空。
|
||||
func setAnthropicAPIKeyAuthHeader(header http.Header, account *Account, token, baseURL string) {
|
||||
if account.Type == AccountTypeAPIKey && isOllamaCloudAnthropicAuthBaseURL(baseURL) {
|
||||
header.Set("Authorization", "Bearer "+token)
|
||||
return
|
||||
}
|
||||
if account.GetAnthropicAPIKeyAuthScheme() == AnthropicAPIKeyAuthSchemeAuthorizationBearer {
|
||||
header.Set("Authorization", "Bearer "+token)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newOllamaCloudAnthropicAuthAccount 构造挂在 Anthropic 平台分组下的 Ollama Cloud
|
||||
// API Key 账号(base_url 指向官方 ollama.com Anthropic 兼容端点)。
|
||||
func newOllamaCloudAnthropicAuthAccount(baseURL string, extra map[string]any) *Account {
|
||||
if extra == nil {
|
||||
extra = map[string]any{}
|
||||
}
|
||||
return &Account{
|
||||
ID: 901,
|
||||
Name: "ollama-cloud-anthropic-auth",
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "ollama-cloud-key",
|
||||
"base_url": baseURL,
|
||||
},
|
||||
Extra: extra,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetAnthropicAPIKeyAuthHeader_OllamaCloudForcesBearer:Ollama Cloud 官方
|
||||
// Anthropic 兼容端点只认 Authorization: Bearer——extra 缺失或显式 x_api_key 均
|
||||
// 强制 Bearer;判定与实际 base_url 同源并做与出站组装一致的尾斜杠归一化;
|
||||
// 其它上游保持历史 extra/default 行为,恶意相似 host 不误判。
|
||||
func TestSetAnthropicAPIKeyAuthHeader_OllamaCloudForcesBearer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
extra map[string]any
|
||||
accountType string
|
||||
wantAuth string // "" = 不应出现 Authorization 头
|
||||
wantXAPIKey string // "" = 不应出现 x-api-key 头
|
||||
}{
|
||||
{
|
||||
name: "ollama 缺 extra 默认 Bearer",
|
||||
baseURL: "https://ollama.com",
|
||||
wantAuth: "Bearer ollama-cloud-key",
|
||||
},
|
||||
{
|
||||
name: "ollama base 尾斜杠仍 Bearer",
|
||||
baseURL: "https://ollama.com/",
|
||||
wantAuth: "Bearer ollama-cloud-key",
|
||||
},
|
||||
{
|
||||
name: "ollama /v1 路径 Bearer",
|
||||
baseURL: "https://ollama.com/v1",
|
||||
wantAuth: "Bearer ollama-cloud-key",
|
||||
},
|
||||
{
|
||||
name: "ollama /v1 尾斜杠 Bearer",
|
||||
baseURL: "https://www.ollama.com/v1/",
|
||||
wantAuth: "Bearer ollama-cloud-key",
|
||||
},
|
||||
{
|
||||
name: "host 大小写不敏感 Bearer",
|
||||
baseURL: "https://OLLAMA.COM",
|
||||
wantAuth: "Bearer ollama-cloud-key",
|
||||
},
|
||||
{
|
||||
name: "ollama 显式 x_api_key 也强制 Bearer",
|
||||
baseURL: "https://ollama.com",
|
||||
extra: map[string]any{anthropicAPIKeyAuthSchemeExtraKey: AnthropicAPIKeyAuthSchemeXAPIKey},
|
||||
wantAuth: "Bearer ollama-cloud-key",
|
||||
},
|
||||
{
|
||||
name: "非 Ollama 默认 x-api-key",
|
||||
baseURL: "https://api.anthropic.com",
|
||||
wantXAPIKey: "ollama-cloud-key",
|
||||
},
|
||||
{
|
||||
name: "默认官方端点(空 base_url)x-api-key",
|
||||
baseURL: "",
|
||||
wantXAPIKey: "ollama-cloud-key",
|
||||
},
|
||||
{
|
||||
name: "非 Ollama 显式 authorization_bearer 保持 Bearer",
|
||||
baseURL: "https://api.anthropic.com",
|
||||
extra: map[string]any{anthropicAPIKeyAuthSchemeExtraKey: AnthropicAPIKeyAuthSchemeAuthorizationBearer},
|
||||
wantAuth: "Bearer ollama-cloud-key",
|
||||
},
|
||||
{
|
||||
name: "恶意相似 host 子串不命中",
|
||||
baseURL: "https://evil.com/ollama.com",
|
||||
wantXAPIKey: "ollama-cloud-key",
|
||||
},
|
||||
{
|
||||
name: "恶意相似 host 后缀不命中",
|
||||
baseURL: "https://ollama.com.evil.com",
|
||||
wantXAPIKey: "ollama-cloud-key",
|
||||
},
|
||||
{
|
||||
name: "http 明文不命中",
|
||||
baseURL: "http://ollama.com",
|
||||
wantXAPIKey: "ollama-cloud-key",
|
||||
},
|
||||
{
|
||||
name: "非标准端口不命中",
|
||||
baseURL: "https://ollama.com:8443",
|
||||
wantXAPIKey: "ollama-cloud-key",
|
||||
},
|
||||
{
|
||||
name: "带 path 的 messages 地址不命中(判定用 base 而非完整 URL)",
|
||||
baseURL: "https://ollama.com/v1/messages",
|
||||
wantXAPIKey: "ollama-cloud-key",
|
||||
},
|
||||
{
|
||||
name: "非 APIKey 账号类型不强制",
|
||||
baseURL: "https://ollama.com",
|
||||
accountType: AccountTypeOAuth,
|
||||
wantXAPIKey: "ollama-cloud-key",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
accountType := AccountTypeAPIKey
|
||||
if tt.accountType != "" {
|
||||
accountType = tt.accountType
|
||||
}
|
||||
account := newOllamaCloudAnthropicAuthAccount(tt.baseURL, tt.extra)
|
||||
account.Type = accountType
|
||||
|
||||
header := http.Header{}
|
||||
setAnthropicAPIKeyAuthHeader(header, account, "ollama-cloud-key", tt.baseURL)
|
||||
require.Equal(t, tt.wantAuth, header.Get("Authorization"), "Authorization 头不符")
|
||||
require.Equal(t, tt.wantXAPIKey, header.Get("x-api-key"), "x-api-key 头不符")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetAnthropicAPIKeyAuthHeader_CNAdaptiveBaseURLResolution:adaptive 账号经
|
||||
// GetCNProtocolBaseURL(anthropic) / GetAnthropicProtocolBaseURL 选出的分协议地址
|
||||
// 才是判定依据,Chat Completions 地址(GetOpenAIBaseURL 选到的 CC base)不参与。
|
||||
func TestSetAnthropicAPIKeyAuthHeader_CNAdaptiveBaseURL(t *testing.T) {
|
||||
// adaptive:anthropic 分协议地址指向 ollama.com → Bearer;CC 地址同挂 ollama
|
||||
// 不影响(判定只用 anthropic 分协议地址)。
|
||||
adaptiveOllamaAnthropic := &Account{
|
||||
ID: 902,
|
||||
Name: "adaptive-ollama-anthropic",
|
||||
Platform: PlatformKimi,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "kimi-key",
|
||||
"api_protocol": APIProtocolAdaptive,
|
||||
"api_base_urls": map[string]any{
|
||||
APIProtocolAnthropic: "https://ollama.com",
|
||||
APIProtocolChatCompletions: "https://api.moonshot.cn",
|
||||
},
|
||||
},
|
||||
}
|
||||
anthropicBase := adaptiveOllamaAnthropic.GetCNProtocolBaseURL(APIProtocolAnthropic)
|
||||
require.Equal(t, "https://ollama.com", anthropicBase)
|
||||
header := http.Header{}
|
||||
setAnthropicAPIKeyAuthHeader(header, adaptiveOllamaAnthropic, "kimi-key", anthropicBase)
|
||||
require.Equal(t, "Bearer kimi-key", header.Get("Authorization"))
|
||||
require.Empty(t, header.Get("x-api-key"))
|
||||
|
||||
// 反向:CC 地址挂 ollama.com,anthropic 分协议地址非 ollama → 不误判,
|
||||
// 保持 x-api-key。
|
||||
adaptiveOllamaCC := &Account{
|
||||
ID: 903,
|
||||
Name: "adaptive-ollama-cc",
|
||||
Platform: PlatformKimi,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "kimi-key",
|
||||
"api_protocol": APIProtocolAdaptive,
|
||||
"api_base_urls": map[string]any{
|
||||
APIProtocolAnthropic: "https://api.moonshot.cn/anthropic",
|
||||
APIProtocolChatCompletions: "https://ollama.com/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
anthropicBase = adaptiveOllamaCC.GetCNProtocolBaseURL(APIProtocolAnthropic)
|
||||
require.Equal(t, "https://api.moonshot.cn/anthropic", anthropicBase)
|
||||
header = http.Header{}
|
||||
setAnthropicAPIKeyAuthHeader(header, adaptiveOllamaCC, "kimi-key", anthropicBase)
|
||||
require.Empty(t, header.Get("Authorization"))
|
||||
require.Equal(t, "kimi-key", header.Get("x-api-key"))
|
||||
|
||||
// anthropic 协议(非 adaptive):凭证 base_url 指向 ollama.com → Bearer。
|
||||
anthropicProtocolOllama := &Account{
|
||||
ID: 904,
|
||||
Name: "anthropic-protocol-ollama",
|
||||
Platform: PlatformKimi,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "kimi-key",
|
||||
"api_protocol": APIProtocolAnthropic,
|
||||
"base_url": "https://ollama.com",
|
||||
},
|
||||
}
|
||||
anthropicBase = anthropicProtocolOllama.GetAnthropicProtocolBaseURL()
|
||||
require.Equal(t, "https://ollama.com", anthropicBase)
|
||||
header = http.Header{}
|
||||
setAnthropicAPIKeyAuthHeader(header, anthropicProtocolOllama, "kimi-key", anthropicBase)
|
||||
require.Equal(t, "Bearer kimi-key", header.Get("Authorization"))
|
||||
require.Empty(t, header.Get("x-api-key"))
|
||||
|
||||
// 同协议但 base 为 kimi 官方 → 保持 x-api-key。
|
||||
anthropicProtocolKimi := &Account{
|
||||
ID: 905,
|
||||
Name: "anthropic-protocol-kimi",
|
||||
Platform: PlatformKimi,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "kimi-key",
|
||||
"api_protocol": APIProtocolAnthropic,
|
||||
"base_url": "https://api.moonshot.cn/anthropic",
|
||||
},
|
||||
}
|
||||
header = http.Header{}
|
||||
setAnthropicAPIKeyAuthHeader(header, anthropicProtocolKimi, "kimi-key", anthropicProtocolKimi.GetAnthropicProtocolBaseURL())
|
||||
require.Empty(t, header.Get("Authorization"))
|
||||
require.Equal(t, "kimi-key", header.Get("x-api-key"))
|
||||
}
|
||||
|
||||
// TestGatewayService_AnthropicPassthrough_OllamaCloudBearer:经 passthrough
|
||||
// builder 真实构造 http.Request 验证最终 header——Ollama Cloud 强制 Bearer 且
|
||||
// 不泄漏客户端入站认证;非 Ollama 上游保持 x-api-key。
|
||||
func TestGatewayService_AnthropicPassthrough_OllamaCloudBearer(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
buildReq := func(t *testing.T, baseURL string) *http.Request {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
// 入站认证残留:正确上游只发一种认证头,不得泄漏客户端凭证
|
||||
c.Request.Header.Set("Authorization", "Bearer inbound-token")
|
||||
c.Request.Header.Set("X-Api-Key", "inbound-api-key")
|
||||
|
||||
svc := &GatewayService{cfg: &config.Config{}}
|
||||
account := newOllamaCloudAnthropicAuthAccount(baseURL, nil)
|
||||
body := []byte(`{"model":"claude-3-7-sonnet-20250219","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "ollama-cloud-key")
|
||||
require.NoError(t, err)
|
||||
return req
|
||||
}
|
||||
|
||||
ollamaReq := buildReq(t, "https://ollama.com/")
|
||||
require.Equal(t, "Bearer ollama-cloud-key", ollamaReq.Header.Get("Authorization"))
|
||||
require.Empty(t, ollamaReq.Header.Get("x-api-key"), "Ollama Cloud 上游不得再发 x-api-key")
|
||||
require.NotContains(t, ollamaReq.Header.Get("Authorization"), "inbound-token")
|
||||
require.Empty(t, ollamaReq.Header.Get("x-inbound"), "sanity")
|
||||
|
||||
anthropicReq := buildReq(t, "https://api.anthropic.com")
|
||||
require.Empty(t, anthropicReq.Header.Get("Authorization"))
|
||||
require.Equal(t, "ollama-cloud-key", anthropicReq.Header.Get("x-api-key"))
|
||||
require.NotContains(t, anthropicReq.Header.Get("x-api-key"), "inbound-api-key")
|
||||
}
|
||||
|
||||
// TestGatewayService_BuildUpstreamRequest_OllamaCloudBearer:经 Anthropic 原生
|
||||
// GetBaseURL builder 真实构造 http.Request 验证最终 header。
|
||||
func TestGatewayService_BuildUpstreamRequest_OllamaCloudBearer(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
buildReq := func(t *testing.T, baseURL string) *http.Request {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
c.Request.Header.Set("Authorization", "Bearer inbound-token")
|
||||
c.Request.Header.Set("X-Api-Key", "inbound-api-key")
|
||||
|
||||
svc := &GatewayService{cfg: &config.Config{}}
|
||||
account := newOllamaCloudAnthropicAuthAccount(baseURL, nil)
|
||||
body := []byte(`{"model":"claude-3-7-sonnet-20250219","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "ollama-cloud-key", "apikey", "claude-3-7-sonnet-20250219", false, false)
|
||||
require.NoError(t, err)
|
||||
return req
|
||||
}
|
||||
|
||||
ollamaReq := buildReq(t, "https://ollama.com")
|
||||
require.Equal(t, "https://ollama.com/v1/messages?beta=true", ollamaReq.URL.String())
|
||||
require.Equal(t, "Bearer ollama-cloud-key", ollamaReq.Header.Get("Authorization"))
|
||||
require.Empty(t, ollamaReq.Header.Get("x-api-key"))
|
||||
|
||||
anthropicReq := buildReq(t, "https://api.anthropic.com")
|
||||
require.Empty(t, anthropicReq.Header.Get("Authorization"))
|
||||
require.Equal(t, "ollama-cloud-key", anthropicReq.Header.Get("x-api-key"))
|
||||
}
|
||||
|
||||
// TestOpenAIGatewayService_NativeAnthropicBridge_OllamaCloudBearer:经 native
|
||||
// Anthropic 桥接 builder(CC/Responses → /v1/messages)真实构造 http.Request
|
||||
// 验证最终 header。
|
||||
func TestOpenAIGatewayService_NativeAnthropicBridge_OllamaCloudBearer(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}}
|
||||
account := &Account{
|
||||
ID: 906,
|
||||
Name: "anthropic-protocol-ollama-bridge",
|
||||
Platform: PlatformKimi,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "ollama-cloud-key",
|
||||
"api_protocol": APIProtocolAnthropic,
|
||||
"base_url": "https://ollama.com",
|
||||
},
|
||||
}
|
||||
targetURL, err := svc.nativeAnthropicTargetURL(account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://ollama.com/v1/messages", targetURL)
|
||||
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
body := []byte(`{"model":"kimi-k2-thinking","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
req, _, err := svc.buildNativeAnthropicUpstreamRequest(context.Background(), c, account, body, "ollama-cloud-key", targetURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Bearer ollama-cloud-key", req.Header.Get("Authorization"))
|
||||
require.Empty(t, req.Header.Get("x-api-key"))
|
||||
|
||||
// 非 Ollama 上游:保持 x-api-key,无 Authorization。
|
||||
account.Credentials["base_url"] = "https://api.moonshot.cn/anthropic"
|
||||
targetURL, err = svc.nativeAnthropicTargetURL(account)
|
||||
require.NoError(t, err)
|
||||
req, _, err = svc.buildNativeAnthropicUpstreamRequest(context.Background(), c, account, body, "ollama-cloud-key", targetURL)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, req.Header.Get("Authorization"))
|
||||
require.Equal(t, "ollama-cloud-key", req.Header.Get("x-api-key"))
|
||||
}
|
||||
@@ -325,6 +325,10 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||||
body = sanitized
|
||||
}
|
||||
|
||||
// Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的
|
||||
// base 取值同源(GetBaseURL),详见 helper 注释。
|
||||
body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -348,7 +352,9 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||||
req.Header.Del("x-api-key")
|
||||
req.Header.Del("x-goog-api-key")
|
||||
req.Header.Del("cookie")
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, token)
|
||||
// Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方
|
||||
// targetURL 的 base 取值),其余保持 extra/default 行为。
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL())
|
||||
|
||||
if getHeaderRaw(req.Header, "content-type") == "" {
|
||||
setHeaderRaw(req.Header, "content-type", "application/json")
|
||||
|
||||
@@ -417,7 +417,9 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
|
||||
req.Header.Del("x-api-key")
|
||||
req.Header.Del("x-goog-api-key")
|
||||
req.Header.Del("cookie")
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, token)
|
||||
// Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方
|
||||
// targetURL 的 base 取值),其余保持 extra/default 行为。
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL())
|
||||
|
||||
if req.Header.Get("content-type") == "" {
|
||||
req.Header.Set("content-type", "application/json")
|
||||
@@ -522,7 +524,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
||||
if tokenType == "oauth" {
|
||||
setHeaderRaw(req.Header, "authorization", "Bearer "+token)
|
||||
} else {
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, token)
|
||||
// Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方
|
||||
// targetURL 的 base 取值),其余保持 extra/default 行为。
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL())
|
||||
}
|
||||
|
||||
// 白名单透传 headers(恢复真实 wire casing)
|
||||
|
||||
@@ -117,6 +117,11 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
||||
body = sanitized
|
||||
}
|
||||
|
||||
// Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的
|
||||
// base 取值同源(GetBaseURL),仅实际上游为 ollama.com 且映射后出站模型
|
||||
// 为 DeepSeek 系时压到 cap,详见 helper 注释。
|
||||
body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -126,7 +131,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
||||
if tokenType == "oauth" {
|
||||
setHeaderRaw(req.Header, "authorization", "Bearer "+token)
|
||||
} else {
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, token)
|
||||
// Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方
|
||||
// targetURL 的 base 取值),其余保持 extra/default 行为。
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL())
|
||||
}
|
||||
|
||||
// 白名单透传 headers
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// clampOllamaCloudAnthropicMessagesMaxTokens 是 Anthropic Messages 出站
|
||||
// (/v1/messages,含 CC / Responses 桥接与 passthrough)的 max_tokens clamp 钩子,
|
||||
// 与 raw CC / Responses 路径共用 cap 配置与实现。判定与出站 URL 组装同源:调用方
|
||||
// 传入本次实际选用的 Anthropic 上游 base_url(GetBaseURL 或
|
||||
// GetAnthropicProtocolBaseURL,不用 GetOpenAIBaseURL——adaptive 时那是 CC/Responses
|
||||
// 地址),与真实出站组装一样先 TrimRight "/"(urlvalidator 与
|
||||
// nativeAnthropicTargetURL 同款归一化),仅当其为 ollama.com 且 body 中映射后出站
|
||||
// model 为 DeepSeek 系时压到 cap,否则原样返回。
|
||||
func clampOllamaCloudAnthropicMessagesMaxTokens(account *Account, baseURL string, body []byte) []byte {
|
||||
if account == nil || len(body) == 0 {
|
||||
return body
|
||||
}
|
||||
if !isOllamaCloudBaseURL(strings.TrimRight(strings.TrimSpace(baseURL), "/")) {
|
||||
return body
|
||||
}
|
||||
if !isDeepSeekModel(gjson.GetBytes(body, "model").String()) {
|
||||
return body
|
||||
}
|
||||
return clampOllamaCloudMaxTokens(account, body)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// Anthropic Messages 出站(三个 builder)的 Ollama Cloud DeepSeek max_tokens clamp
|
||||
// 接线验证。实测背景:POST ollama.com/v1/messages(Bearer)max_tokens=256000 被上游
|
||||
// 以 "max_tokens (256000) exceeds model's maximum output tokens (65536)" 400 拒绝。
|
||||
// 用例直接调用各 builder 实际生成 *http.Request,同时检查出站 URL 与 wire body。
|
||||
|
||||
func messagesClampTestConfig() *config.Config {
|
||||
cfg := rawChatCompletionsTestConfig()
|
||||
cfg.Gateway = config.GatewayConfig{MaxLineSize: defaultMaxLineSize}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func newMessagesClampTestContext(t *testing.T) *gin.Context {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
return c
|
||||
}
|
||||
|
||||
// messagesClampOllamaAccount 构造挂在实际 ollama.com 上游的 APIKey 账号。
|
||||
// builder A / B 的 Messages base 取 GetBaseURL()(凭证 base_url),builder C 的
|
||||
// Anthropic 协议 base 取 GetAnthropicProtocolBaseURL()(anthropic 协议时同为
|
||||
// 凭证 base_url)。
|
||||
func messagesClampOllamaAccount(id int64, platform string) *Account {
|
||||
return &Account{
|
||||
ID: id,
|
||||
Name: "ollama-cloud-messages",
|
||||
Platform: platform,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://ollama.com",
|
||||
},
|
||||
Extra: map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func messagesClampBody(model string, maxTokens int) []byte {
|
||||
return []byte(`{"model":"` + model + `","max_tokens":` + strconv.Itoa(maxTokens) +
|
||||
`,"stream":false,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
}
|
||||
|
||||
// TestBuildUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens 覆盖 GatewayService
|
||||
// .buildUpstreamRequest(Anthropic 平台原生 Messages + 共享重试路径)。
|
||||
func TestBuildUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens(t *testing.T) {
|
||||
svc := &GatewayService{cfg: messagesClampTestConfig()}
|
||||
c := newMessagesClampTestContext(t)
|
||||
|
||||
body := messagesClampBody("deepseek-v4-flash", 256000)
|
||||
account := messagesClampOllamaAccount(401, PlatformAnthropic)
|
||||
|
||||
req, wireBody, err := svc.buildUpstreamRequest(
|
||||
context.Background(), c, account, body, "sk-test", "api_key",
|
||||
"deepseek-v4-flash", false, false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String())
|
||||
require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(wireBody, "model").String())
|
||||
require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int())
|
||||
|
||||
t.Run("official anthropic base is untouched", func(t *testing.T) {
|
||||
official := messagesClampOllamaAccount(402, PlatformAnthropic)
|
||||
official.Credentials["base_url"] = "https://api.anthropic.com"
|
||||
_, wire, err := svc.buildUpstreamRequest(
|
||||
context.Background(), c, official, body, "sk-test", "api_key",
|
||||
"deepseek-v4-flash", false, false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int())
|
||||
})
|
||||
|
||||
t.Run("non deepseek model untouched", func(t *testing.T) {
|
||||
nonDeepSeek := messagesClampBody("k3-256k", 256000)
|
||||
_, wire, err := svc.buildUpstreamRequest(
|
||||
context.Background(), c, account, nonDeepSeek, "sk-test", "api_key",
|
||||
"k3-256k", false, false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int())
|
||||
})
|
||||
|
||||
t.Run("at cap and extra cap zero untouched", func(t *testing.T) {
|
||||
atCap := messagesClampBody("deepseek-v4-flash", 65535)
|
||||
_, wire, err := svc.buildUpstreamRequest(
|
||||
context.Background(), c, account, atCap, "sk-test", "api_key",
|
||||
"deepseek-v4-flash", false, false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(65535), gjson.GetBytes(wire, "max_tokens").Int())
|
||||
|
||||
disabled := messagesClampOllamaAccount(403, PlatformAnthropic)
|
||||
disabled.Extra[OllamaCloudMaxTokensCapExtraKey] = 0
|
||||
_, wire, err = svc.buildUpstreamRequest(
|
||||
context.Background(), c, disabled, body, "sk-test", "api_key",
|
||||
"deepseek-v4-flash", false, false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int())
|
||||
})
|
||||
}
|
||||
|
||||
// TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_ClampsOllamaCloudDeepSeekMaxTokens
|
||||
// 覆盖 Anthropic 平台 APIKey passthrough builder。
|
||||
func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_ClampsOllamaCloudDeepSeekMaxTokens(t *testing.T) {
|
||||
svc := &GatewayService{cfg: messagesClampTestConfig()}
|
||||
c := newMessagesClampTestContext(t)
|
||||
|
||||
body := messagesClampBody("deepseek-v4-flash", 256000)
|
||||
account := messagesClampOllamaAccount(411, PlatformAnthropic)
|
||||
|
||||
req, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||||
context.Background(), c, account, body, "sk-test",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String())
|
||||
require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int())
|
||||
|
||||
// 官方 Anthropic(api.anthropic.com)即使 max_tokens 超过 65535 也字节级不变。
|
||||
official := newAnthropicAPIKeyAccountForTest()
|
||||
_, wire, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||||
context.Background(), c, official, body, "upstream-anthropic-key",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int())
|
||||
}
|
||||
|
||||
// TestBuildNativeAnthropicUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens 覆盖
|
||||
// 国产供应商原生 Anthropic 协议 builder(Messages / Responses / CC 桥接共享)。
|
||||
func TestBuildNativeAnthropicUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{cfg: messagesClampTestConfig()}
|
||||
c := newMessagesClampTestContext(t)
|
||||
|
||||
body := messagesClampBody("deepseek-v4-flash", 256000)
|
||||
|
||||
t.Run("anthropic protocol ollama base is clamped", func(t *testing.T) {
|
||||
account := messagesClampOllamaAccount(421, PlatformDeepseek)
|
||||
account.Credentials["api_protocol"] = APIProtocolAnthropic
|
||||
|
||||
targetURL, err := svc.nativeAnthropicTargetURL(account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://ollama.com/v1/messages", targetURL)
|
||||
|
||||
req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest(
|
||||
context.Background(), c, account, body, "sk-test", targetURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://ollama.com/v1/messages", req.URL.String())
|
||||
require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int())
|
||||
})
|
||||
|
||||
// adaptive 账号按 Anthropic 协议地址(api_base_urls[anthropic])判定,而非 CC
|
||||
// 地址:anthropic 指向 ollama.com、chat_completions 指向官方 DeepSeek 时命中。
|
||||
t.Run("adaptive uses anthropic base not cc base", func(t *testing.T) {
|
||||
account := messagesClampOllamaAccount(422, PlatformDeepseek)
|
||||
account.Credentials["api_protocol"] = APIProtocolAdaptive
|
||||
account.Credentials["api_base_urls"] = map[string]any{
|
||||
APIProtocolAnthropic: "https://ollama.com",
|
||||
APIProtocolChatCompletions: "https://api.deepseek.com",
|
||||
}
|
||||
|
||||
targetURL, err := svc.nativeAnthropicTargetURL(account)
|
||||
require.NoError(t, err)
|
||||
req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest(
|
||||
context.Background(), c, account, body, "sk-test", targetURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://ollama.com/v1/messages", req.URL.String())
|
||||
require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int())
|
||||
})
|
||||
|
||||
// 反向:实际 Anthropic 上游非 ollama.com(官方 anthropic 端点),即使 CC 地址
|
||||
// 指向 ollama.com 且残留 usage extra,也不 clamp。
|
||||
t.Run("non-ollama anthropic base with cc ollama base and usage extra untouched", func(t *testing.T) {
|
||||
account := messagesClampOllamaAccount(423, PlatformDeepseek)
|
||||
account.Credentials["api_protocol"] = APIProtocolAdaptive
|
||||
account.Credentials["api_base_urls"] = map[string]any{
|
||||
APIProtocolAnthropic: "https://api.deepseek.com/anthropic",
|
||||
APIProtocolChatCompletions: "https://ollama.com",
|
||||
}
|
||||
account.Extra[OllamaCloudUsageSnapshotExtraKey] = map[string]any{"status": "ok"}
|
||||
|
||||
targetURL, err := svc.nativeAnthropicTargetURL(account)
|
||||
require.NoError(t, err)
|
||||
req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest(
|
||||
context.Background(), c, account, body, "sk-test", targetURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://api.deepseek.com/anthropic/v1/messages", req.URL.String())
|
||||
require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int())
|
||||
})
|
||||
|
||||
// 官方 DeepSeek Anthropic 协议(平台默认端点)字节级不变。
|
||||
t.Run("official deepseek anthropic default untouched", func(t *testing.T) {
|
||||
account := &Account{
|
||||
ID: 424,
|
||||
Name: "official-deepseek-anthropic",
|
||||
Platform: PlatformDeepseek,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"api_protocol": APIProtocolAnthropic,
|
||||
},
|
||||
Extra: map[string]any{},
|
||||
}
|
||||
targetURL, err := svc.nativeAnthropicTargetURL(account)
|
||||
require.NoError(t, err)
|
||||
_, wireBody, err := svc.buildNativeAnthropicUpstreamRequest(
|
||||
context.Background(), c, account, body, "sk-test", targetURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int())
|
||||
})
|
||||
|
||||
// 非 DeepSeek 模型(映射后出站 model)不变。
|
||||
t.Run("non deepseek model untouched", func(t *testing.T) {
|
||||
account := messagesClampOllamaAccount(425, PlatformDeepseek)
|
||||
account.Credentials["api_protocol"] = APIProtocolAnthropic
|
||||
nonDeepSeek := messagesClampBody("glm-4.7", 256000)
|
||||
targetURL, err := svc.nativeAnthropicTargetURL(account)
|
||||
require.NoError(t, err)
|
||||
_, wireBody, err := svc.buildNativeAnthropicUpstreamRequest(
|
||||
context.Background(), c, account, nonDeepSeek, "sk-test", targetURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int())
|
||||
})
|
||||
}
|
||||
|
||||
// messagesClampProductionAccount 复刻生产账号 162:platform=deepseek、type=apikey、
|
||||
// api_protocol=adaptive、base_url 与 api_base_urls 均指向 ollama.com(anthropic 地址
|
||||
// 带 trailing '/',CC/Responses 地址带 /v1 后缀)。
|
||||
func messagesClampProductionAccount(id int64) *Account {
|
||||
return &Account{
|
||||
ID: id,
|
||||
Name: "ollama-cloud-prod",
|
||||
Platform: PlatformDeepseek,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"api_protocol": APIProtocolAdaptive,
|
||||
"base_url": "https://ollama.com/v1",
|
||||
"api_base_urls": map[string]any{
|
||||
APIProtocolAnthropic: "https://ollama.com/",
|
||||
APIProtocolResponses: "https://ollama.com/v1",
|
||||
APIProtocolChatCompletions: "https://ollama.com/v1",
|
||||
},
|
||||
},
|
||||
Extra: map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildNativeAnthropicUpstreamRequest_ClampsTrailingSlashOllamaBase 复刻生产
|
||||
// 失败:anthropic 协议地址配置为 "https://ollama.com/"(trailing '/')。真实出站
|
||||
// URL 组装(ValidateURLFormat 与 nativeAnthropicTargetURL 均做 TrimRight "/")会
|
||||
// 正确打到 https://ollama.com/v1/messages,因此 clamp 判定也必须对同一归一化后的
|
||||
// base 生效,max_tokens=256000 不得原样透传被上游 400。
|
||||
func TestBuildNativeAnthropicUpstreamRequest_ClampsTrailingSlashOllamaBase(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{cfg: messagesClampTestConfig()}
|
||||
c := newMessagesClampTestContext(t)
|
||||
account := messagesClampProductionAccount(431)
|
||||
body := messagesClampBody("deepseek-v4-flash", 256000)
|
||||
|
||||
// 真实出站 URL:trailing '/' 被既有组装归一化掉,请求确实可达 ollama.com。
|
||||
targetURL, err := svc.nativeAnthropicTargetURL(account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://ollama.com/v1/messages", targetURL)
|
||||
|
||||
req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest(
|
||||
context.Background(), c, account, body, "sk-test", targetURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://ollama.com/v1/messages", req.URL.String())
|
||||
require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(wireBody, "model").String())
|
||||
require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int(),
|
||||
"anthropic base trailing '/' 时仍须命中 clamp(生产 400 回归)")
|
||||
}
|
||||
|
||||
// TestBuildUpstreamRequest_ClampsTrailingSlashOllamaBase 覆盖 builder A / B 的相同
|
||||
// 场景:GetBaseURL() 原样返回 trailing '/',而 validateUpstreamBaseURL 归一化后
|
||||
// 实际出站 URL 可达 ollama.com/v1/messages,clamp 判定须与归一化同源。
|
||||
func TestBuildUpstreamRequest_ClampsTrailingSlashOllamaBase(t *testing.T) {
|
||||
svc := &GatewayService{cfg: messagesClampTestConfig()}
|
||||
c := newMessagesClampTestContext(t)
|
||||
body := messagesClampBody("deepseek-v4-flash", 256000)
|
||||
|
||||
newAccount := func(id int64) *Account {
|
||||
account := messagesClampOllamaAccount(id, PlatformAnthropic)
|
||||
account.Credentials["base_url"] = "https://ollama.com/"
|
||||
return account
|
||||
}
|
||||
|
||||
t.Run("builder A trailing slash base is clamped", func(t *testing.T) {
|
||||
req, wireBody, err := svc.buildUpstreamRequest(
|
||||
context.Background(), c, newAccount(441), body, "sk-test", "api_key",
|
||||
"deepseek-v4-flash", false, false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String())
|
||||
require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int())
|
||||
})
|
||||
|
||||
t.Run("builder B trailing slash base is clamped", func(t *testing.T) {
|
||||
req, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||||
context.Background(), c, newAccount(442), body, "sk-test",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String())
|
||||
require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int())
|
||||
})
|
||||
|
||||
t.Run("evil suffix and custom path never match", func(t *testing.T) {
|
||||
for _, base := range []string{"https://ollama.com.evil.com/", "https://ollama.com/anthropic/"} {
|
||||
account := newAccount(443)
|
||||
account.Credentials["base_url"] = base
|
||||
_, wireBody, err := svc.buildUpstreamRequest(
|
||||
context.Background(), c, account, body, "sk-test", "api_key",
|
||||
"deepseek-v4-flash", false, false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int(),
|
||||
"base %q 不得被识别为 Ollama Cloud", base)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -159,6 +159,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
|
||||
}
|
||||
}
|
||||
upstreamBody = applyOllamaCloudRawChatCompletionsRequest(account, upstreamBody)
|
||||
upstreamBody = clampOllamaCloudUpstreamMaxTokens(account, upstreamBody)
|
||||
|
||||
logger.L().Debug("openai chat_completions raw: forwarding without protocol conversion",
|
||||
zap.Int64("account_id", account.ID),
|
||||
|
||||
@@ -610,6 +610,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ollama Cloud(实际 Responses 上游为 ollama.com)输出上限 clamp:对 Codex 与
|
||||
// 非 Codex 客户端一律执行(真实 Codex 客户端同样会带超限 max_output_tokens 被
|
||||
// ollama.com 以 400 拒绝)。在 `!isCodexCLI` 归一化块之后独立调用:非 Codex 时
|
||||
// 位于平台字段归一化之后,不跳过原有平台 switch(patch 按追加顺序应用,set 在
|
||||
// 先前的 delete/set 之后生效);Codex 请求不做归一化,直接按 body 现值判定。
|
||||
if clampedCap, ok := ollamaCloudResponsesMaxOutputTokensClamp(account, upstreamModel, body); ok {
|
||||
markPatchSet("max_output_tokens", clampedCap)
|
||||
}
|
||||
if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 &&
|
||||
!account.IsOpenAIApiKey() && gjson.GetBytes(body, "previous_response_id").Exists() {
|
||||
markPatchDelete("previous_response_id")
|
||||
|
||||
@@ -158,6 +158,11 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest(
|
||||
body = sanitized
|
||||
}
|
||||
|
||||
// Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与 nativeAnthropicTargetURL
|
||||
// 的 base 取值同源(GetAnthropicProtocolBaseURL,adaptive 时是 Anthropic 协议
|
||||
// 地址而非 CC/Responses 地址),详见 helper 注释。
|
||||
body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetAnthropicProtocolBaseURL(), body)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -177,12 +182,13 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest(
|
||||
}
|
||||
|
||||
// 覆盖入站鉴权残留,注入上游认证(默认 x-api-key;可经 extra
|
||||
// anthropic_apikey_auth_scheme 切换 Authorization: Bearer)。
|
||||
// anthropic_apikey_auth_scheme 切换 Authorization: Bearer;Ollama Cloud
|
||||
// 上游按实际 base_url 强制 Bearer,与 nativeAnthropicTargetURL 同源)。
|
||||
req.Header.Del("authorization")
|
||||
req.Header.Del("x-api-key")
|
||||
req.Header.Del("x-goog-api-key")
|
||||
req.Header.Del("cookie")
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, apiKey)
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, apiKey, account.GetAnthropicProtocolBaseURL())
|
||||
|
||||
if getHeaderRaw(req.Header, "content-type") == "" {
|
||||
setHeaderRaw(req.Header, "content-type", "application/json")
|
||||
|
||||
@@ -47,12 +47,13 @@ func accountHasOllamaCloudUsageExtra(account *Account) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// applyOllamaCloudRawChatCompletionsRequest 只做 Ollama Cloud reasoning 归一化;
|
||||
// max_tokens clamp 已解耦到独立钩子 clampOllamaCloudUpstreamMaxTokens,由出站方依次调用。
|
||||
func applyOllamaCloudRawChatCompletionsRequest(account *Account, body []byte) []byte {
|
||||
if !isOllamaCloudRawChatCompletionsAccount(account) || len(body) == 0 {
|
||||
return body
|
||||
}
|
||||
body = normalizeOllamaCloudChatCompletionsRequest(body)
|
||||
return clampOllamaCloudMaxTokens(account, body)
|
||||
return normalizeOllamaCloudChatCompletionsRequest(body)
|
||||
}
|
||||
|
||||
func applyOllamaCloudRawChatCompletionsResponse(account *Account, body []byte) []byte {
|
||||
|
||||
@@ -18,9 +18,60 @@ const OllamaCloudMaxTokensCapExtraKey = "ollama_max_tokens_cap"
|
||||
// (约 65535),max_tokens 超过该值会被上游直接 400 拒绝;该上限与模型无关,不做模型过滤。
|
||||
const ollamaCloudDefaultMaxTokensCap = 65535
|
||||
|
||||
// 本文件的 clampOllamaCloudMaxTokens 被
|
||||
// applyOllamaCloudRawChatCompletionsRequest(openai_gateway_ollama_cloud_cc_reasoning.go)
|
||||
// 调用,账号检测(isOllamaCloudRawChatCompletionsAccount)由调用方完成,此处不再重复判断。
|
||||
// clampOllamaCloudUpstreamMaxTokens 是 raw CC 出站(forwardAsRawChatCompletions 与
|
||||
// /v1/responses 降级 forwardResponsesViaRawChatCompletions 共用)的独立 token 钩子,
|
||||
// 与 reasoning 钩子解耦:只看本次请求实际选用的 CC 上游 base_url(GetOpenAIBaseURL,
|
||||
// 与 openAIChatCompletionsTargetURL 的取值一致)是否 ollama.com,不读 usage extra。
|
||||
// DeepSeek 系模型(用模型映射后的真实出站 model,body 中 model 已改写)任意平台均
|
||||
// clamp;非 DeepSeek 模型仅保留既有 openai 平台 Ollama 账号(force_chat_completions
|
||||
// 判定)的 clamp,不扩展到其它平台。
|
||||
func clampOllamaCloudUpstreamMaxTokens(account *Account, body []byte) []byte {
|
||||
if account == nil || len(body) == 0 {
|
||||
return body
|
||||
}
|
||||
if !isOllamaCloudBaseURL(account.GetOpenAIBaseURL()) {
|
||||
return body
|
||||
}
|
||||
if !isDeepSeekModel(gjson.GetBytes(body, "model").String()) && !isOllamaCloudRawChatCompletionsAccount(account) {
|
||||
return body
|
||||
}
|
||||
return clampOllamaCloudMaxTokens(account, body)
|
||||
}
|
||||
|
||||
// ollamaCloudResponsesUpstreamBaseURL 返回原生 /v1/responses 本次实际选用的上游
|
||||
// base_url,取值与 buildUpstreamRequest 一致:adaptive 原生 CN 账号用 api_base_urls
|
||||
// 的 responses 地址,其余用 GetOpenAIBaseURL。
|
||||
func ollamaCloudResponsesUpstreamBaseURL(account *Account) string {
|
||||
if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() {
|
||||
return account.GetCNProtocolBaseURL(APIProtocolResponses)
|
||||
}
|
||||
return account.GetOpenAIBaseURL()
|
||||
}
|
||||
|
||||
// ollamaCloudResponsesMaxOutputTokensClamp 是原生 /v1/responses 路径的 clamp:在
|
||||
// 平台字段归一化之后独立调用,不改写原有的 max_output_tokens 平台 switch。判定:
|
||||
// 实际 Responses 上游(ollamaCloudResponsesUpstreamBaseURL)为 ollama.com 的
|
||||
// APIKey 账号 + 映射后的出站模型是 DeepSeek 系;openai 平台 max_tokens 刚被归一化
|
||||
// patch 到 max_output_tokens 时取 max_tokens 为生效值。未命中返回 ok=false。
|
||||
// 实测:ollama.com/v1/responses max_output_tokens=256000 被上游以 "max_tokens
|
||||
// (256000) exceeds model's maximum output tokens (65536)" 400 拒绝(DeepSeek 模型)。
|
||||
func ollamaCloudResponsesMaxOutputTokensClamp(account *Account, upstreamModel string, body []byte) (int64, bool) {
|
||||
if account == nil || account.Type != AccountTypeAPIKey || !isDeepSeekModel(upstreamModel) {
|
||||
return 0, false
|
||||
}
|
||||
if !isOllamaCloudBaseURL(ollamaCloudResponsesUpstreamBaseURL(account)) {
|
||||
return 0, false
|
||||
}
|
||||
value := gjson.GetBytes(body, "max_output_tokens")
|
||||
if !value.Exists() && account.Platform == PlatformOpenAI {
|
||||
value = gjson.GetBytes(body, "max_tokens")
|
||||
}
|
||||
cap := ollamaCloudMaxTokensCap(account)
|
||||
if cap <= 0 || !value.Exists() || value.Type != gjson.Number || value.Int() <= cap {
|
||||
return 0, false
|
||||
}
|
||||
return cap, true
|
||||
}
|
||||
|
||||
// ollamaCloudMaxTokensCap 返回账号配置的 max_tokens 上限。账号为 nil 或 extra 中
|
||||
// 无该键时返回默认值;键值为数值类型(float64/int64/int/json.Number)时返回其整数
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// ollamaMaxTokensCapTestAccount 构造带自定义 cap 的 Ollama Cloud usage 账号。
|
||||
@@ -129,17 +133,19 @@ func TestOllamaCloudMaxTokensCap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens 验证 max_tokens clamp
|
||||
// 已接入组合钩子 applyOllamaCloudRawChatCompletionsRequest,并遵循该钩子的账号判定门槛
|
||||
// (isOllamaCloudRawChatCompletionsAccount:platform openai + type apikey +
|
||||
// force_chat_completions + ollama.com 或 Ollama usage extra)。
|
||||
// TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens 验证 reasoning 钩子
|
||||
// 与 token clamp 已解耦:reasoning 钩子只做 reasoning 归一化,clamp 由独立 token
|
||||
// 钩子 clampOllamaCloudUpstreamMaxTokens 在出站时接续执行(两条 raw CC 出站路径
|
||||
// 均依次调用两者,端到端行为不变)。
|
||||
func TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens(t *testing.T) {
|
||||
body := []byte(`{"model":"deepseek-chat","max_tokens":100000}`)
|
||||
|
||||
// Ollama Cloud 账号(ollama.com + force_chat_completions)→ clamp 到 65535。
|
||||
// Ollama Cloud 账号:reasoning 钩子不再 clamp,字节级原样。
|
||||
ollama := ollamaCloudRawChatCompletionsTestAccount()
|
||||
require.Equal(t, string(body), string(applyOllamaCloudRawChatCompletionsRequest(ollama, body)))
|
||||
// 独立 token 钩子接续 clamp 到既有默认 cap。
|
||||
require.JSONEq(t, `{"model":"deepseek-chat","max_tokens":65535}`,
|
||||
string(applyOllamaCloudRawChatCompletionsRequest(ollama, body)))
|
||||
string(clampOllamaCloudUpstreamMaxTokens(ollama, body)))
|
||||
|
||||
// 官方 DeepSeek(api.deepseek.com + force_chat_completions)→ 字节级不变。
|
||||
official := rawChatCompletionsTestAccount()
|
||||
@@ -148,13 +154,400 @@ func TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens(t *testing.T)
|
||||
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
|
||||
}
|
||||
require.Equal(t, body, applyOllamaCloudRawChatCompletionsRequest(official, body))
|
||||
require.Equal(t, string(body), string(clampOllamaCloudUpstreamMaxTokens(official, body)))
|
||||
|
||||
// ollama.com 但无 force_chat_completions(Extra 缺键)→ 不通过钩子判定门槛,字节级不变。
|
||||
// ollama.com 但无 force_chat_completions:reasoning 钩子不生效;独立钩子按
|
||||
// DeepSeek 系模型判定仍 clamp(DeepSeek 覆盖不依赖 responses_mode extra)。
|
||||
noForce := ollamaCloudRawChatCompletionsTestAccount()
|
||||
noForce.Extra = nil
|
||||
require.Equal(t, body, applyOllamaCloudRawChatCompletionsRequest(noForce, body))
|
||||
require.JSONEq(t, `{"model":"deepseek-chat","max_tokens":65535}`,
|
||||
string(clampOllamaCloudUpstreamMaxTokens(noForce, body)))
|
||||
|
||||
// 空 body → 原样返回。
|
||||
require.Equal(t, []byte(nil), applyOllamaCloudRawChatCompletionsRequest(ollama, nil))
|
||||
require.Equal(t, []byte{}, applyOllamaCloudRawChatCompletionsRequest(ollama, []byte{}))
|
||||
}
|
||||
|
||||
// ollamaUpstreamTestAccount 构造挂在实际 ollama.com 上游的 APIKey 账号。平台标签
|
||||
// 不参与 clamp 判定,这里用不同平台仅为了覆盖生产里的挂载方式;base_url 与生产
|
||||
// 实测一致(https://ollama.com/v1)。
|
||||
func ollamaUpstreamTestAccount(platform string, id int64) *Account {
|
||||
return &Account{
|
||||
ID: id,
|
||||
Name: "ollama-cloud",
|
||||
Platform: platform,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://ollama.com/v1",
|
||||
},
|
||||
Extra: map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
// officialDeepSeekTestAccount 构造官方 DeepSeek 账号,extra 里带残留的 Ollama
|
||||
// usage 快照键:usage extra 不得把非 Ollama host 识别为 Ollama 上游。
|
||||
func officialDeepSeekTestAccount(id int64) *Account {
|
||||
return &Account{
|
||||
ID: id,
|
||||
Name: "official-deepseek",
|
||||
Platform: PlatformDeepseek,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://api.deepseek.com",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
OllamaCloudUsageSnapshotExtraKey: map[string]any{"status": "ok"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampOllamaCloudUpstreamMaxTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// 生产问题场景:deepseek 平台 Ollama 账号,无 openai_responses_mode extra,
|
||||
// raw CC 直转时 max_tokens 超限被上游 400。
|
||||
name: "deepseek platform ollama.com without responses_mode is clamped",
|
||||
account: ollamaUpstreamTestAccount(PlatformDeepseek, 301),
|
||||
body: `{"model":"deepseek-v4-flash","max_tokens":256000}`,
|
||||
want: `{"model":"deepseek-v4-flash","max_tokens":65535}`,
|
||||
},
|
||||
{
|
||||
name: "max_completion_tokens is clamped too",
|
||||
account: ollamaUpstreamTestAccount(PlatformDeepseek, 301),
|
||||
body: `{"model":"deepseek-v4-flash","max_completion_tokens":256000}`,
|
||||
want: `{"model":"deepseek-v4-flash","max_completion_tokens":65535}`,
|
||||
},
|
||||
{
|
||||
// 新增范围只限 DeepSeek 系模型:kimi/zhipu 平台挂 ollama.com 跑非
|
||||
// DeepSeek 模型的请求保持不变。
|
||||
name: "kimi platform non-deepseek model untouched",
|
||||
account: ollamaUpstreamTestAccount(PlatformKimi, 302),
|
||||
body: `{"model":"k3-256k","max_tokens":256000}`,
|
||||
want: `{"model":"k3-256k","max_tokens":256000}`,
|
||||
},
|
||||
{
|
||||
name: "zhipu platform non-deepseek model untouched",
|
||||
account: ollamaUpstreamTestAccount(PlatformZhipu, 302),
|
||||
body: `{"model":"glm-4.7","max_tokens":256000}`,
|
||||
want: `{"model":"glm-4.7","max_tokens":256000}`,
|
||||
},
|
||||
{
|
||||
// 既有 openai 平台 Ollama 账号对 DeepSeek 模型的 clamp 幂等保持。
|
||||
name: "openai platform ollama.com deepseek model stays clamped",
|
||||
account: ollamaUpstreamTestAccount(PlatformOpenAI, 303),
|
||||
body: `{"model":"deepseek-v4-flash","max_tokens":70000}`,
|
||||
want: `{"model":"deepseek-v4-flash","max_tokens":65535}`,
|
||||
},
|
||||
{
|
||||
// 既有 openai 平台 Ollama(真实 ollama.com + force_chat_completions)对
|
||||
// 非 DeepSeek 模型的 clamp 保留。
|
||||
name: "openai platform ollama.com non-deepseek model keeps legacy clamp",
|
||||
account: ollamaCloudRawChatCompletionsTestAccount(),
|
||||
body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`,
|
||||
want: `{"model":"gpt-oss:120b-cloud","max_tokens":65535}`,
|
||||
},
|
||||
{
|
||||
// 非 DeepSeek 模型不扩展到其它平台。
|
||||
name: "openai platform ollama.com non-deepseek model without force_cc untouched",
|
||||
account: func() *Account {
|
||||
account := ollamaUpstreamTestAccount(PlatformOpenAI, 303)
|
||||
account.Extra = nil
|
||||
return account
|
||||
}(),
|
||||
body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`,
|
||||
want: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`,
|
||||
},
|
||||
{
|
||||
// 残留 usage extra 不得把非 Ollama host 变成 Ollama(旧 reasoning 钩子内的
|
||||
// clamp 会命中该账号,解耦后已封堵)。
|
||||
name: "openai platform non-ollama host with leftover usage extra untouched",
|
||||
account: func() *Account {
|
||||
account := ollamaCloudRawChatCompletionsTestAccount()
|
||||
account.Credentials["base_url"] = "https://example.invalid/v1"
|
||||
account.Extra[OllamaCloudUsageSnapshotExtraKey] = map[string]any{"status": "ok"}
|
||||
return account
|
||||
}(),
|
||||
body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`,
|
||||
want: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`,
|
||||
},
|
||||
{
|
||||
name: "extra cap zero disables clamping",
|
||||
account: func() *Account {
|
||||
account := ollamaUpstreamTestAccount(PlatformDeepseek, 304)
|
||||
account.Extra[OllamaCloudMaxTokensCapExtraKey] = 0
|
||||
return account
|
||||
}(),
|
||||
body: `{"model":"deepseek-v4-flash","max_tokens":256000}`,
|
||||
want: `{"model":"deepseek-v4-flash","max_tokens":256000}`,
|
||||
},
|
||||
{
|
||||
name: "value at cap is untouched",
|
||||
account: ollamaUpstreamTestAccount(PlatformDeepseek, 304),
|
||||
body: `{"model":"deepseek-v4-flash","max_tokens":65535}`,
|
||||
want: `{"model":"deepseek-v4-flash","max_tokens":65535}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.JSONEq(t, test.want, string(clampOllamaCloudUpstreamMaxTokens(test.account, []byte(test.body))))
|
||||
})
|
||||
}
|
||||
|
||||
// 非 Ollama 实际上游(api.deepseek.com)即使残留 usage extra 也不 clamp。
|
||||
official := officialDeepSeekTestAccount(305)
|
||||
body := []byte(`{"model":"deepseek-v4-flash","max_tokens":256000,"max_completion_tokens":256000}`)
|
||||
require.Equal(t, string(body), string(clampOllamaCloudUpstreamMaxTokens(official, body)))
|
||||
|
||||
// 空 body / nil 账号原样返回。
|
||||
require.Equal(t, []byte(nil), clampOllamaCloudUpstreamMaxTokens(official, nil))
|
||||
}
|
||||
|
||||
// TestForwardAsRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens 端到端验证 raw
|
||||
// CC 出站钩子:判定用模型映射后的真实出站 model,官方 DeepSeek 字节级不变。
|
||||
func TestForwardAsRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
account := ollamaUpstreamTestAccount(PlatformDeepseek, 311)
|
||||
account.Credentials["api_protocol"] = APIProtocolChatCompletions
|
||||
account.Credentials["model_mapping"] = map[string]any{"deepseek-chat": "deepseek-v4-flash"}
|
||||
|
||||
body := []byte(`{"model":"deepseek-chat","max_tokens":256000,"messages":[{"role":"user","content":"hi"}],"stream":false}`)
|
||||
upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
|
||||
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
|
||||
|
||||
_, err := svc.forwardAsRawChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_tokens").Int())
|
||||
|
||||
// 官方 DeepSeek(api.deepseek.com)+ 残留 usage extra:字节级不变。
|
||||
official := officialDeepSeekTestAccount(312)
|
||||
official.Credentials["api_protocol"] = APIProtocolChatCompletions
|
||||
officialBody := []byte(`{"model":"deepseek-v4-flash","max_tokens":256000,"messages":[{"role":"user","content":"hi"}],"stream":false}`)
|
||||
officialUpstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
|
||||
officialSvc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: officialUpstream}
|
||||
|
||||
_, err = officialSvc.forwardAsRawChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", officialBody), official, officialBody, "")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, string(officialBody), string(officialUpstream.lastBody))
|
||||
}
|
||||
|
||||
// TestForwardResponsesViaRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens 验证
|
||||
// /v1/responses 降级到 raw CC 的路径确实经过同一个独立 token 钩子(Responses 请求的
|
||||
// max_output_tokens 经 apicompat 转成 CC 的 max_completion_tokens)。
|
||||
func TestForwardResponsesViaRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
account := ollamaUpstreamTestAccount(PlatformDeepseek, 321)
|
||||
account.Credentials["api_protocol"] = APIProtocolChatCompletions
|
||||
|
||||
body := []byte(`{"model":"deepseek-v4-flash","input":"Reply with exactly OK and nothing else.","max_output_tokens":256000,"stream":false}`)
|
||||
upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
|
||||
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
|
||||
|
||||
_, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "https://ollama.com/v1/chat/completions", upstream.lastReq.URL.String())
|
||||
require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_completion_tokens").Int())
|
||||
}
|
||||
|
||||
// TestForwardResponsesClampsOllamaCloudMaxOutputTokens 覆盖原生 /v1/responses 路径
|
||||
// (openai force_responses 与 deepseek api_protocol=responses)的 max_output_tokens
|
||||
// clamp,实测依据:POST ollama.com/v1/responses max_output_tokens=256000 被上游以
|
||||
// "max_tokens (256000) exceeds model's maximum output tokens (65536)" 400 拒绝。
|
||||
func TestForwardResponsesClampsOllamaCloudMaxOutputTokens(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
responsesBody := []byte(`{"model":"deepseek-v4-flash","input":"Reply with exactly OK and nothing else.","max_output_tokens":256000,"stream":false}`)
|
||||
|
||||
run := func(account *Account, body []byte) (*httpUpstreamRecorder, error) {
|
||||
upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
|
||||
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
|
||||
_, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body)
|
||||
return upstream, err
|
||||
}
|
||||
|
||||
t.Run("deepseek platform native responses is clamped", func(t *testing.T) {
|
||||
account := ollamaUpstreamTestAccount(PlatformDeepseek, 331)
|
||||
account.Credentials["api_protocol"] = APIProtocolResponses
|
||||
upstream, err := run(account, responsesBody)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "https://ollama.com/v1/responses", upstream.lastReq.URL.String())
|
||||
require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
|
||||
})
|
||||
|
||||
t.Run("openai platform force_responses is clamped", func(t *testing.T) {
|
||||
account := ollamaUpstreamTestAccount(PlatformOpenAI, 332)
|
||||
account.Extra[openai_compat.ExtraKeyResponsesMode] = string(openai_compat.ResponsesSupportModeForceResponses)
|
||||
upstream, err := run(account, responsesBody)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
|
||||
})
|
||||
|
||||
t.Run("official deepseek with leftover usage extra is untouched", func(t *testing.T) {
|
||||
account := officialDeepSeekTestAccount(333)
|
||||
account.Credentials["api_protocol"] = APIProtocolResponses
|
||||
upstream, err := run(account, responsesBody)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, int64(256000), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
|
||||
})
|
||||
|
||||
t.Run("at cap is untouched", func(t *testing.T) {
|
||||
account := ollamaUpstreamTestAccount(PlatformDeepseek, 334)
|
||||
account.Credentials["api_protocol"] = APIProtocolResponses
|
||||
atCap := []byte(`{"model":"deepseek-v4-flash","input":"hi","max_output_tokens":65535,"stream":false}`)
|
||||
upstream, err := run(account, atCap)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
|
||||
})
|
||||
|
||||
t.Run("extra cap zero disables clamping", func(t *testing.T) {
|
||||
account := ollamaUpstreamTestAccount(PlatformDeepseek, 335)
|
||||
account.Credentials["api_protocol"] = APIProtocolResponses
|
||||
account.Extra[OllamaCloudMaxTokensCapExtraKey] = 0
|
||||
upstream, err := run(account, responsesBody)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, int64(256000), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
|
||||
})
|
||||
}
|
||||
|
||||
// TestForwardResponsesClampUsesResponsesUpstreamBaseURL 验证原生 Responses clamp 按
|
||||
// 实际 Responses 上游判定:adaptive 账号取 api_base_urls 的 responses 地址,而非 CC
|
||||
// 地址(与 buildUpstreamRequest 的 URL 选择一致)。
|
||||
func TestForwardResponsesClampUsesResponsesUpstreamBaseURL(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
body := []byte(`{"model":"deepseek-v4-flash","input":"hi","max_output_tokens":256000,"stream":false}`)
|
||||
run := func(account *Account) (*httpUpstreamRecorder, error) {
|
||||
upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
|
||||
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
|
||||
_, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body)
|
||||
return upstream, err
|
||||
}
|
||||
|
||||
t.Run("ollama CC base but non-ollama responses base is not clamped", func(t *testing.T) {
|
||||
account := ollamaUpstreamTestAccount(PlatformDeepseek, 341)
|
||||
account.Credentials["api_protocol"] = APIProtocolAdaptive
|
||||
account.Credentials["api_base_urls"] = map[string]any{
|
||||
APIProtocolChatCompletions: "https://ollama.com/v1",
|
||||
APIProtocolResponses: "https://api.deepseek.com",
|
||||
}
|
||||
upstream, err := run(account)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "https://api.deepseek.com/responses", upstream.lastReq.URL.String())
|
||||
require.Equal(t, int64(256000), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
|
||||
})
|
||||
|
||||
t.Run("non-ollama CC base but ollama responses base is clamped", func(t *testing.T) {
|
||||
account := ollamaUpstreamTestAccount(PlatformDeepseek, 342)
|
||||
account.Credentials["api_protocol"] = APIProtocolAdaptive
|
||||
account.Credentials["api_base_urls"] = map[string]any{
|
||||
APIProtocolChatCompletions: "https://api.deepseek.com",
|
||||
APIProtocolResponses: "https://ollama.com/v1",
|
||||
}
|
||||
upstream, err := run(account)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "https://ollama.com/v1/responses", upstream.lastReq.URL.String())
|
||||
require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
|
||||
})
|
||||
}
|
||||
|
||||
// TestForwardResponsesClampsOllamaCloudMaxOutputTokensForCodexClients 回归:原生
|
||||
// /v1/responses 的 Ollama Cloud clamp 必须对真实 Codex 客户端同样生效(clamp 曾位于
|
||||
// !isCodexCLI 块内被跳过,ollama.com 对 >65535 的 max_output_tokens 一律 400)。
|
||||
func TestForwardResponsesClampsOllamaCloudMaxOutputTokensForCodexClients(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
responsesBody := []byte(`{"model":"deepseek-v4-flash","input":"Reply with exactly OK and nothing else.","max_output_tokens":256000,"stream":false}`)
|
||||
|
||||
ollamaResponsesAccount := func() *Account {
|
||||
account := ollamaUpstreamTestAccount(PlatformDeepseek, 336)
|
||||
account.Credentials["api_protocol"] = APIProtocolResponses
|
||||
return account
|
||||
}
|
||||
officialResponsesAccount := func() *Account {
|
||||
account := officialDeepSeekTestAccount(337)
|
||||
account.Credentials["api_protocol"] = APIProtocolResponses
|
||||
return account
|
||||
}
|
||||
codexUA := func(c *gin.Context) {
|
||||
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.55.0 (Ubuntu 24.04; x86_64)")
|
||||
}
|
||||
codexOriginator := func(c *gin.Context) { c.Request.Header.Set("originator", "codex-tui") }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
setHeaders func(*gin.Context)
|
||||
forceCodexCLI bool
|
||||
wantCap int64
|
||||
wantURL string
|
||||
}{
|
||||
{
|
||||
name: "codex UA is clamped",
|
||||
account: ollamaResponsesAccount(),
|
||||
setHeaders: codexUA,
|
||||
wantCap: 65535,
|
||||
wantURL: "https://ollama.com/v1/responses",
|
||||
},
|
||||
{
|
||||
name: "codex originator is clamped",
|
||||
account: ollamaResponsesAccount(),
|
||||
setHeaders: codexOriginator,
|
||||
wantCap: 65535,
|
||||
wantURL: "https://ollama.com/v1/responses",
|
||||
},
|
||||
{
|
||||
name: "ForceCodexCLI is clamped",
|
||||
account: ollamaResponsesAccount(),
|
||||
forceCodexCLI: true,
|
||||
wantCap: 65535,
|
||||
wantURL: "https://ollama.com/v1/responses",
|
||||
},
|
||||
{
|
||||
// 非 Codex 正常路径对照:与既有用例行为一致,不回归。
|
||||
name: "non-codex control is clamped",
|
||||
account: ollamaResponsesAccount(),
|
||||
wantCap: 65535,
|
||||
wantURL: "https://ollama.com/v1/responses",
|
||||
},
|
||||
{
|
||||
// 非 Ollama 上游:同样的 Codex 头不 clamp。
|
||||
name: "official deepseek with codex UA is untouched",
|
||||
account: officialResponsesAccount(),
|
||||
setHeaders: codexUA,
|
||||
wantCap: 256000,
|
||||
wantURL: "https://api.deepseek.com/responses",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
|
||||
cfg := rawChatCompletionsTestConfig()
|
||||
cfg.Gateway.ForceCodexCLI = test.forceCodexCLI
|
||||
svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
|
||||
c := adaptiveProtocolTestContext("/v1/responses", responsesBody)
|
||||
if test.setHeaders != nil {
|
||||
test.setHeaders(c)
|
||||
}
|
||||
_, err := svc.Forward(context.Background(), c, test.account, responsesBody)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, test.wantURL, upstream.lastReq.URL.String())
|
||||
require.Equal(t, test.wantCap, gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// /v1/responses 降级到 raw CC 的出站与 forwardAsRawChatCompletions 共用同一个
|
||||
// 独立 Ollama Cloud token 钩子;chatReq.Model 已是模型映射后的 upstreamModel。
|
||||
chatBody = clampOllamaCloudUpstreamMaxTokens(account, chatBody)
|
||||
// Keep the final outbound tier for usage-time reconciliation. A policy
|
||||
// filter that removes the field therefore leaves this nil.
|
||||
serviceTier := extractOpenAIServiceTierFromBody(chatBody)
|
||||
|
||||
@@ -940,7 +940,9 @@ func (s *AccountTestService) buildAnthropicUpstreamModelsRequest(ctx context.Con
|
||||
if authHeaderName != "" {
|
||||
req.Header.Set(authHeaderName, authHeaderValue)
|
||||
} else {
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, apiKeyAuthToken)
|
||||
// Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持
|
||||
// extra/default 行为。
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, apiKeyAuthToken, normalizedBaseURL)
|
||||
}
|
||||
// 账号级请求头覆写:模型列表探测与真实转发保持一致的最终头
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
-- 236: 收敛 groups 的模型白名单列,修复 235 未落地却已被记账的实例。
|
||||
--
|
||||
-- 235 只在「models_list_config 存在且 model_allowlist 不存在」时执行重命名,
|
||||
-- 而迁移一旦写入 schema_migrations 就会按「文件名 + checksum」整份跳过,不再重跑。
|
||||
-- 因此只要数据库在 235 之后回到了旧结构——例如为了回滚到旧版本镜像而手工把列名
|
||||
-- 改回 models_list_config,或者用旧结构的备份做了部分恢复——应用依旧能正常启动,
|
||||
-- 但每个关联 groups 的查询都会失败:
|
||||
-- pq: column groups.model_allowlist does not exist
|
||||
-- 表现为「API 密钥」「我的订阅」「管理端订阅列表」等页面加载失败(issue #6780)。
|
||||
--
|
||||
-- 本迁移可重放,并且用 regclass 解析表(跟随 search_path),不再假定 schema 为 public:
|
||||
-- 1) 只有旧列 -> 重命名,数据原样保留;
|
||||
-- 2) 两列并存 -> 新列仍是默认空值时,把旧列里的配置回填过来,旧列保留不动;
|
||||
-- 3) 两列都没有 -> 按默认值补建新列。
|
||||
-- 结束后保证 groups.model_allowlist 一定存在,且为 NOT NULL DEFAULT '{}'。
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_attribute
|
||||
WHERE attrelid = 'groups'::regclass
|
||||
AND attname = 'models_list_config'
|
||||
AND NOT attisdropped
|
||||
) THEN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_attribute
|
||||
WHERE attrelid = 'groups'::regclass
|
||||
AND attname = 'model_allowlist'
|
||||
AND NOT attisdropped
|
||||
) THEN
|
||||
ALTER TABLE groups RENAME COLUMN models_list_config TO model_allowlist;
|
||||
ELSE
|
||||
EXECUTE $backfill$
|
||||
UPDATE groups
|
||||
SET model_allowlist = models_list_config
|
||||
WHERE COALESCE(model_allowlist, '{}'::jsonb) = '{}'::jsonb
|
||||
AND COALESCE(models_list_config, '{}'::jsonb) <> '{}'::jsonb
|
||||
$backfill$;
|
||||
END IF;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
ALTER TABLE groups
|
||||
ADD COLUMN IF NOT EXISTS model_allowlist JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
UPDATE groups SET model_allowlist = '{}'::jsonb WHERE model_allowlist IS NULL;
|
||||
|
||||
ALTER TABLE groups ALTER COLUMN model_allowlist SET DEFAULT '{}'::jsonb;
|
||||
ALTER TABLE groups ALTER COLUMN model_allowlist SET NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN groups.model_allowlist IS
|
||||
'Group model allowlist: constrains both model listing responses and request admission';
|
||||
@@ -0,0 +1,27 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGroupModelAllowlistRepairMigration(t *testing.T) {
|
||||
content, err := FS.ReadFile("236_group_model_allowlist_repair.sql")
|
||||
require.NoError(t, err)
|
||||
|
||||
sql := strings.Join(strings.Fields(string(content)), " ")
|
||||
|
||||
// 三种残留状态都要收敛到 model_allowlist。
|
||||
require.Contains(t, sql, "ALTER TABLE groups RENAME COLUMN models_list_config TO model_allowlist")
|
||||
require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS model_allowlist JSONB NOT NULL DEFAULT '{}'::jsonb")
|
||||
require.Contains(t, sql, "SET model_allowlist = models_list_config")
|
||||
require.Contains(t, sql, "ALTER TABLE groups ALTER COLUMN model_allowlist SET NOT NULL")
|
||||
require.Contains(t, sql, "COMMENT ON COLUMN groups.model_allowlist")
|
||||
|
||||
// 235 用 table_schema = 'public' 判定列是否存在,而 ALTER TABLE 走的是 search_path;
|
||||
// 修复迁移必须用 regclass 解析,两者才不会在非 public schema 上分叉。
|
||||
require.NotContains(t, sql, "table_schema = 'public'")
|
||||
require.Contains(t, sql, "attrelid = 'groups'::regclass")
|
||||
}
|
||||
Reference in New Issue
Block a user