From 037a409919338dd89767440ceab87060117c6a77 Mon Sep 17 00:00:00 2001 From: iBenzene Date: Fri, 6 Feb 2026 00:59:06 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BA=86=20codex?= =?UTF-8?q?=20=E6=9B=B4=E6=96=B0=E7=94=A8=E9=87=8F=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E7=9A=84=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/repository/account_repo.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index e4e837e2..11c206d8 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -1089,8 +1089,9 @@ func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates m result, err := client.ExecContext( ctx, "UPDATE accounts SET extra = COALESCE(extra, '{}'::jsonb) || $1::jsonb, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL", - payload, id, + string(payload), id, ) + if err != nil { return err } From c6a456c7c7d06fa2197c31ebf76aae669fc7b780 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Fri, 6 Feb 2026 08:42:55 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(=E5=85=BC=E5=AE=B9):=20=E5=B0=86=20Kimi?= =?UTF-8?q?=20cached=5Ftokens=20=E6=98=A0=E5=B0=84=E5=88=B0=20Claude=20?= =?UTF-8?q?=E6=A0=87=E5=87=86=20cache=5Fread=5Finput=5Ftokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kimi 等 Claude 兼容 API 返回缓存信息使用 OpenAI 风格的 cached_tokens 字段, 而非 Claude 标准的 cache_read_input_tokens,导致客户端收不到缓存命中信息且 内部计费缓存折扣为 0。 新增 reconcileCachedTokens 辅助函数,在 cache_read_input_tokens == 0 且 cached_tokens > 0 时自动填充,覆盖流式(message_start/message_delta)和 非流式两种响应路径。对 Claude 原生上游无影响。 Co-Authored-By: Claude Opus 4.6 --- .../service/gateway_cached_tokens_test.go | 300 ++++++++++++++++++ backend/internal/service/gateway_service.go | 43 +++ 2 files changed, 343 insertions(+) create mode 100644 backend/internal/service/gateway_cached_tokens_test.go diff --git a/backend/internal/service/gateway_cached_tokens_test.go b/backend/internal/service/gateway_cached_tokens_test.go new file mode 100644 index 00000000..a51e928c --- /dev/null +++ b/backend/internal/service/gateway_cached_tokens_test.go @@ -0,0 +1,300 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// ---------- reconcileCachedTokens 单元测试 ---------- + +func TestReconcileCachedTokens_NilUsage(t *testing.T) { + assert.False(t, reconcileCachedTokens(nil)) +} + +func TestReconcileCachedTokens_AlreadyHasCacheRead(t *testing.T) { + // 已有标准字段,不应覆盖 + usage := map[string]any{ + "cache_read_input_tokens": float64(100), + "cached_tokens": float64(50), + } + assert.False(t, reconcileCachedTokens(usage)) + assert.Equal(t, float64(100), usage["cache_read_input_tokens"]) +} + +func TestReconcileCachedTokens_KimiStyle(t *testing.T) { + // Kimi 风格:cache_read_input_tokens=0,cached_tokens>0 + usage := map[string]any{ + "input_tokens": float64(23), + "cache_creation_input_tokens": float64(0), + "cache_read_input_tokens": float64(0), + "cached_tokens": float64(23), + } + assert.True(t, reconcileCachedTokens(usage)) + assert.Equal(t, float64(23), usage["cache_read_input_tokens"]) +} + +func TestReconcileCachedTokens_NoCachedTokens(t *testing.T) { + // 无 cached_tokens 字段(原生 Claude) + usage := map[string]any{ + "input_tokens": float64(100), + "cache_read_input_tokens": float64(0), + "cache_creation_input_tokens": float64(0), + } + assert.False(t, reconcileCachedTokens(usage)) + assert.Equal(t, float64(0), usage["cache_read_input_tokens"]) +} + +func TestReconcileCachedTokens_CachedTokensZero(t *testing.T) { + // cached_tokens 为 0,不应覆盖 + usage := map[string]any{ + "cache_read_input_tokens": float64(0), + "cached_tokens": float64(0), + } + assert.False(t, reconcileCachedTokens(usage)) + assert.Equal(t, float64(0), usage["cache_read_input_tokens"]) +} + +func TestReconcileCachedTokens_MissingCacheReadField(t *testing.T) { + // cache_read_input_tokens 字段完全不存在,cached_tokens > 0 + usage := map[string]any{ + "cached_tokens": float64(42), + } + assert.True(t, reconcileCachedTokens(usage)) + assert.Equal(t, float64(42), usage["cache_read_input_tokens"]) +} + +// ---------- 流式 message_start 事件 reconcile 测试 ---------- + +func TestStreamingReconcile_MessageStart(t *testing.T) { + // 模拟 Kimi 返回的 message_start SSE 事件 + eventJSON := `{ + "type": "message_start", + "message": { + "id": "msg_123", + "type": "message", + "role": "assistant", + "model": "kimi", + "usage": { + "input_tokens": 23, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cached_tokens": 23 + } + } + }` + + var event map[string]any + require.NoError(t, json.Unmarshal([]byte(eventJSON), &event)) + + eventType, _ := event["type"].(string) + require.Equal(t, "message_start", eventType) + + // 模拟 processSSEEvent 中的 reconcile 逻辑 + if msg, ok := event["message"].(map[string]any); ok { + if u, ok := msg["usage"].(map[string]any); ok { + reconcileCachedTokens(u) + } + } + + // 验证 cache_read_input_tokens 已被填充 + msg := event["message"].(map[string]any) + usage := msg["usage"].(map[string]any) + assert.Equal(t, float64(23), usage["cache_read_input_tokens"]) + + // 验证重新序列化后 JSON 也包含正确值 + data, err := json.Marshal(event) + require.NoError(t, err) + assert.Equal(t, int64(23), gjson.GetBytes(data, "message.usage.cache_read_input_tokens").Int()) +} + +func TestStreamingReconcile_MessageStart_NativeClaude(t *testing.T) { + // 原生 Claude 不返回 cached_tokens,reconcile 不应改变任何值 + eventJSON := `{ + "type": "message_start", + "message": { + "usage": { + "input_tokens": 100, + "cache_creation_input_tokens": 50, + "cache_read_input_tokens": 30 + } + } + }` + + var event map[string]any + require.NoError(t, json.Unmarshal([]byte(eventJSON), &event)) + + if msg, ok := event["message"].(map[string]any); ok { + if u, ok := msg["usage"].(map[string]any); ok { + reconcileCachedTokens(u) + } + } + + msg := event["message"].(map[string]any) + usage := msg["usage"].(map[string]any) + assert.Equal(t, float64(30), usage["cache_read_input_tokens"]) +} + +// ---------- 流式 message_delta 事件 reconcile 测试 ---------- + +func TestStreamingReconcile_MessageDelta(t *testing.T) { + // 模拟 Kimi 返回的 message_delta SSE 事件 + eventJSON := `{ + "type": "message_delta", + "usage": { + "output_tokens": 7, + "cache_read_input_tokens": 0, + "cached_tokens": 15 + } + }` + + var event map[string]any + require.NoError(t, json.Unmarshal([]byte(eventJSON), &event)) + + eventType, _ := event["type"].(string) + require.Equal(t, "message_delta", eventType) + + // 模拟 processSSEEvent 中的 reconcile 逻辑 + if u, ok := event["usage"].(map[string]any); ok { + reconcileCachedTokens(u) + } + + usage := event["usage"].(map[string]any) + assert.Equal(t, float64(15), usage["cache_read_input_tokens"]) +} + +func TestStreamingReconcile_MessageDelta_NativeClaude(t *testing.T) { + // 原生 Claude 的 message_delta 通常没有 cached_tokens + eventJSON := `{ + "type": "message_delta", + "usage": { + "output_tokens": 50 + } + }` + + var event map[string]any + require.NoError(t, json.Unmarshal([]byte(eventJSON), &event)) + + if u, ok := event["usage"].(map[string]any); ok { + reconcileCachedTokens(u) + } + + usage := event["usage"].(map[string]any) + _, hasCacheRead := usage["cache_read_input_tokens"] + assert.False(t, hasCacheRead, "不应为原生 Claude 响应注入 cache_read_input_tokens") +} + +// ---------- 非流式响应 reconcile 测试 ---------- + +func TestNonStreamingReconcile_KimiResponse(t *testing.T) { + // 模拟 Kimi 非流式响应 + body := []byte(`{ + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "hello"}], + "model": "kimi", + "usage": { + "input_tokens": 23, + "output_tokens": 7, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cached_tokens": 23, + "prompt_tokens": 23, + "completion_tokens": 7 + } + }`) + + // 模拟 handleNonStreamingResponse 中的逻辑 + var response struct { + Usage ClaudeUsage `json:"usage"` + } + require.NoError(t, json.Unmarshal(body, &response)) + + // reconcile + if response.Usage.CacheReadInputTokens == 0 { + cachedTokens := gjson.GetBytes(body, "usage.cached_tokens").Int() + if cachedTokens > 0 { + response.Usage.CacheReadInputTokens = int(cachedTokens) + if newBody, err := sjson.SetBytes(body, "usage.cache_read_input_tokens", cachedTokens); err == nil { + body = newBody + } + } + } + + // 验证内部 usage(计费用) + assert.Equal(t, 23, response.Usage.CacheReadInputTokens) + assert.Equal(t, 23, response.Usage.InputTokens) + assert.Equal(t, 7, response.Usage.OutputTokens) + + // 验证返回给客户端的 JSON body + assert.Equal(t, int64(23), gjson.GetBytes(body, "usage.cache_read_input_tokens").Int()) +} + +func TestNonStreamingReconcile_NativeClaude(t *testing.T) { + // 原生 Claude 响应:cache_read_input_tokens 已有值 + body := []byte(`{ + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "cache_creation_input_tokens": 20, + "cache_read_input_tokens": 30 + } + }`) + + var response struct { + Usage ClaudeUsage `json:"usage"` + } + require.NoError(t, json.Unmarshal(body, &response)) + + originalBody := make([]byte, len(body)) + copy(originalBody, body) + + if response.Usage.CacheReadInputTokens == 0 { + cachedTokens := gjson.GetBytes(body, "usage.cached_tokens").Int() + if cachedTokens > 0 { + response.Usage.CacheReadInputTokens = int(cachedTokens) + if newBody, err := sjson.SetBytes(body, "usage.cache_read_input_tokens", cachedTokens); err == nil { + body = newBody + } + } + } + + // 不应修改 + assert.Equal(t, 30, response.Usage.CacheReadInputTokens) +} + +func TestNonStreamingReconcile_NoCachedTokens(t *testing.T) { + // 没有 cached_tokens 字段 + body := []byte(`{ + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0 + } + }`) + + var response struct { + Usage ClaudeUsage `json:"usage"` + } + require.NoError(t, json.Unmarshal(body, &response)) + + if response.Usage.CacheReadInputTokens == 0 { + cachedTokens := gjson.GetBytes(body, "usage.cached_tokens").Int() + if cachedTokens > 0 { + response.Usage.CacheReadInputTokens = int(cachedTokens) + if newBody, err := sjson.SetBytes(body, "usage.cache_read_input_tokens", cachedTokens); err == nil { + body = newBody + } + } + } + + // cache_read_input_tokens 应保持为 0 + assert.Equal(t, 0, response.Usage.CacheReadInputTokens) + assert.Equal(t, int64(0), gjson.GetBytes(body, "usage.cache_read_input_tokens").Int()) +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 9aecce22..bbfb1723 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -4176,6 +4176,20 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http eventName = eventType } + // 兼容 Kimi cached_tokens → cache_read_input_tokens + if eventType == "message_start" { + if msg, ok := event["message"].(map[string]any); ok { + if u, ok := msg["usage"].(map[string]any); ok { + reconcileCachedTokens(u) + } + } + } + if eventType == "message_delta" { + if u, ok := event["usage"].(map[string]any); ok { + reconcileCachedTokens(u) + } + } + if needModelReplace { if msg, ok := event["message"].(map[string]any); ok { if model, ok := msg["model"].(string); ok && model == mappedModel { @@ -4526,6 +4540,17 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h return nil, fmt.Errorf("parse response: %w", err) } + // 兼容 Kimi cached_tokens → cache_read_input_tokens + if response.Usage.CacheReadInputTokens == 0 { + cachedTokens := gjson.GetBytes(body, "usage.cached_tokens").Int() + if cachedTokens > 0 { + response.Usage.CacheReadInputTokens = int(cachedTokens) + if newBody, err := sjson.SetBytes(body, "usage.cache_read_input_tokens", cachedTokens); err == nil { + body = newBody + } + } + } + // 如果有模型映射,替换响应中的model字段 if originalModel != mappedModel { body = s.replaceModelInResponseBody(body, mappedModel, originalModel) @@ -5311,3 +5336,21 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, return models } + +// reconcileCachedTokens 兼容 Kimi 等上游: +// 将 OpenAI 风格的 cached_tokens 映射到 Claude 标准的 cache_read_input_tokens +func reconcileCachedTokens(usage map[string]any) bool { + if usage == nil { + return false + } + cacheRead, _ := usage["cache_read_input_tokens"].(float64) + if cacheRead > 0 { + return false // 已有标准字段,无需处理 + } + cached, _ := usage["cached_tokens"].(float64) + if cached <= 0 { + return false + } + usage["cache_read_input_tokens"] = cached + return true +} From 01b08e1e435e711bba4dabfbe9fa9e611fd79a88 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 6 Feb 2026 08:50:45 +0800 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=E5=89=8D=E7=AB=AF=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0opus4.6=E6=A8=A1=E5=9E=8B=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/pkg/claude/constants.go | 6 ++++++ backend/internal/service/pricing_service.go | 1 + frontend/src/components/account/BulkEditAccountModal.vue | 8 ++++++++ frontend/src/composables/useModelWhitelist.ts | 4 +++- 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 8b3441dc..eecee11e 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -71,6 +71,12 @@ var DefaultModels = []Model{ DisplayName: "Claude Opus 4.5", CreatedAt: "2025-11-01T00:00:00Z", }, + { + ID: "claude-opus-4-6", + Type: "model", + DisplayName: "Claude Opus 4.6", + CreatedAt: "2026-02-06T00:00:00Z", + }, { ID: "claude-sonnet-4-5-20250929", Type: "model", diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index bad08894..d8db0d67 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -579,6 +579,7 @@ func (s *PricingService) extractBaseName(model string) string { func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { // Claude模型系列匹配规则 familyPatterns := map[string][]string{ + "opus-4.6": {"claude-opus-4.6", "claude-opus-4-6"}, "opus-4.5": {"claude-opus-4.5", "claude-opus-4-5"}, "opus-4": {"claude-opus-4", "claude-3-opus"}, "sonnet-4.5": {"claude-sonnet-4.5", "claude-sonnet-4-5"}, diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 1f6b487b..3aa49481 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -707,6 +707,7 @@ const groupIds = ref([]) // All models list (combined Anthropic + OpenAI) const allModels = [ + { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, { value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' }, { value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' }, { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' }, @@ -746,6 +747,13 @@ const presetMappings = [ color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, + { + label: 'Opus 4.6', + from: 'claude-opus-4-6', + to: 'claude-opus-4-6', + color: + 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' + }, { label: 'Opus->Sonnet', from: 'claude-opus-4-5-20251101', diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index d4fa2993..6e3a055b 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -38,6 +38,7 @@ export const claudeModels = [ 'claude-opus-4-1-20250805', 'claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001', 'claude-opus-4-5-20251101', + 'claude-opus-4-6', 'claude-2.1', 'claude-2.0', 'claude-instant-1.2' ] @@ -210,9 +211,10 @@ const anthropicPresetMappings = [ { label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' }, { label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, { label: 'Opus 4.5', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, + { label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'claude-opus-4-6', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' }, { label: 'Haiku 4.5', from: 'claude-haiku-4-5-20251001', to: 'claude-haiku-4-5-20251001', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }, - { label: 'Opus->Sonnet', from: 'claude-opus-4-5-20251101', to: 'claude-sonnet-4-5-20250929', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' } + { label: 'Opus->Sonnet', from: 'claude-opus-4-6', to: 'claude-sonnet-4-5-20250929', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' } ] const openaiPresetMappings = [