mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-08 01:00:21 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a0fe3b143 | ||
|
|
a1292fac81 | ||
|
|
7f98be4f91 | ||
|
|
fd73b8875d | ||
|
|
f9ab1daa3c | ||
|
|
d27b847442 | ||
|
|
dac6bc2228 | ||
|
|
4bd3dbf2ce | ||
|
|
226df1c23a | ||
|
|
2665230a09 | ||
|
|
4f0c2b794c | ||
|
|
e756064c19 | ||
|
|
17dfb0af01 | ||
|
|
ff74f517df | ||
|
|
477a9a180f | ||
|
|
da48df06d2 |
@@ -668,6 +668,15 @@ func (h *AccountHandler) ClearError(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清除错误后,同时清除 token 缓存,确保下次请求会获取最新的 token(触发刷新或从 DB 读取)
|
||||||
|
// 这解决了管理员重置账号状态后,旧的失效 token 仍在缓存中导致立即再次 401 的问题
|
||||||
|
if h.tokenCacheInvalidator != nil && account.IsOAuth() {
|
||||||
|
if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(c.Request.Context(), account); invalidateErr != nil {
|
||||||
|
// 缓存失效失败只记录日志,不影响主流程
|
||||||
|
_ = c.Error(invalidateErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response.Success(c, dto.AccountFromService(account))
|
response.Success(c, dto.AccountFromService(account))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,17 +209,20 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
account := selection.Account
|
account := selection.Account
|
||||||
setOpsSelectedAccount(c, account.ID)
|
setOpsSelectedAccount(c, account.ID)
|
||||||
|
|
||||||
// 检查预热请求拦截(在账号选择后、转发前检查)
|
// 检查请求拦截(预热请求、SUGGESTION MODE等)
|
||||||
if account.IsInterceptWarmupEnabled() && isWarmupRequest(body) {
|
if account.IsInterceptWarmupEnabled() {
|
||||||
if selection.Acquired && selection.ReleaseFunc != nil {
|
interceptType := detectInterceptType(body)
|
||||||
selection.ReleaseFunc()
|
if interceptType != InterceptTypeNone {
|
||||||
|
if selection.Acquired && selection.ReleaseFunc != nil {
|
||||||
|
selection.ReleaseFunc()
|
||||||
|
}
|
||||||
|
if reqStream {
|
||||||
|
sendMockInterceptStream(c, reqModel, interceptType)
|
||||||
|
} else {
|
||||||
|
sendMockInterceptResponse(c, reqModel, interceptType)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if reqStream {
|
|
||||||
sendMockWarmupStream(c, reqModel)
|
|
||||||
} else {
|
|
||||||
sendMockWarmupResponse(c, reqModel)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 获取账号并发槽位
|
// 3. 获取账号并发槽位
|
||||||
@@ -344,17 +347,20 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
account := selection.Account
|
account := selection.Account
|
||||||
setOpsSelectedAccount(c, account.ID)
|
setOpsSelectedAccount(c, account.ID)
|
||||||
|
|
||||||
// 检查预热请求拦截(在账号选择后、转发前检查)
|
// 检查请求拦截(预热请求、SUGGESTION MODE等)
|
||||||
if account.IsInterceptWarmupEnabled() && isWarmupRequest(body) {
|
if account.IsInterceptWarmupEnabled() {
|
||||||
if selection.Acquired && selection.ReleaseFunc != nil {
|
interceptType := detectInterceptType(body)
|
||||||
selection.ReleaseFunc()
|
if interceptType != InterceptTypeNone {
|
||||||
|
if selection.Acquired && selection.ReleaseFunc != nil {
|
||||||
|
selection.ReleaseFunc()
|
||||||
|
}
|
||||||
|
if reqStream {
|
||||||
|
sendMockInterceptStream(c, reqModel, interceptType)
|
||||||
|
} else {
|
||||||
|
sendMockInterceptResponse(c, reqModel, interceptType)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if reqStream {
|
|
||||||
sendMockWarmupStream(c, reqModel)
|
|
||||||
} else {
|
|
||||||
sendMockWarmupResponse(c, reqModel)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 获取账号并发槽位
|
// 3. 获取账号并发槽位
|
||||||
@@ -765,17 +771,30 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isWarmupRequest 检测是否为预热请求(标题生成、Warmup等)
|
// InterceptType 表示请求拦截类型
|
||||||
func isWarmupRequest(body []byte) bool {
|
type InterceptType int
|
||||||
// 快速检查:如果body不包含关键字,直接返回false
|
|
||||||
|
const (
|
||||||
|
InterceptTypeNone InterceptType = iota
|
||||||
|
InterceptTypeWarmup // 预热请求(返回 "New Conversation")
|
||||||
|
InterceptTypeSuggestionMode // SUGGESTION MODE(返回空字符串)
|
||||||
|
)
|
||||||
|
|
||||||
|
// detectInterceptType 检测请求是否需要拦截,返回拦截类型
|
||||||
|
func detectInterceptType(body []byte) InterceptType {
|
||||||
|
// 快速检查:如果不包含任何关键字,直接返回
|
||||||
bodyStr := string(body)
|
bodyStr := string(body)
|
||||||
if !strings.Contains(bodyStr, "title") && !strings.Contains(bodyStr, "Warmup") {
|
hasSuggestionMode := strings.Contains(bodyStr, "[SUGGESTION MODE:")
|
||||||
return false
|
hasWarmupKeyword := strings.Contains(bodyStr, "title") || strings.Contains(bodyStr, "Warmup")
|
||||||
|
|
||||||
|
if !hasSuggestionMode && !hasWarmupKeyword {
|
||||||
|
return InterceptTypeNone
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析完整请求
|
// 解析请求(只解析一次)
|
||||||
var req struct {
|
var req struct {
|
||||||
Messages []struct {
|
Messages []struct {
|
||||||
|
Role string `json:"role"`
|
||||||
Content []struct {
|
Content []struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
@@ -786,43 +805,71 @@ func isWarmupRequest(body []byte) bool {
|
|||||||
} `json:"system"`
|
} `json:"system"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &req); err != nil {
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
return false
|
return InterceptTypeNone
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 messages 中的标题提示模式
|
// 检查 SUGGESTION MODE(最后一条 user 消息)
|
||||||
for _, msg := range req.Messages {
|
if hasSuggestionMode && len(req.Messages) > 0 {
|
||||||
for _, content := range msg.Content {
|
lastMsg := req.Messages[len(req.Messages)-1]
|
||||||
if content.Type == "text" {
|
if lastMsg.Role == "user" && len(lastMsg.Content) > 0 &&
|
||||||
if strings.Contains(content.Text, "Please write a 5-10 word title for the following conversation:") ||
|
lastMsg.Content[0].Type == "text" &&
|
||||||
content.Text == "Warmup" {
|
strings.HasPrefix(lastMsg.Content[0].Text, "[SUGGESTION MODE:") {
|
||||||
return true
|
return InterceptTypeSuggestionMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 Warmup 请求
|
||||||
|
if hasWarmupKeyword {
|
||||||
|
// 检查 messages 中的标题提示模式
|
||||||
|
for _, msg := range req.Messages {
|
||||||
|
for _, content := range msg.Content {
|
||||||
|
if content.Type == "text" {
|
||||||
|
if strings.Contains(content.Text, "Please write a 5-10 word title for the following conversation:") ||
|
||||||
|
content.Text == "Warmup" {
|
||||||
|
return InterceptTypeWarmup
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 检查 system 中的标题提取模式
|
||||||
|
for _, sys := range req.System {
|
||||||
|
if strings.Contains(sys.Text, "nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title") {
|
||||||
|
return InterceptTypeWarmup
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 system 中的标题提取模式
|
return InterceptTypeNone
|
||||||
for _, system := range req.System {
|
|
||||||
if strings.Contains(system.Text, "nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendMockWarmupStream 发送流式 mock 响应(用于预热请求拦截)
|
// sendMockInterceptStream 发送流式 mock 响应(用于请求拦截)
|
||||||
func sendMockWarmupStream(c *gin.Context, model string) {
|
func sendMockInterceptStream(c *gin.Context, model string, interceptType InterceptType) {
|
||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream")
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
c.Header("Connection", "keep-alive")
|
c.Header("Connection", "keep-alive")
|
||||||
c.Header("X-Accel-Buffering", "no")
|
c.Header("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
// 根据拦截类型决定响应内容
|
||||||
|
var msgID string
|
||||||
|
var outputTokens int
|
||||||
|
var textDeltas []string
|
||||||
|
|
||||||
|
switch interceptType {
|
||||||
|
case InterceptTypeSuggestionMode:
|
||||||
|
msgID = "msg_mock_suggestion"
|
||||||
|
outputTokens = 1
|
||||||
|
textDeltas = []string{""} // 空内容
|
||||||
|
default: // InterceptTypeWarmup
|
||||||
|
msgID = "msg_mock_warmup"
|
||||||
|
outputTokens = 2
|
||||||
|
textDeltas = []string{"New", " Conversation"}
|
||||||
|
}
|
||||||
|
|
||||||
// Build message_start event with proper JSON marshaling
|
// Build message_start event with proper JSON marshaling
|
||||||
messageStart := map[string]any{
|
messageStart := map[string]any{
|
||||||
"type": "message_start",
|
"type": "message_start",
|
||||||
"message": map[string]any{
|
"message": map[string]any{
|
||||||
"id": "msg_mock_warmup",
|
"id": msgID,
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"model": model,
|
"model": model,
|
||||||
@@ -837,16 +884,46 @@ func sendMockWarmupStream(c *gin.Context, model string) {
|
|||||||
}
|
}
|
||||||
messageStartJSON, _ := json.Marshal(messageStart)
|
messageStartJSON, _ := json.Marshal(messageStart)
|
||||||
|
|
||||||
|
// Build events
|
||||||
events := []string{
|
events := []string{
|
||||||
`event: message_start` + "\n" + `data: ` + string(messageStartJSON),
|
`event: message_start` + "\n" + `data: ` + string(messageStartJSON),
|
||||||
`event: content_block_start` + "\n" + `data: {"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"}`,
|
`event: content_block_start` + "\n" + `data: {"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"}`,
|
||||||
`event: content_block_delta` + "\n" + `data: {"delta":{"text":"New","type":"text_delta"},"index":0,"type":"content_block_delta"}`,
|
|
||||||
`event: content_block_delta` + "\n" + `data: {"delta":{"text":" Conversation","type":"text_delta"},"index":0,"type":"content_block_delta"}`,
|
|
||||||
`event: content_block_stop` + "\n" + `data: {"index":0,"type":"content_block_stop"}`,
|
|
||||||
`event: message_delta` + "\n" + `data: {"delta":{"stop_reason":"end_turn","stop_sequence":null},"type":"message_delta","usage":{"input_tokens":10,"output_tokens":2}}`,
|
|
||||||
`event: message_stop` + "\n" + `data: {"type":"message_stop"}`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add text deltas
|
||||||
|
for _, text := range textDeltas {
|
||||||
|
delta := map[string]any{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": 0,
|
||||||
|
"delta": map[string]string{
|
||||||
|
"type": "text_delta",
|
||||||
|
"text": text,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
deltaJSON, _ := json.Marshal(delta)
|
||||||
|
events = append(events, `event: content_block_delta`+"\n"+`data: `+string(deltaJSON))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add final events
|
||||||
|
messageDelta := map[string]any{
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": map[string]any{
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"stop_sequence": nil,
|
||||||
|
},
|
||||||
|
"usage": map[string]int{
|
||||||
|
"input_tokens": 10,
|
||||||
|
"output_tokens": outputTokens,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
messageDeltaJSON, _ := json.Marshal(messageDelta)
|
||||||
|
|
||||||
|
events = append(events,
|
||||||
|
`event: content_block_stop`+"\n"+`data: {"index":0,"type":"content_block_stop"}`,
|
||||||
|
`event: message_delta`+"\n"+`data: `+string(messageDeltaJSON),
|
||||||
|
`event: message_stop`+"\n"+`data: {"type":"message_stop"}`,
|
||||||
|
)
|
||||||
|
|
||||||
for _, event := range events {
|
for _, event := range events {
|
||||||
_, _ = c.Writer.WriteString(event + "\n\n")
|
_, _ = c.Writer.WriteString(event + "\n\n")
|
||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
@@ -854,18 +931,32 @@ func sendMockWarmupStream(c *gin.Context, model string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendMockWarmupResponse 发送非流式 mock 响应(用于预热请求拦截)
|
// sendMockInterceptResponse 发送非流式 mock 响应(用于请求拦截)
|
||||||
func sendMockWarmupResponse(c *gin.Context, model string) {
|
func sendMockInterceptResponse(c *gin.Context, model string, interceptType InterceptType) {
|
||||||
|
var msgID, text string
|
||||||
|
var outputTokens int
|
||||||
|
|
||||||
|
switch interceptType {
|
||||||
|
case InterceptTypeSuggestionMode:
|
||||||
|
msgID = "msg_mock_suggestion"
|
||||||
|
text = ""
|
||||||
|
outputTokens = 1
|
||||||
|
default: // InterceptTypeWarmup
|
||||||
|
msgID = "msg_mock_warmup"
|
||||||
|
text = "New Conversation"
|
||||||
|
outputTokens = 2
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"id": "msg_mock_warmup",
|
"id": msgID,
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"model": model,
|
"model": model,
|
||||||
"content": []gin.H{{"type": "text", "text": "New Conversation"}},
|
"content": []gin.H{{"type": "text", "text": text}},
|
||||||
"stop_reason": "end_turn",
|
"stop_reason": "end_turn",
|
||||||
"usage": gin.H{
|
"usage": gin.H{
|
||||||
"input_tokens": 10,
|
"input_tokens": 10,
|
||||||
"output_tokens": 2,
|
"output_tokens": outputTokens,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -594,11 +592,14 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 清理 JSON Schema
|
// 清理 JSON Schema
|
||||||
params := cleanJSONSchema(inputSchema)
|
// 1. 深度清理 [undefined] 值
|
||||||
|
DeepCleanUndefined(inputSchema)
|
||||||
|
// 2. 转换为符合 Gemini v1internal 的 schema
|
||||||
|
params := CleanJSONSchema(inputSchema)
|
||||||
// 为 nil schema 提供默认值
|
// 为 nil schema 提供默认值
|
||||||
if params == nil {
|
if params == nil {
|
||||||
params = map[string]any{
|
params = map[string]any{
|
||||||
"type": "OBJECT",
|
"type": "object", // lowercase type
|
||||||
"properties": map[string]any{},
|
"properties": map[string]any{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -631,236 +632,3 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
|
|||||||
FunctionDeclarations: funcDecls,
|
FunctionDeclarations: funcDecls,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanJSONSchema 清理 JSON Schema,移除 Antigravity/Gemini 不支持的字段
|
|
||||||
// 参考 proxycast 的实现,确保 schema 符合 JSON Schema draft 2020-12
|
|
||||||
func cleanJSONSchema(schema map[string]any) map[string]any {
|
|
||||||
if schema == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cleaned := cleanSchemaValue(schema, "$")
|
|
||||||
result, ok := cleaned.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保有 type 字段(默认 OBJECT)
|
|
||||||
if _, hasType := result["type"]; !hasType {
|
|
||||||
result["type"] = "OBJECT"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保有 properties 字段(默认空对象)
|
|
||||||
if _, hasProps := result["properties"]; !hasProps {
|
|
||||||
result["properties"] = make(map[string]any)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证 required 中的字段都存在于 properties 中
|
|
||||||
if required, ok := result["required"].([]any); ok {
|
|
||||||
if props, ok := result["properties"].(map[string]any); ok {
|
|
||||||
validRequired := make([]any, 0, len(required))
|
|
||||||
for _, r := range required {
|
|
||||||
if reqName, ok := r.(string); ok {
|
|
||||||
if _, exists := props[reqName]; exists {
|
|
||||||
validRequired = append(validRequired, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(validRequired) > 0 {
|
|
||||||
result["required"] = validRequired
|
|
||||||
} else {
|
|
||||||
delete(result, "required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
var schemaValidationKeys = map[string]bool{
|
|
||||||
"minLength": true,
|
|
||||||
"maxLength": true,
|
|
||||||
"pattern": true,
|
|
||||||
"minimum": true,
|
|
||||||
"maximum": true,
|
|
||||||
"exclusiveMinimum": true,
|
|
||||||
"exclusiveMaximum": true,
|
|
||||||
"multipleOf": true,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"minItems": true,
|
|
||||||
"maxItems": true,
|
|
||||||
"minProperties": true,
|
|
||||||
"maxProperties": true,
|
|
||||||
"patternProperties": true,
|
|
||||||
"propertyNames": true,
|
|
||||||
"dependencies": true,
|
|
||||||
"dependentSchemas": true,
|
|
||||||
"dependentRequired": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
var warnedSchemaKeys sync.Map
|
|
||||||
|
|
||||||
func schemaCleaningWarningsEnabled() bool {
|
|
||||||
// 可通过环境变量强制开关,方便排查:SUB2API_SCHEMA_CLEAN_WARN=true/false
|
|
||||||
if v := strings.TrimSpace(os.Getenv("SUB2API_SCHEMA_CLEAN_WARN")); v != "" {
|
|
||||||
switch strings.ToLower(v) {
|
|
||||||
case "1", "true", "yes", "on":
|
|
||||||
return true
|
|
||||||
case "0", "false", "no", "off":
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 默认:非 release 模式下输出(debug/test)
|
|
||||||
return gin.Mode() != gin.ReleaseMode
|
|
||||||
}
|
|
||||||
|
|
||||||
func warnSchemaKeyRemovedOnce(key, path string) {
|
|
||||||
if !schemaCleaningWarningsEnabled() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !schemaValidationKeys[key] {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, loaded := warnedSchemaKeys.LoadOrStore(key, struct{}{}); loaded {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("[SchemaClean] removed unsupported JSON Schema validation field key=%q path=%q", key, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// excludedSchemaKeys 不支持的 schema 字段
|
|
||||||
// 基于 Claude API (Vertex AI) 的实际支持情况
|
|
||||||
// 支持: type, description, enum, properties, required, additionalProperties, items
|
|
||||||
// 不支持: minItems, maxItems, minLength, maxLength, pattern, minimum, maximum 等验证字段
|
|
||||||
var excludedSchemaKeys = map[string]bool{
|
|
||||||
// 元 schema 字段
|
|
||||||
"$schema": true,
|
|
||||||
"$id": true,
|
|
||||||
"$ref": true,
|
|
||||||
|
|
||||||
// 字符串验证(Gemini 不支持)
|
|
||||||
"minLength": true,
|
|
||||||
"maxLength": true,
|
|
||||||
"pattern": true,
|
|
||||||
|
|
||||||
// 数字验证(Claude API 通过 Vertex AI 不支持这些字段)
|
|
||||||
"minimum": true,
|
|
||||||
"maximum": true,
|
|
||||||
"exclusiveMinimum": true,
|
|
||||||
"exclusiveMaximum": true,
|
|
||||||
"multipleOf": true,
|
|
||||||
|
|
||||||
// 数组验证(Claude API 通过 Vertex AI 不支持这些字段)
|
|
||||||
"uniqueItems": true,
|
|
||||||
"minItems": true,
|
|
||||||
"maxItems": true,
|
|
||||||
|
|
||||||
// 组合 schema(Gemini 不支持)
|
|
||||||
"oneOf": true,
|
|
||||||
"anyOf": true,
|
|
||||||
"allOf": true,
|
|
||||||
"not": true,
|
|
||||||
"if": true,
|
|
||||||
"then": true,
|
|
||||||
"else": true,
|
|
||||||
"$defs": true,
|
|
||||||
"definitions": true,
|
|
||||||
|
|
||||||
// 对象验证(仅保留 properties/required/additionalProperties)
|
|
||||||
"minProperties": true,
|
|
||||||
"maxProperties": true,
|
|
||||||
"patternProperties": true,
|
|
||||||
"propertyNames": true,
|
|
||||||
"dependencies": true,
|
|
||||||
"dependentSchemas": true,
|
|
||||||
"dependentRequired": true,
|
|
||||||
|
|
||||||
// 其他不支持的字段
|
|
||||||
"default": true,
|
|
||||||
"const": true,
|
|
||||||
"examples": true,
|
|
||||||
"deprecated": true,
|
|
||||||
"readOnly": true,
|
|
||||||
"writeOnly": true,
|
|
||||||
"contentMediaType": true,
|
|
||||||
"contentEncoding": true,
|
|
||||||
|
|
||||||
// Claude 特有字段
|
|
||||||
"strict": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanSchemaValue 递归清理 schema 值
|
|
||||||
func cleanSchemaValue(value any, path string) any {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
result := make(map[string]any)
|
|
||||||
for k, val := range v {
|
|
||||||
// 跳过不支持的字段
|
|
||||||
if excludedSchemaKeys[k] {
|
|
||||||
warnSchemaKeyRemovedOnce(k, path)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 特殊处理 type 字段
|
|
||||||
if k == "type" {
|
|
||||||
result[k] = cleanTypeValue(val)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 特殊处理 format 字段:只保留 Gemini 支持的 format 值
|
|
||||||
if k == "format" {
|
|
||||||
if formatStr, ok := val.(string); ok {
|
|
||||||
// Gemini 只支持 date-time, date, time
|
|
||||||
if formatStr == "date-time" || formatStr == "date" || formatStr == "time" {
|
|
||||||
result[k] = val
|
|
||||||
}
|
|
||||||
// 其他 format 值直接跳过
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 特殊处理 additionalProperties:Claude API 只支持布尔值,不支持 schema 对象
|
|
||||||
if k == "additionalProperties" {
|
|
||||||
if boolVal, ok := val.(bool); ok {
|
|
||||||
result[k] = boolVal
|
|
||||||
} else {
|
|
||||||
// 如果是 schema 对象,转换为 false(更安全的默认值)
|
|
||||||
result[k] = false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归清理所有值
|
|
||||||
result[k] = cleanSchemaValue(val, path+"."+k)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
|
|
||||||
case []any:
|
|
||||||
// 递归处理数组中的每个元素
|
|
||||||
cleaned := make([]any, 0, len(v))
|
|
||||||
for i, item := range v {
|
|
||||||
cleaned = append(cleaned, cleanSchemaValue(item, fmt.Sprintf("%s[%d]", path, i)))
|
|
||||||
}
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
default:
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanTypeValue 处理 type 字段,转换为大写
|
|
||||||
func cleanTypeValue(value any) any {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case string:
|
|
||||||
return strings.ToUpper(v)
|
|
||||||
case []any:
|
|
||||||
// 联合类型 ["string", "null"] -> 取第一个非 null 类型
|
|
||||||
for _, t := range v {
|
|
||||||
if ts, ok := t.(string); ok && ts != "null" {
|
|
||||||
return strings.ToUpper(ts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 如果只有 null,返回 STRING
|
|
||||||
return "STRING"
|
|
||||||
default:
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package antigravity
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +20,15 @@ func TransformGeminiToClaude(geminiResp []byte, originalModel string) ([]byte, *
|
|||||||
v1Resp.Response = directResp
|
v1Resp.Response = directResp
|
||||||
v1Resp.ResponseID = directResp.ResponseID
|
v1Resp.ResponseID = directResp.ResponseID
|
||||||
v1Resp.ModelVersion = directResp.ModelVersion
|
v1Resp.ModelVersion = directResp.ModelVersion
|
||||||
|
} else if len(v1Resp.Response.Candidates) == 0 {
|
||||||
|
// 第一次解析成功但 candidates 为空,说明是直接的 GeminiResponse 格式
|
||||||
|
var directResp GeminiResponse
|
||||||
|
if err2 := json.Unmarshal(geminiResp, &directResp); err2 != nil {
|
||||||
|
return nil, nil, fmt.Errorf("parse gemini response as direct: %w", err2)
|
||||||
|
}
|
||||||
|
v1Resp.Response = directResp
|
||||||
|
v1Resp.ResponseID = directResp.ResponseID
|
||||||
|
v1Resp.ModelVersion = directResp.ModelVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用处理器转换
|
// 使用处理器转换
|
||||||
@@ -173,16 +183,20 @@ func (p *NonStreamingProcessor) processPart(part *GeminiPart) {
|
|||||||
p.trailingSignature = ""
|
p.trailingSignature = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
p.textBuilder += part.Text
|
// 非空 text 带签名 - 特殊处理:先输出 text,再输出空 thinking 块
|
||||||
|
|
||||||
// 非空 text 带签名 - 立即刷新并输出空 thinking 块
|
|
||||||
if signature != "" {
|
if signature != "" {
|
||||||
p.flushText()
|
p.contentBlocks = append(p.contentBlocks, ClaudeContentItem{
|
||||||
|
Type: "text",
|
||||||
|
Text: part.Text,
|
||||||
|
})
|
||||||
p.contentBlocks = append(p.contentBlocks, ClaudeContentItem{
|
p.contentBlocks = append(p.contentBlocks, ClaudeContentItem{
|
||||||
Type: "thinking",
|
Type: "thinking",
|
||||||
Thinking: "",
|
Thinking: "",
|
||||||
Signature: signature,
|
Signature: signature,
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
// 普通 text (无签名) - 累积到 builder
|
||||||
|
p.textBuilder += part.Text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,6 +256,14 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
|
|||||||
var finishReason string
|
var finishReason string
|
||||||
if len(geminiResp.Candidates) > 0 {
|
if len(geminiResp.Candidates) > 0 {
|
||||||
finishReason = geminiResp.Candidates[0].FinishReason
|
finishReason = geminiResp.Candidates[0].FinishReason
|
||||||
|
if finishReason == "MALFORMED_FUNCTION_CALL" {
|
||||||
|
log.Printf("[Antigravity] MALFORMED_FUNCTION_CALL detected in response for model %s", originalModel)
|
||||||
|
if geminiResp.Candidates[0].Content != nil {
|
||||||
|
if b, err := json.Marshal(geminiResp.Candidates[0].Content); err == nil {
|
||||||
|
log.Printf("[Antigravity] Malformed content: %s", string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopReason := "end_turn"
|
stopReason := "end_turn"
|
||||||
|
|||||||
519
backend/internal/pkg/antigravity/schema_cleaner.go
Normal file
519
backend/internal/pkg/antigravity/schema_cleaner.go
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
package antigravity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CleanJSONSchema 清理 JSON Schema,移除 Antigravity/Gemini 不支持的字段
|
||||||
|
// 参考 Antigravity-Manager/src-tauri/src/proxy/common/json_schema.rs 实现
|
||||||
|
// 确保 schema 符合 JSON Schema draft 2020-12 且适配 Gemini v1internal
|
||||||
|
func CleanJSONSchema(schema map[string]any) map[string]any {
|
||||||
|
if schema == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 0. 预处理:展开 $ref (Schema Flattening)
|
||||||
|
// (Go map 是引用的,直接修改 schema)
|
||||||
|
flattenRefs(schema, extractDefs(schema))
|
||||||
|
|
||||||
|
// 递归清理
|
||||||
|
cleaned := cleanJSONSchemaRecursive(schema)
|
||||||
|
result, ok := cleaned.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDefs 提取并移除定义的 helper
|
||||||
|
func extractDefs(schema map[string]any) map[string]any {
|
||||||
|
defs := make(map[string]any)
|
||||||
|
if d, ok := schema["$defs"].(map[string]any); ok {
|
||||||
|
for k, v := range d {
|
||||||
|
defs[k] = v
|
||||||
|
}
|
||||||
|
delete(schema, "$defs")
|
||||||
|
}
|
||||||
|
if d, ok := schema["definitions"].(map[string]any); ok {
|
||||||
|
for k, v := range d {
|
||||||
|
defs[k] = v
|
||||||
|
}
|
||||||
|
delete(schema, "definitions")
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenRefs 递归展开 $ref
|
||||||
|
func flattenRefs(schema map[string]any, defs map[string]any) {
|
||||||
|
if len(defs) == 0 {
|
||||||
|
return // 无需展开
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查并替换 $ref
|
||||||
|
if ref, ok := schema["$ref"].(string); ok {
|
||||||
|
delete(schema, "$ref")
|
||||||
|
// 解析引用名 (例如 #/$defs/MyType -> MyType)
|
||||||
|
parts := strings.Split(ref, "/")
|
||||||
|
refName := parts[len(parts)-1]
|
||||||
|
|
||||||
|
if defSchema, exists := defs[refName]; exists {
|
||||||
|
if defMap, ok := defSchema.(map[string]any); ok {
|
||||||
|
// 合并定义内容 (不覆盖现有 key)
|
||||||
|
for k, v := range defMap {
|
||||||
|
if _, has := schema[k]; !has {
|
||||||
|
schema[k] = deepCopy(v) // 需深拷贝避免共享引用
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 递归处理刚刚合并进来的内容
|
||||||
|
flattenRefs(schema, defs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历子节点
|
||||||
|
for _, v := range schema {
|
||||||
|
if subMap, ok := v.(map[string]any); ok {
|
||||||
|
flattenRefs(subMap, defs)
|
||||||
|
} else if subArr, ok := v.([]any); ok {
|
||||||
|
for _, item := range subArr {
|
||||||
|
if itemMap, ok := item.(map[string]any); ok {
|
||||||
|
flattenRefs(itemMap, defs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deepCopy 深拷贝 (简单实现,仅针对 JSON 类型)
|
||||||
|
func deepCopy(src any) any {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v := src.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
dst := make(map[string]any)
|
||||||
|
for k, val := range v {
|
||||||
|
dst[k] = deepCopy(val)
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
case []any:
|
||||||
|
dst := make([]any, len(v))
|
||||||
|
for i, val := range v {
|
||||||
|
dst[i] = deepCopy(val)
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
default:
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanJSONSchemaRecursive 递归核心清理逻辑
|
||||||
|
// 返回处理后的值 (通常是 input map,但可能修改内部结构)
|
||||||
|
func cleanJSONSchemaRecursive(value any) any {
|
||||||
|
schemaMap, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0. [NEW] 合并 allOf
|
||||||
|
mergeAllOf(schemaMap)
|
||||||
|
|
||||||
|
// 1. [CRITICAL] 深度递归处理子项
|
||||||
|
if props, ok := schemaMap["properties"].(map[string]any); ok {
|
||||||
|
for _, v := range props {
|
||||||
|
cleanJSONSchemaRecursive(v)
|
||||||
|
}
|
||||||
|
// Go 中不需要像 Rust 那样显式处理 nullable_keys remove required,
|
||||||
|
// 因为我们在子项处理中会正确设置 type 和 description
|
||||||
|
} else if items, ok := schemaMap["items"]; ok {
|
||||||
|
// [FIX] Gemini 期望 "items" 是单个 Schema 对象(列表验证),而不是数组(元组验证)。
|
||||||
|
if itemsArr, ok := items.([]any); ok {
|
||||||
|
// 策略:将元组 [A, B] 视为 A、B 中的最佳匹配项。
|
||||||
|
best := extractBestSchemaFromUnion(itemsArr)
|
||||||
|
if best == nil {
|
||||||
|
// 回退到通用字符串
|
||||||
|
best = map[string]any{"type": "string"}
|
||||||
|
}
|
||||||
|
// 用处理后的对象替换原有数组
|
||||||
|
cleanedBest := cleanJSONSchemaRecursive(best)
|
||||||
|
schemaMap["items"] = cleanedBest
|
||||||
|
} else {
|
||||||
|
cleanJSONSchemaRecursive(items)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 遍历所有值递归
|
||||||
|
for _, v := range schemaMap {
|
||||||
|
if _, isMap := v.(map[string]any); isMap {
|
||||||
|
cleanJSONSchemaRecursive(v)
|
||||||
|
} else if arr, isArr := v.([]any); isArr {
|
||||||
|
for _, item := range arr {
|
||||||
|
cleanJSONSchemaRecursive(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. [FIX] 处理 anyOf/oneOf 联合类型: 合并属性而非直接删除
|
||||||
|
var unionArray []any
|
||||||
|
typeStr, _ := schemaMap["type"].(string)
|
||||||
|
if typeStr == "" || typeStr == "object" {
|
||||||
|
if anyOf, ok := schemaMap["anyOf"].([]any); ok {
|
||||||
|
unionArray = anyOf
|
||||||
|
} else if oneOf, ok := schemaMap["oneOf"].([]any); ok {
|
||||||
|
unionArray = oneOf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unionArray) > 0 {
|
||||||
|
if bestBranch := extractBestSchemaFromUnion(unionArray); bestBranch != nil {
|
||||||
|
if bestMap, ok := bestBranch.(map[string]any); ok {
|
||||||
|
// 合并分支内容
|
||||||
|
for k, v := range bestMap {
|
||||||
|
if k == "properties" {
|
||||||
|
targetProps, _ := schemaMap["properties"].(map[string]any)
|
||||||
|
if targetProps == nil {
|
||||||
|
targetProps = make(map[string]any)
|
||||||
|
schemaMap["properties"] = targetProps
|
||||||
|
}
|
||||||
|
if sourceProps, ok := v.(map[string]any); ok {
|
||||||
|
for pk, pv := range sourceProps {
|
||||||
|
if _, exists := targetProps[pk]; !exists {
|
||||||
|
targetProps[pk] = deepCopy(pv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if k == "required" {
|
||||||
|
targetReq, _ := schemaMap["required"].([]any)
|
||||||
|
if sourceReq, ok := v.([]any); ok {
|
||||||
|
for _, rv := range sourceReq {
|
||||||
|
// 简单的去重添加
|
||||||
|
exists := false
|
||||||
|
for _, tr := range targetReq {
|
||||||
|
if tr == rv {
|
||||||
|
exists = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
targetReq = append(targetReq, rv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schemaMap["required"] = targetReq
|
||||||
|
}
|
||||||
|
} else if _, exists := schemaMap[k]; !exists {
|
||||||
|
schemaMap[k] = deepCopy(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. [SAFETY] 检查当前对象是否为 JSON Schema 节点
|
||||||
|
looksLikeSchema := hasKey(schemaMap, "type") ||
|
||||||
|
hasKey(schemaMap, "properties") ||
|
||||||
|
hasKey(schemaMap, "items") ||
|
||||||
|
hasKey(schemaMap, "enum") ||
|
||||||
|
hasKey(schemaMap, "anyOf") ||
|
||||||
|
hasKey(schemaMap, "oneOf") ||
|
||||||
|
hasKey(schemaMap, "allOf")
|
||||||
|
|
||||||
|
if looksLikeSchema {
|
||||||
|
// 4. [ROBUST] 约束迁移
|
||||||
|
migrateConstraints(schemaMap)
|
||||||
|
|
||||||
|
// 5. [CRITICAL] 白名单过滤
|
||||||
|
allowedFields := map[string]bool{
|
||||||
|
"type": true,
|
||||||
|
"description": true,
|
||||||
|
"properties": true,
|
||||||
|
"required": true,
|
||||||
|
"items": true,
|
||||||
|
"enum": true,
|
||||||
|
"title": true,
|
||||||
|
}
|
||||||
|
for k := range schemaMap {
|
||||||
|
if !allowedFields[k] {
|
||||||
|
delete(schemaMap, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. [SAFETY] 处理空 Object
|
||||||
|
if t, _ := schemaMap["type"].(string); t == "object" {
|
||||||
|
hasProps := false
|
||||||
|
if props, ok := schemaMap["properties"].(map[string]any); ok && len(props) > 0 {
|
||||||
|
hasProps = true
|
||||||
|
}
|
||||||
|
if !hasProps {
|
||||||
|
schemaMap["properties"] = map[string]any{
|
||||||
|
"reason": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reason for calling this tool",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
schemaMap["required"] = []any{"reason"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. [SAFETY] Required 字段对齐
|
||||||
|
if props, ok := schemaMap["properties"].(map[string]any); ok {
|
||||||
|
if req, ok := schemaMap["required"].([]any); ok {
|
||||||
|
var validReq []any
|
||||||
|
for _, r := range req {
|
||||||
|
if rStr, ok := r.(string); ok {
|
||||||
|
if _, exists := props[rStr]; exists {
|
||||||
|
validReq = append(validReq, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(validReq) > 0 {
|
||||||
|
schemaMap["required"] = validReq
|
||||||
|
} else {
|
||||||
|
delete(schemaMap, "required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 处理 type 字段 (Lowercase + Nullable 提取)
|
||||||
|
isEffectivelyNullable := false
|
||||||
|
if typeVal, exists := schemaMap["type"]; exists {
|
||||||
|
var selectedType string
|
||||||
|
switch v := typeVal.(type) {
|
||||||
|
case string:
|
||||||
|
lower := strings.ToLower(v)
|
||||||
|
if lower == "null" {
|
||||||
|
isEffectivelyNullable = true
|
||||||
|
selectedType = "string" // fallback
|
||||||
|
} else {
|
||||||
|
selectedType = lower
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
// ["string", "null"]
|
||||||
|
for _, t := range v {
|
||||||
|
if ts, ok := t.(string); ok {
|
||||||
|
lower := strings.ToLower(ts)
|
||||||
|
if lower == "null" {
|
||||||
|
isEffectivelyNullable = true
|
||||||
|
} else if selectedType == "" {
|
||||||
|
selectedType = lower
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selectedType == "" {
|
||||||
|
selectedType = "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schemaMap["type"] = selectedType
|
||||||
|
} else {
|
||||||
|
// 默认 object 如果有 properties (虽然上面白名单过滤可能删了 type 如果它不在... 但 type 必在 allowlist)
|
||||||
|
// 如果没有 type,但有 properties,补一个
|
||||||
|
if hasKey(schemaMap, "properties") {
|
||||||
|
schemaMap["type"] = "object"
|
||||||
|
} else {
|
||||||
|
// 默认为 string ? or object? Gemini 通常需要明确 type
|
||||||
|
schemaMap["type"] = "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isEffectivelyNullable {
|
||||||
|
desc, _ := schemaMap["description"].(string)
|
||||||
|
if !strings.Contains(desc, "nullable") {
|
||||||
|
if desc != "" {
|
||||||
|
desc += " "
|
||||||
|
}
|
||||||
|
desc += "(nullable)"
|
||||||
|
schemaMap["description"] = desc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Enum 值强制转字符串
|
||||||
|
if enumVals, ok := schemaMap["enum"].([]any); ok {
|
||||||
|
hasNonString := false
|
||||||
|
for i, val := range enumVals {
|
||||||
|
if _, isStr := val.(string); !isStr {
|
||||||
|
hasNonString = true
|
||||||
|
if val == nil {
|
||||||
|
enumVals[i] = "null"
|
||||||
|
} else {
|
||||||
|
enumVals[i] = fmt.Sprintf("%v", val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If we mandated string values, we must ensure type is string
|
||||||
|
if hasNonString {
|
||||||
|
schemaMap["type"] = "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return schemaMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasKey(m map[string]any, k string) bool {
|
||||||
|
_, ok := m[k]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateConstraints(m map[string]any) {
|
||||||
|
constraints := []struct {
|
||||||
|
key string
|
||||||
|
label string
|
||||||
|
}{
|
||||||
|
{"minLength", "minLen"},
|
||||||
|
{"maxLength", "maxLen"},
|
||||||
|
{"pattern", "pattern"},
|
||||||
|
{"minimum", "min"},
|
||||||
|
{"maximum", "max"},
|
||||||
|
{"multipleOf", "multipleOf"},
|
||||||
|
{"exclusiveMinimum", "exclMin"},
|
||||||
|
{"exclusiveMaximum", "exclMax"},
|
||||||
|
{"minItems", "minItems"},
|
||||||
|
{"maxItems", "maxItems"},
|
||||||
|
{"propertyNames", "propertyNames"},
|
||||||
|
{"format", "format"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var hints []string
|
||||||
|
for _, c := range constraints {
|
||||||
|
if val, ok := m[c.key]; ok && val != nil {
|
||||||
|
hints = append(hints, fmt.Sprintf("%s: %v", c.label, val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hints) > 0 {
|
||||||
|
suffix := fmt.Sprintf(" [Constraint: %s]", strings.Join(hints, ", "))
|
||||||
|
desc, _ := m["description"].(string)
|
||||||
|
if !strings.Contains(desc, suffix) {
|
||||||
|
m["description"] = desc + suffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeAllOf 合并 allOf
|
||||||
|
func mergeAllOf(m map[string]any) {
|
||||||
|
allOf, ok := m["allOf"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(m, "allOf")
|
||||||
|
|
||||||
|
mergedProps := make(map[string]any)
|
||||||
|
mergedReq := make(map[string]bool)
|
||||||
|
otherFields := make(map[string]any)
|
||||||
|
|
||||||
|
for _, sub := range allOf {
|
||||||
|
if subMap, ok := sub.(map[string]any); ok {
|
||||||
|
// Props
|
||||||
|
if props, ok := subMap["properties"].(map[string]any); ok {
|
||||||
|
for k, v := range props {
|
||||||
|
mergedProps[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Required
|
||||||
|
if reqs, ok := subMap["required"].([]any); ok {
|
||||||
|
for _, r := range reqs {
|
||||||
|
if s, ok := r.(string); ok {
|
||||||
|
mergedReq[s] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Others
|
||||||
|
for k, v := range subMap {
|
||||||
|
if k != "properties" && k != "required" && k != "allOf" {
|
||||||
|
if _, exists := otherFields[k]; !exists {
|
||||||
|
otherFields[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply
|
||||||
|
for k, v := range otherFields {
|
||||||
|
if _, exists := m[k]; !exists {
|
||||||
|
m[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mergedProps) > 0 {
|
||||||
|
existProps, _ := m["properties"].(map[string]any)
|
||||||
|
if existProps == nil {
|
||||||
|
existProps = make(map[string]any)
|
||||||
|
m["properties"] = existProps
|
||||||
|
}
|
||||||
|
for k, v := range mergedProps {
|
||||||
|
if _, exists := existProps[k]; !exists {
|
||||||
|
existProps[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mergedReq) > 0 {
|
||||||
|
existReq, _ := m["required"].([]any)
|
||||||
|
var validReqs []any
|
||||||
|
for _, r := range existReq {
|
||||||
|
if s, ok := r.(string); ok {
|
||||||
|
validReqs = append(validReqs, s)
|
||||||
|
delete(mergedReq, s) // already exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// append new
|
||||||
|
for r := range mergedReq {
|
||||||
|
validReqs = append(validReqs, r)
|
||||||
|
}
|
||||||
|
m["required"] = validReqs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractBestSchemaFromUnion 从 anyOf/oneOf 中选取最佳分支
|
||||||
|
func extractBestSchemaFromUnion(unionArray []any) any {
|
||||||
|
var bestOption any
|
||||||
|
bestScore := -1
|
||||||
|
|
||||||
|
for _, item := range unionArray {
|
||||||
|
score := scoreSchemaOption(item)
|
||||||
|
if score > bestScore {
|
||||||
|
bestScore = score
|
||||||
|
bestOption = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestOption
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreSchemaOption(val any) int {
|
||||||
|
m, ok := val.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
typeStr, _ := m["type"].(string)
|
||||||
|
|
||||||
|
if hasKey(m, "properties") || typeStr == "object" {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
if hasKey(m, "items") || typeStr == "array" {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if typeStr != "" && typeStr != "null" {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCleanUndefined 深度清理值为 "[undefined]" 的字段
|
||||||
|
func DeepCleanUndefined(value any) {
|
||||||
|
if value == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch v := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for k, val := range v {
|
||||||
|
if s, ok := val.(string); ok && s == "[undefined]" {
|
||||||
|
delete(v, k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
DeepCleanUndefined(val)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, val := range v {
|
||||||
|
DeepCleanUndefined(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,6 +103,14 @@ func (p *StreamingProcessor) ProcessLine(line string) []byte {
|
|||||||
// 检查是否结束
|
// 检查是否结束
|
||||||
if len(geminiResp.Candidates) > 0 {
|
if len(geminiResp.Candidates) > 0 {
|
||||||
finishReason := geminiResp.Candidates[0].FinishReason
|
finishReason := geminiResp.Candidates[0].FinishReason
|
||||||
|
if finishReason == "MALFORMED_FUNCTION_CALL" {
|
||||||
|
log.Printf("[Antigravity] MALFORMED_FUNCTION_CALL detected in stream for model %s", p.originalModel)
|
||||||
|
if geminiResp.Candidates[0].Content != nil {
|
||||||
|
if b, err := json.Marshal(geminiResp.Candidates[0].Content); err == nil {
|
||||||
|
log.Printf("[Antigravity] Malformed content: %s", string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if finishReason != "" {
|
if finishReason != "" {
|
||||||
_, _ = result.Write(p.emitFinish(finishReason))
|
_, _ = result.Write(p.emitFinish(finishReason))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ const (
|
|||||||
RedirectURI = "https://platform.claude.com/oauth/code/callback"
|
RedirectURI = "https://platform.claude.com/oauth/code/callback"
|
||||||
|
|
||||||
// Scopes - Browser URL (includes org:create_api_key for user authorization)
|
// Scopes - Browser URL (includes org:create_api_key for user authorization)
|
||||||
ScopeOAuth = "org:create_api_key user:profile user:inference user:sessions:claude_code"
|
ScopeOAuth = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers"
|
||||||
// Scopes - Internal API call (org:create_api_key not supported in API)
|
// Scopes - Internal API call (org:create_api_key not supported in API)
|
||||||
ScopeAPI = "user:profile user:inference user:sessions:claude_code"
|
ScopeAPI = "user:profile user:inference user:sessions:claude_code user:mcp_servers"
|
||||||
// Scopes - Setup token (inference only)
|
// Scopes - Setup token (inference only)
|
||||||
ScopeInference = "user:inference"
|
ScopeInference = "user:inference"
|
||||||
|
|
||||||
@@ -215,5 +215,6 @@ type OrgInfo struct {
|
|||||||
|
|
||||||
// AccountInfo represents account info from OAuth response
|
// AccountInfo represents account info from OAuth response
|
||||||
type AccountInfo struct {
|
type AccountInfo struct {
|
||||||
UUID string `json:"uuid"`
|
UUID string `json:"uuid"`
|
||||||
|
EmailAddress string `json:"email_address"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ func (s *claudeOAuthService) GetOrganizationUUID(ctx context.Context, sessionKey
|
|||||||
client := s.clientFactory(proxyURL)
|
client := s.clientFactory(proxyURL)
|
||||||
|
|
||||||
var orgs []struct {
|
var orgs []struct {
|
||||||
UUID string `json:"uuid"`
|
UUID string `json:"uuid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
RavenType *string `json:"raven_type"` // nil for personal, "team" for team organization
|
||||||
}
|
}
|
||||||
|
|
||||||
targetURL := s.baseURL + "/api/organizations"
|
targetURL := s.baseURL + "/api/organizations"
|
||||||
@@ -65,7 +67,23 @@ func (s *claudeOAuthService) GetOrganizationUUID(ctx context.Context, sessionKey
|
|||||||
return "", fmt.Errorf("no organizations found")
|
return "", fmt.Errorf("no organizations found")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[OAuth] Step 1 SUCCESS - Got org UUID: %s", orgs[0].UUID)
|
// 如果只有一个组织,直接使用
|
||||||
|
if len(orgs) == 1 {
|
||||||
|
log.Printf("[OAuth] Step 1 SUCCESS - Single org found, UUID: %s, Name: %s", orgs[0].UUID, orgs[0].Name)
|
||||||
|
return orgs[0].UUID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有多个组织,优先选择 raven_type 为 "team" 的组织
|
||||||
|
for _, org := range orgs {
|
||||||
|
if org.RavenType != nil && *org.RavenType == "team" {
|
||||||
|
log.Printf("[OAuth] Step 1 SUCCESS - Selected team org, UUID: %s, Name: %s, RavenType: %s",
|
||||||
|
org.UUID, org.Name, *org.RavenType)
|
||||||
|
return org.UUID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有 team 类型的组织,使用第一个
|
||||||
|
log.Printf("[OAuth] Step 1 SUCCESS - No team org found, using first org, UUID: %s, Name: %s", orgs[0].UUID, orgs[0].Name)
|
||||||
return orgs[0].UUID, nil
|
return orgs[0].UUID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,20 +193,20 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
// 普通用户订阅接口不应包含 assigned_* / notes 等管理员字段。
|
// 普通用户订阅接口不应包含 assigned_* / notes 等管理员字段。
|
||||||
deps.userSubRepo.SetByUserID(1, []service.UserSubscription{
|
deps.userSubRepo.SetByUserID(1, []service.UserSubscription{
|
||||||
{
|
{
|
||||||
ID: 501,
|
ID: 501,
|
||||||
UserID: 1,
|
UserID: 1,
|
||||||
GroupID: 10,
|
GroupID: 10,
|
||||||
StartsAt: deps.now,
|
StartsAt: deps.now,
|
||||||
ExpiresAt: deps.now.Add(24 * time.Hour),
|
ExpiresAt: deps.now.Add(24 * time.Hour),
|
||||||
Status: service.SubscriptionStatusActive,
|
Status: service.SubscriptionStatusActive,
|
||||||
DailyUsageUSD: 1.23,
|
DailyUsageUSD: 1.23,
|
||||||
WeeklyUsageUSD: 2.34,
|
WeeklyUsageUSD: 2.34,
|
||||||
MonthlyUsageUSD: 3.45,
|
MonthlyUsageUSD: 3.45,
|
||||||
AssignedBy: ptr(int64(999)),
|
AssignedBy: ptr(int64(999)),
|
||||||
AssignedAt: deps.now,
|
AssignedAt: deps.now,
|
||||||
Notes: "admin-note",
|
Notes: "admin-note",
|
||||||
CreatedAt: deps.now,
|
CreatedAt: deps.now,
|
||||||
UpdatedAt: deps.now,
|
UpdatedAt: deps.now,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -197,6 +197,35 @@ func (a *Account) GetCredentialAsTime(key string) *time.Time {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCredentialAsInt64 解析凭证中的 int64 字段
|
||||||
|
// 用于读取 _token_version 等内部字段
|
||||||
|
func (a *Account) GetCredentialAsInt64(key string) int64 {
|
||||||
|
if a == nil || a.Credentials == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
val, ok := a.Credentials[key]
|
||||||
|
if !ok || val == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch v := val.(type) {
|
||||||
|
case int64:
|
||||||
|
return v
|
||||||
|
case float64:
|
||||||
|
return int64(v)
|
||||||
|
case int:
|
||||||
|
return int64(v)
|
||||||
|
case json.Number:
|
||||||
|
if i, err := v.Int64(); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
if i, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Account) IsTempUnschedulableEnabled() bool {
|
func (a *Account) IsTempUnschedulableEnabled() bool {
|
||||||
if a.Credentials == nil {
|
if a.Credentials == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -1305,6 +1305,14 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理 Schema
|
||||||
|
if cleanedBody, err := cleanGeminiRequest(injectedBody); err == nil {
|
||||||
|
injectedBody = cleanedBody
|
||||||
|
log.Printf("[Antigravity] Cleaned request schema in forwarded request for account %s", account.Name)
|
||||||
|
} else {
|
||||||
|
log.Printf("[Antigravity] Failed to clean schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 包装请求
|
// 包装请求
|
||||||
wrappedBody, err := s.wrapV1InternalRequest(projectID, mappedModel, injectedBody)
|
wrappedBody, err := s.wrapV1InternalRequest(projectID, mappedModel, injectedBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1705,6 +1713,19 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
|
|||||||
if u := extractGeminiUsage(parsed); u != nil {
|
if u := extractGeminiUsage(parsed); u != nil {
|
||||||
usage = u
|
usage = u
|
||||||
}
|
}
|
||||||
|
// Check for MALFORMED_FUNCTION_CALL
|
||||||
|
if candidates, ok := parsed["candidates"].([]any); ok && len(candidates) > 0 {
|
||||||
|
if cand, ok := candidates[0].(map[string]any); ok {
|
||||||
|
if fr, ok := cand["finishReason"].(string); ok && fr == "MALFORMED_FUNCTION_CALL" {
|
||||||
|
log.Printf("[Antigravity] MALFORMED_FUNCTION_CALL detected in forward stream")
|
||||||
|
if content, ok := cand["content"]; ok {
|
||||||
|
if b, err := json.Marshal(content); err == nil {
|
||||||
|
log.Printf("[Antigravity] Malformed content: %s", string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if firstTokenMs == nil {
|
if firstTokenMs == nil {
|
||||||
@@ -1854,6 +1875,20 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
|
|||||||
usage = u
|
usage = u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for MALFORMED_FUNCTION_CALL
|
||||||
|
if candidates, ok := parsed["candidates"].([]any); ok && len(candidates) > 0 {
|
||||||
|
if cand, ok := candidates[0].(map[string]any); ok {
|
||||||
|
if fr, ok := cand["finishReason"].(string); ok && fr == "MALFORMED_FUNCTION_CALL" {
|
||||||
|
log.Printf("[Antigravity] MALFORMED_FUNCTION_CALL detected in forward non-stream collect")
|
||||||
|
if content, ok := cand["content"]; ok {
|
||||||
|
if b, err := json.Marshal(content); err == nil {
|
||||||
|
log.Printf("[Antigravity] Malformed content: %s", string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 保留最后一个有 parts 的响应
|
// 保留最后一个有 parts 的响应
|
||||||
if parts := extractGeminiParts(parsed); len(parts) > 0 {
|
if parts := extractGeminiParts(parsed); len(parts) > 0 {
|
||||||
lastWithParts = parsed
|
lastWithParts = parsed
|
||||||
@@ -1950,6 +1985,58 @@ func getOrCreateGeminiParts(response map[string]any) (result map[string]any, exi
|
|||||||
return result, existingParts, setParts
|
return result, existingParts, setParts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeCollectedPartsToResponse 将收集的所有 parts 合并到 Gemini 响应中
|
||||||
|
// 这个函数会合并所有类型的 parts:text、thinking、functionCall、inlineData 等
|
||||||
|
// 保持原始顺序,只合并连续的普通 text parts
|
||||||
|
func mergeCollectedPartsToResponse(response map[string]any, collectedParts []map[string]any) map[string]any {
|
||||||
|
if len(collectedParts) == 0 {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
result, _, setParts := getOrCreateGeminiParts(response)
|
||||||
|
|
||||||
|
// 合并策略:
|
||||||
|
// 1. 保持原始顺序
|
||||||
|
// 2. 连续的普通 text parts 合并为一个
|
||||||
|
// 3. thinking、functionCall、inlineData 等保持原样
|
||||||
|
var mergedParts []any
|
||||||
|
var textBuffer strings.Builder
|
||||||
|
|
||||||
|
flushTextBuffer := func() {
|
||||||
|
if textBuffer.Len() > 0 {
|
||||||
|
mergedParts = append(mergedParts, map[string]any{
|
||||||
|
"text": textBuffer.String(),
|
||||||
|
})
|
||||||
|
textBuffer.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range collectedParts {
|
||||||
|
// 检查是否是普通 text part
|
||||||
|
if text, ok := part["text"].(string); ok {
|
||||||
|
// 检查是否有 thought 标记
|
||||||
|
if thought, _ := part["thought"].(bool); thought {
|
||||||
|
// thinking part,先刷新 text buffer,然后保留原样
|
||||||
|
flushTextBuffer()
|
||||||
|
mergedParts = append(mergedParts, part)
|
||||||
|
} else {
|
||||||
|
// 普通 text,累积到 buffer
|
||||||
|
_, _ = textBuffer.WriteString(text)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非 text part(functionCall、inlineData 等),先刷新 text buffer,然后保留原样
|
||||||
|
flushTextBuffer()
|
||||||
|
mergedParts = append(mergedParts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新剩余的 text
|
||||||
|
flushTextBuffer()
|
||||||
|
|
||||||
|
setParts(mergedParts)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// mergeImagePartsToResponse 将收集到的图片 parts 合并到 Gemini 响应中
|
// mergeImagePartsToResponse 将收集到的图片 parts 合并到 Gemini 响应中
|
||||||
func mergeImagePartsToResponse(response map[string]any, imageParts []map[string]any) map[string]any {
|
func mergeImagePartsToResponse(response map[string]any, imageParts []map[string]any) map[string]any {
|
||||||
if len(imageParts) == 0 {
|
if len(imageParts) == 0 {
|
||||||
@@ -2133,6 +2220,7 @@ func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Cont
|
|||||||
var firstTokenMs *int
|
var firstTokenMs *int
|
||||||
var last map[string]any
|
var last map[string]any
|
||||||
var lastWithParts map[string]any
|
var lastWithParts map[string]any
|
||||||
|
var collectedParts []map[string]any // 收集所有 parts(包括 text、thinking、functionCall、inlineData 等)
|
||||||
|
|
||||||
type scanEvent struct {
|
type scanEvent struct {
|
||||||
line string
|
line string
|
||||||
@@ -2227,9 +2315,12 @@ func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Cont
|
|||||||
|
|
||||||
last = parsed
|
last = parsed
|
||||||
|
|
||||||
// 保留最后一个有 parts 的响应
|
// 保留最后一个有 parts 的响应,并收集所有 parts
|
||||||
if parts := extractGeminiParts(parsed); len(parts) > 0 {
|
if parts := extractGeminiParts(parsed); len(parts) > 0 {
|
||||||
lastWithParts = parsed
|
lastWithParts = parsed
|
||||||
|
|
||||||
|
// 收集所有 parts(text、thinking、functionCall、inlineData 等)
|
||||||
|
collectedParts = append(collectedParts, parts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
case <-intervalCh:
|
case <-intervalCh:
|
||||||
@@ -2252,6 +2343,11 @@ returnResponse:
|
|||||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Empty response from upstream")
|
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Empty response from upstream")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将收集的所有 parts 合并到最终响应中
|
||||||
|
if len(collectedParts) > 0 {
|
||||||
|
finalResponse = mergeCollectedPartsToResponse(finalResponse, collectedParts)
|
||||||
|
}
|
||||||
|
|
||||||
// 序列化为 JSON(Gemini 格式)
|
// 序列化为 JSON(Gemini 格式)
|
||||||
geminiBody, err := json.Marshal(finalResponse)
|
geminiBody, err := json.Marshal(finalResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2459,3 +2555,55 @@ func isImageGenerationModel(model string) bool {
|
|||||||
modelLower == "gemini-2.5-flash-image-preview" ||
|
modelLower == "gemini-2.5-flash-image-preview" ||
|
||||||
strings.HasPrefix(modelLower, "gemini-2.5-flash-image-")
|
strings.HasPrefix(modelLower, "gemini-2.5-flash-image-")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanGeminiRequest 清理 Gemini 请求体中的 Schema
|
||||||
|
func cleanGeminiRequest(body []byte) ([]byte, error) {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
modified := false
|
||||||
|
|
||||||
|
// 1. 清理 Tools
|
||||||
|
if tools, ok := payload["tools"].([]any); ok && len(tools) > 0 {
|
||||||
|
for _, t := range tools {
|
||||||
|
toolMap, ok := t.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// function_declarations (snake_case) or functionDeclarations (camelCase)
|
||||||
|
var funcs []any
|
||||||
|
if f, ok := toolMap["functionDeclarations"].([]any); ok {
|
||||||
|
funcs = f
|
||||||
|
} else if f, ok := toolMap["function_declarations"].([]any); ok {
|
||||||
|
funcs = f
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(funcs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range funcs {
|
||||||
|
funcMap, ok := f.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if params, ok := funcMap["parameters"].(map[string]any); ok {
|
||||||
|
antigravity.DeepCleanUndefined(params)
|
||||||
|
cleaned := antigravity.CleanJSONSchema(params)
|
||||||
|
funcMap["parameters"] = cleaned
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !modified {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(payload)
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,14 +94,14 @@ func TestAntigravityRetryLoop_URLFallback_UsesLatestSuccess(t *testing.T) {
|
|||||||
|
|
||||||
var handleErrorCalled bool
|
var handleErrorCalled bool
|
||||||
result, err := antigravityRetryLoop(antigravityRetryLoopParams{
|
result, err := antigravityRetryLoop(antigravityRetryLoopParams{
|
||||||
prefix: "[test]",
|
prefix: "[test]",
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
account: account,
|
account: account,
|
||||||
proxyURL: "",
|
proxyURL: "",
|
||||||
accessToken: "token",
|
accessToken: "token",
|
||||||
action: "generateContent",
|
action: "generateContent",
|
||||||
body: []byte(`{"input":"test"}`),
|
body: []byte(`{"input":"test"}`),
|
||||||
quotaScope: AntigravityQuotaScopeClaude,
|
quotaScope: AntigravityQuotaScopeClaude,
|
||||||
httpUpstream: upstream,
|
httpUpstream: upstream,
|
||||||
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope) {
|
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope) {
|
||||||
handleErrorCalled = true
|
handleErrorCalled = true
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -101,21 +102,32 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
|
|||||||
return "", errors.New("access_token not found in credentials")
|
return "", errors.New("access_token not found in credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 存入缓存
|
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||||
if p.tokenCache != nil {
|
if p.tokenCache != nil {
|
||||||
ttl := 30 * time.Minute
|
latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
|
||||||
if expiresAt != nil {
|
if isStale && latestAccount != nil {
|
||||||
until := time.Until(*expiresAt)
|
// 版本过时,使用 DB 中的最新 token
|
||||||
switch {
|
slog.Debug("antigravity_token_version_stale_use_latest", "account_id", account.ID)
|
||||||
case until > antigravityTokenCacheSkew:
|
accessToken = latestAccount.GetCredential("access_token")
|
||||||
ttl = until - antigravityTokenCacheSkew
|
if strings.TrimSpace(accessToken) == "" {
|
||||||
case until > 0:
|
return "", errors.New("access_token not found after version check")
|
||||||
ttl = until
|
|
||||||
default:
|
|
||||||
ttl = time.Minute
|
|
||||||
}
|
}
|
||||||
|
// 不写入缓存,让下次请求重新处理
|
||||||
|
} else {
|
||||||
|
ttl := 30 * time.Minute
|
||||||
|
if expiresAt != nil {
|
||||||
|
until := time.Until(*expiresAt)
|
||||||
|
switch {
|
||||||
|
case until > antigravityTokenCacheSkew:
|
||||||
|
ttl = until - antigravityTokenCacheSkew
|
||||||
|
case until > 0:
|
||||||
|
ttl = until
|
||||||
|
default:
|
||||||
|
ttl = time.Minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl)
|
||||||
}
|
}
|
||||||
_ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return accessToken, nil
|
return accessToken, nil
|
||||||
|
|||||||
@@ -181,26 +181,37 @@ func (p *ClaudeTokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
return "", errors.New("access_token not found in credentials")
|
return "", errors.New("access_token not found in credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 存入缓存
|
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||||
if p.tokenCache != nil {
|
if p.tokenCache != nil {
|
||||||
ttl := 30 * time.Minute
|
latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
|
||||||
if refreshFailed {
|
if isStale && latestAccount != nil {
|
||||||
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
// 版本过时,使用 DB 中的最新 token
|
||||||
ttl = time.Minute
|
slog.Debug("claude_token_version_stale_use_latest", "account_id", account.ID)
|
||||||
slog.Debug("claude_token_cache_short_ttl", "account_id", account.ID, "reason", "refresh_failed")
|
accessToken = latestAccount.GetCredential("access_token")
|
||||||
} else if expiresAt != nil {
|
if strings.TrimSpace(accessToken) == "" {
|
||||||
until := time.Until(*expiresAt)
|
return "", errors.New("access_token not found after version check")
|
||||||
switch {
|
}
|
||||||
case until > claudeTokenCacheSkew:
|
// 不写入缓存,让下次请求重新处理
|
||||||
ttl = until - claudeTokenCacheSkew
|
} else {
|
||||||
case until > 0:
|
ttl := 30 * time.Minute
|
||||||
ttl = until
|
if refreshFailed {
|
||||||
default:
|
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
||||||
ttl = time.Minute
|
ttl = time.Minute
|
||||||
|
slog.Debug("claude_token_cache_short_ttl", "account_id", account.ID, "reason", "refresh_failed")
|
||||||
|
} else if expiresAt != nil {
|
||||||
|
until := time.Until(*expiresAt)
|
||||||
|
switch {
|
||||||
|
case until > claudeTokenCacheSkew:
|
||||||
|
ttl = until - claudeTokenCacheSkew
|
||||||
|
case until > 0:
|
||||||
|
ttl = until
|
||||||
|
default:
|
||||||
|
ttl = time.Minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl); err != nil {
|
||||||
|
slog.Warn("claude_token_cache_set_failed", "account_id", account.ID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if err := p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl); err != nil {
|
|
||||||
slog.Warn("claude_token_cache_set_failed", "account_id", account.ID, "error", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -131,21 +132,32 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Populate cache with TTL.
|
// 3) Populate cache with TTL(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||||
if p.tokenCache != nil {
|
if p.tokenCache != nil {
|
||||||
ttl := 30 * time.Minute
|
latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
|
||||||
if expiresAt != nil {
|
if isStale && latestAccount != nil {
|
||||||
until := time.Until(*expiresAt)
|
// 版本过时,使用 DB 中的最新 token
|
||||||
switch {
|
slog.Debug("gemini_token_version_stale_use_latest", "account_id", account.ID)
|
||||||
case until > geminiTokenCacheSkew:
|
accessToken = latestAccount.GetCredential("access_token")
|
||||||
ttl = until - geminiTokenCacheSkew
|
if strings.TrimSpace(accessToken) == "" {
|
||||||
case until > 0:
|
return "", errors.New("access_token not found after version check")
|
||||||
ttl = until
|
|
||||||
default:
|
|
||||||
ttl = time.Minute
|
|
||||||
}
|
}
|
||||||
|
// 不写入缓存,让下次请求重新处理
|
||||||
|
} else {
|
||||||
|
ttl := 30 * time.Minute
|
||||||
|
if expiresAt != nil {
|
||||||
|
until := time.Until(*expiresAt)
|
||||||
|
switch {
|
||||||
|
case until > geminiTokenCacheSkew:
|
||||||
|
ttl = until - geminiTokenCacheSkew
|
||||||
|
case until > 0:
|
||||||
|
ttl = until
|
||||||
|
default:
|
||||||
|
ttl = time.Minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl)
|
||||||
}
|
}
|
||||||
_ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return accessToken, nil
|
return accessToken, nil
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ type TokenInfo struct {
|
|||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
OrgUUID string `json:"org_uuid,omitempty"`
|
OrgUUID string `json:"org_uuid,omitempty"`
|
||||||
AccountUUID string `json:"account_uuid,omitempty"`
|
AccountUUID string `json:"account_uuid,omitempty"`
|
||||||
|
EmailAddress string `json:"email_address,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExchangeCode exchanges authorization code for tokens
|
// ExchangeCode exchanges authorization code for tokens
|
||||||
@@ -252,9 +253,15 @@ func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerif
|
|||||||
tokenInfo.OrgUUID = tokenResp.Organization.UUID
|
tokenInfo.OrgUUID = tokenResp.Organization.UUID
|
||||||
log.Printf("[OAuth] Got org_uuid: %s", tokenInfo.OrgUUID)
|
log.Printf("[OAuth] Got org_uuid: %s", tokenInfo.OrgUUID)
|
||||||
}
|
}
|
||||||
if tokenResp.Account != nil && tokenResp.Account.UUID != "" {
|
if tokenResp.Account != nil {
|
||||||
tokenInfo.AccountUUID = tokenResp.Account.UUID
|
if tokenResp.Account.UUID != "" {
|
||||||
log.Printf("[OAuth] Got account_uuid: %s", tokenInfo.AccountUUID)
|
tokenInfo.AccountUUID = tokenResp.Account.UUID
|
||||||
|
log.Printf("[OAuth] Got account_uuid: %s", tokenInfo.AccountUUID)
|
||||||
|
}
|
||||||
|
if tokenResp.Account.EmailAddress != "" {
|
||||||
|
tokenInfo.EmailAddress = tokenResp.Account.EmailAddress
|
||||||
|
log.Printf("[OAuth] Got email_address: %s", tokenInfo.EmailAddress)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tokenInfo, nil
|
return tokenInfo, nil
|
||||||
|
|||||||
@@ -162,26 +162,37 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
return "", errors.New("access_token not found in credentials")
|
return "", errors.New("access_token not found in credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 存入缓存
|
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||||
if p.tokenCache != nil {
|
if p.tokenCache != nil {
|
||||||
ttl := 30 * time.Minute
|
latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
|
||||||
if refreshFailed {
|
if isStale && latestAccount != nil {
|
||||||
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
// 版本过时,使用 DB 中的最新 token
|
||||||
ttl = time.Minute
|
slog.Debug("openai_token_version_stale_use_latest", "account_id", account.ID)
|
||||||
slog.Debug("openai_token_cache_short_ttl", "account_id", account.ID, "reason", "refresh_failed")
|
accessToken = latestAccount.GetOpenAIAccessToken()
|
||||||
} else if expiresAt != nil {
|
if strings.TrimSpace(accessToken) == "" {
|
||||||
until := time.Until(*expiresAt)
|
return "", errors.New("access_token not found after version check")
|
||||||
switch {
|
}
|
||||||
case until > openAITokenCacheSkew:
|
// 不写入缓存,让下次请求重新处理
|
||||||
ttl = until - openAITokenCacheSkew
|
} else {
|
||||||
case until > 0:
|
ttl := 30 * time.Minute
|
||||||
ttl = until
|
if refreshFailed {
|
||||||
default:
|
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
||||||
ttl = time.Minute
|
ttl = time.Minute
|
||||||
|
slog.Debug("openai_token_cache_short_ttl", "account_id", account.ID, "reason", "refresh_failed")
|
||||||
|
} else if expiresAt != nil {
|
||||||
|
until := time.Until(*expiresAt)
|
||||||
|
switch {
|
||||||
|
case until > openAITokenCacheSkew:
|
||||||
|
ttl = until - openAITokenCacheSkew
|
||||||
|
case until > 0:
|
||||||
|
ttl = until
|
||||||
|
default:
|
||||||
|
ttl = time.Minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl); err != nil {
|
||||||
|
slog.Warn("openai_token_cache_set_failed", "account_id", account.ID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if err := p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl); err != nil {
|
|
||||||
slog.Warn("openai_token_cache_set_failed", "account_id", account.ID, "error", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import "context"
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
type TokenCacheInvalidator interface {
|
type TokenCacheInvalidator interface {
|
||||||
InvalidateToken(ctx context.Context, account *Account) error
|
InvalidateToken(ctx context.Context, account *Account) error
|
||||||
@@ -24,18 +28,87 @@ func (c *CompositeTokenCacheInvalidator) InvalidateToken(ctx context.Context, ac
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var cacheKey string
|
var keysToDelete []string
|
||||||
|
accountIDKey := "account:" + strconv.FormatInt(account.ID, 10)
|
||||||
|
|
||||||
switch account.Platform {
|
switch account.Platform {
|
||||||
case PlatformGemini:
|
case PlatformGemini:
|
||||||
cacheKey = GeminiTokenCacheKey(account)
|
// Gemini 可能有两种缓存键:project_id 或 account_id
|
||||||
|
// 首次获取 token 时可能没有 project_id,之后自动检测到 project_id 后会使用新 key
|
||||||
|
// 刷新时需要同时删除两种可能的 key,确保不会遗留旧缓存
|
||||||
|
keysToDelete = append(keysToDelete, GeminiTokenCacheKey(account))
|
||||||
|
keysToDelete = append(keysToDelete, "gemini:"+accountIDKey)
|
||||||
case PlatformAntigravity:
|
case PlatformAntigravity:
|
||||||
cacheKey = AntigravityTokenCacheKey(account)
|
// Antigravity 同样可能有两种缓存键
|
||||||
|
keysToDelete = append(keysToDelete, AntigravityTokenCacheKey(account))
|
||||||
|
keysToDelete = append(keysToDelete, "ag:"+accountIDKey)
|
||||||
case PlatformOpenAI:
|
case PlatformOpenAI:
|
||||||
cacheKey = OpenAITokenCacheKey(account)
|
keysToDelete = append(keysToDelete, OpenAITokenCacheKey(account))
|
||||||
case PlatformAnthropic:
|
case PlatformAnthropic:
|
||||||
cacheKey = ClaudeTokenCacheKey(account)
|
keysToDelete = append(keysToDelete, ClaudeTokenCacheKey(account))
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return c.cache.DeleteAccessToken(ctx, cacheKey)
|
|
||||||
|
// 删除所有可能的缓存键(去重后)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, key := range keysToDelete {
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
if err := c.cache.DeleteAccessToken(ctx, key); err != nil {
|
||||||
|
slog.Warn("token_cache_delete_failed", "key", key, "account_id", account.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckTokenVersion 检查 account 的 token 版本是否已过时,并返回最新的 account
|
||||||
|
// 用于解决异步刷新任务与请求线程的竞态条件:
|
||||||
|
// 如果刷新任务已更新 token 并删除缓存,此时请求线程的旧 account 对象不应写入缓存
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - latestAccount: 从 DB 获取的最新 account(如果查询失败则返回 nil)
|
||||||
|
// - isStale: true 表示 token 已过时(应使用 latestAccount),false 表示可以使用当前 account
|
||||||
|
func CheckTokenVersion(ctx context.Context, account *Account, repo AccountRepository) (latestAccount *Account, isStale bool) {
|
||||||
|
if account == nil || repo == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
currentVersion := account.GetCredentialAsInt64("_token_version")
|
||||||
|
|
||||||
|
latestAccount, err := repo.GetByID(ctx, account.ID)
|
||||||
|
if err != nil || latestAccount == nil {
|
||||||
|
// 查询失败,默认允许缓存,不返回 latestAccount
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
latestVersion := latestAccount.GetCredentialAsInt64("_token_version")
|
||||||
|
|
||||||
|
// 情况1: 当前 account 没有版本号,但 DB 中已有版本号
|
||||||
|
// 说明异步刷新任务已更新 token,当前 account 已过时
|
||||||
|
if currentVersion == 0 && latestVersion > 0 {
|
||||||
|
slog.Debug("token_version_stale_no_current_version",
|
||||||
|
"account_id", account.ID,
|
||||||
|
"latest_version", latestVersion)
|
||||||
|
return latestAccount, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 情况2: 两边都没有版本号,说明从未被异步刷新过,允许缓存
|
||||||
|
if currentVersion == 0 && latestVersion == 0 {
|
||||||
|
return latestAccount, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 情况3: 比较版本号,如果 DB 中的版本更新,当前 account 已过时
|
||||||
|
if latestVersion > currentVersion {
|
||||||
|
slog.Debug("token_version_stale",
|
||||||
|
"account_id", account.ID,
|
||||||
|
"current_version", currentVersion,
|
||||||
|
"latest_version", latestVersion)
|
||||||
|
return latestAccount, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestAccount, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,27 @@ func TestCompositeTokenCacheInvalidator_Gemini(t *testing.T) {
|
|||||||
|
|
||||||
err := invalidator.InvalidateToken(context.Background(), account)
|
err := invalidator.InvalidateToken(context.Background(), account)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, []string{"gemini:project-x"}, cache.deletedKeys)
|
// 新行为:同时删除基于 project_id 和 account_id 的缓存键
|
||||||
|
// 这是为了处理:首次获取 token 时可能没有 project_id,之后自动检测到后会使用新 key
|
||||||
|
require.Equal(t, []string{"gemini:project-x", "gemini:account:10"}, cache.deletedKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompositeTokenCacheInvalidator_GeminiWithoutProjectID(t *testing.T) {
|
||||||
|
cache := &geminiTokenCacheStub{}
|
||||||
|
invalidator := NewCompositeTokenCacheInvalidator(cache)
|
||||||
|
account := &Account{
|
||||||
|
ID: 10,
|
||||||
|
Platform: PlatformGemini,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"access_token": "gemini-token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := invalidator.InvalidateToken(context.Background(), account)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// 没有 project_id 时,两个 key 相同,去重后只删除一个
|
||||||
|
require.Equal(t, []string{"gemini:account:10"}, cache.deletedKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompositeTokenCacheInvalidator_Antigravity(t *testing.T) {
|
func TestCompositeTokenCacheInvalidator_Antigravity(t *testing.T) {
|
||||||
@@ -68,7 +88,26 @@ func TestCompositeTokenCacheInvalidator_Antigravity(t *testing.T) {
|
|||||||
|
|
||||||
err := invalidator.InvalidateToken(context.Background(), account)
|
err := invalidator.InvalidateToken(context.Background(), account)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, []string{"ag:ag-project"}, cache.deletedKeys)
|
// 新行为:同时删除基于 project_id 和 account_id 的缓存键
|
||||||
|
require.Equal(t, []string{"ag:ag-project", "ag:account:99"}, cache.deletedKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompositeTokenCacheInvalidator_AntigravityWithoutProjectID(t *testing.T) {
|
||||||
|
cache := &geminiTokenCacheStub{}
|
||||||
|
invalidator := NewCompositeTokenCacheInvalidator(cache)
|
||||||
|
account := &Account{
|
||||||
|
ID: 99,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"access_token": "ag-token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := invalidator.InvalidateToken(context.Background(), account)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// 没有 project_id 时,两个 key 相同,去重后只删除一个
|
||||||
|
require.Equal(t, []string{"ag:account:99"}, cache.deletedKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompositeTokenCacheInvalidator_OpenAI(t *testing.T) {
|
func TestCompositeTokenCacheInvalidator_OpenAI(t *testing.T) {
|
||||||
@@ -233,9 +272,10 @@ func TestCompositeTokenCacheInvalidator_DeleteError(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// 新行为:删除失败只记录日志,不返回错误
|
||||||
|
// 这是因为缓存失效失败不应影响主业务流程
|
||||||
err := invalidator.InvalidateToken(context.Background(), tt.account)
|
err := invalidator.InvalidateToken(context.Background(), tt.account)
|
||||||
require.Error(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, expectedErr, err)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,9 +292,12 @@ func TestCompositeTokenCacheInvalidator_AllPlatformsIntegration(t *testing.T) {
|
|||||||
{ID: 4, Platform: PlatformAnthropic, Type: AccountTypeOAuth},
|
{ID: 4, Platform: PlatformAnthropic, Type: AccountTypeOAuth},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新行为:Gemini 和 Antigravity 会同时删除基于 project_id 和 account_id 的键
|
||||||
expectedKeys := []string{
|
expectedKeys := []string{
|
||||||
"gemini:gemini-proj",
|
"gemini:gemini-proj",
|
||||||
|
"gemini:account:1",
|
||||||
"ag:ag-proj",
|
"ag:ag-proj",
|
||||||
|
"ag:account:2",
|
||||||
"openai:account:3",
|
"openai:account:3",
|
||||||
"claude:account:4",
|
"claude:account:4",
|
||||||
}
|
}
|
||||||
@@ -266,3 +309,239 @@ func TestCompositeTokenCacheInvalidator_AllPlatformsIntegration(t *testing.T) {
|
|||||||
|
|
||||||
require.Equal(t, expectedKeys, cache.deletedKeys)
|
require.Equal(t, expectedKeys, cache.deletedKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== GetCredentialAsInt64 测试 ==========
|
||||||
|
|
||||||
|
func TestAccount_GetCredentialAsInt64(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
credentials map[string]any
|
||||||
|
key string
|
||||||
|
expected int64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "int64_value",
|
||||||
|
credentials: map[string]any{"_token_version": int64(1737654321000)},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 1737654321000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "float64_value",
|
||||||
|
credentials: map[string]any{"_token_version": float64(1737654321000)},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 1737654321000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "int_value",
|
||||||
|
credentials: map[string]any{"_token_version": 12345},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 12345,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "string_value",
|
||||||
|
credentials: map[string]any{"_token_version": "1737654321000"},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 1737654321000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "string_with_spaces",
|
||||||
|
credentials: map[string]any{"_token_version": " 1737654321000 "},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 1737654321000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil_credentials",
|
||||||
|
credentials: nil,
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing_key",
|
||||||
|
credentials: map[string]any{"other_key": 123},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil_value",
|
||||||
|
credentials: map[string]any{"_token_version": nil},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid_string",
|
||||||
|
credentials: map[string]any{"_token_version": "not_a_number"},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty_string",
|
||||||
|
credentials: map[string]any{"_token_version": ""},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
account := &Account{Credentials: tt.credentials}
|
||||||
|
result := account.GetCredentialAsInt64(tt.key)
|
||||||
|
require.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_GetCredentialAsInt64_NilAccount(t *testing.T) {
|
||||||
|
var account *Account
|
||||||
|
result := account.GetCredentialAsInt64("_token_version")
|
||||||
|
require.Equal(t, int64(0), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== CheckTokenVersion 测试 ==========
|
||||||
|
|
||||||
|
func TestCheckTokenVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
account *Account
|
||||||
|
latestAccount *Account
|
||||||
|
repoErr error
|
||||||
|
expectedStale bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil_account",
|
||||||
|
account: nil,
|
||||||
|
latestAccount: nil,
|
||||||
|
expectedStale: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no_version_in_account_but_db_has_version",
|
||||||
|
account: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{},
|
||||||
|
},
|
||||||
|
latestAccount: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
},
|
||||||
|
expectedStale: true, // 当前 account 无版本但 DB 有,说明已被异步刷新,当前已过时
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both_no_version",
|
||||||
|
account: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{},
|
||||||
|
},
|
||||||
|
latestAccount: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{},
|
||||||
|
},
|
||||||
|
expectedStale: false, // 两边都没有版本号,说明从未被异步刷新过,允许缓存
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same_version",
|
||||||
|
account: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
},
|
||||||
|
latestAccount: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
},
|
||||||
|
expectedStale: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "current_version_newer",
|
||||||
|
account: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(200)},
|
||||||
|
},
|
||||||
|
latestAccount: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
},
|
||||||
|
expectedStale: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "current_version_older_stale",
|
||||||
|
account: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
},
|
||||||
|
latestAccount: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(200)},
|
||||||
|
},
|
||||||
|
expectedStale: true, // 当前版本过时
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repo_error",
|
||||||
|
account: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
},
|
||||||
|
latestAccount: nil,
|
||||||
|
repoErr: errors.New("db error"),
|
||||||
|
expectedStale: false, // 查询失败,默认允许缓存
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repo_returns_nil",
|
||||||
|
account: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
},
|
||||||
|
latestAccount: nil,
|
||||||
|
repoErr: nil,
|
||||||
|
expectedStale: false, // 查询返回 nil,默认允许缓存
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// 由于 CheckTokenVersion 接受 AccountRepository 接口,而创建完整的 mock 很繁琐
|
||||||
|
// 这里我们直接测试函数的核心逻辑来验证行为
|
||||||
|
|
||||||
|
if tt.name == "nil_account" {
|
||||||
|
_, isStale := CheckTokenVersion(context.Background(), nil, nil)
|
||||||
|
require.Equal(t, tt.expectedStale, isStale)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟 CheckTokenVersion 的核心逻辑
|
||||||
|
account := tt.account
|
||||||
|
currentVersion := account.GetCredentialAsInt64("_token_version")
|
||||||
|
|
||||||
|
// 模拟 repo 查询
|
||||||
|
latestAccount := tt.latestAccount
|
||||||
|
if tt.repoErr != nil || latestAccount == nil {
|
||||||
|
require.Equal(t, tt.expectedStale, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
latestVersion := latestAccount.GetCredentialAsInt64("_token_version")
|
||||||
|
|
||||||
|
// 情况1: 当前 account 没有版本号,但 DB 中已有版本号
|
||||||
|
if currentVersion == 0 && latestVersion > 0 {
|
||||||
|
require.Equal(t, tt.expectedStale, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 情况2: 两边都没有版本号
|
||||||
|
if currentVersion == 0 && latestVersion == 0 {
|
||||||
|
require.Equal(t, tt.expectedStale, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 情况3: 比较版本号
|
||||||
|
isStale := latestVersion > currentVersion
|
||||||
|
require.Equal(t, tt.expectedStale, isStale)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckTokenVersion_NilRepo(t *testing.T) {
|
||||||
|
account := &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
}
|
||||||
|
_, isStale := CheckTokenVersion(context.Background(), account, nil)
|
||||||
|
require.False(t, isStale) // nil repo,默认允许缓存
|
||||||
|
}
|
||||||
|
|||||||
@@ -169,6 +169,10 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc
|
|||||||
|
|
||||||
// 如果有新凭证,先更新(即使有错误也要保存 token)
|
// 如果有新凭证,先更新(即使有错误也要保存 token)
|
||||||
if newCredentials != nil {
|
if newCredentials != nil {
|
||||||
|
// 记录刷新版本时间戳,用于解决缓存一致性问题
|
||||||
|
// TokenProvider 写入缓存前会检查此版本,如果版本已更新则跳过写入
|
||||||
|
newCredentials["_token_version"] = time.Now().UnixMilli()
|
||||||
|
|
||||||
account.Credentials = newCredentials
|
account.Credentials = newCredentials
|
||||||
if saveErr := s.accountRepo.Update(ctx, account); saveErr != nil {
|
if saveErr := s.accountRepo.Update(ctx, account); saveErr != nil {
|
||||||
return fmt.Errorf("failed to save credentials: %w", saveErr)
|
return fmt.Errorf("failed to save credentials: %w", saveErr)
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Main Status Badge -->
|
<!-- Rate Limit Display (429) - Two-line layout -->
|
||||||
<button
|
<div v-if="isRateLimited" class="flex flex-col items-center gap-1">
|
||||||
v-if="isTempUnschedulable"
|
<span class="badge text-xs badge-warning">{{ t('admin.accounts.status.rateLimited') }}</span>
|
||||||
type="button"
|
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ rateLimitCountdown }}</span>
|
||||||
:class="['badge text-xs', statusClass, 'cursor-pointer']"
|
</div>
|
||||||
:title="t('admin.accounts.status.viewTempUnschedDetails')"
|
|
||||||
@click="handleTempUnschedClick"
|
<!-- Overload Display (529) - Two-line layout -->
|
||||||
>
|
<div v-else-if="isOverloaded" class="flex flex-col items-center gap-1">
|
||||||
{{ statusText }}
|
<span class="badge text-xs badge-danger">{{ t('admin.accounts.status.overloaded') }}</span>
|
||||||
</button>
|
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ overloadCountdown }}</span>
|
||||||
<span v-else :class="['badge text-xs', statusClass]">
|
</div>
|
||||||
{{ statusText }}
|
|
||||||
</span>
|
<!-- Main Status Badge (shown when not rate limited/overloaded) -->
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
v-if="isTempUnschedulable"
|
||||||
|
type="button"
|
||||||
|
:class="['badge text-xs', statusClass, 'cursor-pointer']"
|
||||||
|
:title="t('admin.accounts.status.viewTempUnschedDetails')"
|
||||||
|
@click="handleTempUnschedClick"
|
||||||
|
>
|
||||||
|
{{ statusText }}
|
||||||
|
</button>
|
||||||
|
<span v-else :class="['badge text-xs', statusClass]">
|
||||||
|
{{ statusText }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Error Info Indicator -->
|
<!-- Error Info Indicator -->
|
||||||
<div v-if="hasError && account.error_message" class="group/error relative">
|
<div v-if="hasError && account.error_message" class="group/error relative">
|
||||||
@@ -42,44 +56,6 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rate Limit Indicator (429) -->
|
|
||||||
<div v-if="isRateLimited" class="group relative">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
|
||||||
>
|
|
||||||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
|
||||||
429
|
|
||||||
</span>
|
|
||||||
<!-- Tooltip -->
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
|
||||||
>
|
|
||||||
{{ t('admin.accounts.status.rateLimitedUntil', { time: formatTime(account.rate_limit_reset_at) }) }}
|
|
||||||
<div
|
|
||||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Overload Indicator (529) -->
|
|
||||||
<div v-if="isOverloaded" class="group relative">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
|
||||||
>
|
|
||||||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
|
||||||
529
|
|
||||||
</span>
|
|
||||||
<!-- Tooltip -->
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
|
||||||
>
|
|
||||||
{{ t('admin.accounts.status.overloadedUntil', { time: formatTime(account.overload_until) }) }}
|
|
||||||
<div
|
|
||||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -87,8 +63,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { Account } from '@/types'
|
import type { Account } from '@/types'
|
||||||
import { formatTime } from '@/utils/format'
|
import { formatCountdownWithSuffix } from '@/utils/format'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -123,6 +98,16 @@ const hasError = computed(() => {
|
|||||||
return props.account.status === 'error'
|
return props.account.status === 'error'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Computed: countdown text for rate limit (429)
|
||||||
|
const rateLimitCountdown = computed(() => {
|
||||||
|
return formatCountdownWithSuffix(props.account.rate_limit_reset_at)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Computed: countdown text for overload (529)
|
||||||
|
const overloadCountdown = computed(() => {
|
||||||
|
return formatCountdownWithSuffix(props.account.overload_until)
|
||||||
|
})
|
||||||
|
|
||||||
// Computed: status badge class
|
// Computed: status badge class
|
||||||
const statusClass = computed(() => {
|
const statusClass = computed(() => {
|
||||||
if (hasError.value) {
|
if (hasError.value) {
|
||||||
@@ -131,7 +116,7 @@ const statusClass = computed(() => {
|
|||||||
if (isTempUnschedulable.value) {
|
if (isTempUnschedulable.value) {
|
||||||
return 'badge-warning'
|
return 'badge-warning'
|
||||||
}
|
}
|
||||||
if (!props.account.schedulable || isRateLimited.value || isOverloaded.value) {
|
if (!props.account.schedulable) {
|
||||||
return 'badge-gray'
|
return 'badge-gray'
|
||||||
}
|
}
|
||||||
switch (props.account.status) {
|
switch (props.account.status) {
|
||||||
@@ -157,9 +142,6 @@ const statusText = computed(() => {
|
|||||||
if (!props.account.schedulable) {
|
if (!props.account.schedulable) {
|
||||||
return t('admin.accounts.status.paused')
|
return t('admin.accounts.status.paused')
|
||||||
}
|
}
|
||||||
if (isRateLimited.value || isOverloaded.value) {
|
|
||||||
return t('admin.accounts.status.limited')
|
|
||||||
}
|
|
||||||
return t(`admin.accounts.status.${props.account.status}`)
|
return t(`admin.accounts.status.${props.account.status}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -167,5 +149,4 @@ const handleTempUnschedClick = () => {
|
|||||||
if (!isTempUnschedulable.value) return
|
if (!isTempUnschedulable.value) return
|
||||||
emit('show-temp-unsched', props.account)
|
emit('show-temp-unsched', props.account)
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,50 +1,78 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="show && position" class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800" :style="{ top: position.top + 'px', left: position.left + 'px' }">
|
<div v-if="show && position">
|
||||||
<div class="py-1">
|
<!-- Backdrop: click anywhere outside to close -->
|
||||||
<template v-if="account">
|
<div class="fixed inset-0 z-[9998]" @click="emit('close')"></div>
|
||||||
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
<div
|
||||||
<Icon name="play" size="sm" class="text-green-500" :stroke-width="2" />
|
class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800"
|
||||||
{{ t('admin.accounts.testConnection') }}
|
:style="{ top: position.top + 'px', left: position.left + 'px' }"
|
||||||
</button>
|
@click.stop
|
||||||
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
>
|
||||||
<Icon name="chart" size="sm" class="text-indigo-500" />
|
<div class="py-1">
|
||||||
{{ t('admin.accounts.viewStats') }}
|
<template v-if="account">
|
||||||
</button>
|
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||||
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
|
<Icon name="play" size="sm" class="text-green-500" :stroke-width="2" />
|
||||||
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
{{ t('admin.accounts.testConnection') }}
|
||||||
<Icon name="link" size="sm" />
|
|
||||||
{{ t('admin.accounts.reAuthorize') }}
|
|
||||||
</button>
|
</button>
|
||||||
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||||
<Icon name="refresh" size="sm" />
|
<Icon name="chart" size="sm" class="text-indigo-500" />
|
||||||
{{ t('admin.accounts.refreshToken') }}
|
{{ t('admin.accounts.viewStats') }}
|
||||||
|
</button>
|
||||||
|
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
|
||||||
|
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||||
|
<Icon name="link" size="sm" />
|
||||||
|
{{ t('admin.accounts.reAuthorize') }}
|
||||||
|
</button>
|
||||||
|
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
{{ t('admin.accounts.refreshToken') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<div v-if="account.status === 'error' || isRateLimited || isOverloaded" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
|
||||||
|
<button v-if="account.status === 'error'" @click="$emit('reset-status', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||||
|
<Icon name="sync" size="sm" />
|
||||||
|
{{ t('admin.accounts.resetStatus') }}
|
||||||
|
</button>
|
||||||
|
<button v-if="isRateLimited || isOverloaded" @click="$emit('clear-rate-limit', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||||
|
<Icon name="clock" size="sm" />
|
||||||
|
{{ t('admin.accounts.clearRateLimit') }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="account.status === 'error' || isRateLimited || isOverloaded" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
|
</div>
|
||||||
<button v-if="account.status === 'error'" @click="$emit('reset-status', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
|
||||||
<Icon name="sync" size="sm" />
|
|
||||||
{{ t('admin.accounts.resetStatus') }}
|
|
||||||
</button>
|
|
||||||
<button v-if="isRateLimited || isOverloaded" @click="$emit('clear-rate-limit', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
|
||||||
<Icon name="clock" size="sm" />
|
|
||||||
{{ t('admin.accounts.clearRateLimit') }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, watch, onUnmounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Icon } from '@/components/icons'
|
import { Icon } from '@/components/icons'
|
||||||
import type { Account } from '@/types'
|
import type { Account } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
|
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
|
||||||
defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit'])
|
const emit = defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit'])
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const isRateLimited = computed(() => props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date())
|
const isRateLimited = computed(() => props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date())
|
||||||
const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date())
|
const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date())
|
||||||
|
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -279,18 +279,143 @@ interface Props {
|
|||||||
expandableActions?: boolean
|
expandableActions?: boolean
|
||||||
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
|
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
|
||||||
rowKey?: string | ((row: any) => string | number)
|
rowKey?: string | ((row: any) => string | number)
|
||||||
|
/**
|
||||||
|
* Default sort configuration (only applied when there is no persisted sort state)
|
||||||
|
*/
|
||||||
|
defaultSortKey?: string
|
||||||
|
defaultSortOrder?: 'asc' | 'desc'
|
||||||
|
/**
|
||||||
|
* Persist sort state (key + order) to localStorage using this key.
|
||||||
|
* If provided, DataTable will load the stored sort state on mount.
|
||||||
|
*/
|
||||||
|
sortStorageKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
loading: false,
|
loading: false,
|
||||||
stickyFirstColumn: true,
|
stickyFirstColumn: true,
|
||||||
stickyActionsColumn: true,
|
stickyActionsColumn: true,
|
||||||
expandableActions: true
|
expandableActions: true,
|
||||||
|
defaultSortOrder: 'asc'
|
||||||
})
|
})
|
||||||
|
|
||||||
const sortKey = ref<string>('')
|
const sortKey = ref<string>('')
|
||||||
const sortOrder = ref<'asc' | 'desc'>('asc')
|
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||||
const actionsExpanded = ref(false)
|
const actionsExpanded = ref(false)
|
||||||
|
|
||||||
|
type PersistedSortState = {
|
||||||
|
key: string
|
||||||
|
order: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
const collator = new Intl.Collator(undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base'
|
||||||
|
})
|
||||||
|
|
||||||
|
const getSortableKeys = () => {
|
||||||
|
const keys = new Set<string>()
|
||||||
|
for (const col of props.columns) {
|
||||||
|
if (col.sortable) keys.add(col.key)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeSortKey = (candidate: string) => {
|
||||||
|
if (!candidate) return ''
|
||||||
|
const sortableKeys = getSortableKeys()
|
||||||
|
return sortableKeys.has(candidate) ? candidate : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeSortOrder = (candidate: any): 'asc' | 'desc' => {
|
||||||
|
return candidate === 'desc' ? 'desc' : 'asc'
|
||||||
|
}
|
||||||
|
|
||||||
|
const readPersistedSortState = (): PersistedSortState | null => {
|
||||||
|
if (!props.sortStorageKey) return null
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(props.sortStorageKey)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw) as Partial<PersistedSortState>
|
||||||
|
const key = normalizeSortKey(typeof parsed.key === 'string' ? parsed.key : '')
|
||||||
|
if (!key) return null
|
||||||
|
return { key, order: normalizeSortOrder(parsed.order) }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[DataTable] Failed to read persisted sort state:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writePersistedSortState = (state: PersistedSortState) => {
|
||||||
|
if (!props.sortStorageKey) return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(props.sortStorageKey, JSON.stringify(state))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[DataTable] Failed to persist sort state:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveInitialSortState = (): PersistedSortState | null => {
|
||||||
|
const persisted = readPersistedSortState()
|
||||||
|
if (persisted) return persisted
|
||||||
|
|
||||||
|
const key = normalizeSortKey(props.defaultSortKey || '')
|
||||||
|
if (!key) return null
|
||||||
|
return { key, order: normalizeSortOrder(props.defaultSortOrder) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const applySortState = (state: PersistedSortState | null) => {
|
||||||
|
if (!state) return
|
||||||
|
sortKey.value = state.key
|
||||||
|
sortOrder.value = state.order
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNullishOrEmpty = (value: any) => value === null || value === undefined || value === ''
|
||||||
|
|
||||||
|
const toFiniteNumberOrNull = (value: any): number | null => {
|
||||||
|
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||||||
|
if (typeof value === 'boolean') return value ? 1 : 0
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
const n = Number(trimmed)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSortableString = (value: any): string => {
|
||||||
|
if (value === null || value === undefined) return ''
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||||
|
if (value instanceof Date) return value.toISOString()
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareSortValues = (a: any, b: any): number => {
|
||||||
|
const aEmpty = isNullishOrEmpty(a)
|
||||||
|
const bEmpty = isNullishOrEmpty(b)
|
||||||
|
if (aEmpty && bEmpty) return 0
|
||||||
|
if (aEmpty) return 1
|
||||||
|
if (bEmpty) return -1
|
||||||
|
|
||||||
|
const aNum = toFiniteNumberOrNull(a)
|
||||||
|
const bNum = toFiniteNumberOrNull(b)
|
||||||
|
if (aNum !== null && bNum !== null) {
|
||||||
|
if (aNum === bNum) return 0
|
||||||
|
return aNum < bNum ? -1 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const aStr = toSortableString(a)
|
||||||
|
const bStr = toSortableString(b)
|
||||||
|
const res = collator.compare(aStr, bStr)
|
||||||
|
if (res === 0) return 0
|
||||||
|
return res < 0 ? -1 : 1
|
||||||
|
}
|
||||||
const resolveRowKey = (row: any, index: number) => {
|
const resolveRowKey = (row: any, index: number) => {
|
||||||
if (typeof props.rowKey === 'function') {
|
if (typeof props.rowKey === 'function') {
|
||||||
const key = props.rowKey(row)
|
const key = props.rowKey(row)
|
||||||
@@ -334,15 +459,18 @@ const handleSort = (key: string) => {
|
|||||||
const sortedData = computed(() => {
|
const sortedData = computed(() => {
|
||||||
if (!sortKey.value || !props.data) return props.data
|
if (!sortKey.value || !props.data) return props.data
|
||||||
|
|
||||||
return [...props.data].sort((a, b) => {
|
const key = sortKey.value
|
||||||
const aVal = a[sortKey.value]
|
const order = sortOrder.value
|
||||||
const bVal = b[sortKey.value]
|
|
||||||
|
|
||||||
if (aVal === bVal) return 0
|
// Stable sort (tie-break with original index) to avoid jitter when values are equal.
|
||||||
|
return props.data
|
||||||
const comparison = aVal > bVal ? 1 : -1
|
.map((row, index) => ({ row, index }))
|
||||||
return sortOrder.value === 'asc' ? comparison : -comparison
|
.sort((a, b) => {
|
||||||
})
|
const cmp = compareSortValues(a.row?.[key], b.row?.[key])
|
||||||
|
if (cmp !== 0) return order === 'asc' ? cmp : -cmp
|
||||||
|
return a.index - b.index
|
||||||
|
})
|
||||||
|
.map(item => item.row)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasActionsColumn = computed(() => {
|
const hasActionsColumn = computed(() => {
|
||||||
@@ -396,6 +524,51 @@ const getAdaptivePaddingClass = () => {
|
|||||||
return 'px-6' // 24px (原始值)
|
return 'px-6' // 24px (原始值)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Init + keep persisted sort state consistent with current columns
|
||||||
|
const didInitSort = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const initial = resolveInitialSortState()
|
||||||
|
applySortState(initial)
|
||||||
|
didInitSort.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.columns,
|
||||||
|
() => {
|
||||||
|
// If current sort key is no longer sortable/visible, fall back to default/persisted.
|
||||||
|
const normalized = normalizeSortKey(sortKey.value)
|
||||||
|
if (!sortKey.value) {
|
||||||
|
const initial = resolveInitialSortState()
|
||||||
|
applySortState(initial)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
const fallback = resolveInitialSortState()
|
||||||
|
if (fallback) {
|
||||||
|
applySortState(fallback)
|
||||||
|
} else {
|
||||||
|
sortKey.value = ''
|
||||||
|
sortOrder.value = 'asc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[sortKey, sortOrder],
|
||||||
|
([nextKey, nextOrder]) => {
|
||||||
|
if (!didInitSort.value) return
|
||||||
|
if (!props.sortStorageKey) return
|
||||||
|
const key = normalizeSortKey(nextKey)
|
||||||
|
if (!key) return
|
||||||
|
writePersistedSortState({ key, order: normalizeSortOrder(nextOrder) })
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ A generic data table component with sorting, loading states, and custom cell ren
|
|||||||
- `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter
|
- `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter
|
||||||
- `data: any[]` - Array of data objects to display
|
- `data: any[]` - Array of data objects to display
|
||||||
- `loading?: boolean` - Show loading skeleton
|
- `loading?: boolean` - Show loading skeleton
|
||||||
|
- `defaultSortKey?: string` - Default sort key (only used if no persisted sort state)
|
||||||
|
- `defaultSortOrder?: 'asc' | 'desc'` - Default sort order (default: `asc`)
|
||||||
|
- `sortStorageKey?: string` - Persist sort state (key + order) to localStorage
|
||||||
- `rowKey?: string | (row: any) => string | number` - Row key field or resolver (defaults to `row.id`, falls back to index)
|
- `rowKey?: string | (row: any) => string | number` - Row key field or resolver (defaults to `row.id`, falls back to index)
|
||||||
|
|
||||||
**Slots:**
|
**Slots:**
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface OAuthState {
|
|||||||
export interface TokenInfo {
|
export interface TokenInfo {
|
||||||
org_uuid?: string
|
org_uuid?: string
|
||||||
account_uuid?: string
|
account_uuid?: string
|
||||||
|
email_address?: string
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +161,9 @@ export function useAccountOAuth() {
|
|||||||
if (tokenInfo.account_uuid) {
|
if (tokenInfo.account_uuid) {
|
||||||
extra.account_uuid = tokenInfo.account_uuid
|
extra.account_uuid = tokenInfo.account_uuid
|
||||||
}
|
}
|
||||||
|
if (tokenInfo.email_address) {
|
||||||
|
extra.email_address = tokenInfo.email_address
|
||||||
|
}
|
||||||
return Object.keys(extra).length > 0 ? extra : undefined
|
return Object.keys(extra).length > 0 ? extra : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,7 +169,13 @@ export default {
|
|||||||
justNow: 'Just now',
|
justNow: 'Just now',
|
||||||
minutesAgo: '{n}m ago',
|
minutesAgo: '{n}m ago',
|
||||||
hoursAgo: '{n}h ago',
|
hoursAgo: '{n}h ago',
|
||||||
daysAgo: '{n}d ago'
|
daysAgo: '{n}d ago',
|
||||||
|
countdown: {
|
||||||
|
daysHours: '{d}d {h}h',
|
||||||
|
hoursMinutes: '{h}h {m}m',
|
||||||
|
minutes: '{m}m',
|
||||||
|
withSuffix: '{time} to lift'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1022,6 +1028,13 @@ export default {
|
|||||||
title: 'Account Management',
|
title: 'Account Management',
|
||||||
description: 'Manage AI platform accounts and credentials',
|
description: 'Manage AI platform accounts and credentials',
|
||||||
createAccount: 'Create Account',
|
createAccount: 'Create Account',
|
||||||
|
autoRefresh: 'Auto Refresh',
|
||||||
|
enableAutoRefresh: 'Enable auto refresh',
|
||||||
|
refreshInterval5s: '5 seconds',
|
||||||
|
refreshInterval10s: '10 seconds',
|
||||||
|
refreshInterval15s: '15 seconds',
|
||||||
|
refreshInterval30s: '30 seconds',
|
||||||
|
autoRefreshCountdown: 'Auto refresh: {seconds}s',
|
||||||
syncFromCrs: 'Sync from CRS',
|
syncFromCrs: 'Sync from CRS',
|
||||||
syncFromCrsTitle: 'Sync Accounts from CRS',
|
syncFromCrsTitle: 'Sync Accounts from CRS',
|
||||||
syncFromCrsDesc:
|
syncFromCrsDesc:
|
||||||
@@ -1083,6 +1096,8 @@ export default {
|
|||||||
cooldown: 'Cooldown',
|
cooldown: 'Cooldown',
|
||||||
paused: 'Paused',
|
paused: 'Paused',
|
||||||
limited: 'Limited',
|
limited: 'Limited',
|
||||||
|
rateLimited: 'Rate Limited',
|
||||||
|
overloaded: 'Overloaded',
|
||||||
tempUnschedulable: 'Temp Unschedulable',
|
tempUnschedulable: 'Temp Unschedulable',
|
||||||
rateLimitedUntil: 'Rate limited until {time}',
|
rateLimitedUntil: 'Rate limited until {time}',
|
||||||
overloadedUntil: 'Overloaded until {time}',
|
overloadedUntil: 'Overloaded until {time}',
|
||||||
|
|||||||
@@ -166,7 +166,13 @@ export default {
|
|||||||
justNow: '刚刚',
|
justNow: '刚刚',
|
||||||
minutesAgo: '{n}分钟前',
|
minutesAgo: '{n}分钟前',
|
||||||
hoursAgo: '{n}小时前',
|
hoursAgo: '{n}小时前',
|
||||||
daysAgo: '{n}天前'
|
daysAgo: '{n}天前',
|
||||||
|
countdown: {
|
||||||
|
daysHours: '{d}d {h}h',
|
||||||
|
hoursMinutes: '{h}h {m}m',
|
||||||
|
minutes: '{m}m',
|
||||||
|
withSuffix: '{time} 后解除'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1096,6 +1102,13 @@ export default {
|
|||||||
title: '账号管理',
|
title: '账号管理',
|
||||||
description: '管理 AI 平台账号和 Cookie',
|
description: '管理 AI 平台账号和 Cookie',
|
||||||
createAccount: '添加账号',
|
createAccount: '添加账号',
|
||||||
|
autoRefresh: '自动刷新',
|
||||||
|
enableAutoRefresh: '启用自动刷新',
|
||||||
|
refreshInterval5s: '5 秒',
|
||||||
|
refreshInterval10s: '10 秒',
|
||||||
|
refreshInterval15s: '15 秒',
|
||||||
|
refreshInterval30s: '30 秒',
|
||||||
|
autoRefreshCountdown: '自动刷新:{seconds}s',
|
||||||
syncFromCrs: '从 CRS 同步',
|
syncFromCrs: '从 CRS 同步',
|
||||||
syncFromCrsTitle: '从 CRS 同步账号',
|
syncFromCrsTitle: '从 CRS 同步账号',
|
||||||
syncFromCrsDesc:
|
syncFromCrsDesc:
|
||||||
@@ -1205,6 +1218,8 @@ export default {
|
|||||||
cooldown: '冷却中',
|
cooldown: '冷却中',
|
||||||
paused: '暂停',
|
paused: '暂停',
|
||||||
limited: '限流',
|
limited: '限流',
|
||||||
|
rateLimited: '限流中',
|
||||||
|
overloaded: '过载中',
|
||||||
tempUnschedulable: '临时不可调度',
|
tempUnschedulable: '临时不可调度',
|
||||||
rateLimitedUntil: '限流中,重置时间:{time}',
|
rateLimitedUntil: '限流中,重置时间:{time}',
|
||||||
overloadedUntil: '负载过重,重置时间:{time}',
|
overloadedUntil: '负载过重,重置时间:{time}',
|
||||||
|
|||||||
@@ -216,3 +216,48 @@ export function formatTokensK(tokens: number): string {
|
|||||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
||||||
return tokens.toString()
|
return tokens.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化倒计时(从现在到目标时间的剩余时间)
|
||||||
|
* @param targetDate 目标日期字符串或 Date 对象
|
||||||
|
* @returns 倒计时字符串,如 "2h 41m", "3d 5h", "15m"
|
||||||
|
*/
|
||||||
|
export function formatCountdown(targetDate: string | Date | null | undefined): string | null {
|
||||||
|
if (!targetDate) return null
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const target = new Date(targetDate)
|
||||||
|
const diffMs = target.getTime() - now.getTime()
|
||||||
|
|
||||||
|
// 如果目标时间已过或无效
|
||||||
|
if (diffMs <= 0 || isNaN(diffMs)) return null
|
||||||
|
|
||||||
|
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||||
|
const diffHours = Math.floor(diffMins / 60)
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
|
||||||
|
const remainingHours = diffHours % 24
|
||||||
|
const remainingMins = diffMins % 60
|
||||||
|
|
||||||
|
if (diffDays > 0) {
|
||||||
|
// 超过1天:显示 "Xd Yh"
|
||||||
|
return i18n.global.t('common.time.countdown.daysHours', { d: diffDays, h: remainingHours })
|
||||||
|
}
|
||||||
|
if (diffHours > 0) {
|
||||||
|
// 小于1天:显示 "Xh Ym"
|
||||||
|
return i18n.global.t('common.time.countdown.hoursMinutes', { h: diffHours, m: remainingMins })
|
||||||
|
}
|
||||||
|
// 小于1小时:显示 "Ym"
|
||||||
|
return i18n.global.t('common.time.countdown.minutes', { m: diffMins })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化倒计时并带后缀(如 "2h 41m 后解除")
|
||||||
|
* @param targetDate 目标日期字符串或 Date 对象
|
||||||
|
* @returns 完整的倒计时字符串,如 "2h 41m to lift", "2小时41分钟后解除"
|
||||||
|
*/
|
||||||
|
export function formatCountdownWithSuffix(targetDate: string | Date | null | undefined): string | null {
|
||||||
|
const countdown = formatCountdown(targetDate)
|
||||||
|
if (!countdown) return null
|
||||||
|
return i18n.global.t('common.time.countdown.withSuffix', { time: countdown })
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,10 +17,58 @@
|
|||||||
@create="showCreate = true"
|
@create="showCreate = true"
|
||||||
>
|
>
|
||||||
<template #after>
|
<template #after>
|
||||||
|
<!-- Auto Refresh Dropdown -->
|
||||||
|
<div class="relative" ref="autoRefreshDropdownRef">
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
showAutoRefreshDropdown = !showAutoRefreshDropdown;
|
||||||
|
showColumnDropdown = false
|
||||||
|
"
|
||||||
|
class="btn btn-secondary px-2 md:px-3"
|
||||||
|
:title="t('admin.accounts.autoRefresh')"
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" :class="[autoRefreshEnabled ? 'animate-spin' : '']" />
|
||||||
|
<span class="hidden md:inline">
|
||||||
|
{{
|
||||||
|
autoRefreshEnabled
|
||||||
|
? t('admin.accounts.autoRefreshCountdown', { seconds: autoRefreshCountdown })
|
||||||
|
: t('admin.accounts.autoRefresh')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showAutoRefreshDropdown"
|
||||||
|
class="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div class="p-2">
|
||||||
|
<button
|
||||||
|
@click="setAutoRefreshEnabled(!autoRefreshEnabled)"
|
||||||
|
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<span>{{ t('admin.accounts.enableAutoRefresh') }}</span>
|
||||||
|
<Icon v-if="autoRefreshEnabled" name="check" size="sm" class="text-primary-500" />
|
||||||
|
</button>
|
||||||
|
<div class="my-1 border-t border-gray-100 dark:border-gray-700"></div>
|
||||||
|
<button
|
||||||
|
v-for="sec in autoRefreshIntervals"
|
||||||
|
:key="sec"
|
||||||
|
@click="setAutoRefreshInterval(sec)"
|
||||||
|
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<span>{{ autoRefreshIntervalLabel(sec) }}</span>
|
||||||
|
<Icon v-if="autoRefreshIntervalSeconds === sec" name="check" size="sm" class="text-primary-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Column Settings Dropdown -->
|
<!-- Column Settings Dropdown -->
|
||||||
<div class="relative" ref="columnDropdownRef">
|
<div class="relative" ref="columnDropdownRef">
|
||||||
<button
|
<button
|
||||||
@click="showColumnDropdown = !showColumnDropdown"
|
@click="
|
||||||
|
showColumnDropdown = !showColumnDropdown;
|
||||||
|
showAutoRefreshDropdown = false
|
||||||
|
"
|
||||||
class="btn btn-secondary px-2 md:px-3"
|
class="btn btn-secondary px-2 md:px-3"
|
||||||
:title="t('admin.users.columnSettings')"
|
:title="t('admin.users.columnSettings')"
|
||||||
>
|
>
|
||||||
@@ -53,12 +101,29 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #table>
|
<template #table>
|
||||||
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
||||||
<DataTable :columns="cols" :data="accounts" :loading="loading" row-key="id">
|
<DataTable
|
||||||
|
:columns="cols"
|
||||||
|
:data="accounts"
|
||||||
|
:loading="loading"
|
||||||
|
row-key="id"
|
||||||
|
default-sort-key="name"
|
||||||
|
default-sort-order="asc"
|
||||||
|
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
|
||||||
|
>
|
||||||
<template #cell-select="{ row }">
|
<template #cell-select="{ row }">
|
||||||
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
||||||
</template>
|
</template>
|
||||||
<template #cell-name="{ value }">
|
<template #cell-name="{ row, value }">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
<div class="flex flex-col">
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||||
|
<span
|
||||||
|
v-if="row.extra?.email_address"
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]"
|
||||||
|
:title="row.extra.email_address"
|
||||||
|
>
|
||||||
|
{{ row.extra.email_address }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-notes="{ value }">
|
<template #cell-notes="{ value }">
|
||||||
<span v-if="value" :title="value" class="block max-w-xs truncate text-sm text-gray-600 dark:text-gray-300">{{ value }}</span>
|
<span v-if="value" :title="value" class="block max-w-xs truncate text-sm text-gray-600 dark:text-gray-300">{{ value }}</span>
|
||||||
@@ -161,6 +226,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useIntervalFn } from '@vueuse/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -221,6 +287,26 @@ const hiddenColumns = reactive<Set<string>>(new Set())
|
|||||||
const DEFAULT_HIDDEN_COLUMNS = ['proxy', 'notes', 'priority', 'rate_multiplier']
|
const DEFAULT_HIDDEN_COLUMNS = ['proxy', 'notes', 'priority', 'rate_multiplier']
|
||||||
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
|
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
|
||||||
|
|
||||||
|
// Sorting settings
|
||||||
|
const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort'
|
||||||
|
|
||||||
|
// Auto refresh settings
|
||||||
|
const showAutoRefreshDropdown = ref(false)
|
||||||
|
const autoRefreshDropdownRef = ref<HTMLElement | null>(null)
|
||||||
|
const AUTO_REFRESH_STORAGE_KEY = 'account-auto-refresh'
|
||||||
|
const autoRefreshIntervals = [5, 10, 15, 30] as const
|
||||||
|
const autoRefreshEnabled = ref(false)
|
||||||
|
const autoRefreshIntervalSeconds = ref<(typeof autoRefreshIntervals)[number]>(30)
|
||||||
|
const autoRefreshCountdown = ref(0)
|
||||||
|
|
||||||
|
const autoRefreshIntervalLabel = (sec: number) => {
|
||||||
|
if (sec === 5) return t('admin.accounts.refreshInterval5s')
|
||||||
|
if (sec === 10) return t('admin.accounts.refreshInterval10s')
|
||||||
|
if (sec === 15) return t('admin.accounts.refreshInterval15s')
|
||||||
|
if (sec === 30) return t('admin.accounts.refreshInterval30s')
|
||||||
|
return `${sec}s`
|
||||||
|
}
|
||||||
|
|
||||||
const loadSavedColumns = () => {
|
const loadSavedColumns = () => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
||||||
@@ -244,6 +330,60 @@ const saveColumnsToStorage = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadSavedAutoRefresh = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(AUTO_REFRESH_STORAGE_KEY)
|
||||||
|
if (!saved) return
|
||||||
|
const parsed = JSON.parse(saved) as { enabled?: boolean; interval_seconds?: number }
|
||||||
|
autoRefreshEnabled.value = parsed.enabled === true
|
||||||
|
const interval = Number(parsed.interval_seconds)
|
||||||
|
if (autoRefreshIntervals.includes(interval as any)) {
|
||||||
|
autoRefreshIntervalSeconds.value = interval as any
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load saved auto refresh settings:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveAutoRefreshToStorage = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
AUTO_REFRESH_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
enabled: autoRefreshEnabled.value,
|
||||||
|
interval_seconds: autoRefreshIntervalSeconds.value
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save auto refresh settings:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
loadSavedColumns()
|
||||||
|
loadSavedAutoRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAutoRefreshEnabled = (enabled: boolean) => {
|
||||||
|
autoRefreshEnabled.value = enabled
|
||||||
|
saveAutoRefreshToStorage()
|
||||||
|
if (enabled) {
|
||||||
|
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||||
|
resumeAutoRefresh()
|
||||||
|
} else {
|
||||||
|
pauseAutoRefresh()
|
||||||
|
autoRefreshCountdown.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAutoRefreshInterval = (seconds: (typeof autoRefreshIntervals)[number]) => {
|
||||||
|
autoRefreshIntervalSeconds.value = seconds
|
||||||
|
saveAutoRefreshToStorage()
|
||||||
|
if (autoRefreshEnabled.value) {
|
||||||
|
autoRefreshCountdown.value = seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleColumn = (key: string) => {
|
const toggleColumn = (key: string) => {
|
||||||
if (hiddenColumns.has(key)) {
|
if (hiddenColumns.has(key)) {
|
||||||
hiddenColumns.delete(key)
|
hiddenColumns.delete(key)
|
||||||
@@ -260,6 +400,44 @@ const { items: accounts, loading, params, pagination, load, reload, debouncedRel
|
|||||||
initialParams: { platform: '', type: '', status: '', search: '' }
|
initialParams: { platform: '', type: '', status: '', search: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isAnyModalOpen = computed(() => {
|
||||||
|
return (
|
||||||
|
showCreate.value ||
|
||||||
|
showEdit.value ||
|
||||||
|
showSync.value ||
|
||||||
|
showBulkEdit.value ||
|
||||||
|
showTempUnsched.value ||
|
||||||
|
showDeleteDialog.value ||
|
||||||
|
showReAuth.value ||
|
||||||
|
showTest.value ||
|
||||||
|
showStats.value
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
|
||||||
|
async () => {
|
||||||
|
if (!autoRefreshEnabled.value) return
|
||||||
|
if (document.hidden) return
|
||||||
|
if (loading.value) return
|
||||||
|
if (isAnyModalOpen.value) return
|
||||||
|
if (menu.show) return
|
||||||
|
|
||||||
|
if (autoRefreshCountdown.value <= 0) {
|
||||||
|
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||||
|
try {
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Auto refresh failed:', e)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
autoRefreshCountdown.value -= 1
|
||||||
|
},
|
||||||
|
1000,
|
||||||
|
{ immediate: false }
|
||||||
|
)
|
||||||
|
|
||||||
// All available columns
|
// All available columns
|
||||||
const allColumns = computed(() => {
|
const allColumns = computed(() => {
|
||||||
const c = [
|
const c = [
|
||||||
@@ -512,10 +690,12 @@ const handleClickOutside = (event: MouseEvent) => {
|
|||||||
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
||||||
showColumnDropdown.value = false
|
showColumnDropdown.value = false
|
||||||
}
|
}
|
||||||
|
if (autoRefreshDropdownRef.value && !autoRefreshDropdownRef.value.contains(target)) {
|
||||||
|
showAutoRefreshDropdown.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loadSavedColumns()
|
|
||||||
load()
|
load()
|
||||||
try {
|
try {
|
||||||
const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()])
|
const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()])
|
||||||
@@ -526,6 +706,13 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
window.addEventListener('scroll', handleScroll, true)
|
window.addEventListener('scroll', handleScroll, true)
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
|
||||||
|
if (autoRefreshEnabled.value) {
|
||||||
|
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||||
|
resumeAutoRefresh()
|
||||||
|
} else {
|
||||||
|
pauseAutoRefresh()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user