Commit 2220fd18 authored by song's avatar song
Browse files

merge upstream main

parents 11ff73b5 df4c0adf
...@@ -29,6 +29,10 @@ type GroupRepository interface { ...@@ -29,6 +29,10 @@ type GroupRepository interface {
ExistsByName(ctx context.Context, name string) (bool, error) ExistsByName(ctx context.Context, name string) (bool, error)
GetAccountCount(ctx context.Context, groupID int64) (int64, error) GetAccountCount(ctx context.Context, groupID int64) (int64, error)
DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error)
// GetAccountIDsByGroupIDs 获取多个分组的所有账号 ID(去重)
GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error)
// BindAccountsToGroup 将多个账号绑定到指定分组
BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error
} }
// CreateGroupRequest 创建分组请求 // CreateGroupRequest 创建分组请求
......
...@@ -26,13 +26,13 @@ var ( ...@@ -26,13 +26,13 @@ var (
// 默认指纹值(当客户端未提供时使用) // 默认指纹值(当客户端未提供时使用)
var defaultFingerprint = Fingerprint{ var defaultFingerprint = Fingerprint{
UserAgent: "claude-cli/2.0.62 (external, cli)", UserAgent: "claude-cli/2.1.22 (external, cli)",
StainlessLang: "js", StainlessLang: "js",
StainlessPackageVersion: "0.52.0", StainlessPackageVersion: "0.70.0",
StainlessOS: "Linux", StainlessOS: "Linux",
StainlessArch: "x64", StainlessArch: "arm64",
StainlessRuntime: "node", StainlessRuntime: "node",
StainlessRuntimeVersion: "v22.14.0", StainlessRuntimeVersion: "v24.13.0",
} }
// Fingerprint represents account fingerprint data // Fingerprint represents account fingerprint data
...@@ -327,7 +327,7 @@ func generateUUIDFromSeed(seed string) string { ...@@ -327,7 +327,7 @@ func generateUUIDFromSeed(seed string) string {
} }
// parseUserAgentVersion 解析user-agent版本号 // parseUserAgentVersion 解析user-agent版本号
// 例如:claude-cli/2.0.62 -> (2, 0, 62) // 例如:claude-cli/2.1.2 -> (2, 1, 2)
func parseUserAgentVersion(ua string) (major, minor, patch int, ok bool) { func parseUserAgentVersion(ua string) (major, minor, patch int, ok bool) {
// 匹配 xxx/x.y.z 格式 // 匹配 xxx/x.y.z 格式
matches := userAgentVersionRegex.FindStringSubmatch(ua) matches := userAgentVersionRegex.FindStringSubmatch(ua)
......
...@@ -159,6 +159,9 @@ type OpenAIForwardResult struct { ...@@ -159,6 +159,9 @@ type OpenAIForwardResult struct {
RequestID string RequestID string
Usage OpenAIUsage Usage OpenAIUsage
Model string Model string
// ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix.
// Stored for usage records display; nil means not provided / not applicable.
ReasoningEffort *string
Stream bool Stream bool
Duration time.Duration Duration time.Duration
FirstTokenMs *int FirstTokenMs *int
...@@ -958,10 +961,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco ...@@ -958,10 +961,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
} }
} }
reasoningEffort := extractOpenAIReasoningEffort(reqBody, originalModel)
return &OpenAIForwardResult{ return &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"), RequestID: resp.Header.Get("x-request-id"),
Usage: *usage, Usage: *usage,
Model: originalModel, Model: originalModel,
ReasoningEffort: reasoningEffort,
Stream: reqStream, Stream: reqStream,
Duration: time.Since(startTime), Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs, FirstTokenMs: firstTokenMs,
...@@ -1260,16 +1266,30 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp ...@@ -1260,16 +1266,30 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
// 记录上次收到上游数据的时间,用于控制 keepalive 发送频率 // 记录上次收到上游数据的时间,用于控制 keepalive 发送频率
lastDataAt := time.Now() lastDataAt := time.Now()
// 仅发送一次错误事件,避免多次写入导致协议混乱(写失败时尽力通知客户端) // 仅发送一次错误事件,避免多次写入导致协议混乱。
// 注意:OpenAI `/v1/responses` streaming 事件必须符合 OpenAI Responses schema;
// 否则下游 SDK(例如 OpenCode)会因为类型校验失败而报错。
errorEventSent := false errorEventSent := false
clientDisconnected := false // 客户端断开后继续 drain 上游以收集 usage
sendErrorEvent := func(reason string) { sendErrorEvent := func(reason string) {
if errorEventSent { if errorEventSent || clientDisconnected {
return return
} }
errorEventSent = true errorEventSent = true
_, _ = fmt.Fprintf(w, "event: error\ndata: {\"error\":\"%s\"}\n\n", reason) payload := map[string]any{
"type": "error",
"sequence_number": 0,
"error": map[string]any{
"type": "upstream_error",
"message": reason,
"code": reason,
},
}
if b, err := json.Marshal(payload); err == nil {
_, _ = fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush() flusher.Flush()
} }
}
needModelReplace := originalModel != mappedModel needModelReplace := originalModel != mappedModel
...@@ -1280,6 +1300,17 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp ...@@ -1280,6 +1300,17 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil
} }
if ev.err != nil { if ev.err != nil {
// 客户端断开/取消请求时,上游读取往往会返回 context canceled。
// /v1/responses 的 SSE 事件必须符合 OpenAI 协议;这里不注入自定义 error event,避免下游 SDK 解析失败。
if errors.Is(ev.err, context.Canceled) || errors.Is(ev.err, context.DeadlineExceeded) {
log.Printf("Context canceled during streaming, returning collected usage")
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil
}
// 客户端已断开时,上游出错仅影响体验,不影响计费;返回已收集 usage
if clientDisconnected {
log.Printf("Upstream read error after client disconnect: %v, returning collected usage", ev.err)
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil
}
if errors.Is(ev.err, bufio.ErrTooLong) { if errors.Is(ev.err, bufio.ErrTooLong) {
log.Printf("SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, ev.err) log.Printf("SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, ev.err)
sendErrorEvent("response_too_large") sendErrorEvent("response_too_large")
...@@ -1303,15 +1334,19 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp ...@@ -1303,15 +1334,19 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
// Correct Codex tool calls if needed (apply_patch -> edit, etc.) // Correct Codex tool calls if needed (apply_patch -> edit, etc.)
if correctedData, corrected := s.toolCorrector.CorrectToolCallsInSSEData(data); corrected { if correctedData, corrected := s.toolCorrector.CorrectToolCallsInSSEData(data); corrected {
data = correctedData
line = "data: " + correctedData line = "data: " + correctedData
} }
// Forward line // 写入客户端(客户端断开后继续 drain 上游)
if !clientDisconnected {
if _, err := fmt.Fprintf(w, "%s\n", line); err != nil { if _, err := fmt.Fprintf(w, "%s\n", line); err != nil {
sendErrorEvent("write_failed") clientDisconnected = true
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, err log.Printf("Client disconnected during streaming, continuing to drain upstream for billing")
} } else {
flusher.Flush() flusher.Flush()
}
}
// Record first token time // Record first token time
if firstTokenMs == nil && data != "" && data != "[DONE]" { if firstTokenMs == nil && data != "" && data != "[DONE]" {
...@@ -1321,18 +1356,25 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp ...@@ -1321,18 +1356,25 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
s.parseSSEUsage(data, usage) s.parseSSEUsage(data, usage)
} else { } else {
// Forward non-data lines as-is // Forward non-data lines as-is
if !clientDisconnected {
if _, err := fmt.Fprintf(w, "%s\n", line); err != nil { if _, err := fmt.Fprintf(w, "%s\n", line); err != nil {
sendErrorEvent("write_failed") clientDisconnected = true
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, err log.Printf("Client disconnected during streaming, continuing to drain upstream for billing")
} } else {
flusher.Flush() flusher.Flush()
} }
}
}
case <-intervalCh: case <-intervalCh:
lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
if time.Since(lastRead) < streamInterval { if time.Since(lastRead) < streamInterval {
continue continue
} }
if clientDisconnected {
log.Printf("Upstream timeout after client disconnect, returning collected usage")
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil
}
log.Printf("Stream data interval timeout: account=%d model=%s interval=%s", account.ID, originalModel, streamInterval) log.Printf("Stream data interval timeout: account=%d model=%s interval=%s", account.ID, originalModel, streamInterval)
// 处理流超时,可能标记账户为临时不可调度或错误状态 // 处理流超时,可能标记账户为临时不可调度或错误状态
if s.rateLimitService != nil { if s.rateLimitService != nil {
...@@ -1342,11 +1384,16 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp ...@@ -1342,11 +1384,16 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout") return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout")
case <-keepaliveCh: case <-keepaliveCh:
if clientDisconnected {
continue
}
if time.Since(lastDataAt) < keepaliveInterval { if time.Since(lastDataAt) < keepaliveInterval {
continue continue
} }
if _, err := fmt.Fprint(w, ":\n\n"); err != nil { if _, err := fmt.Fprint(w, ":\n\n"); err != nil {
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, err clientDisconnected = true
log.Printf("Client disconnected during streaming, continuing to drain upstream for billing")
continue
} }
flusher.Flush() flusher.Flush()
} }
...@@ -1687,6 +1734,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec ...@@ -1687,6 +1734,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
AccountID: account.ID, AccountID: account.ID,
RequestID: result.RequestID, RequestID: result.RequestID,
Model: result.Model, Model: result.Model,
ReasoningEffort: result.ReasoningEffort,
InputTokens: actualInputTokens, InputTokens: actualInputTokens,
OutputTokens: result.Usage.OutputTokens, OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens, CacheCreationTokens: result.Usage.CacheCreationInputTokens,
...@@ -1881,3 +1929,86 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc ...@@ -1881,3 +1929,86 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc
_ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates) _ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates)
}() }()
} }
func getOpenAIReasoningEffortFromReqBody(reqBody map[string]any) (value string, present bool) {
if reqBody == nil {
return "", false
}
// Primary: reasoning.effort
if reasoning, ok := reqBody["reasoning"].(map[string]any); ok {
if effort, ok := reasoning["effort"].(string); ok {
return normalizeOpenAIReasoningEffort(effort), true
}
}
// Fallback: some clients may use a flat field.
if effort, ok := reqBody["reasoning_effort"].(string); ok {
return normalizeOpenAIReasoningEffort(effort), true
}
return "", false
}
func deriveOpenAIReasoningEffortFromModel(model string) string {
if strings.TrimSpace(model) == "" {
return ""
}
modelID := strings.TrimSpace(model)
if strings.Contains(modelID, "/") {
parts := strings.Split(modelID, "/")
modelID = parts[len(parts)-1]
}
parts := strings.FieldsFunc(strings.ToLower(modelID), func(r rune) bool {
switch r {
case '-', '_', ' ':
return true
default:
return false
}
})
if len(parts) == 0 {
return ""
}
return normalizeOpenAIReasoningEffort(parts[len(parts)-1])
}
func extractOpenAIReasoningEffort(reqBody map[string]any, requestedModel string) *string {
if value, present := getOpenAIReasoningEffortFromReqBody(reqBody); present {
if value == "" {
return nil
}
return &value
}
value := deriveOpenAIReasoningEffortFromModel(requestedModel)
if value == "" {
return nil
}
return &value
}
func normalizeOpenAIReasoningEffort(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
if value == "" {
return ""
}
// Normalize separators for "x-high"/"x_high" variants.
value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value)
switch value {
case "none", "minimal":
return ""
case "low", "medium", "high":
return value
case "xhigh", "extrahigh":
return "xhigh"
default:
// Only store known effort levels for now to keep UI consistent.
return ""
}
}
...@@ -59,6 +59,25 @@ type stubConcurrencyCache struct { ...@@ -59,6 +59,25 @@ type stubConcurrencyCache struct {
skipDefaultLoad bool skipDefaultLoad bool
} }
type cancelReadCloser struct{}
func (c cancelReadCloser) Read(p []byte) (int, error) { return 0, context.Canceled }
func (c cancelReadCloser) Close() error { return nil }
type failingGinWriter struct {
gin.ResponseWriter
failAfter int
writes int
}
func (w *failingGinWriter) Write(p []byte) (int, error) {
if w.writes >= w.failAfter {
return 0, errors.New("write failed")
}
w.writes++
return w.ResponseWriter.Write(p)
}
func (c stubConcurrencyCache) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) { func (c stubConcurrencyCache) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
if c.acquireResults != nil { if c.acquireResults != nil {
if result, ok := c.acquireResults[accountID]; ok { if result, ok := c.acquireResults[accountID]; ok {
...@@ -814,8 +833,85 @@ func TestOpenAIStreamingTimeout(t *testing.T) { ...@@ -814,8 +833,85 @@ func TestOpenAIStreamingTimeout(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") { if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
t.Fatalf("expected stream timeout error, got %v", err) t.Fatalf("expected stream timeout error, got %v", err)
} }
if !strings.Contains(rec.Body.String(), "stream_timeout") { if !strings.Contains(rec.Body.String(), "\"type\":\"error\"") || !strings.Contains(rec.Body.String(), "stream_timeout") {
t.Fatalf("expected stream_timeout SSE error, got %q", rec.Body.String()) t.Fatalf("expected OpenAI-compatible error SSE event, got %q", rec.Body.String())
}
}
func TestOpenAIStreamingContextCanceledDoesNotInjectErrorEvent(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: 0,
StreamKeepaliveInterval: 0,
MaxLineSize: defaultMaxLineSize,
},
}
svc := &OpenAIGatewayService{cfg: cfg}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
ctx, cancel := context.WithCancel(context.Background())
cancel()
c.Request = httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
resp := &http.Response{
StatusCode: http.StatusOK,
Body: cancelReadCloser{},
Header: http.Header{},
}
_, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1}, time.Now(), "model", "model")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if strings.Contains(rec.Body.String(), "event: error") || strings.Contains(rec.Body.String(), "stream_read_error") {
t.Fatalf("expected no injected SSE error event, got %q", rec.Body.String())
}
}
func TestOpenAIStreamingClientDisconnectDrainsUpstreamUsage(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: 0,
StreamKeepaliveInterval: 0,
MaxLineSize: defaultMaxLineSize,
},
}
svc := &OpenAIGatewayService{cfg: cfg}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
c.Writer = &failingGinWriter{ResponseWriter: c.Writer, failAfter: 0}
pr, pw := io.Pipe()
resp := &http.Response{
StatusCode: http.StatusOK,
Body: pr,
Header: http.Header{},
}
go func() {
defer func() { _ = pw.Close() }()
_, _ = pw.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{}}\n\n"))
_, _ = pw.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":3,\"output_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n"))
}()
result, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1}, time.Now(), "model", "model")
_ = pr.Close()
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if result == nil || result.usage == nil {
t.Fatalf("expected usage result")
}
if result.usage.InputTokens != 3 || result.usage.OutputTokens != 5 || result.usage.CacheReadInputTokens != 1 {
t.Fatalf("unexpected usage: %+v", *result.usage)
}
if strings.Contains(rec.Body.String(), "event: error") || strings.Contains(rec.Body.String(), "write_failed") {
t.Fatalf("expected no injected SSE error event, got %q", rec.Body.String())
} }
} }
...@@ -854,8 +950,8 @@ func TestOpenAIStreamingTooLong(t *testing.T) { ...@@ -854,8 +950,8 @@ func TestOpenAIStreamingTooLong(t *testing.T) {
if !errors.Is(err, bufio.ErrTooLong) { if !errors.Is(err, bufio.ErrTooLong) {
t.Fatalf("expected ErrTooLong, got %v", err) t.Fatalf("expected ErrTooLong, got %v", err)
} }
if !strings.Contains(rec.Body.String(), "response_too_large") { if !strings.Contains(rec.Body.String(), "\"type\":\"error\"") || !strings.Contains(rec.Body.String(), "response_too_large") {
t.Fatalf("expected response_too_large SSE error, got %q", rec.Body.String()) t.Fatalf("expected OpenAI-compatible error SSE event, got %q", rec.Body.String())
} }
} }
......
...@@ -126,7 +126,8 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ ...@@ -126,7 +126,8 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
return nil, errors.New("count must be greater than 0") return nil, errors.New("count must be greater than 0")
} }
if req.Value <= 0 { // 邀请码类型不需要数值,其他类型需要
if req.Type != RedeemTypeInvitation && req.Value <= 0 {
return nil, errors.New("value must be greater than 0") return nil, errors.New("value must be greater than 0")
} }
...@@ -139,6 +140,12 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ ...@@ -139,6 +140,12 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
codeType = RedeemTypeBalance codeType = RedeemTypeBalance
} }
// 邀请码类型的 value 设为 0
value := req.Value
if codeType == RedeemTypeInvitation {
value = 0
}
codes := make([]RedeemCode, 0, req.Count) codes := make([]RedeemCode, 0, req.Count)
for i := 0; i < req.Count; i++ { for i := 0; i < req.Count; i++ {
code, err := s.GenerateRandomCode() code, err := s.GenerateRandomCode()
...@@ -149,7 +156,7 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ ...@@ -149,7 +156,7 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
codes = append(codes, RedeemCode{ codes = append(codes, RedeemCode{
Code: code, Code: code,
Type: codeType, Type: codeType,
Value: req.Value, Value: value,
Status: StatusUnused, Status: StatusUnused,
}) })
} }
......
...@@ -62,6 +62,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -62,6 +62,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyEmailVerifyEnabled, SettingKeyEmailVerifyEnabled,
SettingKeyPromoCodeEnabled, SettingKeyPromoCodeEnabled,
SettingKeyPasswordResetEnabled, SettingKeyPasswordResetEnabled,
SettingKeyInvitationCodeEnabled,
SettingKeyTotpEnabled, SettingKeyTotpEnabled,
SettingKeyTurnstileEnabled, SettingKeyTurnstileEnabled,
SettingKeyTurnstileSiteKey, SettingKeyTurnstileSiteKey,
...@@ -99,6 +100,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -99,6 +100,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
EmailVerifyEnabled: emailVerifyEnabled, EmailVerifyEnabled: emailVerifyEnabled,
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
PasswordResetEnabled: passwordResetEnabled, PasswordResetEnabled: passwordResetEnabled,
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
TotpEnabled: settings[SettingKeyTotpEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
...@@ -141,6 +143,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ...@@ -141,6 +143,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` TotpEnabled bool `json:"totp_enabled"`
TurnstileEnabled bool `json:"turnstile_enabled"` TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"` TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
...@@ -161,6 +164,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ...@@ -161,6 +164,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled, PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled, PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled, TotpEnabled: settings.TotpEnabled,
TurnstileEnabled: settings.TurnstileEnabled, TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey, TurnstileSiteKey: settings.TurnstileSiteKey,
...@@ -188,6 +192,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet ...@@ -188,6 +192,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled) updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled)
updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled)
updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled)
// 邮件服务设置(只有非空才更新密码) // 邮件服务设置(只有非空才更新密码)
...@@ -286,6 +291,14 @@ func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool { ...@@ -286,6 +291,14 @@ func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool {
return value != "false" return value != "false"
} }
// IsInvitationCodeEnabled 检查是否启用邀请码注册功能
func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyInvitationCodeEnabled)
if err != nil {
return false // 默认关闭
}
return value == "true"
}
// IsPasswordResetEnabled 检查是否启用密码重置功能 // IsPasswordResetEnabled 检查是否启用密码重置功能
// 要求:必须同时开启邮件验证 // 要求:必须同时开启邮件验证
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool { func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
...@@ -401,6 +414,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin ...@@ -401,6 +414,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
EmailVerifyEnabled: emailVerifyEnabled, EmailVerifyEnabled: emailVerifyEnabled,
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
TotpEnabled: settings[SettingKeyTotpEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
SMTPHost: settings[SettingKeySMTPHost], SMTPHost: settings[SettingKeySMTPHost],
SMTPUsername: settings[SettingKeySMTPUsername], SMTPUsername: settings[SettingKeySMTPUsername],
......
...@@ -5,6 +5,7 @@ type SystemSettings struct { ...@@ -5,6 +5,7 @@ type SystemSettings struct {
EmailVerifyEnabled bool EmailVerifyEnabled bool
PromoCodeEnabled bool PromoCodeEnabled bool
PasswordResetEnabled bool PasswordResetEnabled bool
InvitationCodeEnabled bool
TotpEnabled bool // TOTP 双因素认证 TotpEnabled bool // TOTP 双因素认证
SMTPHost string SMTPHost string
...@@ -65,6 +66,7 @@ type PublicSettings struct { ...@@ -65,6 +66,7 @@ type PublicSettings struct {
EmailVerifyEnabled bool EmailVerifyEnabled bool
PromoCodeEnabled bool PromoCodeEnabled bool
PasswordResetEnabled bool PasswordResetEnabled bool
InvitationCodeEnabled bool
TotpEnabled bool // TOTP 双因素认证 TotpEnabled bool // TOTP 双因素认证
TurnstileEnabled bool TurnstileEnabled bool
TurnstileSiteKey string TurnstileSiteKey string
......
...@@ -14,6 +14,9 @@ type UsageLog struct { ...@@ -14,6 +14,9 @@ type UsageLog struct {
AccountID int64 AccountID int64
RequestID string RequestID string
Model string Model string
// ReasoningEffort is the request's reasoning effort level (OpenAI Responses API),
// e.g. "low" / "medium" / "high" / "xhigh". Nil means not provided / not applicable.
ReasoningEffort *string
GroupID *int64 GroupID *int64
SubscriptionID *int64 SubscriptionID *int64
......
-- Add reasoning_effort field to usage_logs for OpenAI/Codex requests.
-- This stores the request's reasoning effort level (e.g. low/medium/high/xhigh).
ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS reasoning_effort VARCHAR(20);
...@@ -24,4 +24,11 @@ WHERE filename = '044_add_group_mcp_xml_inject.sql' ...@@ -24,4 +24,11 @@ WHERE filename = '044_add_group_mcp_xml_inject.sql'
SELECT 1 FROM schema_migrations WHERE filename = '044b_add_group_mcp_xml_inject.sql' SELECT 1 FROM schema_migrations WHERE filename = '044b_add_group_mcp_xml_inject.sql'
); );
UPDATE schema_migrations
SET filename = '046b_add_group_supported_model_scopes.sql'
WHERE filename = '046_add_group_supported_model_scopes.sql'
AND NOT EXISTS (
SELECT 1 FROM schema_migrations WHERE filename = '046b_add_group_supported_model_scopes.sql'
);
COMMIT; COMMIT;
...@@ -14,6 +14,7 @@ export interface SystemSettings { ...@@ -14,6 +14,7 @@ export interface SystemSettings {
email_verify_enabled: boolean email_verify_enabled: boolean
promo_code_enabled: boolean promo_code_enabled: boolean
password_reset_enabled: boolean password_reset_enabled: boolean
invitation_code_enabled: boolean
totp_enabled: boolean // TOTP 双因素认证 totp_enabled: boolean // TOTP 双因素认证
totp_encryption_key_configured: boolean // TOTP 加密密钥是否已配置 totp_encryption_key_configured: boolean // TOTP 加密密钥是否已配置
// Default settings // Default settings
...@@ -72,6 +73,7 @@ export interface UpdateSettingsRequest { ...@@ -72,6 +73,7 @@ export interface UpdateSettingsRequest {
email_verify_enabled?: boolean email_verify_enabled?: boolean
promo_code_enabled?: boolean promo_code_enabled?: boolean
password_reset_enabled?: boolean password_reset_enabled?: boolean
invitation_code_enabled?: boolean
totp_enabled?: boolean // TOTP 双因素认证 totp_enabled?: boolean // TOTP 双因素认证
default_balance?: number default_balance?: number
default_concurrency?: number default_concurrency?: number
......
...@@ -164,6 +164,24 @@ export async function validatePromoCode(code: string): Promise<ValidatePromoCode ...@@ -164,6 +164,24 @@ export async function validatePromoCode(code: string): Promise<ValidatePromoCode
return data return data
} }
/**
* Validate invitation code response
*/
export interface ValidateInvitationCodeResponse {
valid: boolean
error_code?: string
}
/**
* Validate invitation code (public endpoint, no auth required)
* @param code - Invitation code to validate
* @returns Validation result
*/
export async function validateInvitationCode(code: string): Promise<ValidateInvitationCodeResponse> {
const { data } = await apiClient.post<ValidateInvitationCodeResponse>('/auth/validate-invitation-code', { code })
return data
}
/** /**
* Forgot password request * Forgot password request
*/ */
...@@ -229,6 +247,7 @@ export const authAPI = { ...@@ -229,6 +247,7 @@ export const authAPI = {
getPublicSettings, getPublicSettings,
sendVerifyCode, sendVerifyCode,
validatePromoCode, validatePromoCode,
validateInvitationCode,
forgotPassword, forgotPassword,
resetPassword resetPassword
} }
......
...@@ -21,6 +21,12 @@ ...@@ -21,6 +21,12 @@
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template> </template>
<template #cell-reasoning_effort="{ row }">
<span class="text-sm text-gray-900 dark:text-white">
{{ formatReasoningEffort(row.reasoning_effort) }}
</span>
</template>
<template #cell-group="{ row }"> <template #cell-group="{ row }">
<span v-if="row.group" class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200"> <span v-if="row.group" class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200">
{{ row.group.name }} {{ row.group.name }}
...@@ -235,7 +241,7 @@ ...@@ -235,7 +241,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { formatDateTime } from '@/utils/format' import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
...@@ -259,6 +265,7 @@ const cols = computed(() => [ ...@@ -259,6 +265,7 @@ const cols = computed(() => [
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false }, { key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
{ key: 'account', label: t('admin.usage.account'), sortable: false }, { key: 'account', label: t('admin.usage.account'), sortable: false },
{ key: 'model', label: t('usage.model'), sortable: true }, { key: 'model', label: t('usage.model'), sortable: true },
{ key: 'reasoning_effort', label: t('usage.reasoningEffort'), sortable: false },
{ key: 'group', label: t('admin.usage.group'), sortable: false }, { key: 'group', label: t('admin.usage.group'), sortable: false },
{ key: 'stream', label: t('usage.type'), sortable: false }, { key: 'stream', label: t('usage.type'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false }, { key: 'tokens', label: t('usage.tokens'), sortable: false },
......
...@@ -265,6 +265,13 @@ export default { ...@@ -265,6 +265,13 @@ export default {
promoCodeAlreadyUsed: 'You have already used this promo code', promoCodeAlreadyUsed: 'You have already used this promo code',
promoCodeValidating: 'Promo code is being validated, please wait', promoCodeValidating: 'Promo code is being validated, please wait',
promoCodeInvalidCannotRegister: 'Invalid promo code. Please check and try again or clear the promo code field', promoCodeInvalidCannotRegister: 'Invalid promo code. Please check and try again or clear the promo code field',
invitationCodeLabel: 'Invitation Code',
invitationCodePlaceholder: 'Enter invitation code',
invitationCodeRequired: 'Invitation code is required',
invitationCodeValid: 'Invitation code is valid',
invitationCodeInvalid: 'Invalid or used invitation code',
invitationCodeValidating: 'Validating invitation code...',
invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again',
linuxdo: { linuxdo: {
signIn: 'Continue with Linux.do', signIn: 'Continue with Linux.do',
orContinue: 'or continue with email', orContinue: 'or continue with email',
...@@ -495,6 +502,7 @@ export default { ...@@ -495,6 +502,7 @@ export default {
exporting: 'Exporting...', exporting: 'Exporting...',
preparingExport: 'Preparing export...', preparingExport: 'Preparing export...',
model: 'Model', model: 'Model',
reasoningEffort: 'Reasoning Effort',
type: 'Type', type: 'Type',
tokens: 'Tokens', tokens: 'Tokens',
cost: 'Cost', cost: 'Cost',
...@@ -1009,6 +1017,14 @@ export default { ...@@ -1009,6 +1017,14 @@ export default {
hint: 'Triggered only when upstream explicitly returns prompt too long. Leave empty to disable fallback.', hint: 'Triggered only when upstream explicitly returns prompt too long. Leave empty to disable fallback.',
noFallback: 'No Fallback' noFallback: 'No Fallback'
}, },
copyAccounts: {
title: 'Copy Accounts from Groups',
tooltip: 'Select one or more groups of the same platform. After creation, all accounts from these groups will be automatically bound to the new group (deduplicated).',
tooltipEdit: 'Select one or more groups of the same platform. After saving, current group accounts will be replaced with accounts from these groups (deduplicated).',
selectPlaceholder: 'Select groups to copy accounts from...',
hint: 'Multiple groups can be selected, accounts will be deduplicated',
hintEdit: '⚠️ Warning: This will replace all existing account bindings'
},
modelRouting: { modelRouting: {
title: 'Model Routing', title: 'Model Routing',
tooltip: 'Configure specific model requests to be routed to designated accounts. Supports wildcard matching, e.g., claude-opus-* matches all opus models.', tooltip: 'Configure specific model requests to be routed to designated accounts. Supports wildcard matching, e.g., claude-opus-* matches all opus models.',
...@@ -1922,6 +1938,8 @@ export default { ...@@ -1922,6 +1938,8 @@ export default {
balance: 'Balance', balance: 'Balance',
concurrency: 'Concurrency', concurrency: 'Concurrency',
subscription: 'Subscription', subscription: 'Subscription',
invitation: 'Invitation',
invitationHint: 'Invitation codes are used to restrict user registration. They are automatically marked as used after use.',
unused: 'Unused', unused: 'Unused',
used: 'Used', used: 'Used',
columns: { columns: {
...@@ -1968,6 +1986,7 @@ export default { ...@@ -1968,6 +1986,7 @@ export default {
balance: 'Balance', balance: 'Balance',
concurrency: 'Concurrency', concurrency: 'Concurrency',
subscription: 'Subscription', subscription: 'Subscription',
invitation: 'Invitation',
// Admin adjustment types (created when admin modifies user balance/concurrency) // Admin adjustment types (created when admin modifies user balance/concurrency)
admin_balance: 'Balance (Admin)', admin_balance: 'Balance (Admin)',
admin_concurrency: 'Concurrency (Admin)' admin_concurrency: 'Concurrency (Admin)'
...@@ -2925,6 +2944,8 @@ export default { ...@@ -2925,6 +2944,8 @@ export default {
emailVerificationHint: 'Require email verification for new registrations', emailVerificationHint: 'Require email verification for new registrations',
promoCode: 'Promo Code', promoCode: 'Promo Code',
promoCodeHint: 'Allow users to use promo codes during registration', promoCodeHint: 'Allow users to use promo codes during registration',
invitationCode: 'Invitation Code Registration',
invitationCodeHint: 'When enabled, users must enter a valid invitation code to register',
passwordReset: 'Password Reset', passwordReset: 'Password Reset',
passwordResetHint: 'Allow users to reset their password via email', passwordResetHint: 'Allow users to reset their password via email',
totp: 'Two-Factor Authentication (2FA)', totp: 'Two-Factor Authentication (2FA)',
......
...@@ -262,6 +262,13 @@ export default { ...@@ -262,6 +262,13 @@ export default {
promoCodeAlreadyUsed: '您已使用过此优惠码', promoCodeAlreadyUsed: '您已使用过此优惠码',
promoCodeValidating: '优惠码正在验证中,请稍候', promoCodeValidating: '优惠码正在验证中,请稍候',
promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码', promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码',
invitationCodeLabel: '邀请码',
invitationCodePlaceholder: '请输入邀请码',
invitationCodeRequired: '请输入邀请码',
invitationCodeValid: '邀请码有效',
invitationCodeInvalid: '邀请码无效或已被使用',
invitationCodeValidating: '正在验证邀请码...',
invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试',
linuxdo: { linuxdo: {
signIn: '使用 Linux.do 登录', signIn: '使用 Linux.do 登录',
orContinue: '或使用邮箱密码继续', orContinue: '或使用邮箱密码继续',
...@@ -491,6 +498,7 @@ export default { ...@@ -491,6 +498,7 @@ export default {
exporting: '导出中...', exporting: '导出中...',
preparingExport: '正在准备导出...', preparingExport: '正在准备导出...',
model: '模型', model: '模型',
reasoningEffort: '推理强度',
type: '类型', type: '类型',
tokens: 'Token', tokens: 'Token',
cost: '费用', cost: '费用',
...@@ -1084,6 +1092,14 @@ export default { ...@@ -1084,6 +1092,14 @@ export default {
hint: '仅当上游明确返回 prompt too long 时才会触发,留空表示不兜底', hint: '仅当上游明确返回 prompt too long 时才会触发,留空表示不兜底',
noFallback: '不兜底' noFallback: '不兜底'
}, },
copyAccounts: {
title: '从分组复制账号',
tooltip: '选择一个或多个相同平台的分组,创建后会自动将这些分组的所有账号绑定到新分组(去重)。',
tooltipEdit: '选择一个或多个相同平台的分组,保存后当前分组的账号会被替换为这些分组的账号(去重)。',
selectPlaceholder: '选择分组以复制其账号...',
hint: '可选多个分组,账号会自动去重',
hintEdit: '⚠️ 注意:这会替换当前分组的所有账号绑定'
},
modelRouting: { modelRouting: {
title: '模型路由配置', title: '模型路由配置',
tooltip: '配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。', tooltip: '配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。',
...@@ -2045,6 +2061,7 @@ export default { ...@@ -2045,6 +2061,7 @@ export default {
balance: '余额', balance: '余额',
concurrency: '并发数', concurrency: '并发数',
subscription: '订阅', subscription: '订阅',
invitation: '邀请码',
// 管理员在用户管理页面调整余额/并发时产生的记录 // 管理员在用户管理页面调整余额/并发时产生的记录
admin_balance: '余额(管理员)', admin_balance: '余额(管理员)',
admin_concurrency: '并发数(管理员)' admin_concurrency: '并发数(管理员)'
...@@ -2053,6 +2070,8 @@ export default { ...@@ -2053,6 +2070,8 @@ export default {
balance: '余额', balance: '余额',
concurrency: '并发数', concurrency: '并发数',
subscription: '订阅', subscription: '订阅',
invitation: '邀请码',
invitationHint: '邀请码用于限制用户注册,使用后自动标记为已使用。',
allTypes: '全部类型', allTypes: '全部类型',
allStatus: '全部状态', allStatus: '全部状态',
unused: '未使用', unused: '未使用',
...@@ -3078,6 +3097,8 @@ export default { ...@@ -3078,6 +3097,8 @@ export default {
emailVerificationHint: '新用户注册时需要验证邮箱', emailVerificationHint: '新用户注册时需要验证邮箱',
promoCode: '优惠码', promoCode: '优惠码',
promoCodeHint: '允许用户在注册时使用优惠码', promoCodeHint: '允许用户在注册时使用优惠码',
invitationCode: '邀请码注册',
invitationCodeHint: '开启后,用户注册时需要填写有效的邀请码',
passwordReset: '忘记密码', passwordReset: '忘记密码',
passwordResetHint: '允许用户通过邮箱重置密码', passwordResetHint: '允许用户通过邮箱重置密码',
totp: '双因素认证 (2FA)', totp: '双因素认证 (2FA)',
......
...@@ -314,6 +314,7 @@ export const useAppStore = defineStore('app', () => { ...@@ -314,6 +314,7 @@ export const useAppStore = defineStore('app', () => {
email_verify_enabled: false, email_verify_enabled: false,
promo_code_enabled: true, promo_code_enabled: true,
password_reset_enabled: false, password_reset_enabled: false,
invitation_code_enabled: false,
turnstile_enabled: false, turnstile_enabled: false,
turnstile_site_key: '', turnstile_site_key: '',
site_name: siteName.value, site_name: siteName.value,
......
...@@ -55,6 +55,7 @@ export interface RegisterRequest { ...@@ -55,6 +55,7 @@ export interface RegisterRequest {
verify_code?: string verify_code?: string
turnstile_token?: string turnstile_token?: string
promo_code?: string promo_code?: string
invitation_code?: string
} }
export interface SendVerifyCodeRequest { export interface SendVerifyCodeRequest {
...@@ -72,6 +73,7 @@ export interface PublicSettings { ...@@ -72,6 +73,7 @@ export interface PublicSettings {
email_verify_enabled: boolean email_verify_enabled: boolean
promo_code_enabled: boolean promo_code_enabled: boolean
password_reset_enabled: boolean password_reset_enabled: boolean
invitation_code_enabled: boolean
turnstile_enabled: boolean turnstile_enabled: boolean
turnstile_site_key: string turnstile_site_key: string
site_name: string site_name: string
...@@ -419,7 +421,10 @@ export interface CreateGroupRequest { ...@@ -419,7 +421,10 @@ export interface CreateGroupRequest {
claude_code_only?: boolean claude_code_only?: boolean
fallback_group_id?: number | null fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean
supported_model_scopes?: string[] supported_model_scopes?: string[]
// 从指定分组复制账号
copy_accounts_from_group_ids?: number[]
} }
export interface UpdateGroupRequest { export interface UpdateGroupRequest {
...@@ -439,7 +444,9 @@ export interface UpdateGroupRequest { ...@@ -439,7 +444,9 @@ export interface UpdateGroupRequest {
claude_code_only?: boolean claude_code_only?: boolean
fallback_group_id?: number | null fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean
supported_model_scopes?: string[] supported_model_scopes?: string[]
copy_accounts_from_group_ids?: number[]
} }
// ==================== Account & Proxy Types ==================== // ==================== Account & Proxy Types ====================
...@@ -712,7 +719,7 @@ export interface UpdateProxyRequest { ...@@ -712,7 +719,7 @@ export interface UpdateProxyRequest {
// ==================== Usage & Redeem Types ==================== // ==================== Usage & Redeem Types ====================
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
export interface UsageLog { export interface UsageLog {
id: number id: number
...@@ -721,6 +728,7 @@ export interface UsageLog { ...@@ -721,6 +728,7 @@ export interface UsageLog {
account_id: number | null account_id: number | null
request_id: string request_id: string
model: string model: string
reasoning_effort?: string | null
group_id: number | null group_id: number | null
subscription_id: number | null subscription_id: number | null
......
...@@ -174,6 +174,35 @@ export function parseDateTimeLocalInput(value: string): number | null { ...@@ -174,6 +174,35 @@ export function parseDateTimeLocalInput(value: string): number | null {
return Math.floor(date.getTime() / 1000) return Math.floor(date.getTime() / 1000)
} }
/**
* 格式化 OpenAI reasoning effort(用于使用记录展示)
* @param effort 原始 effort(如 "low" / "medium" / "high" / "xhigh")
* @returns 格式化后的字符串(Low / Medium / High / Xhigh),无值返回 "-"
*/
export function formatReasoningEffort(effort: string | null | undefined): string {
const raw = (effort ?? '').toString().trim()
if (!raw) return '-'
const normalized = raw.toLowerCase().replace(/[-_\s]/g, '')
switch (normalized) {
case 'low':
return 'Low'
case 'medium':
return 'Medium'
case 'high':
return 'High'
case 'xhigh':
case 'extrahigh':
return 'Xhigh'
case 'none':
case 'minimal':
return '-'
default:
// best-effort: Title-case first letter
return raw.length > 1 ? raw[0].toUpperCase() + raw.slice(1) : raw.toUpperCase()
}
}
/** /**
* 格式化时间(只显示时分) * 格式化时间(只显示时分)
* @param date 日期字符串或 Date 对象 * @param date 日期字符串或 Date 对象
......
...@@ -240,9 +240,73 @@ ...@@ -240,9 +240,73 @@
v-model="createForm.platform" v-model="createForm.platform"
:options="platformOptions" :options="platformOptions"
data-tour="group-form-platform" data-tour="group-form-platform"
@change="createForm.copy_accounts_from_group_ids = []"
/> />
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p> <p class="input-hint">{{ t('admin.groups.platformHint') }}</p>
</div> </div>
<!-- 从分组复制账号 -->
<div v-if="copyAccountsGroupOptions.length > 0">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.copyAccounts.title') }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.copyAccounts.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<!-- 已选分组标签 -->
<div v-if="createForm.copy_accounts_from_group_ids.length > 0" class="flex flex-wrap gap-1.5 mb-2">
<span
v-for="groupId in createForm.copy_accounts_from_group_ids"
:key="groupId"
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{ copyAccountsGroupOptions.find(o => o.value === groupId)?.label || `#${groupId}` }}
<button
type="button"
@click="createForm.copy_accounts_from_group_ids = createForm.copy_accounts_from_group_ids.filter(id => id !== groupId)"
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
>
<Icon name="x" size="xs" />
</button>
</span>
</div>
<!-- 分组选择下拉 -->
<select
class="input"
@change="(e) => {
const val = Number((e.target as HTMLSelectElement).value)
if (val && !createForm.copy_accounts_from_group_ids.includes(val)) {
createForm.copy_accounts_from_group_ids.push(val)
}
(e.target as HTMLSelectElement).value = ''
}"
>
<option value="">{{ t('admin.groups.copyAccounts.selectPlaceholder') }}</option>
<option
v-for="opt in copyAccountsGroupOptions"
:key="opt.value"
:value="opt.value"
:disabled="createForm.copy_accounts_from_group_ids.includes(opt.value)"
>
{{ opt.label }}
</option>
</select>
<p class="input-hint">{{ t('admin.groups.copyAccounts.hint') }}</p>
</div>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label> <label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
<input <input
...@@ -795,6 +859,69 @@ ...@@ -795,6 +859,69 @@
/> />
<p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p> <p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p>
</div> </div>
<!-- 从分组复制账号编辑时 -->
<div v-if="copyAccountsGroupOptionsForEdit.length > 0">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.copyAccounts.title') }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.copyAccounts.tooltipEdit') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<!-- 已选分组标签 -->
<div v-if="editForm.copy_accounts_from_group_ids.length > 0" class="flex flex-wrap gap-1.5 mb-2">
<span
v-for="groupId in editForm.copy_accounts_from_group_ids"
:key="groupId"
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{ copyAccountsGroupOptionsForEdit.find(o => o.value === groupId)?.label || `#${groupId}` }}
<button
type="button"
@click="editForm.copy_accounts_from_group_ids = editForm.copy_accounts_from_group_ids.filter(id => id !== groupId)"
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
>
<Icon name="x" size="xs" />
</button>
</span>
</div>
<!-- 分组选择下拉 -->
<select
class="input"
@change="(e) => {
const val = Number((e.target as HTMLSelectElement).value)
if (val && !editForm.copy_accounts_from_group_ids.includes(val)) {
editForm.copy_accounts_from_group_ids.push(val)
}
(e.target as HTMLSelectElement).value = ''
}"
>
<option value="">{{ t('admin.groups.copyAccounts.selectPlaceholder') }}</option>
<option
v-for="opt in copyAccountsGroupOptionsForEdit"
:key="opt.value"
:value="opt.value"
:disabled="editForm.copy_accounts_from_group_ids.includes(opt.value)"
>
{{ opt.label }}
</option>
</select>
<p class="input-hint">{{ t('admin.groups.copyAccounts.hintEdit') }}</p>
</div>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label> <label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
<input <input
...@@ -1470,6 +1597,29 @@ const invalidRequestFallbackOptionsForEdit = computed(() => { ...@@ -1470,6 +1597,29 @@ const invalidRequestFallbackOptionsForEdit = computed(() => {
return options return options
}) })
// 复制账号的源分组选项(创建时)- 仅包含相同平台且有账号的分组
const copyAccountsGroupOptions = computed(() => {
const eligibleGroups = groups.value.filter(
(g) => g.platform === createForm.platform && (g.account_count || 0) > 0
)
return eligibleGroups.map((g) => ({
value: g.id,
label: `${g.name} (${g.account_count || 0} 个账号)`
}))
})
// 复制账号的源分组选项(编辑时)- 仅包含相同平台且有账号的分组,排除自身
const copyAccountsGroupOptionsForEdit = computed(() => {
const currentId = editingGroup.value?.id
const eligibleGroups = groups.value.filter(
(g) => g.platform === editForm.platform && (g.account_count || 0) > 0 && g.id !== currentId
)
return eligibleGroups.map((g) => ({
value: g.id,
label: `${g.name} (${g.account_count || 0} 个账号)`
}))
})
const groups = ref<AdminGroup[]>([]) const groups = ref<AdminGroup[]>([])
const loading = ref(false) const loading = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
...@@ -1517,7 +1667,9 @@ const createForm = reactive({ ...@@ -1517,7 +1667,9 @@ const createForm = reactive({
// 支持的模型系列(仅 antigravity 平台) // 支持的模型系列(仅 antigravity 平台)
supported_model_scopes: ['claude', 'gemini_text', 'gemini_image'] as string[], supported_model_scopes: ['claude', 'gemini_text', 'gemini_image'] as string[],
// MCP XML 协议注入开关(仅 antigravity 平台) // MCP XML 协议注入开关(仅 antigravity 平台)
mcp_xml_inject: true mcp_xml_inject: true,
// 从分组复制账号
copy_accounts_from_group_ids: [] as number[]
}) })
// 简单账号类型(用于模型路由选择) // 简单账号类型(用于模型路由选择)
...@@ -1713,7 +1865,9 @@ const editForm = reactive({ ...@@ -1713,7 +1865,9 @@ const editForm = reactive({
// 支持的模型系列(仅 antigravity 平台) // 支持的模型系列(仅 antigravity 平台)
supported_model_scopes: ['claude', 'gemini_text', 'gemini_image'] as string[], supported_model_scopes: ['claude', 'gemini_text', 'gemini_image'] as string[],
// MCP XML 协议注入开关(仅 antigravity 平台) // MCP XML 协议注入开关(仅 antigravity 平台)
mcp_xml_inject: true mcp_xml_inject: true,
// 从分组复制账号
copy_accounts_from_group_ids: [] as number[]
}) })
// 根据分组类型返回不同的删除确认消息 // 根据分组类型返回不同的删除确认消息
...@@ -1798,6 +1952,7 @@ const closeCreateModal = () => { ...@@ -1798,6 +1952,7 @@ const closeCreateModal = () => {
createForm.fallback_group_id_on_invalid_request = null createForm.fallback_group_id_on_invalid_request = null
createForm.supported_model_scopes = ['claude', 'gemini_text', 'gemini_image'] createForm.supported_model_scopes = ['claude', 'gemini_text', 'gemini_image']
createForm.mcp_xml_inject = true createForm.mcp_xml_inject = true
createForm.copy_accounts_from_group_ids = []
createModelRoutingRules.value = [] createModelRoutingRules.value = []
} }
...@@ -1851,6 +2006,7 @@ const handleEdit = async (group: AdminGroup) => { ...@@ -1851,6 +2006,7 @@ const handleEdit = async (group: AdminGroup) => {
editForm.model_routing_enabled = group.model_routing_enabled || false editForm.model_routing_enabled = group.model_routing_enabled || false
editForm.supported_model_scopes = group.supported_model_scopes || ['claude', 'gemini_text', 'gemini_image'] editForm.supported_model_scopes = group.supported_model_scopes || ['claude', 'gemini_text', 'gemini_image']
editForm.mcp_xml_inject = group.mcp_xml_inject ?? true editForm.mcp_xml_inject = group.mcp_xml_inject ?? true
editForm.copy_accounts_from_group_ids = [] // 复制账号字段每次编辑时重置为空
// 加载模型路由规则(异步加载账号名称) // 加载模型路由规则(异步加载账号名称)
editModelRoutingRules.value = await convertApiFormatToRoutingRules(group.model_routing) editModelRoutingRules.value = await convertApiFormatToRoutingRules(group.model_routing)
showEditModal.value = true showEditModal.value = true
...@@ -1860,6 +2016,7 @@ const closeEditModal = () => { ...@@ -1860,6 +2016,7 @@ const closeEditModal = () => {
showEditModal.value = false showEditModal.value = false
editingGroup.value = null editingGroup.value = null
editModelRoutingRules.value = [] editModelRoutingRules.value = []
editForm.copy_accounts_from_group_ids = []
} }
const handleUpdateGroup = async () => { const handleUpdateGroup = async () => {
......
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