Commit f8ac5538 authored by yangjianbo's avatar yangjianbo
Browse files

Merge branch 'test' into release

parents 773f20ed 1985be26
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestAccountHandler_Create_AnthropicAPIKeyPassthroughExtraForwarded(t *testing.T) {
gin.SetMode(gin.TestMode)
adminSvc := newStubAdminService()
handler := NewAccountHandler(
adminSvc,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
)
router := gin.New()
router.POST("/api/v1/admin/accounts", handler.Create)
body := map[string]any{
"name": "anthropic-key-1",
"platform": "anthropic",
"type": "apikey",
"credentials": map[string]any{
"api_key": "sk-ant-xxx",
"base_url": "https://api.anthropic.com",
},
"extra": map[string]any{
"anthropic_passthrough": true,
},
"concurrency": 1,
"priority": 1,
}
raw, err := json.Marshal(body)
require.NoError(t, err)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Len(t, adminSvc.createdAccounts, 1)
created := adminSvc.createdAccounts[0]
require.Equal(t, "anthropic", created.Platform)
require.Equal(t, "apikey", created.Type)
require.NotNil(t, created.Extra)
require.Equal(t, true, created.Extra["anthropic_passthrough"])
}
...@@ -43,12 +43,12 @@ func RegisterGatewayRoutes( ...@@ -43,12 +43,12 @@ func RegisterGatewayRoutes(
gateway.GET("/usage", h.Gateway.Usage) gateway.GET("/usage", h.Gateway.Usage)
// OpenAI Responses API // OpenAI Responses API
gateway.POST("/responses", h.OpenAIGateway.Responses) gateway.POST("/responses", h.OpenAIGateway.Responses)
// 明确阻止旧入口误用到 Sora,避免客户端把 OpenAI Chat Completions 当作 Sora 入口 // 明确阻止旧协议入口:OpenAI 仅支持 Responses API,避免客户端误解为会自动路由到其它平台。
gateway.POST("/chat/completions", func(c *gin.Context) { gateway.POST("/chat/completions", func(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{ "error": gin.H{
"type": "invalid_request_error", "type": "invalid_request_error",
"message": "For Sora, use /sora/v1/chat/completions. OpenAI should use /v1/responses.", "message": "Unsupported legacy protocol: /v1/chat/completions is not supported. Please use /v1/responses.",
}, },
}) })
}) })
......
...@@ -719,6 +719,17 @@ func (a *Account) IsOpenAIOAuthPassthroughEnabled() bool { ...@@ -719,6 +719,17 @@ func (a *Account) IsOpenAIOAuthPassthroughEnabled() bool {
return a != nil && a.IsOpenAIOAuth() && a.IsOpenAIPassthroughEnabled() return a != nil && a.IsOpenAIOAuth() && a.IsOpenAIPassthroughEnabled()
} }
// IsAnthropicAPIKeyPassthroughEnabled 返回 Anthropic API Key 账号是否启用“自动透传(仅替换认证)”。
// 字段:accounts.extra.anthropic_passthrough。
// 字段缺失或类型不正确时,按 false(关闭)处理。
func (a *Account) IsAnthropicAPIKeyPassthroughEnabled() bool {
if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey || a.Extra == nil {
return false
}
enabled, ok := a.Extra["anthropic_passthrough"].(bool)
return ok && enabled
}
// IsCodexCLIOnlyEnabled 返回 OpenAI OAuth 账号是否启用“仅允许 Codex 官方客户端”。 // IsCodexCLIOnlyEnabled 返回 OpenAI OAuth 账号是否启用“仅允许 Codex 官方客户端”。
// 字段:accounts.extra.codex_cli_only。 // 字段:accounts.extra.codex_cli_only。
// 字段缺失或类型不正确时,按 false(关闭)处理。 // 字段缺失或类型不正确时,按 false(关闭)处理。
......
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestAccount_IsAnthropicAPIKeyPassthroughEnabled(t *testing.T) {
t.Run("Anthropic API Key 开启", func(t *testing.T) {
account := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeAPIKey,
Extra: map[string]any{
"anthropic_passthrough": true,
},
}
require.True(t, account.IsAnthropicAPIKeyPassthroughEnabled())
})
t.Run("Anthropic API Key 关闭", func(t *testing.T) {
account := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeAPIKey,
Extra: map[string]any{
"anthropic_passthrough": false,
},
}
require.False(t, account.IsAnthropicAPIKeyPassthroughEnabled())
})
t.Run("字段类型非法默认关闭", func(t *testing.T) {
account := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeAPIKey,
Extra: map[string]any{
"anthropic_passthrough": "true",
},
}
require.False(t, account.IsAnthropicAPIKeyPassthroughEnabled())
})
t.Run("非 Anthropic API Key 账号始终关闭", func(t *testing.T) {
oauth := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeOAuth,
Extra: map[string]any{
"anthropic_passthrough": true,
},
}
require.False(t, oauth.IsAnthropicAPIKeyPassthroughEnabled())
openai := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Extra: map[string]any{
"anthropic_passthrough": true,
},
}
require.False(t, openai.IsAnthropicAPIKeyPassthroughEnabled())
})
}
package service
import "testing"
func BenchmarkGatewayService_ParseSSEUsage_MessageStart(b *testing.B) {
svc := &GatewayService{}
data := `{"type":"message_start","message":{"usage":{"input_tokens":123,"cache_creation_input_tokens":45,"cache_read_input_tokens":6,"cached_tokens":6,"cache_creation":{"ephemeral_5m_input_tokens":20,"ephemeral_1h_input_tokens":25}}}}`
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
usage := &ClaudeUsage{}
svc.parseSSEUsage(data, usage)
}
}
func BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart(b *testing.B) {
svc := &GatewayService{}
data := `{"type":"message_start","message":{"usage":{"input_tokens":123,"cache_creation_input_tokens":45,"cache_read_input_tokens":6,"cached_tokens":6,"cache_creation":{"ephemeral_5m_input_tokens":20,"ephemeral_1h_input_tokens":25}}}}`
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
usage := &ClaudeUsage{}
svc.parseSSEUsagePassthrough(data, usage)
}
}
func BenchmarkGatewayService_ParseSSEUsage_MessageDelta(b *testing.B) {
svc := &GatewayService{}
data := `{"type":"message_delta","usage":{"output_tokens":456,"cache_creation_input_tokens":30,"cache_read_input_tokens":7,"cached_tokens":7,"cache_creation":{"ephemeral_5m_input_tokens":10,"ephemeral_1h_input_tokens":20}}}`
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
usage := &ClaudeUsage{}
svc.parseSSEUsage(data, usage)
}
}
func BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta(b *testing.B) {
svc := &GatewayService{}
data := `{"type":"message_delta","usage":{"output_tokens":456,"cache_creation_input_tokens":30,"cache_read_input_tokens":7,"cached_tokens":7,"cache_creation":{"ephemeral_5m_input_tokens":10,"ephemeral_1h_input_tokens":20}}}`
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
usage := &ClaudeUsage{}
svc.parseSSEUsagePassthrough(data, usage)
}
}
func BenchmarkParseClaudeUsageFromResponseBody(b *testing.B) {
body := []byte(`{"id":"msg_123","type":"message","usage":{"input_tokens":123,"output_tokens":456,"cache_creation_input_tokens":45,"cache_read_input_tokens":6,"cached_tokens":6,"cache_creation":{"ephemeral_5m_input_tokens":20,"ephemeral_1h_input_tokens":25}}}`)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = parseClaudeUsageFromResponseBody(body)
}
}
This diff is collapsed.
...@@ -1697,6 +1697,36 @@ ...@@ -1697,6 +1697,36 @@
</div> </div>
</div> </div>
<!-- Anthropic API Key 自动透传开关 -->
<div
v-if="form.platform === 'anthropic' && accountCategory === 'apikey'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.anthropic.apiKeyPassthrough') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.anthropic.apiKeyPassthroughDesc') }}
</p>
</div>
<button
type="button"
@click="anthropicPassthroughEnabled = !anthropicPassthroughEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
anthropicPassthroughEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
anthropicPassthroughEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<!-- OpenAI OAuth Codex 官方客户端限制开关 --> <!-- OpenAI OAuth Codex 官方客户端限制开关 -->
<div <div
v-if="form.platform === 'openai' && accountCategory === 'oauth-based'" v-if="form.platform === 'openai' && accountCategory === 'oauth-based'"
...@@ -2290,6 +2320,7 @@ const interceptWarmupRequests = ref(false) ...@@ -2290,6 +2320,7 @@ const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true) const autoPauseOnExpired = ref(true)
const openaiPassthroughEnabled = ref(false) const openaiPassthroughEnabled = ref(false)
const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
const upstreamBaseUrl = ref('') // For upstream type: base URL const upstreamBaseUrl = ref('') // For upstream type: base URL
...@@ -2526,6 +2557,9 @@ watch( ...@@ -2526,6 +2557,9 @@ watch(
openaiPassthroughEnabled.value = false openaiPassthroughEnabled.value = false
codexCLIOnlyEnabled.value = false codexCLIOnlyEnabled.value = false
} }
if (newPlatform !== 'anthropic') {
anthropicPassthroughEnabled.value = false
}
// Reset OAuth states // Reset OAuth states
oauth.resetState() oauth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
...@@ -2542,6 +2576,9 @@ watch( ...@@ -2542,6 +2576,9 @@ watch(
if (platform === 'openai' && category !== 'oauth-based') { if (platform === 'openai' && category !== 'oauth-based') {
codexCLIOnlyEnabled.value = false codexCLIOnlyEnabled.value = false
} }
if (platform !== 'anthropic' || category !== 'apikey') {
anthropicPassthroughEnabled.value = false
}
} }
) )
...@@ -2791,6 +2828,7 @@ const resetForm = () => { ...@@ -2791,6 +2828,7 @@ const resetForm = () => {
autoPauseOnExpired.value = true autoPauseOnExpired.value = true
openaiPassthroughEnabled.value = false openaiPassthroughEnabled.value = false
codexCLIOnlyEnabled.value = false codexCLIOnlyEnabled.value = false
anthropicPassthroughEnabled.value = false
// Reset quota control state // Reset quota control state
windowCostEnabled.value = false windowCostEnabled.value = false
windowCostLimit.value = null windowCostLimit.value = null
...@@ -2845,6 +2883,21 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow ...@@ -2845,6 +2883,21 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
return Object.keys(extra).length > 0 ? extra : undefined return Object.keys(extra).length > 0 ? extra : undefined
} }
const buildAnthropicExtra = (base?: Record<string, unknown>): Record<string, unknown> | undefined => {
if (form.platform !== 'anthropic' || accountCategory.value !== 'apikey') {
return base
}
const extra: Record<string, unknown> = { ...(base || {}) }
if (anthropicPassthroughEnabled.value) {
extra.anthropic_passthrough = true
} else {
delete extra.anthropic_passthrough
}
return Object.keys(extra).length > 0 ? extra : undefined
}
const buildSoraExtra = ( const buildSoraExtra = (
base?: Record<string, unknown>, base?: Record<string, unknown>,
linkedOpenAIAccountId?: string | number linkedOpenAIAccountId?: string | number
...@@ -3015,7 +3068,7 @@ const handleSubmit = async () => { ...@@ -3015,7 +3068,7 @@ const handleSubmit = async () => {
} }
form.credentials = credentials form.credentials = credentials
const extra = buildOpenAIExtra() const extra = buildAnthropicExtra(buildOpenAIExtra())
await doCreateAccount({ await doCreateAccount({
...form, ...form,
......
...@@ -735,6 +735,36 @@ ...@@ -735,6 +735,36 @@
</div> </div>
</div> </div>
<!-- Anthropic API Key 自动透传开关 -->
<div
v-if="account?.platform === 'anthropic' && account?.type === 'apikey'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.anthropic.apiKeyPassthrough') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.anthropic.apiKeyPassthroughDesc') }}
</p>
</div>
<button
type="button"
@click="anthropicPassthroughEnabled = !anthropicPassthroughEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
anthropicPassthroughEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
anthropicPassthroughEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<!-- OpenAI OAuth Codex 官方客户端限制开关 --> <!-- OpenAI OAuth Codex 官方客户端限制开关 -->
<div <div
v-if="account?.platform === 'openai' && account?.type === 'oauth'" v-if="account?.platform === 'openai' && account?.type === 'oauth'"
...@@ -1223,6 +1253,7 @@ const cacheTTLOverrideTarget = ref<string>('5m') ...@@ -1223,6 +1253,7 @@ const cacheTTLOverrideTarget = ref<string>('5m')
// OpenAI 自动透传开关(OAuth/API Key) // OpenAI 自动透传开关(OAuth/API Key)
const openaiPassthroughEnabled = ref(false) const openaiPassthroughEnabled = ref(false)
const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = ref(false)
const isOpenAIModelRestrictionDisabled = computed(() => const isOpenAIModelRestrictionDisabled = computed(() =>
props.account?.platform === 'openai' && openaiPassthroughEnabled.value props.account?.platform === 'openai' && openaiPassthroughEnabled.value
) )
...@@ -1317,12 +1348,16 @@ watch( ...@@ -1317,12 +1348,16 @@ watch(
// Load OpenAI passthrough toggle (OpenAI OAuth/API Key) // Load OpenAI passthrough toggle (OpenAI OAuth/API Key)
openaiPassthroughEnabled.value = false openaiPassthroughEnabled.value = false
codexCLIOnlyEnabled.value = false codexCLIOnlyEnabled.value = false
anthropicPassthroughEnabled.value = false
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) { if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) {
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
if (newAccount.type === 'oauth') { if (newAccount.type === 'oauth') {
codexCLIOnlyEnabled.value = extra?.codex_cli_only === true codexCLIOnlyEnabled.value = extra?.codex_cli_only === true
} }
} }
if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') {
anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true
}
// Load antigravity model mapping (Antigravity 只支持映射模式) // Load antigravity model mapping (Antigravity 只支持映射模式)
if (newAccount.platform === 'antigravity') { if (newAccount.platform === 'antigravity') {
...@@ -1882,6 +1917,18 @@ const handleSubmit = async () => { ...@@ -1882,6 +1917,18 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra updatePayload.extra = newExtra
} }
// For Anthropic API Key accounts, handle passthrough mode in extra
if (props.account.platform === 'anthropic' && props.account.type === 'apikey') {
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra }
if (anthropicPassthroughEnabled.value) {
newExtra.anthropic_passthrough = true
} else {
delete newExtra.anthropic_passthrough
}
updatePayload.extra = newExtra
}
// For OpenAI OAuth/API Key accounts, handle passthrough mode in extra // For OpenAI OAuth/API Key accounts, handle passthrough mode in extra
if (props.account.platform === 'openai' && (props.account.type === 'oauth' || props.account.type === 'apikey')) { if (props.account.platform === 'openai' && (props.account.type === 'oauth' || props.account.type === 'apikey')) {
const currentExtra = (props.account.extra as Record<string, unknown>) || {} const currentExtra = (props.account.extra as Record<string, unknown>) || {}
......
...@@ -1548,6 +1548,11 @@ export default { ...@@ -1548,6 +1548,11 @@ export default {
enableSora: 'Enable Sora simultaneously', enableSora: 'Enable Sora simultaneously',
enableSoraHint: 'Sora uses the same OpenAI account. Enable to create Sora account simultaneously.' enableSoraHint: 'Sora uses the same OpenAI account. Enable to create Sora account simultaneously.'
}, },
anthropic: {
apiKeyPassthrough: 'Auto passthrough (auth only)',
apiKeyPassthroughDesc:
'Only applies to Anthropic API Key accounts. When enabled, messages/count_tokens are forwarded in passthrough mode with auth replacement only, while billing/concurrency/audit and safety filtering are preserved. Disable to roll back immediately.'
},
modelRestriction: 'Model Restriction (Optional)', modelRestriction: 'Model Restriction (Optional)',
modelWhitelist: 'Model Whitelist', modelWhitelist: 'Model Whitelist',
modelMapping: 'Model Mapping', modelMapping: 'Model Mapping',
......
...@@ -1696,6 +1696,11 @@ export default { ...@@ -1696,6 +1696,11 @@ export default {
enableSora: '同时启用 Sora', enableSora: '同时启用 Sora',
enableSoraHint: 'Sora 使用相同的 OpenAI 账号,开启后将同时创建 Sora 平台账号' enableSoraHint: 'Sora 使用相同的 OpenAI 账号,开启后将同时创建 Sora 平台账号'
}, },
anthropic: {
apiKeyPassthrough: '自动透传(仅替换认证)',
apiKeyPassthroughDesc:
'仅对 Anthropic API Key 生效。开启后,messages/count_tokens 请求将透传上游并仅替换认证,保留计费/并发/审计及必要安全过滤;关闭即可回滚到现有兼容链路。'
},
modelRestriction: '模型限制(可选)', modelRestriction: '模型限制(可选)',
modelWhitelist: '模型白名单', modelWhitelist: '模型白名单',
modelMapping: '模型映射', modelMapping: '模型映射',
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment