mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-28 18:56:10 +08:00
Default to generating a fresh random user_id per request instead of reusing cached IDs. Add cache-user-id config option to opt in to the previous caching behavior. - Add CacheUserID field to CloakConfig - Extract user_id cache logic to dedicated file - Generate fresh user_id by default, cache only when enabled - Add tests for both paths
351 lines
14 KiB
Go
351 lines
14 KiB
Go
package executor
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
func TestApplyClaudeToolPrefix(t *testing.T) {
|
|
input := []byte(`{"tools":[{"name":"alpha"},{"name":"proxy_bravo"}],"tool_choice":{"type":"tool","name":"charlie"},"messages":[{"role":"assistant","content":[{"type":"tool_use","name":"delta","id":"t1","input":{}}]}]}`)
|
|
out := applyClaudeToolPrefix(input, "proxy_")
|
|
|
|
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "proxy_alpha" {
|
|
t.Fatalf("tools.0.name = %q, want %q", got, "proxy_alpha")
|
|
}
|
|
if got := gjson.GetBytes(out, "tools.1.name").String(); got != "proxy_bravo" {
|
|
t.Fatalf("tools.1.name = %q, want %q", got, "proxy_bravo")
|
|
}
|
|
if got := gjson.GetBytes(out, "tool_choice.name").String(); got != "proxy_charlie" {
|
|
t.Fatalf("tool_choice.name = %q, want %q", got, "proxy_charlie")
|
|
}
|
|
if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != "proxy_delta" {
|
|
t.Fatalf("messages.0.content.0.name = %q, want %q", got, "proxy_delta")
|
|
}
|
|
}
|
|
|
|
func TestApplyClaudeToolPrefix_WithToolReference(t *testing.T) {
|
|
input := []byte(`{"tools":[{"name":"alpha"}],"messages":[{"role":"user","content":[{"type":"tool_reference","tool_name":"beta"},{"type":"tool_reference","tool_name":"proxy_gamma"}]}]}`)
|
|
out := applyClaudeToolPrefix(input, "proxy_")
|
|
|
|
if got := gjson.GetBytes(out, "messages.0.content.0.tool_name").String(); got != "proxy_beta" {
|
|
t.Fatalf("messages.0.content.0.tool_name = %q, want %q", got, "proxy_beta")
|
|
}
|
|
if got := gjson.GetBytes(out, "messages.0.content.1.tool_name").String(); got != "proxy_gamma" {
|
|
t.Fatalf("messages.0.content.1.tool_name = %q, want %q", got, "proxy_gamma")
|
|
}
|
|
}
|
|
|
|
func TestApplyClaudeToolPrefix_SkipsBuiltinTools(t *testing.T) {
|
|
input := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"},{"name":"my_custom_tool","input_schema":{"type":"object"}}]}`)
|
|
out := applyClaudeToolPrefix(input, "proxy_")
|
|
|
|
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "web_search" {
|
|
t.Fatalf("built-in tool name should not be prefixed: tools.0.name = %q, want %q", got, "web_search")
|
|
}
|
|
if got := gjson.GetBytes(out, "tools.1.name").String(); got != "proxy_my_custom_tool" {
|
|
t.Fatalf("custom tool should be prefixed: tools.1.name = %q, want %q", got, "proxy_my_custom_tool")
|
|
}
|
|
}
|
|
|
|
func TestApplyClaudeToolPrefix_BuiltinToolSkipped(t *testing.T) {
|
|
body := []byte(`{
|
|
"tools": [
|
|
{"type": "web_search_20250305", "name": "web_search", "max_uses": 5},
|
|
{"name": "Read"}
|
|
],
|
|
"messages": [
|
|
{"role": "user", "content": [
|
|
{"type": "tool_use", "name": "web_search", "id": "ws1", "input": {}},
|
|
{"type": "tool_use", "name": "Read", "id": "r1", "input": {}}
|
|
]}
|
|
]
|
|
}`)
|
|
out := applyClaudeToolPrefix(body, "proxy_")
|
|
|
|
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "web_search" {
|
|
t.Fatalf("tools.0.name = %q, want %q", got, "web_search")
|
|
}
|
|
if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != "web_search" {
|
|
t.Fatalf("messages.0.content.0.name = %q, want %q", got, "web_search")
|
|
}
|
|
if got := gjson.GetBytes(out, "tools.1.name").String(); got != "proxy_Read" {
|
|
t.Fatalf("tools.1.name = %q, want %q", got, "proxy_Read")
|
|
}
|
|
if got := gjson.GetBytes(out, "messages.0.content.1.name").String(); got != "proxy_Read" {
|
|
t.Fatalf("messages.0.content.1.name = %q, want %q", got, "proxy_Read")
|
|
}
|
|
}
|
|
|
|
func TestApplyClaudeToolPrefix_KnownBuiltinInHistoryOnly(t *testing.T) {
|
|
body := []byte(`{
|
|
"tools": [
|
|
{"name": "Read"}
|
|
],
|
|
"messages": [
|
|
{"role": "user", "content": [
|
|
{"type": "tool_use", "name": "web_search", "id": "ws1", "input": {}}
|
|
]}
|
|
]
|
|
}`)
|
|
out := applyClaudeToolPrefix(body, "proxy_")
|
|
|
|
if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != "web_search" {
|
|
t.Fatalf("messages.0.content.0.name = %q, want %q", got, "web_search")
|
|
}
|
|
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "proxy_Read" {
|
|
t.Fatalf("tools.0.name = %q, want %q", got, "proxy_Read")
|
|
}
|
|
}
|
|
|
|
func TestApplyClaudeToolPrefix_CustomToolsPrefixed(t *testing.T) {
|
|
body := []byte(`{
|
|
"tools": [{"name": "Read"}, {"name": "Write"}],
|
|
"messages": [
|
|
{"role": "user", "content": [
|
|
{"type": "tool_use", "name": "Read", "id": "r1", "input": {}},
|
|
{"type": "tool_use", "name": "Write", "id": "w1", "input": {}}
|
|
]}
|
|
]
|
|
}`)
|
|
out := applyClaudeToolPrefix(body, "proxy_")
|
|
|
|
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "proxy_Read" {
|
|
t.Fatalf("tools.0.name = %q, want %q", got, "proxy_Read")
|
|
}
|
|
if got := gjson.GetBytes(out, "tools.1.name").String(); got != "proxy_Write" {
|
|
t.Fatalf("tools.1.name = %q, want %q", got, "proxy_Write")
|
|
}
|
|
if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != "proxy_Read" {
|
|
t.Fatalf("messages.0.content.0.name = %q, want %q", got, "proxy_Read")
|
|
}
|
|
if got := gjson.GetBytes(out, "messages.0.content.1.name").String(); got != "proxy_Write" {
|
|
t.Fatalf("messages.0.content.1.name = %q, want %q", got, "proxy_Write")
|
|
}
|
|
}
|
|
|
|
func TestApplyClaudeToolPrefix_ToolChoiceBuiltin(t *testing.T) {
|
|
body := []byte(`{
|
|
"tools": [
|
|
{"type": "web_search_20250305", "name": "web_search"},
|
|
{"name": "Read"}
|
|
],
|
|
"tool_choice": {"type": "tool", "name": "web_search"}
|
|
}`)
|
|
out := applyClaudeToolPrefix(body, "proxy_")
|
|
|
|
if got := gjson.GetBytes(out, "tool_choice.name").String(); got != "web_search" {
|
|
t.Fatalf("tool_choice.name = %q, want %q", got, "web_search")
|
|
}
|
|
}
|
|
|
|
func TestStripClaudeToolPrefixFromResponse(t *testing.T) {
|
|
input := []byte(`{"content":[{"type":"tool_use","name":"proxy_alpha","id":"t1","input":{}},{"type":"tool_use","name":"bravo","id":"t2","input":{}}]}`)
|
|
out := stripClaudeToolPrefixFromResponse(input, "proxy_")
|
|
|
|
if got := gjson.GetBytes(out, "content.0.name").String(); got != "alpha" {
|
|
t.Fatalf("content.0.name = %q, want %q", got, "alpha")
|
|
}
|
|
if got := gjson.GetBytes(out, "content.1.name").String(); got != "bravo" {
|
|
t.Fatalf("content.1.name = %q, want %q", got, "bravo")
|
|
}
|
|
}
|
|
|
|
func TestStripClaudeToolPrefixFromResponse_WithToolReference(t *testing.T) {
|
|
input := []byte(`{"content":[{"type":"tool_reference","tool_name":"proxy_alpha"},{"type":"tool_reference","tool_name":"bravo"}]}`)
|
|
out := stripClaudeToolPrefixFromResponse(input, "proxy_")
|
|
|
|
if got := gjson.GetBytes(out, "content.0.tool_name").String(); got != "alpha" {
|
|
t.Fatalf("content.0.tool_name = %q, want %q", got, "alpha")
|
|
}
|
|
if got := gjson.GetBytes(out, "content.1.tool_name").String(); got != "bravo" {
|
|
t.Fatalf("content.1.tool_name = %q, want %q", got, "bravo")
|
|
}
|
|
}
|
|
|
|
func TestStripClaudeToolPrefixFromStreamLine(t *testing.T) {
|
|
line := []byte(`data: {"type":"content_block_start","content_block":{"type":"tool_use","name":"proxy_alpha","id":"t1"},"index":0}`)
|
|
out := stripClaudeToolPrefixFromStreamLine(line, "proxy_")
|
|
|
|
payload := bytes.TrimSpace(out)
|
|
if bytes.HasPrefix(payload, []byte("data:")) {
|
|
payload = bytes.TrimSpace(payload[len("data:"):])
|
|
}
|
|
if got := gjson.GetBytes(payload, "content_block.name").String(); got != "alpha" {
|
|
t.Fatalf("content_block.name = %q, want %q", got, "alpha")
|
|
}
|
|
}
|
|
|
|
func TestStripClaudeToolPrefixFromStreamLine_WithToolReference(t *testing.T) {
|
|
line := []byte(`data: {"type":"content_block_start","content_block":{"type":"tool_reference","tool_name":"proxy_beta"},"index":0}`)
|
|
out := stripClaudeToolPrefixFromStreamLine(line, "proxy_")
|
|
|
|
payload := bytes.TrimSpace(out)
|
|
if bytes.HasPrefix(payload, []byte("data:")) {
|
|
payload = bytes.TrimSpace(payload[len("data:"):])
|
|
}
|
|
if got := gjson.GetBytes(payload, "content_block.tool_name").String(); got != "beta" {
|
|
t.Fatalf("content_block.tool_name = %q, want %q", got, "beta")
|
|
}
|
|
}
|
|
|
|
func TestApplyClaudeToolPrefix_NestedToolReference(t *testing.T) {
|
|
input := []byte(`{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_123","content":[{"type":"tool_reference","tool_name":"mcp__nia__manage_resource"}]}]}]}`)
|
|
out := applyClaudeToolPrefix(input, "proxy_")
|
|
got := gjson.GetBytes(out, "messages.0.content.0.content.0.tool_name").String()
|
|
if got != "proxy_mcp__nia__manage_resource" {
|
|
t.Fatalf("nested tool_reference tool_name = %q, want %q", got, "proxy_mcp__nia__manage_resource")
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_ReusesUserIDAcrossModelsWhenCacheEnabled(t *testing.T) {
|
|
resetUserIDCache()
|
|
|
|
var userIDs []string
|
|
var requestModels []string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
userID := gjson.GetBytes(body, "metadata.user_id").String()
|
|
model := gjson.GetBytes(body, "model").String()
|
|
userIDs = append(userIDs, userID)
|
|
requestModels = append(requestModels, model)
|
|
t.Logf("HTTP Server received request: model=%s, user_id=%s, url=%s", model, userID, r.URL.String())
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-3-5-sonnet","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
t.Logf("End-to-end test: Fake HTTP server started at %s", server.URL)
|
|
|
|
cacheEnabled := true
|
|
executor := NewClaudeExecutor(&config.Config{
|
|
ClaudeKey: []config.ClaudeKey{
|
|
{
|
|
APIKey: "key-123",
|
|
BaseURL: server.URL,
|
|
Cloak: &config.CloakConfig{
|
|
CacheUserID: &cacheEnabled,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
auth := &cliproxyauth.Auth{Attributes: map[string]string{
|
|
"api_key": "key-123",
|
|
"base_url": server.URL,
|
|
}}
|
|
|
|
payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
|
models := []string{"claude-3-5-sonnet", "claude-3-5-haiku"}
|
|
for _, model := range models {
|
|
t.Logf("Sending request for model: %s", model)
|
|
modelPayload, _ := sjson.SetBytes(payload, "model", model)
|
|
if _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
|
Model: model,
|
|
Payload: modelPayload,
|
|
}, cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FromString("claude"),
|
|
}); err != nil {
|
|
t.Fatalf("Execute(%s) error: %v", model, err)
|
|
}
|
|
}
|
|
|
|
if len(userIDs) != 2 {
|
|
t.Fatalf("expected 2 requests, got %d", len(userIDs))
|
|
}
|
|
if userIDs[0] == "" || userIDs[1] == "" {
|
|
t.Fatal("expected user_id to be populated")
|
|
}
|
|
t.Logf("user_id[0] (model=%s): %s", requestModels[0], userIDs[0])
|
|
t.Logf("user_id[1] (model=%s): %s", requestModels[1], userIDs[1])
|
|
if userIDs[0] != userIDs[1] {
|
|
t.Fatalf("expected user_id to be reused across models, got %q and %q", userIDs[0], userIDs[1])
|
|
}
|
|
if !isValidUserID(userIDs[0]) {
|
|
t.Fatalf("user_id %q is not valid", userIDs[0])
|
|
}
|
|
t.Logf("✓ End-to-end test passed: Same user_id (%s) was used for both models", userIDs[0])
|
|
}
|
|
|
|
func TestClaudeExecutor_GeneratesNewUserIDByDefault(t *testing.T) {
|
|
resetUserIDCache()
|
|
|
|
var userIDs []string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
userIDs = append(userIDs, gjson.GetBytes(body, "metadata.user_id").String())
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-3-5-sonnet","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
executor := NewClaudeExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{Attributes: map[string]string{
|
|
"api_key": "key-123",
|
|
"base_url": server.URL,
|
|
}}
|
|
|
|
payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
|
|
|
for i := 0; i < 2; i++ {
|
|
if _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
|
Model: "claude-3-5-sonnet",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FromString("claude"),
|
|
}); err != nil {
|
|
t.Fatalf("Execute call %d error: %v", i, err)
|
|
}
|
|
}
|
|
|
|
if len(userIDs) != 2 {
|
|
t.Fatalf("expected 2 requests, got %d", len(userIDs))
|
|
}
|
|
if userIDs[0] == "" || userIDs[1] == "" {
|
|
t.Fatal("expected user_id to be populated")
|
|
}
|
|
if userIDs[0] == userIDs[1] {
|
|
t.Fatalf("expected user_id to change when caching is not enabled, got identical values %q", userIDs[0])
|
|
}
|
|
if !isValidUserID(userIDs[0]) || !isValidUserID(userIDs[1]) {
|
|
t.Fatalf("user_ids should be valid, got %q and %q", userIDs[0], userIDs[1])
|
|
}
|
|
}
|
|
|
|
func TestStripClaudeToolPrefixFromResponse_NestedToolReference(t *testing.T) {
|
|
input := []byte(`{"content":[{"type":"tool_result","tool_use_id":"toolu_123","content":[{"type":"tool_reference","tool_name":"proxy_mcp__nia__manage_resource"}]}]}`)
|
|
out := stripClaudeToolPrefixFromResponse(input, "proxy_")
|
|
got := gjson.GetBytes(out, "content.0.content.0.tool_name").String()
|
|
if got != "mcp__nia__manage_resource" {
|
|
t.Fatalf("nested tool_reference tool_name = %q, want %q", got, "mcp__nia__manage_resource")
|
|
}
|
|
}
|
|
|
|
func TestApplyClaudeToolPrefix_NestedToolReferenceWithStringContent(t *testing.T) {
|
|
// tool_result.content can be a string - should not be processed
|
|
input := []byte(`{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_123","content":"plain string result"}]}]}`)
|
|
out := applyClaudeToolPrefix(input, "proxy_")
|
|
got := gjson.GetBytes(out, "messages.0.content.0.content").String()
|
|
if got != "plain string result" {
|
|
t.Fatalf("string content should remain unchanged = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyClaudeToolPrefix_SkipsBuiltinToolReference(t *testing.T) {
|
|
input := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"}],"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"tool_reference","tool_name":"web_search"}]}]}]}`)
|
|
out := applyClaudeToolPrefix(input, "proxy_")
|
|
got := gjson.GetBytes(out, "messages.0.content.0.content.0.tool_name").String()
|
|
if got != "web_search" {
|
|
t.Fatalf("built-in tool_reference should not be prefixed, got %q", got)
|
|
}
|
|
}
|