Commit 7319122e authored by LLLLLLiulei's avatar LLLLLLiulei
Browse files

merge upstream/main

parents 029994a8 4809fa4f
//go:build unit
package service
import (
"context"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockErrorPassthroughRepo 用于测试的 mock repository
type mockErrorPassthroughRepo struct {
rules []*model.ErrorPassthroughRule
}
func (m *mockErrorPassthroughRepo) List(ctx context.Context) ([]*model.ErrorPassthroughRule, error) {
return m.rules, nil
}
func (m *mockErrorPassthroughRepo) GetByID(ctx context.Context, id int64) (*model.ErrorPassthroughRule, error) {
for _, r := range m.rules {
if r.ID == id {
return r, nil
}
}
return nil, nil
}
func (m *mockErrorPassthroughRepo) Create(ctx context.Context, rule *model.ErrorPassthroughRule) (*model.ErrorPassthroughRule, error) {
rule.ID = int64(len(m.rules) + 1)
m.rules = append(m.rules, rule)
return rule, nil
}
func (m *mockErrorPassthroughRepo) Update(ctx context.Context, rule *model.ErrorPassthroughRule) (*model.ErrorPassthroughRule, error) {
for i, r := range m.rules {
if r.ID == rule.ID {
m.rules[i] = rule
return rule, nil
}
}
return rule, nil
}
func (m *mockErrorPassthroughRepo) Delete(ctx context.Context, id int64) error {
for i, r := range m.rules {
if r.ID == id {
m.rules = append(m.rules[:i], m.rules[i+1:]...)
return nil
}
}
return nil
}
// newTestService 创建测试用的服务实例
func newTestService(rules []*model.ErrorPassthroughRule) *ErrorPassthroughService {
repo := &mockErrorPassthroughRepo{rules: rules}
svc := &ErrorPassthroughService{
repo: repo,
cache: nil, // 不使用缓存
}
// 直接设置本地缓存,避免调用 refreshLocalCache
svc.setLocalCache(rules)
return svc
}
// =============================================================================
// 测试 ruleMatches 核心匹配逻辑
// =============================================================================
func TestRuleMatches_NoConditions(t *testing.T) {
// 没有配置任何条件时,不应该匹配
svc := newTestService(nil)
rule := &model.ErrorPassthroughRule{
Enabled: true,
ErrorCodes: []int{},
Keywords: []string{},
MatchMode: model.MatchModeAny,
}
assert.False(t, svc.ruleMatches(rule, 422, "some error message"),
"没有配置条件时不应该匹配")
}
func TestRuleMatches_OnlyErrorCodes_AnyMode(t *testing.T) {
svc := newTestService(nil)
rule := &model.ErrorPassthroughRule{
Enabled: true,
ErrorCodes: []int{422, 400},
Keywords: []string{},
MatchMode: model.MatchModeAny,
}
tests := []struct {
name string
statusCode int
body string
expected bool
}{
{"状态码匹配 422", 422, "any message", true},
{"状态码匹配 400", 400, "any message", true},
{"状态码不匹配 500", 500, "any message", false},
{"状态码不匹配 429", 429, "any message", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := svc.ruleMatches(rule, tt.statusCode, tt.body)
assert.Equal(t, tt.expected, result)
})
}
}
func TestRuleMatches_OnlyKeywords_AnyMode(t *testing.T) {
svc := newTestService(nil)
rule := &model.ErrorPassthroughRule{
Enabled: true,
ErrorCodes: []int{},
Keywords: []string{"context limit", "model not supported"},
MatchMode: model.MatchModeAny,
}
tests := []struct {
name string
statusCode int
body string
expected bool
}{
{"关键词匹配 context limit", 500, "error: context limit reached", true},
{"关键词匹配 model not supported", 400, "the model not supported here", true},
{"关键词不匹配", 422, "some other error", false},
// 注意:ruleMatches 接收的 body 参数应该是已经转换为小写的
// 实际使用时,MatchRule 会先将 body 转换为小写再传给 ruleMatches
{"关键词大小写 - 输入已小写", 500, "context limit exceeded", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 模拟 MatchRule 的行为:先转换为小写
bodyLower := strings.ToLower(tt.body)
result := svc.ruleMatches(rule, tt.statusCode, bodyLower)
assert.Equal(t, tt.expected, result)
})
}
}
func TestRuleMatches_BothConditions_AnyMode(t *testing.T) {
// any 模式:错误码 OR 关键词
svc := newTestService(nil)
rule := &model.ErrorPassthroughRule{
Enabled: true,
ErrorCodes: []int{422, 400},
Keywords: []string{"context limit"},
MatchMode: model.MatchModeAny,
}
tests := []struct {
name string
statusCode int
body string
expected bool
reason string
}{
{
name: "状态码和关键词都匹配",
statusCode: 422,
body: "context limit reached",
expected: true,
reason: "both match",
},
{
name: "只有状态码匹配",
statusCode: 422,
body: "some other error",
expected: true,
reason: "code matches, keyword doesn't - OR mode should match",
},
{
name: "只有关键词匹配",
statusCode: 500,
body: "context limit exceeded",
expected: true,
reason: "keyword matches, code doesn't - OR mode should match",
},
{
name: "都不匹配",
statusCode: 500,
body: "some other error",
expected: false,
reason: "neither matches",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := svc.ruleMatches(rule, tt.statusCode, tt.body)
assert.Equal(t, tt.expected, result, tt.reason)
})
}
}
func TestRuleMatches_BothConditions_AllMode(t *testing.T) {
// all 模式:错误码 AND 关键词
svc := newTestService(nil)
rule := &model.ErrorPassthroughRule{
Enabled: true,
ErrorCodes: []int{422, 400},
Keywords: []string{"context limit"},
MatchMode: model.MatchModeAll,
}
tests := []struct {
name string
statusCode int
body string
expected bool
reason string
}{
{
name: "状态码和关键词都匹配",
statusCode: 422,
body: "context limit reached",
expected: true,
reason: "both match - AND mode should match",
},
{
name: "只有状态码匹配",
statusCode: 422,
body: "some other error",
expected: false,
reason: "code matches but keyword doesn't - AND mode should NOT match",
},
{
name: "只有关键词匹配",
statusCode: 500,
body: "context limit exceeded",
expected: false,
reason: "keyword matches but code doesn't - AND mode should NOT match",
},
{
name: "都不匹配",
statusCode: 500,
body: "some other error",
expected: false,
reason: "neither matches",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := svc.ruleMatches(rule, tt.statusCode, tt.body)
assert.Equal(t, tt.expected, result, tt.reason)
})
}
}
// =============================================================================
// 测试 platformMatches 平台匹配逻辑
// =============================================================================
func TestPlatformMatches(t *testing.T) {
svc := newTestService(nil)
tests := []struct {
name string
rulePlatforms []string
requestPlatform string
expected bool
}{
{
name: "空平台列表匹配所有",
rulePlatforms: []string{},
requestPlatform: "anthropic",
expected: true,
},
{
name: "nil平台列表匹配所有",
rulePlatforms: nil,
requestPlatform: "openai",
expected: true,
},
{
name: "精确匹配 anthropic",
rulePlatforms: []string{"anthropic", "openai"},
requestPlatform: "anthropic",
expected: true,
},
{
name: "精确匹配 openai",
rulePlatforms: []string{"anthropic", "openai"},
requestPlatform: "openai",
expected: true,
},
{
name: "不匹配 gemini",
rulePlatforms: []string{"anthropic", "openai"},
requestPlatform: "gemini",
expected: false,
},
{
name: "大小写不敏感",
rulePlatforms: []string{"Anthropic", "OpenAI"},
requestPlatform: "anthropic",
expected: true,
},
{
name: "匹配 antigravity",
rulePlatforms: []string{"antigravity"},
requestPlatform: "antigravity",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rule := &model.ErrorPassthroughRule{
Platforms: tt.rulePlatforms,
}
result := svc.platformMatches(rule, tt.requestPlatform)
assert.Equal(t, tt.expected, result)
})
}
}
// =============================================================================
// 测试 MatchRule 完整匹配流程
// =============================================================================
func TestMatchRule_Priority(t *testing.T) {
// 测试规则按优先级排序,优先级小的先匹配
rules := []*model.ErrorPassthroughRule{
{
ID: 1,
Name: "Low Priority",
Enabled: true,
Priority: 10,
ErrorCodes: []int{422},
MatchMode: model.MatchModeAny,
},
{
ID: 2,
Name: "High Priority",
Enabled: true,
Priority: 1,
ErrorCodes: []int{422},
MatchMode: model.MatchModeAny,
},
}
svc := newTestService(rules)
matched := svc.MatchRule("anthropic", 422, []byte("error"))
require.NotNil(t, matched)
assert.Equal(t, int64(2), matched.ID, "应该匹配优先级更高(数值更小)的规则")
assert.Equal(t, "High Priority", matched.Name)
}
func TestMatchRule_DisabledRule(t *testing.T) {
rules := []*model.ErrorPassthroughRule{
{
ID: 1,
Name: "Disabled Rule",
Enabled: false,
Priority: 1,
ErrorCodes: []int{422},
MatchMode: model.MatchModeAny,
},
{
ID: 2,
Name: "Enabled Rule",
Enabled: true,
Priority: 10,
ErrorCodes: []int{422},
MatchMode: model.MatchModeAny,
},
}
svc := newTestService(rules)
matched := svc.MatchRule("anthropic", 422, []byte("error"))
require.NotNil(t, matched)
assert.Equal(t, int64(2), matched.ID, "应该跳过禁用的规则")
}
func TestMatchRule_PlatformFilter(t *testing.T) {
rules := []*model.ErrorPassthroughRule{
{
ID: 1,
Name: "Anthropic Only",
Enabled: true,
Priority: 1,
ErrorCodes: []int{422},
Platforms: []string{"anthropic"},
MatchMode: model.MatchModeAny,
},
{
ID: 2,
Name: "OpenAI Only",
Enabled: true,
Priority: 2,
ErrorCodes: []int{422},
Platforms: []string{"openai"},
MatchMode: model.MatchModeAny,
},
{
ID: 3,
Name: "All Platforms",
Enabled: true,
Priority: 3,
ErrorCodes: []int{422},
Platforms: []string{},
MatchMode: model.MatchModeAny,
},
}
svc := newTestService(rules)
t.Run("Anthropic 请求匹配 Anthropic 规则", func(t *testing.T) {
matched := svc.MatchRule("anthropic", 422, []byte("error"))
require.NotNil(t, matched)
assert.Equal(t, int64(1), matched.ID)
})
t.Run("OpenAI 请求匹配 OpenAI 规则", func(t *testing.T) {
matched := svc.MatchRule("openai", 422, []byte("error"))
require.NotNil(t, matched)
assert.Equal(t, int64(2), matched.ID)
})
t.Run("Gemini 请求匹配全平台规则", func(t *testing.T) {
matched := svc.MatchRule("gemini", 422, []byte("error"))
require.NotNil(t, matched)
assert.Equal(t, int64(3), matched.ID)
})
t.Run("Antigravity 请求匹配全平台规则", func(t *testing.T) {
matched := svc.MatchRule("antigravity", 422, []byte("error"))
require.NotNil(t, matched)
assert.Equal(t, int64(3), matched.ID)
})
}
func TestMatchRule_NoMatch(t *testing.T) {
rules := []*model.ErrorPassthroughRule{
{
ID: 1,
Name: "Rule for 422",
Enabled: true,
Priority: 1,
ErrorCodes: []int{422},
MatchMode: model.MatchModeAny,
},
}
svc := newTestService(rules)
matched := svc.MatchRule("anthropic", 500, []byte("error"))
assert.Nil(t, matched, "不匹配任何规则时应返回 nil")
}
func TestMatchRule_EmptyRules(t *testing.T) {
svc := newTestService([]*model.ErrorPassthroughRule{})
matched := svc.MatchRule("anthropic", 422, []byte("error"))
assert.Nil(t, matched, "没有规则时应返回 nil")
}
func TestMatchRule_CaseInsensitiveKeyword(t *testing.T) {
rules := []*model.ErrorPassthroughRule{
{
ID: 1,
Name: "Context Limit",
Enabled: true,
Priority: 1,
Keywords: []string{"Context Limit"},
MatchMode: model.MatchModeAny,
},
}
svc := newTestService(rules)
tests := []struct {
name string
body string
expected bool
}{
{"完全匹配", "Context Limit reached", true},
{"小写匹配", "context limit reached", true},
{"大写匹配", "CONTEXT LIMIT REACHED", true},
{"混合大小写", "ConTeXt LiMiT error", true},
{"不匹配", "some other error", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matched := svc.MatchRule("anthropic", 500, []byte(tt.body))
if tt.expected {
assert.NotNil(t, matched)
} else {
assert.Nil(t, matched)
}
})
}
}
// =============================================================================
// 测试真实场景
// =============================================================================
func TestMatchRule_RealWorldScenario_ContextLimitPassthrough(t *testing.T) {
// 场景:上游返回 422 + "context limit has been reached",需要透传给客户端
rules := []*model.ErrorPassthroughRule{
{
ID: 1,
Name: "Context Limit Passthrough",
Enabled: true,
Priority: 1,
ErrorCodes: []int{422},
Keywords: []string{"context limit"},
MatchMode: model.MatchModeAll, // 必须同时满足
Platforms: []string{"anthropic", "antigravity"},
PassthroughCode: true,
PassthroughBody: true,
},
}
svc := newTestService(rules)
// 测试 Anthropic 平台
t.Run("Anthropic 422 with context limit", func(t *testing.T) {
body := []byte(`{"type":"error","error":{"type":"invalid_request","message":"The context limit has been reached"}}`)
matched := svc.MatchRule("anthropic", 422, body)
require.NotNil(t, matched)
assert.True(t, matched.PassthroughCode)
assert.True(t, matched.PassthroughBody)
})
// 测试 Antigravity 平台
t.Run("Antigravity 422 with context limit", func(t *testing.T) {
body := []byte(`{"error":"context limit exceeded"}`)
matched := svc.MatchRule("antigravity", 422, body)
require.NotNil(t, matched)
})
// 测试 OpenAI 平台(不在规则的平台列表中)
t.Run("OpenAI should not match", func(t *testing.T) {
body := []byte(`{"error":"context limit exceeded"}`)
matched := svc.MatchRule("openai", 422, body)
assert.Nil(t, matched, "OpenAI 不在规则的平台列表中")
})
// 测试状态码不匹配
t.Run("Wrong status code", func(t *testing.T) {
body := []byte(`{"error":"context limit exceeded"}`)
matched := svc.MatchRule("anthropic", 400, body)
assert.Nil(t, matched, "状态码不匹配")
})
// 测试关键词不匹配
t.Run("Wrong keyword", func(t *testing.T) {
body := []byte(`{"error":"rate limit exceeded"}`)
matched := svc.MatchRule("anthropic", 422, body)
assert.Nil(t, matched, "关键词不匹配")
})
}
func TestMatchRule_RealWorldScenario_CustomErrorMessage(t *testing.T) {
// 场景:某些错误需要返回自定义消息,隐藏上游详细信息
customMsg := "Service temporarily unavailable, please try again later"
responseCode := 503
rules := []*model.ErrorPassthroughRule{
{
ID: 1,
Name: "Hide Internal Errors",
Enabled: true,
Priority: 1,
ErrorCodes: []int{500, 502, 503},
MatchMode: model.MatchModeAny,
PassthroughCode: false,
ResponseCode: &responseCode,
PassthroughBody: false,
CustomMessage: &customMsg,
},
}
svc := newTestService(rules)
matched := svc.MatchRule("anthropic", 500, []byte("internal server error"))
require.NotNil(t, matched)
assert.False(t, matched.PassthroughCode)
assert.Equal(t, 503, *matched.ResponseCode)
assert.False(t, matched.PassthroughBody)
assert.Equal(t, customMsg, *matched.CustomMessage)
}
// =============================================================================
// 测试 model.Validate
// =============================================================================
func TestErrorPassthroughRule_Validate(t *testing.T) {
tests := []struct {
name string
rule *model.ErrorPassthroughRule
expectError bool
errorField string
}{
{
name: "有效规则 - 透传模式(含错误码)",
rule: &model.ErrorPassthroughRule{
Name: "Valid Rule",
MatchMode: model.MatchModeAny,
ErrorCodes: []int{422},
PassthroughCode: true,
PassthroughBody: true,
},
expectError: false,
},
{
name: "有效规则 - 透传模式(含关键词)",
rule: &model.ErrorPassthroughRule{
Name: "Valid Rule",
MatchMode: model.MatchModeAny,
Keywords: []string{"context limit"},
PassthroughCode: true,
PassthroughBody: true,
},
expectError: false,
},
{
name: "有效规则 - 自定义响应",
rule: &model.ErrorPassthroughRule{
Name: "Valid Rule",
MatchMode: model.MatchModeAll,
ErrorCodes: []int{500},
Keywords: []string{"internal error"},
PassthroughCode: false,
ResponseCode: testIntPtr(503),
PassthroughBody: false,
CustomMessage: testStrPtr("Custom error"),
},
expectError: false,
},
{
name: "缺少名称",
rule: &model.ErrorPassthroughRule{
Name: "",
MatchMode: model.MatchModeAny,
ErrorCodes: []int{422},
PassthroughCode: true,
PassthroughBody: true,
},
expectError: true,
errorField: "name",
},
{
name: "无效的匹配模式",
rule: &model.ErrorPassthroughRule{
Name: "Invalid Mode",
MatchMode: "invalid",
ErrorCodes: []int{422},
PassthroughCode: true,
PassthroughBody: true,
},
expectError: true,
errorField: "match_mode",
},
{
name: "缺少匹配条件(错误码和关键词都为空)",
rule: &model.ErrorPassthroughRule{
Name: "No Conditions",
MatchMode: model.MatchModeAny,
ErrorCodes: []int{},
Keywords: []string{},
PassthroughCode: true,
PassthroughBody: true,
},
expectError: true,
errorField: "conditions",
},
{
name: "缺少匹配条件(nil切片)",
rule: &model.ErrorPassthroughRule{
Name: "Nil Conditions",
MatchMode: model.MatchModeAny,
ErrorCodes: nil,
Keywords: nil,
PassthroughCode: true,
PassthroughBody: true,
},
expectError: true,
errorField: "conditions",
},
{
name: "自定义状态码但未提供值",
rule: &model.ErrorPassthroughRule{
Name: "Missing Code",
MatchMode: model.MatchModeAny,
ErrorCodes: []int{422},
PassthroughCode: false,
ResponseCode: nil,
PassthroughBody: true,
},
expectError: true,
errorField: "response_code",
},
{
name: "自定义消息但未提供值",
rule: &model.ErrorPassthroughRule{
Name: "Missing Message",
MatchMode: model.MatchModeAny,
ErrorCodes: []int{422},
PassthroughCode: true,
PassthroughBody: false,
CustomMessage: nil,
},
expectError: true,
errorField: "custom_message",
},
{
name: "自定义消息为空字符串",
rule: &model.ErrorPassthroughRule{
Name: "Empty Message",
MatchMode: model.MatchModeAny,
ErrorCodes: []int{422},
PassthroughCode: true,
PassthroughBody: false,
CustomMessage: testStrPtr(""),
},
expectError: true,
errorField: "custom_message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.rule.Validate()
if tt.expectError {
require.Error(t, err)
validationErr, ok := err.(*model.ValidationError)
require.True(t, ok, "应该返回 ValidationError")
assert.Equal(t, tt.errorField, validationErr.Field)
} else {
assert.NoError(t, err)
}
})
}
}
// Helper functions
func testIntPtr(i int) *int { return &i }
func testStrPtr(s string) *string { return &s }
package service
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ---------- reconcileCachedTokens 单元测试 ----------
func TestReconcileCachedTokens_NilUsage(t *testing.T) {
assert.False(t, reconcileCachedTokens(nil))
}
func TestReconcileCachedTokens_AlreadyHasCacheRead(t *testing.T) {
// 已有标准字段,不应覆盖
usage := map[string]any{
"cache_read_input_tokens": float64(100),
"cached_tokens": float64(50),
}
assert.False(t, reconcileCachedTokens(usage))
assert.Equal(t, float64(100), usage["cache_read_input_tokens"])
}
func TestReconcileCachedTokens_KimiStyle(t *testing.T) {
// Kimi 风格:cache_read_input_tokens=0,cached_tokens>0
usage := map[string]any{
"input_tokens": float64(23),
"cache_creation_input_tokens": float64(0),
"cache_read_input_tokens": float64(0),
"cached_tokens": float64(23),
}
assert.True(t, reconcileCachedTokens(usage))
assert.Equal(t, float64(23), usage["cache_read_input_tokens"])
}
func TestReconcileCachedTokens_NoCachedTokens(t *testing.T) {
// 无 cached_tokens 字段(原生 Claude)
usage := map[string]any{
"input_tokens": float64(100),
"cache_read_input_tokens": float64(0),
"cache_creation_input_tokens": float64(0),
}
assert.False(t, reconcileCachedTokens(usage))
assert.Equal(t, float64(0), usage["cache_read_input_tokens"])
}
func TestReconcileCachedTokens_CachedTokensZero(t *testing.T) {
// cached_tokens 为 0,不应覆盖
usage := map[string]any{
"cache_read_input_tokens": float64(0),
"cached_tokens": float64(0),
}
assert.False(t, reconcileCachedTokens(usage))
assert.Equal(t, float64(0), usage["cache_read_input_tokens"])
}
func TestReconcileCachedTokens_MissingCacheReadField(t *testing.T) {
// cache_read_input_tokens 字段完全不存在,cached_tokens > 0
usage := map[string]any{
"cached_tokens": float64(42),
}
assert.True(t, reconcileCachedTokens(usage))
assert.Equal(t, float64(42), usage["cache_read_input_tokens"])
}
// ---------- 流式 message_start 事件 reconcile 测试 ----------
func TestStreamingReconcile_MessageStart(t *testing.T) {
// 模拟 Kimi 返回的 message_start SSE 事件
eventJSON := `{
"type": "message_start",
"message": {
"id": "msg_123",
"type": "message",
"role": "assistant",
"model": "kimi",
"usage": {
"input_tokens": 23,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"cached_tokens": 23
}
}
}`
var event map[string]any
require.NoError(t, json.Unmarshal([]byte(eventJSON), &event))
eventType, _ := event["type"].(string)
require.Equal(t, "message_start", eventType)
// 模拟 processSSEEvent 中的 reconcile 逻辑
if msg, ok := event["message"].(map[string]any); ok {
if u, ok := msg["usage"].(map[string]any); ok {
reconcileCachedTokens(u)
}
}
// 验证 cache_read_input_tokens 已被填充
msg, ok := event["message"].(map[string]any)
require.True(t, ok)
usage, ok := msg["usage"].(map[string]any)
require.True(t, ok)
assert.Equal(t, float64(23), usage["cache_read_input_tokens"])
// 验证重新序列化后 JSON 也包含正确值
data, err := json.Marshal(event)
require.NoError(t, err)
assert.Equal(t, int64(23), gjson.GetBytes(data, "message.usage.cache_read_input_tokens").Int())
}
func TestStreamingReconcile_MessageStart_NativeClaude(t *testing.T) {
// 原生 Claude 不返回 cached_tokens,reconcile 不应改变任何值
eventJSON := `{
"type": "message_start",
"message": {
"usage": {
"input_tokens": 100,
"cache_creation_input_tokens": 50,
"cache_read_input_tokens": 30
}
}
}`
var event map[string]any
require.NoError(t, json.Unmarshal([]byte(eventJSON), &event))
if msg, ok := event["message"].(map[string]any); ok {
if u, ok := msg["usage"].(map[string]any); ok {
reconcileCachedTokens(u)
}
}
msg, ok := event["message"].(map[string]any)
require.True(t, ok)
usage, ok := msg["usage"].(map[string]any)
require.True(t, ok)
assert.Equal(t, float64(30), usage["cache_read_input_tokens"])
}
// ---------- 流式 message_delta 事件 reconcile 测试 ----------
func TestStreamingReconcile_MessageDelta(t *testing.T) {
// 模拟 Kimi 返回的 message_delta SSE 事件
eventJSON := `{
"type": "message_delta",
"usage": {
"output_tokens": 7,
"cache_read_input_tokens": 0,
"cached_tokens": 15
}
}`
var event map[string]any
require.NoError(t, json.Unmarshal([]byte(eventJSON), &event))
eventType, _ := event["type"].(string)
require.Equal(t, "message_delta", eventType)
// 模拟 processSSEEvent 中的 reconcile 逻辑
usage, ok := event["usage"].(map[string]any)
require.True(t, ok)
reconcileCachedTokens(usage)
assert.Equal(t, float64(15), usage["cache_read_input_tokens"])
}
func TestStreamingReconcile_MessageDelta_NativeClaude(t *testing.T) {
// 原生 Claude 的 message_delta 通常没有 cached_tokens
eventJSON := `{
"type": "message_delta",
"usage": {
"output_tokens": 50
}
}`
var event map[string]any
require.NoError(t, json.Unmarshal([]byte(eventJSON), &event))
usage, ok := event["usage"].(map[string]any)
require.True(t, ok)
reconcileCachedTokens(usage)
_, hasCacheRead := usage["cache_read_input_tokens"]
assert.False(t, hasCacheRead, "不应为原生 Claude 响应注入 cache_read_input_tokens")
}
// ---------- 非流式响应 reconcile 测试 ----------
func TestNonStreamingReconcile_KimiResponse(t *testing.T) {
// 模拟 Kimi 非流式响应
body := []byte(`{
"id": "msg_123",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "hello"}],
"model": "kimi",
"usage": {
"input_tokens": 23,
"output_tokens": 7,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"cached_tokens": 23,
"prompt_tokens": 23,
"completion_tokens": 7
}
}`)
// 模拟 handleNonStreamingResponse 中的逻辑
var response struct {
Usage ClaudeUsage `json:"usage"`
}
require.NoError(t, json.Unmarshal(body, &response))
// reconcile
if response.Usage.CacheReadInputTokens == 0 {
cachedTokens := gjson.GetBytes(body, "usage.cached_tokens").Int()
if cachedTokens > 0 {
response.Usage.CacheReadInputTokens = int(cachedTokens)
if newBody, err := sjson.SetBytes(body, "usage.cache_read_input_tokens", cachedTokens); err == nil {
body = newBody
}
}
}
// 验证内部 usage(计费用)
assert.Equal(t, 23, response.Usage.CacheReadInputTokens)
assert.Equal(t, 23, response.Usage.InputTokens)
assert.Equal(t, 7, response.Usage.OutputTokens)
// 验证返回给客户端的 JSON body
assert.Equal(t, int64(23), gjson.GetBytes(body, "usage.cache_read_input_tokens").Int())
}
func TestNonStreamingReconcile_NativeClaude(t *testing.T) {
// 原生 Claude 响应:cache_read_input_tokens 已有值
body := []byte(`{
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"cache_creation_input_tokens": 20,
"cache_read_input_tokens": 30
}
}`)
var response struct {
Usage ClaudeUsage `json:"usage"`
}
require.NoError(t, json.Unmarshal(body, &response))
// CacheReadInputTokens == 30,条件不成立,整个 reconcile 分支不会执行
assert.NotZero(t, response.Usage.CacheReadInputTokens)
assert.Equal(t, 30, response.Usage.CacheReadInputTokens)
}
func TestNonStreamingReconcile_NoCachedTokens(t *testing.T) {
// 没有 cached_tokens 字段
body := []byte(`{
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0
}
}`)
var response struct {
Usage ClaudeUsage `json:"usage"`
}
require.NoError(t, json.Unmarshal(body, &response))
if response.Usage.CacheReadInputTokens == 0 {
cachedTokens := gjson.GetBytes(body, "usage.cached_tokens").Int()
if cachedTokens > 0 {
response.Usage.CacheReadInputTokens = int(cachedTokens)
if newBody, err := sjson.SetBytes(body, "usage.cache_read_input_tokens", cachedTokens); err == nil {
body = newBody
}
}
}
// cache_read_input_tokens 应保持为 0
assert.Equal(t, 0, response.Usage.CacheReadInputTokens)
assert.Equal(t, int64(0), gjson.GetBytes(body, "usage.cache_read_input_tokens").Int())
}
...@@ -370,7 +370,8 @@ type ForwardResult struct { ...@@ -370,7 +370,8 @@ type ForwardResult struct {
// UpstreamFailoverError indicates an upstream error that should trigger account failover. // UpstreamFailoverError indicates an upstream error that should trigger account failover.
type UpstreamFailoverError struct { type UpstreamFailoverError struct {
StatusCode int StatusCode int
ResponseBody []byte // 上游响应体,用于错误透传规则匹配
} }
func (e *UpstreamFailoverError) Error() string { func (e *UpstreamFailoverError) Error() string {
...@@ -384,6 +385,7 @@ type GatewayService struct { ...@@ -384,6 +385,7 @@ type GatewayService struct {
usageLogRepo UsageLogRepository usageLogRepo UsageLogRepository
userRepo UserRepository userRepo UserRepository
userSubRepo UserSubscriptionRepository userSubRepo UserSubscriptionRepository
userGroupRateRepo UserGroupRateRepository
cache GatewayCache cache GatewayCache
cfg *config.Config cfg *config.Config
schedulerSnapshot *SchedulerSnapshotService schedulerSnapshot *SchedulerSnapshotService
...@@ -405,6 +407,7 @@ func NewGatewayService( ...@@ -405,6 +407,7 @@ func NewGatewayService(
usageLogRepo UsageLogRepository, usageLogRepo UsageLogRepository,
userRepo UserRepository, userRepo UserRepository,
userSubRepo UserSubscriptionRepository, userSubRepo UserSubscriptionRepository,
userGroupRateRepo UserGroupRateRepository,
cache GatewayCache, cache GatewayCache,
cfg *config.Config, cfg *config.Config,
schedulerSnapshot *SchedulerSnapshotService, schedulerSnapshot *SchedulerSnapshotService,
...@@ -424,6 +427,7 @@ func NewGatewayService( ...@@ -424,6 +427,7 @@ func NewGatewayService(
usageLogRepo: usageLogRepo, usageLogRepo: usageLogRepo,
userRepo: userRepo, userRepo: userRepo,
userSubRepo: userSubRepo, userSubRepo: userSubRepo,
userGroupRateRepo: userGroupRateRepo,
cache: cache, cache: cache,
cfg: cfg, cfg: cfg,
schedulerSnapshot: schedulerSnapshot, schedulerSnapshot: schedulerSnapshot,
...@@ -3281,7 +3285,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A ...@@ -3281,7 +3285,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
return "" return ""
}(), }(),
}) })
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody}
} }
return s.handleRetryExhaustedError(ctx, resp, c, account) return s.handleRetryExhaustedError(ctx, resp, c, account)
} }
...@@ -3311,10 +3315,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A ...@@ -3311,10 +3315,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
return "" return ""
}(), }(),
}) })
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody}
} }
// 处理错误响应(不可重试的错误)
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
// 可选:对部分 400 触发 failover(默认关闭以保持语义) // 可选:对部分 400 触发 failover(默认关闭以保持语义)
if resp.StatusCode == 400 && s.cfg != nil && s.cfg.Gateway.FailoverOn400 { if resp.StatusCode == 400 && s.cfg != nil && s.cfg.Gateway.FailoverOn400 {
...@@ -3358,7 +3360,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A ...@@ -3358,7 +3360,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
log.Printf("Account %d: 400 error, attempting failover", account.ID) log.Printf("Account %d: 400 error, attempting failover", account.ID)
} }
s.handleFailoverSideEffects(ctx, resp, account) s.handleFailoverSideEffects(ctx, resp, account)
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody}
} }
} }
return s.handleErrorResponse(ctx, resp, c, account) return s.handleErrorResponse(ctx, resp, c, account)
...@@ -3755,6 +3757,12 @@ func (s *GatewayService) shouldFailoverOn400(respBody []byte) bool { ...@@ -3755,6 +3757,12 @@ func (s *GatewayService) shouldFailoverOn400(respBody []byte) bool {
return false return false
} }
// ExtractUpstreamErrorMessage 从上游响应体中提取错误消息
// 支持 Claude 风格的错误格式:{"type":"error","error":{"type":"...","message":"..."}}
func ExtractUpstreamErrorMessage(body []byte) string {
return extractUpstreamErrorMessage(body)
}
func extractUpstreamErrorMessage(body []byte) string { func extractUpstreamErrorMessage(body []byte) string {
// Claude 风格:{"type":"error","error":{"type":"...","message":"..."}} // Claude 风格:{"type":"error","error":{"type":"...","message":"..."}}
if m := gjson.GetBytes(body, "error.message").String(); strings.TrimSpace(m) != "" { if m := gjson.GetBytes(body, "error.message").String(); strings.TrimSpace(m) != "" {
...@@ -3822,7 +3830,7 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res ...@@ -3822,7 +3830,7 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res
shouldDisable = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body) shouldDisable = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
} }
if shouldDisable { if shouldDisable {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: body}
} }
// 记录上游错误响应体摘要便于排障(可选:由配置控制;不回显到客户端) // 记录上游错误响应体摘要便于排障(可选:由配置控制;不回显到客户端)
...@@ -4168,6 +4176,20 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http ...@@ -4168,6 +4176,20 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
eventName = eventType eventName = eventType
} }
// 兼容 Kimi cached_tokens → cache_read_input_tokens
if eventType == "message_start" {
if msg, ok := event["message"].(map[string]any); ok {
if u, ok := msg["usage"].(map[string]any); ok {
reconcileCachedTokens(u)
}
}
}
if eventType == "message_delta" {
if u, ok := event["usage"].(map[string]any); ok {
reconcileCachedTokens(u)
}
}
if needModelReplace { if needModelReplace {
if msg, ok := event["message"].(map[string]any); ok { if msg, ok := event["message"].(map[string]any); ok {
if model, ok := msg["model"].(string); ok && model == mappedModel { if model, ok := msg["model"].(string); ok && model == mappedModel {
...@@ -4518,6 +4540,17 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h ...@@ -4518,6 +4540,17 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h
return nil, fmt.Errorf("parse response: %w", err) return nil, fmt.Errorf("parse response: %w", err)
} }
// 兼容 Kimi cached_tokens → cache_read_input_tokens
if response.Usage.CacheReadInputTokens == 0 {
cachedTokens := gjson.GetBytes(body, "usage.cached_tokens").Int()
if cachedTokens > 0 {
response.Usage.CacheReadInputTokens = int(cachedTokens)
if newBody, err := sjson.SetBytes(body, "usage.cache_read_input_tokens", cachedTokens); err == nil {
body = newBody
}
}
}
// 如果有模型映射,替换响应中的model字段 // 如果有模型映射,替换响应中的model字段
if originalModel != mappedModel { if originalModel != mappedModel {
body = s.replaceModelInResponseBody(body, mappedModel, originalModel) body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
...@@ -4609,10 +4642,17 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu ...@@ -4609,10 +4642,17 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
account := input.Account account := input.Account
subscription := input.Subscription subscription := input.Subscription
// 获取费率倍数 // 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认)
multiplier := s.cfg.Default.RateMultiplier multiplier := s.cfg.Default.RateMultiplier
if apiKey.GroupID != nil && apiKey.Group != nil { if apiKey.GroupID != nil && apiKey.Group != nil {
multiplier = apiKey.Group.RateMultiplier multiplier = apiKey.Group.RateMultiplier
// 检查用户专属倍率
if s.userGroupRateRepo != nil {
if userRate, err := s.userGroupRateRepo.GetByUserAndGroup(ctx, user.ID, *apiKey.GroupID); err == nil && userRate != nil {
multiplier = *userRate
}
}
} }
var cost *CostBreakdown var cost *CostBreakdown
...@@ -4773,10 +4813,17 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input * ...@@ -4773,10 +4813,17 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
account := input.Account account := input.Account
subscription := input.Subscription subscription := input.Subscription
// 获取费率倍数 // 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认)
multiplier := s.cfg.Default.RateMultiplier multiplier := s.cfg.Default.RateMultiplier
if apiKey.GroupID != nil && apiKey.Group != nil { if apiKey.GroupID != nil && apiKey.Group != nil {
multiplier = apiKey.Group.RateMultiplier multiplier = apiKey.Group.RateMultiplier
// 检查用户专属倍率
if s.userGroupRateRepo != nil {
if userRate, err := s.userGroupRateRepo.GetByUserAndGroup(ctx, user.ID, *apiKey.GroupID); err == nil && userRate != nil {
multiplier = *userRate
}
}
} }
var cost *CostBreakdown var cost *CostBreakdown
...@@ -5289,3 +5336,21 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, ...@@ -5289,3 +5336,21 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64,
return models return models
} }
// reconcileCachedTokens 兼容 Kimi 等上游:
// 将 OpenAI 风格的 cached_tokens 映射到 Claude 标准的 cache_read_input_tokens
func reconcileCachedTokens(usage map[string]any) bool {
if usage == nil {
return false
}
cacheRead, _ := usage["cache_read_input_tokens"].(float64)
if cacheRead > 0 {
return false // 已有标准字段,无需处理
}
cached, _ := usage["cached_tokens"].(float64)
if cached <= 0 {
return false
}
usage["cache_read_input_tokens"] = cached
return true
}
...@@ -864,7 +864,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex ...@@ -864,7 +864,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
Message: upstreamMsg, Message: upstreamMsg,
Detail: upstreamDetail, Detail: upstreamDetail,
}) })
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody}
} }
if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) { if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) {
upstreamReqID := resp.Header.Get(requestIDHeader) upstreamReqID := resp.Header.Get(requestIDHeader)
...@@ -891,7 +891,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex ...@@ -891,7 +891,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
Message: upstreamMsg, Message: upstreamMsg,
Detail: upstreamDetail, Detail: upstreamDetail,
}) })
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody}
} }
upstreamReqID := resp.Header.Get(requestIDHeader) upstreamReqID := resp.Header.Get(requestIDHeader)
if upstreamReqID == "" { if upstreamReqID == "" {
...@@ -1301,7 +1301,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. ...@@ -1301,7 +1301,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
Message: upstreamMsg, Message: upstreamMsg,
Detail: upstreamDetail, Detail: upstreamDetail,
}) })
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody}
} }
if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) { if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) {
evBody := unwrapIfNeeded(isOAuth, respBody) evBody := unwrapIfNeeded(isOAuth, respBody)
...@@ -1325,7 +1325,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. ...@@ -1325,7 +1325,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
Message: upstreamMsg, Message: upstreamMsg,
Detail: upstreamDetail, Detail: upstreamDetail,
}) })
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: evBody}
} }
respBody = unwrapIfNeeded(isOAuth, respBody) respBody = unwrapIfNeeded(isOAuth, respBody)
......
...@@ -944,6 +944,32 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr ...@@ -944,6 +944,32 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
return strings.TrimSpace(loadResp.CloudAICompanionProject), tierID, nil return strings.TrimSpace(loadResp.CloudAICompanionProject), tierID, nil
} }
// 关键逻辑:对齐 Gemini CLI 对“已注册用户”的处理方式。
// 当 LoadCodeAssist 返回了 currentTier / paidTier(表示账号已注册)但没有返回 cloudaicompanionProject 时:
// - 不要再调用 onboardUser(通常不会再分配 project_id,且可能触发 INVALID_ARGUMENT)
// - 先尝试从 Cloud Resource Manager 获取可用项目;仍失败则提示用户手动填写 project_id
if loadResp != nil {
registeredTierID := strings.TrimSpace(loadResp.GetTier())
if registeredTierID != "" {
// 已注册但未返回 cloudaicompanionProject,这在 Google One 用户中较常见:需要用户自行提供 project_id。
log.Printf("[GeminiOAuth] User has tier (%s) but no cloudaicompanionProject, trying Cloud Resource Manager...", registeredTierID)
// Try to get project from Cloud Resource Manager
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
if fbErr == nil && strings.TrimSpace(fallback) != "" {
log.Printf("[GeminiOAuth] Found project from Cloud Resource Manager: %s", fallback)
return strings.TrimSpace(fallback), tierID, nil
}
// No project found - user must provide project_id manually
log.Printf("[GeminiOAuth] No project found from Cloud Resource Manager, user must provide project_id manually")
return "", tierID, fmt.Errorf("user is registered (tier: %s) but no project_id available. Please provide Project ID manually in the authorization form, or create a project at https://console.cloud.google.com", registeredTierID)
}
}
// 未检测到 currentTier/paidTier,视为新用户,继续调用 onboardUser
log.Printf("[GeminiOAuth] No currentTier/paidTier found, proceeding with onboardUser (tierID: %s)", tierID)
req := &geminicli.OnboardUserRequest{ req := &geminicli.OnboardUserRequest{
TierID: tierID, TierID: tierID,
Metadata: geminicli.LoadCodeAssistMetadata{ Metadata: geminicli.LoadCodeAssistMetadata{
......
...@@ -21,6 +21,17 @@ const ( ...@@ -21,6 +21,17 @@ const (
var codexCLIInstructions string var codexCLIInstructions string
var codexModelMap = map[string]string{ var codexModelMap = map[string]string{
"gpt-5.3": "gpt-5.3",
"gpt-5.3-none": "gpt-5.3",
"gpt-5.3-low": "gpt-5.3",
"gpt-5.3-medium": "gpt-5.3",
"gpt-5.3-high": "gpt-5.3",
"gpt-5.3-xhigh": "gpt-5.3",
"gpt-5.3-codex": "gpt-5.3-codex",
"gpt-5.3-codex-low": "gpt-5.3-codex",
"gpt-5.3-codex-medium": "gpt-5.3-codex",
"gpt-5.3-codex-high": "gpt-5.3-codex",
"gpt-5.3-codex-xhigh": "gpt-5.3-codex",
"gpt-5.1-codex": "gpt-5.1-codex", "gpt-5.1-codex": "gpt-5.1-codex",
"gpt-5.1-codex-low": "gpt-5.1-codex", "gpt-5.1-codex-low": "gpt-5.1-codex",
"gpt-5.1-codex-medium": "gpt-5.1-codex", "gpt-5.1-codex-medium": "gpt-5.1-codex",
...@@ -156,6 +167,12 @@ func normalizeCodexModel(model string) string { ...@@ -156,6 +167,12 @@ func normalizeCodexModel(model string) string {
if strings.Contains(normalized, "gpt-5.2") || strings.Contains(normalized, "gpt 5.2") { if strings.Contains(normalized, "gpt-5.2") || strings.Contains(normalized, "gpt 5.2") {
return "gpt-5.2" return "gpt-5.2"
} }
if strings.Contains(normalized, "gpt-5.3-codex") || strings.Contains(normalized, "gpt 5.3 codex") {
return "gpt-5.3-codex"
}
if strings.Contains(normalized, "gpt-5.3") || strings.Contains(normalized, "gpt 5.3") {
return "gpt-5.3"
}
if strings.Contains(normalized, "gpt-5.1-codex-max") || strings.Contains(normalized, "gpt 5.1 codex max") { if strings.Contains(normalized, "gpt-5.1-codex-max") || strings.Contains(normalized, "gpt 5.1 codex max") {
return "gpt-5.1-codex-max" return "gpt-5.1-codex-max"
} }
......
...@@ -176,6 +176,19 @@ func TestApplyCodexOAuthTransform_EmptyInput(t *testing.T) { ...@@ -176,6 +176,19 @@ func TestApplyCodexOAuthTransform_EmptyInput(t *testing.T) {
require.Len(t, input, 0) require.Len(t, input, 0)
} }
func TestNormalizeCodexModel_Gpt53(t *testing.T) {
cases := map[string]string{
"gpt-5.3": "gpt-5.3",
"gpt-5.3-codex": "gpt-5.3-codex",
"gpt-5.3-codex-xhigh": "gpt-5.3-codex",
"gpt 5.3 codex": "gpt-5.3-codex",
}
for input, expected := range cases {
require.Equal(t, expected, normalizeCodexModel(input))
}
}
func setupCodexCache(t *testing.T) { func setupCodexCache(t *testing.T) {
t.Helper() t.Helper()
......
...@@ -940,7 +940,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco ...@@ -940,7 +940,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}) })
s.handleFailoverSideEffects(ctx, resp, account) s.handleFailoverSideEffects(ctx, resp, account)
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody}
} }
return s.handleErrorResponse(ctx, resp, c, account) return s.handleErrorResponse(ctx, resp, c, account)
} }
...@@ -1131,7 +1131,7 @@ func (s *OpenAIGatewayService) handleErrorResponse(ctx context.Context, resp *ht ...@@ -1131,7 +1131,7 @@ func (s *OpenAIGatewayService) handleErrorResponse(ctx context.Context, resp *ht
Detail: upstreamDetail, Detail: upstreamDetail,
}) })
if shouldDisable { if shouldDisable {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: body}
} }
// Return appropriate error response // Return appropriate error response
......
...@@ -579,6 +579,7 @@ func (s *PricingService) extractBaseName(model string) string { ...@@ -579,6 +579,7 @@ func (s *PricingService) extractBaseName(model string) string {
func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing {
// Claude模型系列匹配规则 // Claude模型系列匹配规则
familyPatterns := map[string][]string{ familyPatterns := map[string][]string{
"opus-4.6": {"claude-opus-4.6", "claude-opus-4-6"},
"opus-4.5": {"claude-opus-4.5", "claude-opus-4-5"}, "opus-4.5": {"claude-opus-4.5", "claude-opus-4-5"},
"opus-4": {"claude-opus-4", "claude-3-opus"}, "opus-4": {"claude-opus-4", "claude-3-opus"},
"sonnet-4.5": {"claude-sonnet-4.5", "claude-sonnet-4-5"}, "sonnet-4.5": {"claude-sonnet-4.5", "claude-sonnet-4-5"},
...@@ -651,7 +652,8 @@ func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { ...@@ -651,7 +652,8 @@ func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing {
// 回退顺序: // 回退顺序:
// 1. gpt-5.2-codex -> gpt-5.2(去掉后缀如 -codex, -mini, -max 等) // 1. gpt-5.2-codex -> gpt-5.2(去掉后缀如 -codex, -mini, -max 等)
// 2. gpt-5.2-20251222 -> gpt-5.2(去掉日期版本号) // 2. gpt-5.2-20251222 -> gpt-5.2(去掉日期版本号)
// 3. 最终回退到 DefaultTestModel (gpt-5.1-codex) // 3. gpt-5.3-codex -> gpt-5.2-codex
// 4. 最终回退到 DefaultTestModel (gpt-5.1-codex)
func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing { func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing {
// 尝试的回退变体 // 尝试的回退变体
variants := s.generateOpenAIModelVariants(model, openAIModelDatePattern) variants := s.generateOpenAIModelVariants(model, openAIModelDatePattern)
...@@ -663,6 +665,13 @@ func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing { ...@@ -663,6 +665,13 @@ func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing {
} }
} }
if strings.HasPrefix(model, "gpt-5.3-codex") {
if pricing, ok := s.pricingData["gpt-5.2-codex"]; ok {
log.Printf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.2-codex")
return pricing
}
}
// 最终回退到 DefaultTestModel // 最终回退到 DefaultTestModel
defaultModel := strings.ToLower(openai.DefaultTestModel) defaultModel := strings.ToLower(openai.DefaultTestModel)
if pricing, ok := s.pricingData[defaultModel]; ok { if pricing, ok := s.pricingData[defaultModel]; ok {
......
...@@ -21,6 +21,10 @@ type User struct { ...@@ -21,6 +21,10 @@ type User struct {
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
// GroupRates 用户专属分组倍率配置
// map[groupID]rateMultiplier
GroupRates map[int64]float64
// TOTP 双因素认证字段 // TOTP 双因素认证字段
TotpSecretEncrypted *string // AES-256-GCM 加密的 TOTP 密钥 TotpSecretEncrypted *string // AES-256-GCM 加密的 TOTP 密钥
TotpEnabled bool // 是否启用 TOTP TotpEnabled bool // 是否启用 TOTP
...@@ -40,18 +44,20 @@ func (u *User) IsActive() bool { ...@@ -40,18 +44,20 @@ func (u *User) IsActive() bool {
// CanBindGroup checks whether a user can bind to a given group. // CanBindGroup checks whether a user can bind to a given group.
// For standard groups: // For standard groups:
// - If AllowedGroups is non-empty, only allow binding to IDs in that list. // - Public groups (non-exclusive): all users can bind
// - If AllowedGroups is empty (nil or length 0), allow binding to any non-exclusive group. // - Exclusive groups: only users with the group in AllowedGroups can bind
func (u *User) CanBindGroup(groupID int64, isExclusive bool) bool { func (u *User) CanBindGroup(groupID int64, isExclusive bool) bool {
if len(u.AllowedGroups) > 0 { // 公开分组(非专属):所有用户都可以绑定
for _, id := range u.AllowedGroups { if !isExclusive {
if id == groupID { return true
return true }
} // 专属分组:需要在 AllowedGroups 中
for _, id := range u.AllowedGroups {
if id == groupID {
return true
} }
return false
} }
return !isExclusive return false
} }
func (u *User) SetPassword(password string) error { func (u *User) SetPassword(password string) error {
......
package service
import "context"
// UserGroupRateRepository 用户专属分组倍率仓储接口
// 允许管理员为特定用户设置分组的专属计费倍率,覆盖分组默认倍率
type UserGroupRateRepository interface {
// GetByUserID 获取用户的所有专属分组倍率
// 返回 map[groupID]rateMultiplier
GetByUserID(ctx context.Context, userID int64) (map[int64]float64, error)
// GetByUserAndGroup 获取用户在特定分组的专属倍率
// 如果未设置专属倍率,返回 nil
GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error)
// SyncUserGroupRates 同步用户的分组专属倍率
// rates: map[groupID]*rateMultiplier,nil 表示删除该分组的专属倍率
SyncUserGroupRates(ctx context.Context, userID int64, rates map[int64]*float64) error
// DeleteByGroupID 删除指定分组的所有用户专属倍率(分组删除时调用)
DeleteByGroupID(ctx context.Context, groupID int64) error
// DeleteByUserID 删除指定用户的所有专属倍率(用户删除时调用)
DeleteByUserID(ctx context.Context, userID int64) error
}
...@@ -274,4 +274,5 @@ var ProviderSet = wire.NewSet( ...@@ -274,4 +274,5 @@ var ProviderSet = wire.NewSet(
NewUserAttributeService, NewUserAttributeService,
NewUsageCache, NewUsageCache,
NewTotpService, NewTotpService,
NewErrorPassthroughService,
) )
-- 用户专属分组倍率表
-- 允许管理员为特定用户设置分组的专属计费倍率,覆盖分组默认倍率
CREATE TABLE IF NOT EXISTS user_group_rate_multipliers (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id BIGINT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
rate_multiplier DECIMAL(10,4) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, group_id)
);
-- 按 group_id 查询索引(删除分组时清理关联记录)
CREATE INDEX IF NOT EXISTS idx_user_group_rate_multipliers_group_id
ON user_group_rate_multipliers(group_id);
COMMENT ON TABLE user_group_rate_multipliers IS '用户专属分组倍率配置';
COMMENT ON COLUMN user_group_rate_multipliers.user_id IS '用户ID';
COMMENT ON COLUMN user_group_rate_multipliers.group_id IS '分组ID';
COMMENT ON COLUMN user_group_rate_multipliers.rate_multiplier IS '专属计费倍率(覆盖分组默认倍率)';
-- Error Passthrough Rules table
-- Allows administrators to configure how upstream errors are passed through to clients
CREATE TABLE IF NOT EXISTS error_passthrough_rules (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
priority INTEGER NOT NULL DEFAULT 0,
error_codes JSONB DEFAULT '[]',
keywords JSONB DEFAULT '[]',
match_mode VARCHAR(10) NOT NULL DEFAULT 'any',
platforms JSONB DEFAULT '[]',
passthrough_code BOOLEAN NOT NULL DEFAULT true,
response_code INTEGER,
passthrough_body BOOLEAN NOT NULL DEFAULT true,
custom_message TEXT,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes for efficient queries
CREATE INDEX IF NOT EXISTS idx_error_passthrough_rules_enabled ON error_passthrough_rules (enabled);
CREATE INDEX IF NOT EXISTS idx_error_passthrough_rules_priority ON error_passthrough_rules (priority);
...@@ -1605,7 +1605,7 @@ ...@@ -1605,7 +1605,7 @@
"cache_read_input_token_cost": 1.4e-07, "cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 1.38e-06, "input_cost_per_token": 1.38e-06,
"litellm_provider": "azure", "litellm_provider": "azure",
"max_input_tokens": 272000, "max_input_tokens": 400000,
"max_output_tokens": 128000, "max_output_tokens": 128000,
"max_tokens": 128000, "max_tokens": 128000,
"mode": "responses", "mode": "responses",
...@@ -16951,6 +16951,209 @@ ...@@ -16951,6 +16951,209 @@
"supports_tool_choice": false, "supports_tool_choice": false,
"supports_vision": true "supports_vision": true
}, },
"gpt-5.3": {
"cache_read_input_token_cost": 1.75e-07,
"cache_read_input_token_cost_priority": 3.5e-07,
"input_cost_per_token": 1.75e-06,
"input_cost_per_token_priority": 3.5e-06,
"litellm_provider": "openai",
"max_input_tokens": 400000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.4e-05,
"output_cost_per_token_priority": 2.8e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text",
"image"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_service_tier": true,
"supports_vision": true
},
"gpt-5.3-2025-12-11": {
"cache_read_input_token_cost": 1.75e-07,
"cache_read_input_token_cost_priority": 3.5e-07,
"input_cost_per_token": 1.75e-06,
"input_cost_per_token_priority": 3.5e-06,
"litellm_provider": "openai",
"max_input_tokens": 400000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.4e-05,
"output_cost_per_token_priority": 2.8e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text",
"image"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_service_tier": true,
"supports_vision": true
},
"gpt-5.3-chat-latest": {
"cache_read_input_token_cost": 1.75e-07,
"cache_read_input_token_cost_priority": 3.5e-07,
"input_cost_per_token": 1.75e-06,
"input_cost_per_token_priority": 3.5e-06,
"litellm_provider": "openai",
"max_input_tokens": 128000,
"max_output_tokens": 16384,
"max_tokens": 16384,
"mode": "chat",
"output_cost_per_token": 1.4e-05,
"output_cost_per_token_priority": 2.8e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true
},
"gpt-5.3-pro": {
"input_cost_per_token": 2.1e-05,
"litellm_provider": "openai",
"max_input_tokens": 400000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"output_cost_per_token": 1.68e-04,
"supported_endpoints": [
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true
},
"gpt-5.3-pro-2025-12-11": {
"input_cost_per_token": 2.1e-05,
"litellm_provider": "openai",
"max_input_tokens": 400000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"output_cost_per_token": 1.68e-04,
"supported_endpoints": [
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true
},
"gpt-5.3-codex": {
"cache_read_input_token_cost": 1.75e-07,
"cache_read_input_token_cost_priority": 3.5e-07,
"input_cost_per_token": 1.75e-06,
"input_cost_per_token_priority": 3.5e-06,
"litellm_provider": "openai",
"max_input_tokens": 400000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"output_cost_per_token": 1.4e-05,
"output_cost_per_token_priority": 2.8e-05,
"supported_endpoints": [
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": false,
"supports_tool_choice": true,
"supports_vision": true
},
"gpt-5.2": { "gpt-5.2": {
"cache_read_input_token_cost": 1.75e-07, "cache_read_input_token_cost": 1.75e-07,
"cache_read_input_token_cost_priority": 3.5e-07, "cache_read_input_token_cost_priority": 3.5e-07,
...@@ -16988,6 +17191,39 @@ ...@@ -16988,6 +17191,39 @@
"supports_service_tier": true, "supports_service_tier": true,
"supports_vision": true "supports_vision": true
}, },
"gpt-5.2-codex": {
"cache_read_input_token_cost": 1.75e-07,
"cache_read_input_token_cost_priority": 3.5e-07,
"input_cost_per_token": 1.75e-06,
"input_cost_per_token_priority": 3.5e-06,
"litellm_provider": "openai",
"max_input_tokens": 400000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"output_cost_per_token": 1.4e-05,
"output_cost_per_token_priority": 2.8e-05,
"supported_endpoints": [
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": false,
"supports_tool_choice": true,
"supports_vision": true
},
"gpt-5.2-2025-12-11": { "gpt-5.2-2025-12-11": {
"cache_read_input_token_cost": 1.75e-07, "cache_read_input_token_cost": 1.75e-07,
"cache_read_input_token_cost_priority": 3.5e-07, "cache_read_input_token_cost_priority": 3.5e-07,
/**
* Admin Error Passthrough Rules API endpoints
* Handles error passthrough rule management for administrators
*/
import { apiClient } from '../client'
/**
* Error passthrough rule interface
*/
export interface ErrorPassthroughRule {
id: number
name: string
enabled: boolean
priority: number
error_codes: number[]
keywords: string[]
match_mode: 'any' | 'all'
platforms: string[]
passthrough_code: boolean
response_code: number | null
passthrough_body: boolean
custom_message: string | null
description: string | null
created_at: string
updated_at: string
}
/**
* Create rule request
*/
export interface CreateRuleRequest {
name: string
enabled?: boolean
priority?: number
error_codes?: number[]
keywords?: string[]
match_mode?: 'any' | 'all'
platforms?: string[]
passthrough_code?: boolean
response_code?: number | null
passthrough_body?: boolean
custom_message?: string | null
description?: string | null
}
/**
* Update rule request
*/
export interface UpdateRuleRequest {
name?: string
enabled?: boolean
priority?: number
error_codes?: number[]
keywords?: string[]
match_mode?: 'any' | 'all'
platforms?: string[]
passthrough_code?: boolean
response_code?: number | null
passthrough_body?: boolean
custom_message?: string | null
description?: string | null
}
/**
* List all error passthrough rules
* @returns List of all rules sorted by priority
*/
export async function list(): Promise<ErrorPassthroughRule[]> {
const { data } = await apiClient.get<ErrorPassthroughRule[]>('/admin/error-passthrough-rules')
return data
}
/**
* Get rule by ID
* @param id - Rule ID
* @returns Rule details
*/
export async function getById(id: number): Promise<ErrorPassthroughRule> {
const { data } = await apiClient.get<ErrorPassthroughRule>(`/admin/error-passthrough-rules/${id}`)
return data
}
/**
* Create new rule
* @param ruleData - Rule data
* @returns Created rule
*/
export async function create(ruleData: CreateRuleRequest): Promise<ErrorPassthroughRule> {
const { data } = await apiClient.post<ErrorPassthroughRule>('/admin/error-passthrough-rules', ruleData)
return data
}
/**
* Update rule
* @param id - Rule ID
* @param updates - Fields to update
* @returns Updated rule
*/
export async function update(id: number, updates: UpdateRuleRequest): Promise<ErrorPassthroughRule> {
const { data } = await apiClient.put<ErrorPassthroughRule>(`/admin/error-passthrough-rules/${id}`, updates)
return data
}
/**
* Delete rule
* @param id - Rule ID
* @returns Success confirmation
*/
export async function deleteRule(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/error-passthrough-rules/${id}`)
return data
}
/**
* Toggle rule enabled status
* @param id - Rule ID
* @param enabled - New enabled status
* @returns Updated rule
*/
export async function toggleEnabled(id: number, enabled: boolean): Promise<ErrorPassthroughRule> {
return update(id, { enabled })
}
export const errorPassthroughAPI = {
list,
getById,
create,
update,
delete: deleteRule,
toggleEnabled
}
export default errorPassthroughAPI
...@@ -19,6 +19,7 @@ import geminiAPI from './gemini' ...@@ -19,6 +19,7 @@ import geminiAPI from './gemini'
import antigravityAPI from './antigravity' import antigravityAPI from './antigravity'
import userAttributesAPI from './userAttributes' import userAttributesAPI from './userAttributes'
import opsAPI from './ops' import opsAPI from './ops'
import errorPassthroughAPI from './errorPassthrough'
/** /**
* Unified admin API object for convenient access * Unified admin API object for convenient access
...@@ -39,7 +40,8 @@ export const adminAPI = { ...@@ -39,7 +40,8 @@ export const adminAPI = {
gemini: geminiAPI, gemini: geminiAPI,
antigravity: antigravityAPI, antigravity: antigravityAPI,
userAttributes: userAttributesAPI, userAttributes: userAttributesAPI,
ops: opsAPI ops: opsAPI,
errorPassthrough: errorPassthroughAPI
} }
export { export {
...@@ -58,10 +60,12 @@ export { ...@@ -58,10 +60,12 @@ export {
geminiAPI, geminiAPI,
antigravityAPI, antigravityAPI,
userAttributesAPI, userAttributesAPI,
opsAPI opsAPI,
errorPassthroughAPI
} }
export default adminAPI export default adminAPI
// Re-export types used by components // Re-export types used by components
export type { BalanceHistoryItem } from './users' export type { BalanceHistoryItem } from './users'
export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from './errorPassthrough'
...@@ -18,8 +18,18 @@ export async function getAvailable(): Promise<Group[]> { ...@@ -18,8 +18,18 @@ export async function getAvailable(): Promise<Group[]> {
return data return data
} }
/**
* Get current user's custom group rate multipliers
* @returns Map of group_id to custom rate_multiplier
*/
export async function getUserGroupRates(): Promise<Record<number, number>> {
const { data } = await apiClient.get<Record<number, number> | null>('/groups/rates')
return data || {}
}
export const userGroupsAPI = { export const userGroupsAPI = {
getAvailable getAvailable,
getUserGroupRates
} }
export default userGroupsAPI export default userGroupsAPI
...@@ -707,6 +707,7 @@ const groupIds = ref<number[]>([]) ...@@ -707,6 +707,7 @@ const groupIds = ref<number[]>([])
// All models list (combined Anthropic + OpenAI) // All models list (combined Anthropic + OpenAI)
const allModels = [ const allModels = [
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6' },
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' }, { value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' }, { value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' }, { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
...@@ -746,6 +747,13 @@ const presetMappings = [ ...@@ -746,6 +747,13 @@ const presetMappings = [
color: color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
}, },
{
label: 'Opus 4.6',
from: 'claude-opus-4-6',
to: 'claude-opus-4-6',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{ {
label: 'Opus->Sonnet', label: 'Opus->Sonnet',
from: 'claude-opus-4-5-20251101', from: 'claude-opus-4-5-20251101',
......
<template>
<BaseDialog
:show="show"
:title="t('admin.errorPassthrough.title')"
width="extra-wide"
@close="$emit('close')"
>
<div class="space-y-4">
<!-- Header -->
<div class="flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.description') }}
</p>
<button @click="showCreateModal = true" class="btn btn-primary btn-sm">
<Icon name="plus" size="sm" class="mr-1" />
{{ t('admin.errorPassthrough.createRule') }}
</button>
</div>
<!-- Rules Table -->
<div v-if="loading" class="flex items-center justify-center py-8">
<Icon name="refresh" size="lg" class="animate-spin text-gray-400" />
</div>
<div v-else-if="rules.length === 0" class="py-8 text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700">
<Icon name="shield" size="lg" class="text-gray-400" />
</div>
<h4 class="mb-1 text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.errorPassthrough.noRules') }}
</h4>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.createFirstRule') }}
</p>
</div>
<div v-else class="max-h-96 overflow-auto rounded-lg border border-gray-200 dark:border-dark-600">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="sticky top-0 bg-gray-50 dark:bg-dark-700">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.columns.priority') }}
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.columns.name') }}
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.columns.conditions') }}
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.columns.platforms') }}
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.columns.behavior') }}
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.columns.status') }}
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.columns.actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
<tr v-for="rule in rules" :key="rule.id" class="hover:bg-gray-50 dark:hover:bg-dark-700">
<td class="whitespace-nowrap px-3 py-2">
<span class="inline-flex h-5 w-5 items-center justify-center rounded bg-gray-100 text-xs font-medium text-gray-700 dark:bg-dark-600 dark:text-gray-300">
{{ rule.priority }}
</span>
</td>
<td class="px-3 py-2">
<div class="font-medium text-gray-900 dark:text-white text-sm">{{ rule.name }}</div>
<div v-if="rule.description" class="mt-0.5 text-xs text-gray-500 dark:text-gray-400 max-w-xs truncate">
{{ rule.description }}
</div>
</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1 max-w-48">
<span
v-for="code in rule.error_codes.slice(0, 3)"
:key="code"
class="badge badge-danger text-xs"
>
{{ code }}
</span>
<span
v-if="rule.error_codes.length > 3"
class="text-xs text-gray-500"
>
+{{ rule.error_codes.length - 3 }}
</span>
<span
v-for="keyword in rule.keywords.slice(0, 1)"
:key="keyword"
class="badge badge-gray text-xs"
>
"{{ keyword.length > 10 ? keyword.substring(0, 10) + '...' : keyword }}"
</span>
<span
v-if="rule.keywords.length > 1"
class="text-xs text-gray-500"
>
+{{ rule.keywords.length - 1 }}
</span>
</div>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.matchMode.' + rule.match_mode) }}
</div>
</td>
<td class="px-3 py-2">
<div v-if="rule.platforms.length === 0" class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.allPlatforms') }}
</div>
<div v-else class="flex flex-wrap gap-1">
<span
v-for="platform in rule.platforms.slice(0, 2)"
:key="platform"
class="badge badge-primary text-xs"
>
{{ platform }}
</span>
<span v-if="rule.platforms.length > 2" class="text-xs text-gray-500">
+{{ rule.platforms.length - 2 }}
</span>
</div>
</td>
<td class="px-3 py-2">
<div class="text-xs space-y-0.5">
<div class="flex items-center gap-1">
<Icon
:name="rule.passthrough_code ? 'checkCircle' : 'xCircle'"
size="xs"
:class="rule.passthrough_code ? 'text-green-500' : 'text-gray-400'"
/>
<span class="text-gray-600 dark:text-gray-400">
{{ t('admin.errorPassthrough.code') }}:
{{ rule.passthrough_code ? t('admin.errorPassthrough.passthrough') : (rule.response_code || '-') }}
</span>
</div>
<div class="flex items-center gap-1">
<Icon
:name="rule.passthrough_body ? 'checkCircle' : 'xCircle'"
size="xs"
:class="rule.passthrough_body ? 'text-green-500' : 'text-gray-400'"
/>
<span class="text-gray-600 dark:text-gray-400">
{{ t('admin.errorPassthrough.body') }}:
{{ rule.passthrough_body ? t('admin.errorPassthrough.passthrough') : t('admin.errorPassthrough.custom') }}
</span>
</div>
</div>
</td>
<td class="px-3 py-2">
<button
@click="toggleEnabled(rule)"
:class="[
'relative inline-flex h-4 w-7 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',
rule.enabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-3 w-3 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
rule.enabled ? 'translate-x-3' : 'translate-x-0'
]"
/>
</button>
</td>
<td class="px-3 py-2">
<div class="flex items-center gap-1">
<button
@click="handleEdit(rule)"
class="p-1 text-gray-500 hover:text-primary-600 dark:hover:text-primary-400"
:title="t('common.edit')"
>
<Icon name="edit" size="sm" />
</button>
<button
@click="handleDelete(rule)"
class="p-1 text-gray-500 hover:text-red-600 dark:hover:text-red-400"
:title="t('common.delete')"
>
<Icon name="trash" size="sm" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<button @click="$emit('close')" class="btn btn-secondary">
{{ t('common.close') }}
</button>
</div>
</template>
<!-- Create/Edit Modal -->
<BaseDialog
:show="showCreateModal || showEditModal"
:title="showEditModal ? t('admin.errorPassthrough.editRule') : t('admin.errorPassthrough.createRule')"
width="wide"
@close="closeFormModal"
>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Basic Info -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.errorPassthrough.form.name') }}</label>
<input
v-model="form.name"
type="text"
required
class="input"
:placeholder="t('admin.errorPassthrough.form.namePlaceholder')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.errorPassthrough.form.priority') }}</label>
<input
v-model.number="form.priority"
type="number"
min="0"
class="input"
/>
<p class="input-hint">{{ t('admin.errorPassthrough.form.priorityHint') }}</p>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.errorPassthrough.form.description') }}</label>
<input
v-model="form.description"
type="text"
class="input"
:placeholder="t('admin.errorPassthrough.form.descriptionPlaceholder')"
/>
</div>
<!-- Match Conditions -->
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<h4 class="mb-2 text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.errorPassthrough.form.matchConditions') }}
</h4>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.errorCodes') }}</label>
<input
v-model="errorCodesInput"
type="text"
class="input text-sm"
:placeholder="t('admin.errorPassthrough.form.errorCodesPlaceholder')"
/>
<p class="input-hint text-xs">{{ t('admin.errorPassthrough.form.errorCodesHint') }}</p>
</div>
<div>
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.keywords') }}</label>
<textarea
v-model="keywordsInput"
rows="2"
class="input font-mono text-xs"
:placeholder="t('admin.errorPassthrough.form.keywordsPlaceholder')"
/>
<p class="input-hint text-xs">{{ t('admin.errorPassthrough.form.keywordsHint') }}</p>
</div>
</div>
<div class="mt-3">
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.matchMode') }}</label>
<div class="mt-1 space-y-2">
<label
v-for="option in matchModeOptions"
:key="option.value"
class="flex items-start gap-2 cursor-pointer"
>
<input
type="radio"
:value="option.value"
v-model="form.match_mode"
class="mt-0.5 h-3.5 w-3.5 border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<div class="flex-1">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ option.label }}</span>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ option.description }}</p>
</div>
</label>
</div>
</div>
<div class="mt-3">
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.platforms') }}</label>
<div class="flex flex-wrap gap-3">
<label
v-for="platform in platformOptions"
:key="platform.value"
class="inline-flex items-center gap-1.5"
>
<input
type="checkbox"
:value="platform.value"
v-model="form.platforms"
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-xs text-gray-700 dark:text-gray-300">{{ platform.label }}</span>
</label>
</div>
<p class="input-hint text-xs mt-1">{{ t('admin.errorPassthrough.form.platformsHint') }}</p>
</div>
</div>
<!-- Response Behavior -->
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<h4 class="mb-2 text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.errorPassthrough.form.responseBehavior') }}
</h4>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="flex items-center gap-1.5">
<input
type="checkbox"
v-model="form.passthrough_code"
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.errorPassthrough.form.passthroughCode') }}
</span>
</label>
<div v-if="!form.passthrough_code" class="mt-2">
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.responseCode') }}</label>
<input
v-model.number="form.response_code"
type="number"
min="100"
max="599"
class="input text-sm"
placeholder="422"
/>
</div>
</div>
<div>
<label class="flex items-center gap-1.5">
<input
type="checkbox"
v-model="form.passthrough_body"
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.errorPassthrough.form.passthroughBody') }}
</span>
</label>
<div v-if="!form.passthrough_body" class="mt-2">
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.customMessage') }}</label>
<input
v-model="form.custom_message"
type="text"
class="input text-sm"
:placeholder="t('admin.errorPassthrough.form.customMessagePlaceholder')"
/>
</div>
</div>
</div>
</div>
<!-- Enabled -->
<div class="flex items-center gap-1.5">
<input
type="checkbox"
v-model="form.enabled"
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.errorPassthrough.form.enabled') }}
</span>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="closeFormModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button @click="handleSubmit" :disabled="submitting" class="btn btn-primary">
<Icon v-if="submitting" name="refresh" size="sm" class="mr-1 animate-spin" />
{{ showEditModal ? t('common.update') : t('common.create') }}
</button>
</div>
</template>
</BaseDialog>
<!-- Delete Confirmation -->
<ConfirmDialog
:show="showDeleteDialog"
:title="t('admin.errorPassthrough.deleteRule')"
:message="t('admin.errorPassthrough.deleteConfirm', { name: deletingRule?.name })"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { ErrorPassthroughRule } from '@/api/admin/errorPassthrough'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Icon from '@/components/icons/Icon.vue'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
close: []
}>()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
void emit // suppress unused warning - emit is used via $emit in template
const { t } = useI18n()
const appStore = useAppStore()
const rules = ref<ErrorPassthroughRule[]>([])
const loading = ref(false)
const submitting = ref(false)
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
const editingRule = ref<ErrorPassthroughRule | null>(null)
const deletingRule = ref<ErrorPassthroughRule | null>(null)
// Form inputs for arrays
const errorCodesInput = ref('')
const keywordsInput = ref('')
const form = reactive({
name: '',
enabled: true,
priority: 0,
match_mode: 'any' as 'any' | 'all',
platforms: [] as string[],
passthrough_code: true,
response_code: null as number | null,
passthrough_body: true,
custom_message: null as string | null,
description: null as string | null
})
const matchModeOptions = computed(() => [
{ value: 'any', label: t('admin.errorPassthrough.matchMode.any'), description: t('admin.errorPassthrough.matchMode.anyHint') },
{ value: 'all', label: t('admin.errorPassthrough.matchMode.all'), description: t('admin.errorPassthrough.matchMode.allHint') }
])
const platformOptions = [
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'antigravity', label: 'Antigravity' }
]
// Load rules when dialog opens
watch(() => props.show, (newVal) => {
if (newVal) {
loadRules()
}
})
const loadRules = async () => {
loading.value = true
try {
rules.value = await adminAPI.errorPassthrough.list()
} catch (error) {
appStore.showError(t('admin.errorPassthrough.failedToLoad'))
console.error('Error loading rules:', error)
} finally {
loading.value = false
}
}
const resetForm = () => {
form.name = ''
form.enabled = true
form.priority = 0
form.match_mode = 'any'
form.platforms = []
form.passthrough_code = true
form.response_code = null
form.passthrough_body = true
form.custom_message = null
form.description = null
errorCodesInput.value = ''
keywordsInput.value = ''
}
const closeFormModal = () => {
showCreateModal.value = false
showEditModal.value = false
editingRule.value = null
resetForm()
}
const handleEdit = (rule: ErrorPassthroughRule) => {
editingRule.value = rule
form.name = rule.name
form.enabled = rule.enabled
form.priority = rule.priority
form.match_mode = rule.match_mode
form.platforms = [...rule.platforms]
form.passthrough_code = rule.passthrough_code
form.response_code = rule.response_code
form.passthrough_body = rule.passthrough_body
form.custom_message = rule.custom_message
form.description = rule.description
errorCodesInput.value = rule.error_codes.join(', ')
keywordsInput.value = rule.keywords.join('\n')
showEditModal.value = true
}
const handleDelete = (rule: ErrorPassthroughRule) => {
deletingRule.value = rule
showDeleteDialog.value = true
}
const parseErrorCodes = (): number[] => {
if (!errorCodesInput.value.trim()) return []
return errorCodesInput.value
.split(/[,\s]+/)
.map(s => parseInt(s.trim(), 10))
.filter(n => !isNaN(n) && n > 0)
}
const parseKeywords = (): string[] => {
if (!keywordsInput.value.trim()) return []
return keywordsInput.value
.split('\n')
.map(s => s.trim())
.filter(s => s.length > 0)
}
const handleSubmit = async () => {
if (!form.name.trim()) {
appStore.showError(t('admin.errorPassthrough.nameRequired'))
return
}
const errorCodes = parseErrorCodes()
const keywords = parseKeywords()
if (errorCodes.length === 0 && keywords.length === 0) {
appStore.showError(t('admin.errorPassthrough.conditionsRequired'))
return
}
submitting.value = true
try {
const data = {
name: form.name.trim(),
enabled: form.enabled,
priority: form.priority,
error_codes: errorCodes,
keywords: keywords,
match_mode: form.match_mode,
platforms: form.platforms,
passthrough_code: form.passthrough_code,
response_code: form.passthrough_code ? null : form.response_code,
passthrough_body: form.passthrough_body,
custom_message: form.passthrough_body ? null : form.custom_message,
description: form.description?.trim() || null
}
if (showEditModal.value && editingRule.value) {
await adminAPI.errorPassthrough.update(editingRule.value.id, data)
appStore.showSuccess(t('admin.errorPassthrough.ruleUpdated'))
} else {
await adminAPI.errorPassthrough.create(data)
appStore.showSuccess(t('admin.errorPassthrough.ruleCreated'))
}
closeFormModal()
loadRules()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.errorPassthrough.failedToSave'))
console.error('Error saving rule:', error)
} finally {
submitting.value = false
}
}
const toggleEnabled = async (rule: ErrorPassthroughRule) => {
try {
await adminAPI.errorPassthrough.toggleEnabled(rule.id, !rule.enabled)
rule.enabled = !rule.enabled
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.errorPassthrough.failedToToggle'))
console.error('Error toggling rule:', error)
}
}
const confirmDelete = async () => {
if (!deletingRule.value) return
try {
await adminAPI.errorPassthrough.delete(deletingRule.value.id)
appStore.showSuccess(t('admin.errorPassthrough.ruleDeleted'))
showDeleteDialog.value = false
deletingRule.value = null
loadRules()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.errorPassthrough.failedToDelete'))
console.error('Error deleting rule:', error)
}
}
</script>
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