| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537 |
- package service
- import (
- "encoding/json"
- "strings"
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- func TestIsClaudeCodeClient(t *testing.T) {
- // 合法的 legacy 格式 metadata.user_id(64位 hex + account uuid + session uuid)
- legacyUserID := "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account_550e8400-e29b-41d4-a716-446655440000_session_123e4567-e89b-12d3-a456-426614174000"
- // 合法的 JSON 格式 metadata.user_id(2.1.78+ 版本)
- jsonUserID := `{"device_id":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2","account_uuid":"550e8400-e29b-41d4-a716-446655440000","session_id":"123e4567-e89b-12d3-a456-426614174000"}`
- tests := []struct {
- name string
- userAgent string
- metadataUserID string
- want bool
- }{
- {
- name: "Claude Code client with legacy user_id",
- userAgent: "claude-cli/1.0.62 (darwin; arm64)",
- metadataUserID: legacyUserID,
- want: true,
- },
- {
- name: "Claude Code client with JSON user_id",
- userAgent: "claude-cli/2.1.92 (external, cli)",
- metadataUserID: jsonUserID,
- want: true,
- },
- {
- name: "Claude Code case insensitive UA",
- userAgent: "Claude-CLI/2.0.0",
- metadataUserID: legacyUserID,
- want: true,
- },
- {
- name: "Missing metadata user_id",
- userAgent: "claude-cli/1.0.0",
- metadataUserID: "",
- want: false,
- },
- {
- name: "Claude CLI UA with invalid user_id format",
- userAgent: "claude-cli/2.0.0",
- metadataUserID: "fake-user-id-12345",
- want: false,
- },
- {
- name: "Different user agent with valid user_id",
- userAgent: "curl/7.68.0",
- metadataUserID: legacyUserID,
- want: false,
- },
- {
- name: "Empty user agent",
- userAgent: "",
- metadataUserID: legacyUserID,
- want: false,
- },
- {
- name: "Similar but not Claude CLI",
- userAgent: "claude-api/1.0.0",
- metadataUserID: legacyUserID,
- want: false,
- },
- {
- name: "Opencode spoofing UA with arbitrary user_id",
- userAgent: "claude-cli/2.1.92",
- metadataUserID: "session_abc",
- want: false,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := isClaudeCodeClient(tt.userAgent, tt.metadataUserID)
- require.Equal(t, tt.want, got)
- })
- }
- }
- func TestSystemIncludesClaudeCodePrompt(t *testing.T) {
- tests := []struct {
- name string
- system any
- want bool
- }{
- {
- name: "nil system",
- system: nil,
- want: false,
- },
- {
- name: "empty string",
- system: "",
- want: false,
- },
- {
- name: "string with Claude Code prompt",
- system: claudeCodeSystemPrompt,
- want: true,
- },
- {
- name: "string with different content",
- system: "You are a helpful assistant.",
- want: false,
- },
- {
- name: "empty array",
- system: []any{},
- want: false,
- },
- {
- name: "array with Claude Code prompt",
- system: []any{
- map[string]any{
- "type": "text",
- "text": claudeCodeSystemPrompt,
- },
- },
- want: true,
- },
- {
- name: "array with Claude Code prompt in second position",
- system: []any{
- map[string]any{"type": "text", "text": "First prompt"},
- map[string]any{"type": "text", "text": claudeCodeSystemPrompt},
- },
- want: true,
- },
- {
- name: "array without Claude Code prompt",
- system: []any{
- map[string]any{"type": "text", "text": "Custom prompt"},
- },
- want: false,
- },
- {
- name: "array with partial match (should not match)",
- system: []any{
- map[string]any{"type": "text", "text": "You are Claude"},
- },
- want: false,
- },
- // json.RawMessage cases (conversion path: ForwardAsResponses / ForwardAsChatCompletions)
- {
- name: "json.RawMessage string with Claude Code prompt",
- system: json.RawMessage(`"` + claudeCodeSystemPrompt + `"`),
- want: true,
- },
- {
- name: "json.RawMessage string without Claude Code prompt",
- system: json.RawMessage(`"You are a helpful assistant"`),
- want: false,
- },
- {
- name: "json.RawMessage nil (empty)",
- system: json.RawMessage(nil),
- want: false,
- },
- {
- name: "json.RawMessage empty string",
- system: json.RawMessage(`""`),
- want: false,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := systemIncludesClaudeCodePrompt(tt.system)
- require.Equal(t, tt.want, got)
- })
- }
- }
- func TestInjectClaudeCodePrompt(t *testing.T) {
- claudePrefix := strings.TrimSpace(claudeCodeSystemPrompt)
- tests := []struct {
- name string
- body string
- system any
- wantSystemLen int
- wantFirstText string
- wantSecondText string
- }{
- {
- name: "nil system",
- body: `{"model":"claude-3"}`,
- system: nil,
- wantSystemLen: 1,
- wantFirstText: claudeCodeSystemPrompt,
- },
- {
- name: "empty string system",
- body: `{"model":"claude-3"}`,
- system: "",
- wantSystemLen: 1,
- wantFirstText: claudeCodeSystemPrompt,
- },
- {
- name: "string system",
- body: `{"model":"claude-3"}`,
- system: "Custom prompt",
- wantSystemLen: 2,
- wantFirstText: claudeCodeSystemPrompt,
- wantSecondText: claudePrefix + "\n\nCustom prompt",
- },
- {
- name: "string system equals Claude Code prompt",
- body: `{"model":"claude-3"}`,
- system: claudeCodeSystemPrompt,
- wantSystemLen: 1,
- wantFirstText: claudeCodeSystemPrompt,
- },
- {
- name: "array system",
- body: `{"model":"claude-3"}`,
- system: []any{map[string]any{"type": "text", "text": "Custom"}},
- // Claude Code + Custom = 2
- wantSystemLen: 2,
- wantFirstText: claudeCodeSystemPrompt,
- wantSecondText: claudePrefix + "\n\nCustom",
- },
- {
- name: "array system with existing Claude Code prompt (should dedupe)",
- body: `{"model":"claude-3"}`,
- system: []any{
- map[string]any{"type": "text", "text": claudeCodeSystemPrompt},
- map[string]any{"type": "text", "text": "Other"},
- },
- // Claude Code at start + Other = 2 (deduped)
- wantSystemLen: 2,
- wantFirstText: claudeCodeSystemPrompt,
- wantSecondText: claudePrefix + "\n\nOther",
- },
- {
- name: "empty array",
- body: `{"model":"claude-3"}`,
- system: []any{},
- wantSystemLen: 1,
- wantFirstText: claudeCodeSystemPrompt,
- },
- // json.RawMessage cases (conversion path: ForwardAsResponses / ForwardAsChatCompletions)
- {
- name: "json.RawMessage string system",
- body: `{"model":"claude-3","system":"Custom prompt"}`,
- system: json.RawMessage(`"Custom prompt"`),
- wantSystemLen: 2,
- wantFirstText: claudeCodeSystemPrompt,
- wantSecondText: claudePrefix + "\n\nCustom prompt",
- },
- {
- name: "json.RawMessage nil system",
- body: `{"model":"claude-3"}`,
- system: json.RawMessage(nil),
- wantSystemLen: 1,
- wantFirstText: claudeCodeSystemPrompt,
- },
- {
- name: "json.RawMessage Claude Code prompt (should not duplicate)",
- body: `{"model":"claude-3","system":"` + claudeCodeSystemPrompt + `"}`,
- system: json.RawMessage(`"` + claudeCodeSystemPrompt + `"`),
- wantSystemLen: 1,
- wantFirstText: claudeCodeSystemPrompt,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := injectClaudeCodePrompt([]byte(tt.body), tt.system)
- var parsed map[string]any
- err := json.Unmarshal(result, &parsed)
- require.NoError(t, err)
- system, ok := parsed["system"].([]any)
- require.True(t, ok, "system should be an array")
- require.Len(t, system, tt.wantSystemLen)
- first, ok := system[0].(map[string]any)
- require.True(t, ok)
- require.Equal(t, tt.wantFirstText, first["text"])
- require.Equal(t, "text", first["type"])
- // Check cache_control
- cc, ok := first["cache_control"].(map[string]any)
- require.True(t, ok)
- require.Equal(t, "ephemeral", cc["type"])
- if tt.wantSecondText != "" && len(system) > 1 {
- second, ok := system[1].(map[string]any)
- require.True(t, ok)
- require.Equal(t, tt.wantSecondText, second["text"])
- }
- })
- }
- }
- func TestRewriteSystemForNonClaudeCode(t *testing.T) {
- tests := []struct {
- name string
- body string
- system any
- wantSystemText string // system array 第一个 block 的 text
- wantMessagesLen int // messages 数组长度
- wantFirstMsgRole string // 第一条消息的 role
- wantFirstMsgText string // 第一条消息的 content[0].text
- wantAckMsgText string // 第二条消息的 content[0].text
- }{
- {
- name: "nil system - no messages injected",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
- system: nil,
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 1, // 原始 1 条消息,不注入
- },
- {
- name: "empty string system - no messages injected",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
- system: "",
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 1,
- },
- {
- name: "custom string system - migrated to messages",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
- system: "You are a personal assistant running inside OpenClaw.",
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 3, // instruction + ack + original
- wantFirstMsgRole: "user",
- wantFirstMsgText: "[System Instructions]\nYou are a personal assistant running inside OpenClaw.",
- wantAckMsgText: "Understood. I will follow these instructions.",
- },
- {
- name: "system equals Claude Code prompt - no messages injected",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
- system: claudeCodeSystemPrompt,
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 1,
- },
- {
- name: "array system with custom blocks - text joined and migrated",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
- system: []any{
- map[string]any{"type": "text", "text": "First instruction"},
- map[string]any{"type": "text", "text": "Second instruction"},
- },
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 3,
- wantFirstMsgRole: "user",
- wantFirstMsgText: "[System Instructions]\nFirst instruction\n\nSecond instruction",
- wantAckMsgText: "Understood. I will follow these instructions.",
- },
- {
- name: "empty array system - no messages injected",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
- system: []any{},
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 1,
- },
- {
- name: "json.RawMessage string system",
- body: `{"model":"claude-3","system":"Custom prompt","messages":[{"role":"user","content":"hello"}]}`,
- system: json.RawMessage(`"Custom prompt"`),
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 3,
- wantFirstMsgRole: "user",
- wantFirstMsgText: "[System Instructions]\nCustom prompt",
- wantAckMsgText: "Understood. I will follow these instructions.",
- },
- {
- name: "json.RawMessage nil system",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
- system: json.RawMessage(nil),
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 1,
- },
- {
- name: "multiple original messages preserved",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"msg1"},{"role":"assistant","content":"resp1"},{"role":"user","content":"msg2"}]}`,
- system: "Be helpful",
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 5, // 2 injected + 3 original
- wantFirstMsgRole: "user",
- wantFirstMsgText: "[System Instructions]\nBe helpful",
- wantAckMsgText: "Understood. I will follow these instructions.",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := rewriteSystemForNonClaudeCode([]byte(tt.body), tt.system)
- var parsed map[string]any
- err := json.Unmarshal(result, &parsed)
- require.NoError(t, err)
- // system 应为 array 格式,对齐真实 Claude Code CLI 的 3-block 形态:
- // [0] billing attribution block (x-anthropic-billing-header: cc_version=...;)
- // [1] Claude Code 身份前缀 block (不带 cache_control)
- // [2] 工具无关的通用提示词扩充 block (带 cache_control,作为缓存断点)
- systemArr, ok := parsed["system"].([]any)
- require.True(t, ok, "system should be an array, got %T", parsed["system"])
- require.Len(t, systemArr, 3, "system array should have exactly 3 blocks (billing + cc prompt + expansion)")
- billingBlock, ok := systemArr[0].(map[string]any)
- require.True(t, ok)
- require.Equal(t, "text", billingBlock["type"])
- require.Contains(t, billingBlock["text"], "x-anthropic-billing-header:")
- require.Contains(t, billingBlock["text"], "cc_version=")
- require.Contains(t, billingBlock["text"], "cc_entrypoint=cli")
- // 新版 CLI 已取消 cch=... 签名字段,注入的 billing block 不应再带 cch。
- require.NotContains(t, billingBlock["text"], "cch=")
- systemBlock, ok := systemArr[1].(map[string]any)
- require.True(t, ok)
- require.Equal(t, "text", systemBlock["type"])
- require.Equal(t, tt.wantSystemText, systemBlock["text"])
- _, hasCC := systemBlock["cache_control"]
- require.False(t, hasCC, "身份前缀 block 不应带 cache_control(断点落在扩充块)")
- expansionBlock, ok := systemArr[2].(map[string]any)
- require.True(t, ok)
- require.Equal(t, "text", expansionBlock["type"])
- require.Equal(t, claudeCodeSystemPromptExpansion, expansionBlock["text"])
- cc, ok := expansionBlock["cache_control"].(map[string]any)
- require.True(t, ok, "expansion block should have cache_control")
- require.Equal(t, "ephemeral", cc["type"])
- // 检查 messages
- messages, ok := parsed["messages"].([]any)
- require.True(t, ok, "messages should be an array")
- require.Len(t, messages, tt.wantMessagesLen)
- if tt.wantFirstMsgRole != "" && len(messages) >= 2 {
- // 检查注入的 instruction 消息
- firstMsg, ok := messages[0].(map[string]any)
- require.True(t, ok)
- require.Equal(t, tt.wantFirstMsgRole, firstMsg["role"])
- firstContent, ok := firstMsg["content"].([]any)
- require.True(t, ok)
- require.Len(t, firstContent, 1)
- firstBlock, ok := firstContent[0].(map[string]any)
- require.True(t, ok)
- require.Equal(t, tt.wantFirstMsgText, firstBlock["text"])
- // 检查注入的 ack 消息
- ackMsg, ok := messages[1].(map[string]any)
- require.True(t, ok)
- require.Equal(t, "assistant", ackMsg["role"])
- ackContent, ok := ackMsg["content"].([]any)
- require.True(t, ok)
- require.Len(t, ackContent, 1)
- ackBlock, ok := ackContent[0].(map[string]any)
- require.True(t, ok)
- require.Equal(t, tt.wantAckMsgText, ackBlock["text"])
- }
- })
- }
- }
- func TestRewriteSystemForNonClaudeCodeWithPrompt_UsesCustomExpansionPrompt(t *testing.T) {
- body := []byte(`{"model":"claude-3","system":"Project instructions","messages":[{"role":"user","content":"hello"}]}`)
- customPrompt := "Custom Claude OAuth expansion prompt"
- result := rewriteSystemForNonClaudeCodeWithPrompt(body, "Project instructions", customPrompt)
- system := gjson.GetBytes(result, "system")
- require.True(t, system.IsArray())
- require.Len(t, system.Array(), 3)
- require.Equal(t, customPrompt, system.Array()[2].Get("text").String())
- require.Equal(t, "ephemeral", system.Array()[2].Get("cache_control.type").String())
- }
- func TestRewriteSystemForNonClaudeCode_PreservesSystemCacheControlOnMigratedMessage(t *testing.T) {
- body := []byte(`{"model":"claude-3","system":[{"type":"text","text":"Stable project instructions","cache_control":{"type":"ephemeral","ttl":"1h"}}],"messages":[{"role":"user","content":"hello"}]}`)
- system := []any{
- map[string]any{
- "type": "text",
- "text": "Stable project instructions",
- "cache_control": map[string]any{"type": "ephemeral", "ttl": "1h"},
- },
- }
- result := rewriteSystemForNonClaudeCode(body, system)
- require.Equal(t, "[System Instructions]\nStable project instructions", gjson.GetBytes(result, "messages.0.content.0.text").String())
- require.Equal(t, "ephemeral", gjson.GetBytes(result, "messages.0.content.0.cache_control.type").String())
- require.Equal(t, "1h", gjson.GetBytes(result, "messages.0.content.0.cache_control.ttl").String())
- }
- func TestRewriteSystemForNonClaudeCode_LeavesMigratedMessageUncachedWithoutSystemBreakpoint(t *testing.T) {
- body := []byte(`{"model":"claude-3","system":[{"type":"text","text":"Project instructions"}],"messages":[{"role":"user","content":"hello"}]}`)
- system := []any{
- map[string]any{"type": "text", "text": "Project instructions"},
- }
- result := rewriteSystemForNonClaudeCode(body, system)
- require.False(t, gjson.GetBytes(result, "messages.0.content.0.cache_control").Exists())
- }
- func TestRewriteSystemForNonClaudeCodeWithPromptBlocks_UsesConfiguredBlocks(t *testing.T) {
- body := []byte(`{"model":"claude-3","system":"Project instructions","messages":[{"role":"user","content":"hello"}]}`)
- blocks := `{
- "blocks": [
- {"type":"text","text":"prefix {cc_version}.{fp}","cache_control":true},
- {"enabled":false,"type":"text","text":"disabled"},
- {"type":"text","text":"{claude_code_system_prompt}"},
- {"type":"text","text":"tail","cache_control":{"type":"ephemeral","ttl":"1h"}}
- ]
- }`
- result := rewriteSystemForNonClaudeCodeWithPromptBlocks(body, "Project instructions", "", blocks)
- system := gjson.GetBytes(result, "system")
- require.True(t, system.IsArray())
- arr := system.Array()
- require.Len(t, arr, 3)
- require.Contains(t, arr[0].Get("text").String(), "prefix "+claude.CLICurrentVersion+".")
- require.Equal(t, "ephemeral", arr[0].Get("cache_control.type").String())
- require.Equal(t, claude.DefaultCacheControlTTL, arr[0].Get("cache_control.ttl").String())
- require.Equal(t, claudeCodeSystemPrompt, arr[1].Get("text").String())
- require.False(t, arr[1].Get("cache_control").Exists())
- require.Equal(t, "tail", arr[2].Get("text").String())
- require.Equal(t, "1h", arr[2].Get("cache_control.ttl").String())
- }
|