Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
69
backend/internal/util/claude_attribution.go
Normal file
69
backend/internal/util/claude_attribution.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const claudeCodeAttributionSystemPrefix = "x-anthropic-billing-header:"
|
||||
|
||||
// IsClaudeCodeAttributionSystemText reports whether text is the Claude Code
|
||||
// attribution block that carries per-request billing and prompt fingerprint data.
|
||||
func IsClaudeCodeAttributionSystemText(text string) bool {
|
||||
text = strings.TrimLeftFunc(text, unicode.IsSpace)
|
||||
return strings.HasPrefix(text, claudeCodeAttributionSystemPrefix)
|
||||
}
|
||||
|
||||
// StripClaudeCodeAttributionSystem removes Claude Code billing/CCH attribution
|
||||
// blocks from a Messages body. Other system content is kept. Providers such as
|
||||
// Kimi and Antigravity may treat this block as prompt text, so callers use this
|
||||
// helper when the active policy has not explicitly opted into a full CLI profile.
|
||||
func StripClaudeCodeAttributionSystem(payload []byte) []byte {
|
||||
system := gjson.GetBytes(payload, "system")
|
||||
if !system.Exists() {
|
||||
return payload
|
||||
}
|
||||
if system.Type == gjson.String {
|
||||
if !IsClaudeCodeAttributionSystemText(system.String()) {
|
||||
return payload
|
||||
}
|
||||
updated, errDelete := sjson.DeleteBytes(payload, "system")
|
||||
if errDelete != nil {
|
||||
return payload
|
||||
}
|
||||
return updated
|
||||
}
|
||||
if !system.IsArray() {
|
||||
return payload
|
||||
}
|
||||
kept := make([]string, 0, len(system.Array()))
|
||||
removed := false
|
||||
system.ForEach(func(_, block gjson.Result) bool {
|
||||
if block.Get("type").String() == "text" && IsClaudeCodeAttributionSystemText(block.Get("text").String()) {
|
||||
removed = true
|
||||
return true
|
||||
}
|
||||
if block.Raw != "" {
|
||||
kept = append(kept, block.Raw)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !removed {
|
||||
return payload
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
updated, errDelete := sjson.DeleteBytes(payload, "system")
|
||||
if errDelete != nil {
|
||||
return payload
|
||||
}
|
||||
return updated
|
||||
}
|
||||
updated, errSet := sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(kept, ",")+"]"))
|
||||
if errSet != nil {
|
||||
return payload
|
||||
}
|
||||
return updated
|
||||
}
|
||||
94
backend/internal/util/claude_attribution_test.go
Normal file
94
backend/internal/util/claude_attribution_test.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestIsClaudeCodeAttributionSystemText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Claude Code attribution block",
|
||||
text: "x-anthropic-billing-header: cc_version=2.1.63.abc; cc_entrypoint=cli; cch=12345;",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "leading whitespace",
|
||||
text: "\n\t x-anthropic-billing-header: cc_version=2.1.63.abc; cch=12345;",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "regular system prompt",
|
||||
text: "You are helpful.",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty text",
|
||||
text: "",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsClaudeCodeAttributionSystemText(tt.text); got != tt.want {
|
||||
t.Fatalf("IsClaudeCodeAttributionSystemText(%q) = %v, want %v", tt.text, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripClaudeCodeAttributionSystem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantSystem string
|
||||
wantPresent bool
|
||||
}{
|
||||
{
|
||||
name: "string attribution deleted",
|
||||
body: `{"system":"x-anthropic-billing-header: cc_version=2.1.220; cch=abcde;","messages":[]}`,
|
||||
},
|
||||
{
|
||||
name: "string regular prompt kept",
|
||||
body: `{"system":"You are helpful.","messages":[]}`,
|
||||
wantSystem: `"You are helpful."`,
|
||||
wantPresent: true,
|
||||
},
|
||||
{
|
||||
name: "array drops billing keeps identity",
|
||||
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220; cch=abcde;"},{"type":"text","text":"You are Claude Code"}],"messages":[]}`,
|
||||
wantSystem: `[{"type":"text","text":"You are Claude Code"}]`,
|
||||
wantPresent: true,
|
||||
},
|
||||
{
|
||||
name: "array only billing deleted",
|
||||
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220; cch=abcde;"}],"messages":[]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := StripClaudeCodeAttributionSystem([]byte(tt.body))
|
||||
system := gjson.GetBytes(got, "system")
|
||||
if system.Exists() != tt.wantPresent {
|
||||
t.Fatalf("system exists = %v, want %v: %s", system.Exists(), tt.wantPresent, got)
|
||||
}
|
||||
if tt.wantPresent && system.Raw != tt.wantSystem {
|
||||
t.Fatalf("system = %s, want %s", system.Raw, tt.wantSystem)
|
||||
}
|
||||
if strings.Contains(string(got), "cch=") {
|
||||
t.Fatalf("stripped body still contains cch=: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
10
backend/internal/util/claude_model.go
Normal file
10
backend/internal/util/claude_model.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package util
|
||||
|
||||
import "strings"
|
||||
|
||||
// IsClaudeThinkingModel checks if the model is a Claude thinking model
|
||||
// that requires the interleaved-thinking beta header.
|
||||
func IsClaudeThinkingModel(model string) bool {
|
||||
lower := strings.ToLower(model)
|
||||
return strings.Contains(lower, "claude") && strings.Contains(lower, "thinking")
|
||||
}
|
||||
42
backend/internal/util/claude_model_test.go
Normal file
42
backend/internal/util/claude_model_test.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package util
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsClaudeThinkingModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
expected bool
|
||||
}{
|
||||
// Claude thinking models - should return true
|
||||
{"claude-sonnet-4-5-thinking", "claude-sonnet-4-5-thinking", true},
|
||||
{"claude-opus-4-5-thinking", "claude-opus-4-5-thinking", true},
|
||||
{"claude-opus-4-6-thinking", "claude-opus-4-6-thinking", true},
|
||||
{"Claude-Sonnet-Thinking uppercase", "Claude-Sonnet-4-5-Thinking", true},
|
||||
{"claude thinking mixed case", "Claude-THINKING-Model", true},
|
||||
|
||||
// Non-thinking Claude models - should return false
|
||||
{"claude-sonnet-4-5 (no thinking)", "claude-sonnet-4-5", false},
|
||||
{"claude-opus-4-5 (no thinking)", "claude-opus-4-5", false},
|
||||
{"claude-3-5-sonnet", "claude-3-5-sonnet-20240620", false},
|
||||
|
||||
// Non-Claude models - should return false
|
||||
{"gemini-3-pro-preview", "gemini-3-pro-preview", false},
|
||||
{"gemini-thinking model", "gemini-3-pro-thinking", false}, // not Claude
|
||||
{"gpt-4o", "gpt-4o", false},
|
||||
{"empty string", "", false},
|
||||
|
||||
// Edge cases
|
||||
{"thinking without claude", "thinking-model", false},
|
||||
{"claude without thinking", "claude-model", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := IsClaudeThinkingModel(tt.model)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsClaudeThinkingModel(%q) = %v, expected %v", tt.model, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
122
backend/internal/util/claude_schema.go
Normal file
122
backend/internal/util/claude_schema.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package util
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const emptyClaudeToolInputSchema = `{"type":"object","properties":{}}`
|
||||
|
||||
// NormalizeClaudeToolInputSchema makes a JSON Schema compatible with Claude's
|
||||
// requirement that a tool input schema is an object without root-level unions.
|
||||
func NormalizeClaudeToolInputSchema(schema []byte) []byte {
|
||||
var root map[string]json.RawMessage
|
||||
if len(schema) == 0 || json.Unmarshal(schema, &root) != nil || root == nil {
|
||||
return []byte(emptyClaudeToolInputSchema)
|
||||
}
|
||||
|
||||
properties := claudeSchemaObject(root["properties"])
|
||||
for _, unionName := range []string{"anyOf", "oneOf", "allOf"} {
|
||||
unionRaw, exists := root[unionName]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
delete(root, unionName)
|
||||
|
||||
var branches []json.RawMessage
|
||||
if json.Unmarshal(unionRaw, &branches) != nil {
|
||||
continue
|
||||
}
|
||||
for _, branchRaw := range branches {
|
||||
var branch map[string]json.RawMessage
|
||||
if json.Unmarshal(branchRaw, &branch) != nil || !claudeSchemaCanBeObject(branch) {
|
||||
continue
|
||||
}
|
||||
for name, property := range claudeSchemaObject(branch["properties"]) {
|
||||
if _, exists = properties[name]; !exists {
|
||||
properties[name] = property
|
||||
}
|
||||
}
|
||||
if unionName == "allOf" {
|
||||
mergeClaudeSchemaRequired(root, branch["required"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
root["type"] = json.RawMessage(`"object"`)
|
||||
propertiesRaw, errMarshalProperties := json.Marshal(properties)
|
||||
if errMarshalProperties != nil {
|
||||
return []byte(emptyClaudeToolInputSchema)
|
||||
}
|
||||
root["properties"] = propertiesRaw
|
||||
|
||||
normalized, errMarshalRoot := json.Marshal(root)
|
||||
if errMarshalRoot != nil {
|
||||
return []byte(emptyClaudeToolInputSchema)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func claudeSchemaObject(raw json.RawMessage) map[string]json.RawMessage {
|
||||
object := make(map[string]json.RawMessage)
|
||||
if len(raw) == 0 {
|
||||
return object
|
||||
}
|
||||
if errUnmarshal := json.Unmarshal(raw, &object); errUnmarshal != nil || object == nil {
|
||||
return make(map[string]json.RawMessage)
|
||||
}
|
||||
return object
|
||||
}
|
||||
|
||||
func claudeSchemaCanBeObject(schema map[string]json.RawMessage) bool {
|
||||
typeRaw, exists := schema["type"]
|
||||
if !exists {
|
||||
return true
|
||||
}
|
||||
|
||||
var schemaType string
|
||||
if json.Unmarshal(typeRaw, &schemaType) == nil {
|
||||
return schemaType == "object"
|
||||
}
|
||||
|
||||
var schemaTypes []string
|
||||
if json.Unmarshal(typeRaw, &schemaTypes) != nil {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range schemaTypes {
|
||||
if candidate == "object" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeClaudeSchemaRequired(root map[string]json.RawMessage, branchRequired json.RawMessage) {
|
||||
var required []string
|
||||
if rootRequired, exists := root["required"]; exists {
|
||||
if errUnmarshal := json.Unmarshal(rootRequired, &required); errUnmarshal != nil {
|
||||
required = nil
|
||||
}
|
||||
}
|
||||
|
||||
var branchNames []string
|
||||
if json.Unmarshal(branchRequired, &branchNames) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(required)+len(branchNames))
|
||||
for _, name := range required {
|
||||
seen[name] = struct{}{}
|
||||
}
|
||||
for _, name := range branchNames {
|
||||
if _, exists := seen[name]; exists {
|
||||
continue
|
||||
}
|
||||
required = append(required, name)
|
||||
seen[name] = struct{}{}
|
||||
}
|
||||
if len(required) == 0 {
|
||||
return
|
||||
}
|
||||
requiredRaw, errMarshal := json.Marshal(required)
|
||||
if errMarshal == nil {
|
||||
root["required"] = requiredRaw
|
||||
}
|
||||
}
|
||||
114
backend/internal/util/claude_schema_test.go
Normal file
114
backend/internal/util/claude_schema_test.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package util
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeClaudeToolInputSchema(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "root anyOf without type",
|
||||
input: `{
|
||||
"anyOf": [
|
||||
{"type":"object","properties":{"a":{"type":"string"}}},
|
||||
{"type":"object","properties":{"b":{"type":"integer"}}}
|
||||
]
|
||||
}`,
|
||||
expected: `{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"a":{"type":"string"},
|
||||
"b":{"type":"integer"}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "root oneOf keeps nested union",
|
||||
input: `{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"nested":{"oneOf":[{"type":"string"},{"type":"number"}]}
|
||||
},
|
||||
"oneOf":[
|
||||
{"properties":{"a":{"type":"string"}},"required":["a"]},
|
||||
{"properties":{"b":{"type":"string"}},"required":["b"]}
|
||||
]
|
||||
}`,
|
||||
expected: `{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"nested":{"oneOf":[{"type":"string"},{"type":"number"}]},
|
||||
"a":{"type":"string"},
|
||||
"b":{"type":"string"}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "root anyOf drops alternative required fields",
|
||||
input: `{
|
||||
"type":"object",
|
||||
"properties":{"a":{"type":"string"},"b":{"type":"string"}},
|
||||
"anyOf":[{"required":["a"]},{"required":["b"]}]
|
||||
}`,
|
||||
expected: `{
|
||||
"type":"object",
|
||||
"properties":{"a":{"type":"string"},"b":{"type":"string"}}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "root allOf merges properties and required fields",
|
||||
input: `{
|
||||
"type":"object",
|
||||
"properties":{"base":{"type":"boolean"}},
|
||||
"required":["base"],
|
||||
"allOf":[
|
||||
{"type":"object","properties":{"a":{"type":"string"}},"required":["a"]},
|
||||
{"properties":{"b":{"type":"integer"}},"required":["a","b"]}
|
||||
]
|
||||
}`,
|
||||
expected: `{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"base":{"type":"boolean"},
|
||||
"a":{"type":"string"},
|
||||
"b":{"type":"integer"}
|
||||
},
|
||||
"required":["base","a","b"]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "ordinary object schema",
|
||||
input: `{
|
||||
"type":"object",
|
||||
"properties":{"query":{"type":"string"}},
|
||||
"required":["query"],
|
||||
"additionalProperties":false
|
||||
}`,
|
||||
expected: `{
|
||||
"type":"object",
|
||||
"properties":{"query":{"type":"string"}},
|
||||
"required":["query"],
|
||||
"additionalProperties":false
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "invalid schema",
|
||||
input: `{"type":`,
|
||||
expected: `{"type":"object","properties":{}}`,
|
||||
},
|
||||
{
|
||||
name: "boolean schema",
|
||||
input: `true`,
|
||||
expected: `{"type":"object","properties":{}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
actual := NormalizeClaudeToolInputSchema([]byte(test.input))
|
||||
compareJSON(t, test.expected, string(actual))
|
||||
})
|
||||
}
|
||||
}
|
||||
68
backend/internal/util/claude_tool_id.go
Normal file
68
backend/internal/util/claude_tool_id.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const geminiClaudeToolUseIDPrefix = "cpa_gemini_"
|
||||
|
||||
var (
|
||||
claudeToolUseIDSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
||||
claudeToolUseIDCounter uint64
|
||||
)
|
||||
|
||||
// SanitizeClaudeToolID ensures the given id conforms to Claude's
|
||||
// tool_use.id regex ^[a-zA-Z0-9_-]+$. Non-conforming characters are
|
||||
// replaced with '_'; an empty result gets a generated fallback.
|
||||
func SanitizeClaudeToolID(id string) string {
|
||||
s := claudeToolUseIDSanitizer.ReplaceAllString(id, "_")
|
||||
if s == "" {
|
||||
s = fmt.Sprintf("toolu_%d_%d", time.Now().UnixNano(), atomic.AddUint64(&claudeToolUseIDCounter, 1))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GeminiClaudeToolUseID returns a stable Claude-facing ID for a provider-native
|
||||
// Gemini function call. The opaque ID lets the executor recover the exact
|
||||
// provider call from its replay ledger instead of trusting client-mutated args.
|
||||
func GeminiClaudeToolUseID(callID, name, argsRaw string) string {
|
||||
callID = strings.TrimSpace(callID)
|
||||
name = strings.TrimSpace(name)
|
||||
if callID == "" || name == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(argsRaw) != "" {
|
||||
var value any
|
||||
if json.Unmarshal([]byte(argsRaw), &value) == nil {
|
||||
if canonical, errMarshal := json.Marshal(value); errMarshal == nil {
|
||||
argsRaw = string(canonical)
|
||||
}
|
||||
} else {
|
||||
argsRaw = strings.TrimSpace(argsRaw)
|
||||
}
|
||||
}
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{callID, name, argsRaw}, "\x00")))
|
||||
return geminiClaudeToolUseIDPrefix + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// IsGeminiClaudeToolUseID reports whether id belongs to the reserved
|
||||
// Claude-facing Gemini provenance namespace.
|
||||
func IsGeminiClaudeToolUseID(id string) bool {
|
||||
id = strings.TrimSpace(id)
|
||||
if !strings.HasPrefix(id, geminiClaudeToolUseIDPrefix) {
|
||||
return false
|
||||
}
|
||||
digest := strings.TrimPrefix(id, geminiClaudeToolUseIDPrefix)
|
||||
if len(digest) != 32 {
|
||||
return false
|
||||
}
|
||||
_, errDecode := hex.DecodeString(digest)
|
||||
return errDecode == nil
|
||||
}
|
||||
21
backend/internal/util/claude_tool_id_test.go
Normal file
21
backend/internal/util/claude_tool_id_test.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package util
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGeminiClaudeToolUseIDStableAndBound(t *testing.T) {
|
||||
args := `{"file_path":"/tmp/a","old_string":"x","new_string":"y"}`
|
||||
first := GeminiClaudeToolUseID("native-call-1", "Edit", args)
|
||||
second := GeminiClaudeToolUseID("native-call-1", "Edit", `{"new_string":"y","old_string":"x","file_path":"/tmp/a"}`)
|
||||
if first == "" || first != second || !IsGeminiClaudeToolUseID(first) {
|
||||
t.Fatalf("stable tool id mismatch: first=%q second=%q", first, second)
|
||||
}
|
||||
if changed := GeminiClaudeToolUseID("native-call-1", "Edit", `{"file_path":"/tmp/a","old_string":"x","new_string":"z"}`); changed == first {
|
||||
t.Fatal("tool id must be bound to native call semantics")
|
||||
}
|
||||
if GeminiClaudeToolUseID("", "Edit", args) != "" {
|
||||
t.Fatal("ID-less provider calls must keep the existing fallback path")
|
||||
}
|
||||
if IsGeminiClaudeToolUseID("toolu_client_value") {
|
||||
t.Fatal("ordinary client tool IDs must not be treated as CPA provenance IDs")
|
||||
}
|
||||
}
|
||||
109
backend/internal/util/claude_tool_result.go
Normal file
109
backend/internal/util/claude_tool_result.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// ClaudeToolResultImage represents a base64-encoded image extracted from a Claude
|
||||
// tool_result content block. Callers emit it as a provider-specific inline data
|
||||
// part so that image bytes do not bloat the textual function response result.
|
||||
type ClaudeToolResultImage struct {
|
||||
MimeType string
|
||||
Data string
|
||||
}
|
||||
|
||||
// ClaudeToolResult is the normalized form of a Claude tool_result `content` field,
|
||||
// ready to be written into a Gemini-style functionResponse.
|
||||
type ClaudeToolResult struct {
|
||||
// Result is the value for functionResponse.response.result.
|
||||
Result string
|
||||
// ResultIsRaw reports whether Result holds raw JSON (write with sjson.SetRaw*)
|
||||
// or a plain string (write with sjson.Set*). Writing raw JSON text through
|
||||
// sjson.Set as a string value would double-encode it, so callers must honor
|
||||
// this flag.
|
||||
ResultIsRaw bool
|
||||
// Images holds base64 image blocks separated out of the content.
|
||||
Images []ClaudeToolResultImage
|
||||
}
|
||||
|
||||
// ConvertClaudeToolResultContent normalizes a Claude tool_result `content` field into
|
||||
// a deterministic Gemini functionResponse result plus any extracted images.
|
||||
//
|
||||
// Claude tool_result content may be a plain string, an array of mixed text/image
|
||||
// blocks, a single object, or absent. Some Claude->Gemini translators previously
|
||||
// wrote content.Raw straight through sjson.SetBytes, which double-encoded string
|
||||
// content and flattened structured arrays (including base64 image data) into one
|
||||
// opaque escaped string. This helper mirrors the Antigravity Claude translator,
|
||||
// which already handles structured content correctly:
|
||||
//
|
||||
// - string -> plain string result (no double-encoding)
|
||||
// - single non-image -> raw JSON result (structure preserved)
|
||||
// - multiple non-image -> raw JSON array result
|
||||
// - base64 image block -> separated into Images (emitted as inline data parts)
|
||||
// - object -> raw JSON result, or image -> Images with empty result
|
||||
// - absent/empty -> empty string result
|
||||
//
|
||||
// Unlike Antigravity, image blocks without base64 data are dropped rather than
|
||||
// emitted as empty inline data parts, matching the Gemini image part guards.
|
||||
func ConvertClaudeToolResultContent(content gjson.Result) ClaudeToolResult {
|
||||
switch {
|
||||
case content.Type == gjson.String:
|
||||
return ClaudeToolResult{Result: content.String()}
|
||||
case content.IsArray():
|
||||
var images []ClaudeToolResultImage
|
||||
nonImageCount := 0
|
||||
lastNonImageRaw := ""
|
||||
filtered := []byte(`[]`)
|
||||
content.ForEach(func(_, block gjson.Result) bool {
|
||||
if isClaudeBase64Image(block) {
|
||||
if img, ok := claudeImageFromBlock(block); ok {
|
||||
images = append(images, img)
|
||||
}
|
||||
return true
|
||||
}
|
||||
nonImageCount++
|
||||
lastNonImageRaw = block.Raw
|
||||
filtered, _ = sjson.SetRawBytes(filtered, "-1", []byte(block.Raw))
|
||||
return true
|
||||
})
|
||||
switch {
|
||||
case nonImageCount == 1:
|
||||
return ClaudeToolResult{Result: lastNonImageRaw, ResultIsRaw: true, Images: images}
|
||||
case nonImageCount > 1:
|
||||
return ClaudeToolResult{Result: string(filtered), ResultIsRaw: true, Images: images}
|
||||
default:
|
||||
return ClaudeToolResult{Images: images}
|
||||
}
|
||||
case content.IsObject():
|
||||
if isClaudeBase64Image(content) {
|
||||
if img, ok := claudeImageFromBlock(content); ok {
|
||||
return ClaudeToolResult{Images: []ClaudeToolResultImage{img}}
|
||||
}
|
||||
return ClaudeToolResult{}
|
||||
}
|
||||
return ClaudeToolResult{Result: content.Raw, ResultIsRaw: true}
|
||||
case content.Raw != "":
|
||||
return ClaudeToolResult{Result: content.Raw, ResultIsRaw: true}
|
||||
default:
|
||||
return ClaudeToolResult{}
|
||||
}
|
||||
}
|
||||
|
||||
// isClaudeBase64Image reports whether a content block is a base64-encoded image block.
|
||||
func isClaudeBase64Image(block gjson.Result) bool {
|
||||
return block.Get("type").String() == "image" && block.Get("source.type").String() == "base64"
|
||||
}
|
||||
|
||||
// claudeImageFromBlock extracts image data from a base64 image block. It returns false
|
||||
// when the block carries no base64 data, so empty inline data parts are not emitted.
|
||||
func claudeImageFromBlock(block gjson.Result) (ClaudeToolResultImage, bool) {
|
||||
data := block.Get("source.data").String()
|
||||
if data == "" {
|
||||
return ClaudeToolResultImage{}, false
|
||||
}
|
||||
return ClaudeToolResultImage{
|
||||
MimeType: block.Get("source.media_type").String(),
|
||||
Data: data,
|
||||
}, true
|
||||
}
|
||||
110
backend/internal/util/claude_tool_result_test.go
Normal file
110
backend/internal/util/claude_tool_result_test.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertClaudeToolResultContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
wrapper string
|
||||
wantResult string
|
||||
wantRaw bool
|
||||
wantImages int
|
||||
}{
|
||||
{
|
||||
name: "StringContent",
|
||||
wrapper: `{"content":"alpha"}`,
|
||||
wantResult: "alpha",
|
||||
wantRaw: false,
|
||||
wantImages: 0,
|
||||
},
|
||||
{
|
||||
name: "SingleTextBlock",
|
||||
wrapper: `{"content":[{"type":"text","text":"alpha"}]}`,
|
||||
wantResult: `{"type":"text","text":"alpha"}`,
|
||||
wantRaw: true,
|
||||
wantImages: 0,
|
||||
},
|
||||
{
|
||||
name: "MultipleTextBlocks",
|
||||
wrapper: `{"content":[{"type":"text","text":"alpha"},{"type":"text","text":"beta"}]}`,
|
||||
wantResult: `[{"type":"text","text":"alpha"},{"type":"text","text":"beta"}]`,
|
||||
wantRaw: true,
|
||||
wantImages: 0,
|
||||
},
|
||||
{
|
||||
name: "TextAndImage",
|
||||
wrapper: `{"content":[{"type":"text","text":"alpha"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}]}`,
|
||||
wantResult: `{"type":"text","text":"alpha"}`,
|
||||
wantRaw: true,
|
||||
wantImages: 1,
|
||||
},
|
||||
{
|
||||
name: "ImageOnly",
|
||||
wrapper: `{"content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}]}`,
|
||||
wantResult: "",
|
||||
wantRaw: false,
|
||||
wantImages: 1,
|
||||
},
|
||||
{
|
||||
name: "ImageWithoutDataDropped",
|
||||
wrapper: `{"content":[{"type":"image","source":{"type":"base64","media_type":"image/png"}}]}`,
|
||||
wantResult: "",
|
||||
wantRaw: false,
|
||||
wantImages: 0,
|
||||
},
|
||||
{
|
||||
name: "ObjectContent",
|
||||
wrapper: `{"content":{"foo":"bar"}}`,
|
||||
wantResult: `{"foo":"bar"}`,
|
||||
wantRaw: true,
|
||||
wantImages: 0,
|
||||
},
|
||||
{
|
||||
name: "ObjectImage",
|
||||
wrapper: `{"content":{"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}}`,
|
||||
wantResult: "",
|
||||
wantRaw: false,
|
||||
wantImages: 1,
|
||||
},
|
||||
{
|
||||
name: "AbsentContent",
|
||||
wrapper: `{}`,
|
||||
wantResult: "",
|
||||
wantRaw: false,
|
||||
wantImages: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ConvertClaudeToolResultContent(gjson.Get(tt.wrapper, "content"))
|
||||
if got.Result != tt.wantResult {
|
||||
t.Errorf("Result = %q, want %q", got.Result, tt.wantResult)
|
||||
}
|
||||
if got.ResultIsRaw != tt.wantRaw {
|
||||
t.Errorf("ResultIsRaw = %v, want %v", got.ResultIsRaw, tt.wantRaw)
|
||||
}
|
||||
if len(got.Images) != tt.wantImages {
|
||||
t.Errorf("len(Images) = %d, want %d", len(got.Images), tt.wantImages)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeToolResultContent_ImageFields(t *testing.T) {
|
||||
content := gjson.Get(`{"content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}]}`, "content")
|
||||
got := ConvertClaudeToolResultContent(content)
|
||||
if len(got.Images) != 1 {
|
||||
t.Fatalf("expected 1 image, got %d", len(got.Images))
|
||||
}
|
||||
if got.Images[0].MimeType != "image/png" {
|
||||
t.Errorf("MimeType = %q, want image/png", got.Images[0].MimeType)
|
||||
}
|
||||
if got.Images[0].Data != "aGVsbG8=" {
|
||||
t.Errorf("Data = %q, want aGVsbG8=", got.Images[0].Data)
|
||||
}
|
||||
}
|
||||
1541
backend/internal/util/gemini_schema.go
Normal file
1541
backend/internal/util/gemini_schema.go
Normal file
File diff suppressed because it is too large
Load diff
2234
backend/internal/util/gemini_schema_test.go
Normal file
2234
backend/internal/util/gemini_schema_test.go
Normal file
File diff suppressed because it is too large
Load diff
27
backend/internal/util/gjson.go
Normal file
27
backend/internal/util/gjson.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// GetGJSONBytesNoCopy returns a GJSON result that may reference data directly.
|
||||
// Callers must not retain the result or mutate data while using it.
|
||||
func GetGJSONBytesNoCopy(data []byte, path string) gjson.Result {
|
||||
if len(data) == 0 {
|
||||
return gjson.Result{}
|
||||
}
|
||||
return gjson.Get(unsafe.String(unsafe.SliceData(data), len(data)), path)
|
||||
}
|
||||
|
||||
// ParseGJSONBytesNoCopy parses data into a GJSON result that references data
|
||||
// directly. gjson.ParseBytes copies the whole document, which is prohibitive
|
||||
// for multi-megabyte payloads. Callers must not retain the result or mutate
|
||||
// data while using it.
|
||||
func ParseGJSONBytesNoCopy(data []byte) gjson.Result {
|
||||
if len(data) == 0 {
|
||||
return gjson.Result{}
|
||||
}
|
||||
return gjson.Parse(unsafe.String(unsafe.SliceData(data), len(data)))
|
||||
}
|
||||
45
backend/internal/util/gjson_test.go
Normal file
45
backend/internal/util/gjson_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func TestGetGJSONBytesNoCopy(t *testing.T) {
|
||||
input := []byte(`{"request":{"contents":[{"role":"user"}]}}`)
|
||||
contents := GetGJSONBytesNoCopy(input, "request.contents")
|
||||
if !contents.IsArray() || contents.Get("0.role").String() != "user" {
|
||||
t.Fatalf("request.contents = %s, want user content array", contents.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGJSONBytesNoCopyEmptyInput(t *testing.T) {
|
||||
if result := GetGJSONBytesNoCopy(nil, "contents"); result.Exists() {
|
||||
t.Fatalf("empty input result = %s, want missing", result.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGJSONBytesNoCopy(t *testing.T) {
|
||||
input := []byte(`{"request":{"contents":[{"role":"user"}]}}`)
|
||||
root := ParseGJSONBytesNoCopy(input)
|
||||
if !root.IsObject() || root.Get("request.contents.0.role").String() != "user" {
|
||||
t.Fatalf("parsed root = %s, want user content array", root.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGJSONBytesNoCopyReferencesInput(t *testing.T) {
|
||||
input := []byte(`{"contents":[{"role":"user"}]}`)
|
||||
root := ParseGJSONBytesNoCopy(input)
|
||||
if len(root.Raw) != len(input) {
|
||||
t.Fatalf("raw length = %d, want %d", len(root.Raw), len(input))
|
||||
}
|
||||
if unsafe.StringData(root.Raw) != unsafe.SliceData(input) {
|
||||
t.Fatal("parsed result copied the input instead of referencing it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGJSONBytesNoCopyEmptyInput(t *testing.T) {
|
||||
if result := ParseGJSONBytesNoCopy(nil); result.Exists() {
|
||||
t.Fatalf("empty input result = %s, want missing", result.Raw)
|
||||
}
|
||||
}
|
||||
95
backend/internal/util/header_helpers.go
Normal file
95
backend/internal/util/header_helpers.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ApplyCustomHeadersFromAttrs applies user-defined headers stored in the provided attributes map.
|
||||
// Custom headers override built-in defaults when conflicts occur.
|
||||
// If clientHeaders is provided (or if the request context carries a Gin context), any custom header
|
||||
// whose value starts with "$" (e.g. "$ABC" or "$X-Claude-Code-Session-Id") is dynamically
|
||||
// resolved from the client's request headers. If the client did not provide that header,
|
||||
// the custom header is omitted from the outgoing request.
|
||||
func ApplyCustomHeadersFromAttrs(r *http.Request, attrs map[string]string, clientHeaders ...http.Header) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
var ch http.Header
|
||||
if len(clientHeaders) > 0 && clientHeaders[0] != nil {
|
||||
ch = clientHeaders[0]
|
||||
} else if r.Context() != nil {
|
||||
if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
|
||||
ch = ginCtx.Request.Header
|
||||
} else if ginCtx, ok := r.Context().(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
|
||||
ch = ginCtx.Request.Header
|
||||
}
|
||||
}
|
||||
applyCustomHeaders(r, extractCustomHeaders(attrs, ch))
|
||||
}
|
||||
|
||||
func extractCustomHeaders(attrs map[string]string, clientHeaders http.Header) map[string]string {
|
||||
if len(attrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
headers := make(map[string]string)
|
||||
for k, v := range attrs {
|
||||
if !strings.HasPrefix(k, "header:") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(strings.TrimPrefix(k, "header:"))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
val := strings.TrimSpace(v)
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(val, "$") {
|
||||
varName := strings.TrimSpace(strings.TrimPrefix(val, "$"))
|
||||
if varName == "" || clientHeaders == nil {
|
||||
continue
|
||||
}
|
||||
clientVal := clientHeaders.Get(varName)
|
||||
if clientVal == "" {
|
||||
for ck, cv := range clientHeaders {
|
||||
if strings.EqualFold(ck, varName) && len(cv) > 0 && cv[0] != "" {
|
||||
clientVal = cv[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if clientVal == "" {
|
||||
continue
|
||||
}
|
||||
val = clientVal
|
||||
}
|
||||
headers[name] = val
|
||||
}
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func applyCustomHeaders(r *http.Request, headers map[string]string) {
|
||||
if r == nil || len(headers) == 0 {
|
||||
return
|
||||
}
|
||||
for k, v := range headers {
|
||||
if k == "" || v == "" {
|
||||
continue
|
||||
}
|
||||
// net/http reads Host from req.Host (not req.Header) when writing
|
||||
// a real request, so we must mirror it there. Some callers pass
|
||||
// synthetic requests (e.g. &http.Request{Header: ...}) and only
|
||||
// consume r.Header afterwards, so keep the value in the header
|
||||
// map too.
|
||||
if http.CanonicalHeaderKey(k) == "Host" {
|
||||
r.Host = v
|
||||
}
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
116
backend/internal/util/header_helpers_test.go
Normal file
116
backend/internal/util/header_helpers_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestApplyCustomHeadersFromAttrs_StaticHeaders(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
||||
attrs := map[string]string{
|
||||
"header:X-Custom-Static": "static-value",
|
||||
"header:Host": "custom.host.com",
|
||||
}
|
||||
|
||||
ApplyCustomHeadersFromAttrs(req, attrs)
|
||||
|
||||
if got := req.Header.Get("X-Custom-Static"); got != "static-value" {
|
||||
t.Errorf("X-Custom-Static = %q, want %q", got, "static-value")
|
||||
}
|
||||
if got := req.Host; got != "custom.host.com" {
|
||||
t.Errorf("req.Host = %q, want %q", got, "custom.host.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCustomHeadersFromAttrs_MagicVariable(t *testing.T) {
|
||||
t.Run("present in clientHeaders sets header", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
||||
attrs := map[string]string{
|
||||
"header:X-Claude-Code-Session-Id": "$ABC",
|
||||
"header:X-Target-Session": "$X-Claude-Code-Session-Id",
|
||||
"header:Static-Header": "static-123",
|
||||
}
|
||||
clientHeaders := http.Header{
|
||||
"Abc": []string{"session-abc-456"},
|
||||
"X-Claude-Code-Session-Id": []string{"claude-code-uuid-789"},
|
||||
}
|
||||
|
||||
ApplyCustomHeadersFromAttrs(req, attrs, clientHeaders)
|
||||
|
||||
if got := req.Header.Get("X-Claude-Code-Session-Id"); got != "session-abc-456" {
|
||||
t.Errorf("X-Claude-Code-Session-Id = %q, want %q", got, "session-abc-456")
|
||||
}
|
||||
if got := req.Header.Get("X-Target-Session"); got != "claude-code-uuid-789" {
|
||||
t.Errorf("X-Target-Session = %q, want %q", got, "claude-code-uuid-789")
|
||||
}
|
||||
if got := req.Header.Get("Static-Header"); got != "static-123" {
|
||||
t.Errorf("Static-Header = %q, want %q", got, "static-123")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("absent in clientHeaders does not set header", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
||||
attrs := map[string]string{
|
||||
"header:X-Claude-Code-Session-Id": "$ABC",
|
||||
"header:X-Other": "$NONEXISTENT",
|
||||
"header:Static-Header": "static-123",
|
||||
}
|
||||
clientHeaders := http.Header{
|
||||
"Other-Header": []string{"some-value"},
|
||||
}
|
||||
|
||||
ApplyCustomHeadersFromAttrs(req, attrs, clientHeaders)
|
||||
|
||||
if _, exists := req.Header["X-Claude-Code-Session-Id"]; exists {
|
||||
t.Errorf("expected X-Claude-Code-Session-Id to be omitted when $ABC is absent in clientHeaders, got %q", req.Header.Get("X-Claude-Code-Session-Id"))
|
||||
}
|
||||
if _, exists := req.Header["X-Other"]; exists {
|
||||
t.Errorf("expected X-Other to be omitted when $NONEXISTENT is absent in clientHeaders, got %q", req.Header.Get("X-Other"))
|
||||
}
|
||||
if got := req.Header.Get("Static-Header"); got != "static-123" {
|
||||
t.Errorf("Static-Header = %q, want %q", got, "static-123")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil clientHeaders does not set variable headers", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
||||
attrs := map[string]string{
|
||||
"header:X-Claude-Code-Session-Id": "$ABC",
|
||||
"header:Static-Header": "static-123",
|
||||
}
|
||||
|
||||
ApplyCustomHeadersFromAttrs(req, attrs)
|
||||
|
||||
if _, exists := req.Header["X-Claude-Code-Session-Id"]; exists {
|
||||
t.Errorf("expected X-Claude-Code-Session-Id to be omitted with nil clientHeaders, got %q", req.Header.Get("X-Claude-Code-Session-Id"))
|
||||
}
|
||||
if got := req.Header.Get("Static-Header"); got != "static-123" {
|
||||
t.Errorf("Static-Header = %q, want %q", got, "static-123")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fallback to gin context in request context", func(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
ginCtx, _ := gin.CreateTestContext(w)
|
||||
ginReq := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
ginReq.Header.Set("ABC", "from-gin-ctx-123")
|
||||
ginCtx.Request = ginReq
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
||||
req = req.WithContext(ginCtx)
|
||||
|
||||
attrs := map[string]string{
|
||||
"header:X-Claude-Code-Session-Id": "$ABC",
|
||||
}
|
||||
|
||||
ApplyCustomHeadersFromAttrs(req, attrs)
|
||||
|
||||
if got := req.Header.Get("X-Claude-Code-Session-Id"); got != "from-gin-ctx-123" {
|
||||
t.Errorf("X-Claude-Code-Session-Id = %q, want %q", got, "from-gin-ctx-123")
|
||||
}
|
||||
})
|
||||
}
|
||||
59
backend/internal/util/image.go
Normal file
59
backend/internal/util/image.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
)
|
||||
|
||||
func CreateWhiteImageBase64(aspectRatio string) (string, error) {
|
||||
width := 1024
|
||||
height := 1024
|
||||
|
||||
switch aspectRatio {
|
||||
case "1:1":
|
||||
width = 1024
|
||||
height = 1024
|
||||
case "2:3":
|
||||
width = 832
|
||||
height = 1248
|
||||
case "3:2":
|
||||
width = 1248
|
||||
height = 832
|
||||
case "3:4":
|
||||
width = 864
|
||||
height = 1184
|
||||
case "4:3":
|
||||
width = 1184
|
||||
height = 864
|
||||
case "4:5":
|
||||
width = 896
|
||||
height = 1152
|
||||
case "5:4":
|
||||
width = 1152
|
||||
height = 896
|
||||
case "9:16":
|
||||
width = 768
|
||||
height = 1344
|
||||
case "16:9":
|
||||
width = 1344
|
||||
height = 768
|
||||
case "21:9":
|
||||
width = 1536
|
||||
height = 672
|
||||
}
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
draw.Draw(img, img.Bounds(), image.White, image.Point{}, draw.Src)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
base64String := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
return base64String, nil
|
||||
}
|
||||
164
backend/internal/util/nocopy_invariant_test.go
Normal file
164
backend/internal/util/nocopy_invariant_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// inPlaceSJSONTokens are the sjson knobs that let a write reuse the caller's
|
||||
// backing array instead of allocating a new one.
|
||||
var inPlaceSJSONTokens = []string{"ReplaceInPlace", "Optimistic"}
|
||||
|
||||
// inPlaceSJSONAllowlist holds files that are allowed to opt into in-place
|
||||
// sjson writes. A file may only be added here once it is proven that no
|
||||
// no-copy GJSON result (GetGJSONBytesNoCopy / ParseGJSONBytesNoCopy) derived
|
||||
// from the same buffer can still be alive at that point.
|
||||
var inPlaceSJSONAllowlist = map[string]struct{}{}
|
||||
|
||||
// forEachSourceFile visits every non-test Go file in the repository.
|
||||
func forEachSourceFile(t *testing.T, root string, visit func(rel string, data []byte)) {
|
||||
t.Helper()
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
switch d.Name() {
|
||||
case ".git", "vendor", "node_modules", "testdata":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
rel, errRel := filepath.Rel(root, path)
|
||||
if errRel != nil {
|
||||
return errRel
|
||||
}
|
||||
data, errRead := os.ReadFile(path)
|
||||
if errRead != nil {
|
||||
return errRead
|
||||
}
|
||||
visit(filepath.ToSlash(rel), data)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk repository: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoInPlaceSJSONWrites protects the invariant that request payload buffers
|
||||
// stay immutable for their whole lifetime.
|
||||
//
|
||||
// GetGJSONBytesNoCopy and ParseGJSONBytesNoCopy hand out gjson.Result values
|
||||
// whose Raw and Str alias the caller's []byte. Go strings must never change,
|
||||
// so any in-place mutation of that buffer turns already-derived results into
|
||||
// silently wrong data: re-parsing sees the new bytes, and strings that were
|
||||
// used as map keys keep a hash computed from the old ones. The race detector
|
||||
// cannot see this, and normal tests rarely trigger it, so the invariant is
|
||||
// enforced statically here instead.
|
||||
func TestNoInPlaceSJSONWrites(t *testing.T) {
|
||||
root := repoRoot(t)
|
||||
var offenders []string
|
||||
forEachSourceFile(t, root, func(rel string, data []byte) {
|
||||
if _, allowed := inPlaceSJSONAllowlist[rel]; allowed {
|
||||
return
|
||||
}
|
||||
for _, token := range inPlaceSJSONTokens {
|
||||
if strings.Contains(string(data), token) {
|
||||
offenders = append(offenders, rel+" uses "+token)
|
||||
}
|
||||
}
|
||||
})
|
||||
if len(offenders) > 0 {
|
||||
t.Fatalf("in-place sjson writes would corrupt no-copy GJSON results that alias the same buffer:\n %s\n"+
|
||||
"Either keep the default (allocating) sjson call, or prove no no-copy result derived from that buffer is still alive and add the file to inPlaceSJSONAllowlist.",
|
||||
strings.Join(offenders, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// inPlaceByteWritePatterns match the realistic ways Go code overwrites bytes
|
||||
// of an existing buffer: copying into a slice expression, or zeroing elements
|
||||
// in a loop. They do not catch every possible form, so they are a tripwire for
|
||||
// new code rather than a proof of absence.
|
||||
var inPlaceByteWritePatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`\bcopy\([a-zA-Z_][A-Za-z0-9_.]*\[`),
|
||||
regexp.MustCompile(`^\s*[a-zA-Z_][A-Za-z0-9_.]*\[[a-zA-Z0-9_]+\] = 0$`),
|
||||
}
|
||||
|
||||
// reviewedInPlaceByteWrites records the reviewed in-place byte writes per file.
|
||||
// The count is part of the contract: a new write inside an already reviewed file
|
||||
// must be reviewed too, so the count must be updated deliberately. Each reason
|
||||
// states why the write cannot corrupt a no-copy GJSON result, either because the
|
||||
// buffer is private to the writer or because every reader copies out first.
|
||||
type reviewedInPlaceByteWrite struct {
|
||||
count int
|
||||
reason string
|
||||
}
|
||||
|
||||
var reviewedInPlaceByteWrites = map[string]reviewedInPlaceByteWrite{
|
||||
"internal/runtime/executor/claude_signing.go": {2, "writes CCH digits into bytes.Clone(body); the caller's body is never touched"},
|
||||
"internal/runtime/executor/claude_executor_cloaking.go": {1, "shifts []string headers to prepend a block; no byte of any payload is rewritten"},
|
||||
"internal/runtime/executor/claude_executor_request.go": {2, "shifts []string headers to insert a part; no byte of any payload is rewritten"},
|
||||
"internal/runtime/executor/helps/claude_mcp_alias.go": {1, "copies an HMAC sum into a local fixed-size digest array"},
|
||||
"internal/client/codex/live/tcp_proxy.go": {1, "copies header and payload into a freshly allocated frame"},
|
||||
"internal/home/client.go": {1, "zeroes a secret buffer after json.Unmarshal has copied every value out"},
|
||||
"internal/pluginstore/auth.go": {1, "zeroes a locally built credential buffer after base64 encoding copied it out"},
|
||||
}
|
||||
|
||||
// TestInPlaceByteWritesAreReviewed keeps the set of in-place byte writes small
|
||||
// and justified. Any change to the set, including a new write in an already
|
||||
// reviewed file, fails until the author proves that no no-copy GJSON result
|
||||
// derived from that buffer can still be alive and records it above.
|
||||
func TestInPlaceByteWritesAreReviewed(t *testing.T) {
|
||||
root := repoRoot(t)
|
||||
found := make(map[string][]string)
|
||||
forEachSourceFile(t, root, func(rel string, data []byte) {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
for _, pattern := range inPlaceByteWritePatterns {
|
||||
if pattern.MatchString(line) {
|
||||
found[rel] = append(found[rel], strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
for rel, lines := range found {
|
||||
reviewed, ok := reviewedInPlaceByteWrites[rel]
|
||||
if !ok {
|
||||
t.Errorf("unreviewed in-place byte write in %s:\n %s\nProve that no no-copy GJSON result derived from that buffer is still alive, then record it in reviewedInPlaceByteWrites.",
|
||||
rel, strings.Join(lines, "\n "))
|
||||
continue
|
||||
}
|
||||
if len(lines) != reviewed.count {
|
||||
t.Errorf("%s has %d in-place byte write(s), reviewed %d (%s):\n %s",
|
||||
rel, len(lines), reviewed.count, reviewed.reason, strings.Join(lines, "\n "))
|
||||
}
|
||||
}
|
||||
for rel := range reviewedInPlaceByteWrites {
|
||||
if _, ok := found[rel]; !ok {
|
||||
t.Errorf("stale entry in reviewedInPlaceByteWrites: %s no longer contains an in-place byte write", rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
for {
|
||||
if _, errStat := os.Stat(filepath.Join(dir, "go.mod")); errStat == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
t.Fatal("go.mod not found above working directory")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
288
backend/internal/util/provider.go
Normal file
288
backend/internal/util/provider.go
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
// Package util provides utility functions used across the CLIProxyAPI application.
|
||||
// These functions handle common tasks such as determining AI service providers
|
||||
// from model names and managing HTTP proxies.
|
||||
package util
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const openAICompatibleProviderPrefix = "openai-compatible-"
|
||||
|
||||
// OpenAICompatibleProviderKey returns the internal provider key for an OpenAI-compatible provider.
|
||||
func OpenAICompatibleProviderKey(name string) string {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" || name == "openai-compatibility" || strings.HasPrefix(name, openAICompatibleProviderPrefix) {
|
||||
if name == "" {
|
||||
return "openai-compatibility"
|
||||
}
|
||||
return name
|
||||
}
|
||||
return openAICompatibleProviderPrefix + name
|
||||
}
|
||||
|
||||
// GetProviderName determines all AI service providers capable of serving a registered model.
|
||||
// It first queries the global model registry to retrieve the providers backing the supplied model name.
|
||||
// When the model has not been registered yet, it falls back to legacy string heuristics to infer
|
||||
// potential providers.
|
||||
//
|
||||
// Supported providers include (but are not limited to):
|
||||
// - "gemini" for Google's Gemini family
|
||||
// - "codex" for OpenAI GPT-compatible providers
|
||||
// - "claude" for Anthropic models
|
||||
// - "openai-compatibility" for external OpenAI-compatible providers
|
||||
//
|
||||
// Parameters:
|
||||
// - modelName: The name of the model to identify providers for.
|
||||
// - cfg: The application configuration containing OpenAI compatibility settings.
|
||||
//
|
||||
// Returns:
|
||||
// - []string: All provider identifiers capable of serving the model, ordered by preference.
|
||||
func GetProviderName(modelName string) []string {
|
||||
if modelName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
providers := make([]string, 0, 4)
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
appendProvider := func(name string) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := seen[name]; exists {
|
||||
return
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
providers = append(providers, name)
|
||||
}
|
||||
|
||||
for _, provider := range registry.GetGlobalRegistry().GetModelProviders(modelName) {
|
||||
appendProvider(provider)
|
||||
}
|
||||
|
||||
if len(providers) > 0 {
|
||||
return providers
|
||||
}
|
||||
|
||||
return providers
|
||||
}
|
||||
|
||||
// ResolveAutoModel resolves the "auto" model name to an actual available model.
|
||||
// It uses an empty handler type to get any available model from the registry.
|
||||
//
|
||||
// Parameters:
|
||||
// - modelName: The model name to check (should be "auto")
|
||||
//
|
||||
// Returns:
|
||||
// - string: The resolved model name, or the original if not "auto" or resolution fails
|
||||
func ResolveAutoModel(modelName string) string {
|
||||
if modelName != "auto" {
|
||||
return modelName
|
||||
}
|
||||
|
||||
// Use empty string as handler type to get any available model
|
||||
firstModel, err := registry.GetGlobalRegistry().GetFirstAvailableModel("")
|
||||
if err != nil {
|
||||
log.Warnf("Failed to resolve 'auto' model: %v, falling back to original model name", err)
|
||||
return modelName
|
||||
}
|
||||
|
||||
log.Infof("Resolved 'auto' model to: %s", firstModel)
|
||||
return firstModel
|
||||
}
|
||||
|
||||
// IsOpenAICompatibilityAlias checks if the given model name is an alias
|
||||
// configured for OpenAI compatibility routing.
|
||||
//
|
||||
// Parameters:
|
||||
// - modelName: The model name to check
|
||||
// - cfg: The application configuration containing OpenAI compatibility settings
|
||||
//
|
||||
// Returns:
|
||||
// - bool: True if the model name is an OpenAI compatibility alias, false otherwise
|
||||
func IsOpenAICompatibilityAlias(modelName string, cfg *config.Config) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, compat := range cfg.OpenAICompatibility {
|
||||
if compat.Disabled {
|
||||
continue
|
||||
}
|
||||
for _, model := range compat.Models {
|
||||
if model.Alias == modelName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetOpenAICompatibilityConfig returns the OpenAI compatibility configuration
|
||||
// and model details for the given alias.
|
||||
//
|
||||
// Parameters:
|
||||
// - alias: The model alias to find configuration for
|
||||
// - cfg: The application configuration containing OpenAI compatibility settings
|
||||
//
|
||||
// Returns:
|
||||
// - *config.OpenAICompatibility: The matching compatibility configuration, or nil if not found
|
||||
// - *config.OpenAICompatibilityModel: The matching model configuration, or nil if not found
|
||||
func GetOpenAICompatibilityConfig(alias string, cfg *config.Config) (*config.OpenAICompatibility, *config.OpenAICompatibilityModel) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
for _, compat := range cfg.OpenAICompatibility {
|
||||
if compat.Disabled {
|
||||
continue
|
||||
}
|
||||
for _, model := range compat.Models {
|
||||
if model.Alias == alias {
|
||||
return &compat, &model
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// InArray checks if a string exists in a slice of strings.
|
||||
// It iterates through the slice and returns true if the target string is found,
|
||||
// otherwise it returns false.
|
||||
//
|
||||
// Parameters:
|
||||
// - hystack: The slice of strings to search in
|
||||
// - needle: The string to search for
|
||||
//
|
||||
// Returns:
|
||||
// - bool: True if the string is found, false otherwise
|
||||
func InArray(hystack []string, needle string) bool {
|
||||
for _, item := range hystack {
|
||||
if needle == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HideAPIKey obscures an API key for logging purposes, showing only the first and last few characters.
|
||||
//
|
||||
// Parameters:
|
||||
// - apiKey: The API key to hide.
|
||||
//
|
||||
// Returns:
|
||||
// - string: The obscured API key.
|
||||
func HideAPIKey(apiKey string) string {
|
||||
if len(apiKey) > 8 {
|
||||
return apiKey[:4] + "..." + apiKey[len(apiKey)-4:]
|
||||
} else if len(apiKey) > 4 {
|
||||
return apiKey[:2] + "..." + apiKey[len(apiKey)-2:]
|
||||
} else if len(apiKey) > 2 {
|
||||
return apiKey[:1] + "..." + apiKey[len(apiKey)-1:]
|
||||
}
|
||||
return apiKey
|
||||
}
|
||||
|
||||
// maskAuthorizationHeader masks the Authorization header value while preserving the auth type prefix.
|
||||
// Common formats: "Bearer <token>", "Basic <credentials>", "ApiKey <key>", etc.
|
||||
// It preserves the prefix (e.g., "Bearer ") and only masks the token/credential part.
|
||||
//
|
||||
// Parameters:
|
||||
// - value: The Authorization header value
|
||||
//
|
||||
// Returns:
|
||||
// - string: The masked Authorization value with prefix preserved
|
||||
func MaskAuthorizationHeader(value string) string {
|
||||
parts := strings.SplitN(strings.TrimSpace(value), " ", 2)
|
||||
if len(parts) < 2 {
|
||||
return HideAPIKey(value)
|
||||
}
|
||||
return parts[0] + " " + HideAPIKey(parts[1])
|
||||
}
|
||||
|
||||
// MaskSensitiveHeaderValue masks sensitive header values while preserving expected formats.
|
||||
//
|
||||
// Behavior by header key (case-insensitive):
|
||||
// - "Authorization": Preserve the auth type prefix (e.g., "Bearer ") and mask only the credential part.
|
||||
// - Headers containing "api-key": Mask the entire value using HideAPIKey.
|
||||
// - Others: Return the original value unchanged.
|
||||
//
|
||||
// Parameters:
|
||||
// - key: The HTTP header name to inspect (case-insensitive matching).
|
||||
// - value: The header value to mask when sensitive.
|
||||
//
|
||||
// Returns:
|
||||
// - string: The masked value according to the header type; unchanged if not sensitive.
|
||||
func MaskSensitiveHeaderValue(key, value string) string {
|
||||
lowerKey := strings.ToLower(strings.TrimSpace(key))
|
||||
switch {
|
||||
case strings.Contains(lowerKey, "authorization"):
|
||||
return MaskAuthorizationHeader(value)
|
||||
case strings.Contains(lowerKey, "api-key"),
|
||||
strings.Contains(lowerKey, "apikey"),
|
||||
strings.Contains(lowerKey, "token"),
|
||||
strings.Contains(lowerKey, "secret"):
|
||||
return HideAPIKey(value)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// MaskSensitiveQuery masks sensitive query parameters, e.g. auth_token, within the raw query string.
|
||||
func MaskSensitiveQuery(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(raw, "&")
|
||||
changed := false
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
keyPart := part
|
||||
valuePart := ""
|
||||
if idx := strings.Index(part, "="); idx >= 0 {
|
||||
keyPart = part[:idx]
|
||||
valuePart = part[idx+1:]
|
||||
}
|
||||
decodedKey, err := url.QueryUnescape(keyPart)
|
||||
if err != nil {
|
||||
decodedKey = keyPart
|
||||
}
|
||||
if !shouldMaskQueryParam(decodedKey) {
|
||||
continue
|
||||
}
|
||||
decodedValue, err := url.QueryUnescape(valuePart)
|
||||
if err != nil {
|
||||
decodedValue = valuePart
|
||||
}
|
||||
masked := HideAPIKey(strings.TrimSpace(decodedValue))
|
||||
parts[i] = keyPart + "=" + url.QueryEscape(masked)
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return raw
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func shouldMaskQueryParam(key string) bool {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
key = strings.TrimSuffix(key, "[]")
|
||||
if key == "key" || strings.Contains(key, "api-key") || strings.Contains(key, "apikey") || strings.Contains(key, "api_key") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(key, "token") || strings.Contains(key, "secret") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
30
backend/internal/util/proxy.go
Normal file
30
backend/internal/util/proxy.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Package util provides utility functions for the CLI Proxy API server.
|
||||
// It includes helper functions for proxy configuration, HTTP client setup,
|
||||
// log level management, and other common operations used across the application.
|
||||
package util
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// SetProxy configures the provided HTTP client with proxy settings from the configuration.
|
||||
// It supports SOCKS5, HTTP, and HTTPS proxies. The function modifies the client's transport
|
||||
// to route requests through the configured proxy server.
|
||||
func SetProxy(cfg *config.SDKConfig, httpClient *http.Client) *http.Client {
|
||||
if cfg == nil || httpClient == nil {
|
||||
return httpClient
|
||||
}
|
||||
|
||||
transport, _, errBuild := proxyutil.BuildHTTPTransport(cfg.ProxyURL)
|
||||
if errBuild != nil {
|
||||
log.Errorf("%v", errBuild)
|
||||
}
|
||||
if transport != nil {
|
||||
httpClient.Transport = transport
|
||||
}
|
||||
return httpClient
|
||||
}
|
||||
418
backend/internal/util/responses_tools.go
Normal file
418
backend/internal/util/responses_tools.go
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// ResponsesToolIdentity represents the resolved identity of a tool in OpenAI Responses format.
|
||||
type ResponsesToolIdentity struct {
|
||||
Name string
|
||||
Namespace string
|
||||
Custom bool
|
||||
}
|
||||
|
||||
// ResponsesToolDescriptor is an internal representation of a tool declaration in a Responses request.
|
||||
type ResponsesToolDescriptor struct {
|
||||
Name string // Qualified name (e.g. "functions__exec" or "exec")
|
||||
LocalName string // Local name without namespace (e.g. "exec")
|
||||
Namespace string // Namespace if any (e.g. "functions")
|
||||
ToolType string // "function", "custom", etc.
|
||||
Tool gjson.Result
|
||||
SourcePriority int // 0 for top-level tools, 1 for additional_tools
|
||||
Direct bool // true if declared directly, false if declared as namespace child
|
||||
Order int // original discovery order
|
||||
}
|
||||
|
||||
// QualifyResponsesNamespaceToolName qualifies a child tool name with its namespace.
|
||||
func QualifyResponsesNamespaceToolName(namespaceName, childName string) string {
|
||||
childName = strings.TrimSpace(childName)
|
||||
namespaceName = strings.TrimSpace(namespaceName)
|
||||
if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") {
|
||||
return childName
|
||||
}
|
||||
if childName == namespaceName || strings.HasPrefix(childName, namespaceName+"__") {
|
||||
return childName
|
||||
}
|
||||
if strings.HasSuffix(namespaceName, "__") {
|
||||
return namespaceName + childName
|
||||
}
|
||||
return namespaceName + "__" + childName
|
||||
}
|
||||
|
||||
func responsesToolSources(root gjson.Result) []struct {
|
||||
tools gjson.Result
|
||||
priority int
|
||||
} {
|
||||
var sources []struct {
|
||||
tools gjson.Result
|
||||
priority int
|
||||
}
|
||||
appendSource := func(tools gjson.Result, priority int) {
|
||||
if tools.Exists() && tools.IsArray() {
|
||||
sources = append(sources, struct {
|
||||
tools gjson.Result
|
||||
priority int
|
||||
}{tools: tools, priority: priority})
|
||||
}
|
||||
}
|
||||
appendSource(root.Get("tools"), 0)
|
||||
if input := root.Get("input"); input.Exists() && input.IsArray() {
|
||||
input.ForEach(func(_, item gjson.Result) bool {
|
||||
if item.Get("type").String() == "additional_tools" {
|
||||
appendSource(item.Get("tools"), 1)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func responsesToolName(tool gjson.Result) string {
|
||||
if name := strings.TrimSpace(tool.Get("name").String()); name != "" {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSpace(tool.Get("function.name").String())
|
||||
}
|
||||
|
||||
func responsesToolDescription(tool gjson.Result) string {
|
||||
if description := tool.Get("description").String(); description != "" {
|
||||
return description
|
||||
}
|
||||
return tool.Get("function.description").String()
|
||||
}
|
||||
|
||||
func responsesToolParameters(tool gjson.Result) gjson.Result {
|
||||
for _, path := range []string{
|
||||
"parameters",
|
||||
"parametersJsonSchema",
|
||||
"input_schema",
|
||||
"function.parameters",
|
||||
"function.parametersJsonSchema",
|
||||
} {
|
||||
if parameters := tool.Get(path); parameters.Exists() {
|
||||
return parameters
|
||||
}
|
||||
}
|
||||
return gjson.Result{}
|
||||
}
|
||||
|
||||
// CollectResponsesToolDescriptors extracts all tool descriptors from a Responses request root.
|
||||
func CollectResponsesToolDescriptors(root gjson.Result) []ResponsesToolDescriptor {
|
||||
var descriptors []ResponsesToolDescriptor
|
||||
appendDescriptor := func(tool gjson.Result, name, localName, namespace string, toolType string, sourcePriority int, direct bool) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
descriptors = append(descriptors, ResponsesToolDescriptor{
|
||||
Name: name,
|
||||
LocalName: localName,
|
||||
Namespace: namespace,
|
||||
ToolType: toolType,
|
||||
Tool: tool,
|
||||
SourcePriority: sourcePriority,
|
||||
Direct: direct,
|
||||
Order: len(descriptors),
|
||||
})
|
||||
}
|
||||
appendNamespaceChildren := func(namespaceTool gjson.Result, sourcePriority int) {
|
||||
namespaceName := strings.TrimSpace(namespaceTool.Get("name").String())
|
||||
children := namespaceTool.Get("tools")
|
||||
if !children.Exists() || !children.IsArray() {
|
||||
return
|
||||
}
|
||||
children.ForEach(func(_, child gjson.Result) bool {
|
||||
childName := responsesToolName(child)
|
||||
if childName == "" {
|
||||
return true
|
||||
}
|
||||
qualifiedName := QualifyResponsesNamespaceToolName(namespaceName, childName)
|
||||
switch strings.TrimSpace(child.Get("type").String()) {
|
||||
case "", "function":
|
||||
appendDescriptor(child, qualifiedName, childName, namespaceName, "function", sourcePriority, false)
|
||||
case "custom":
|
||||
appendDescriptor(child, qualifiedName, childName, namespaceName, "custom", sourcePriority, false)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
for _, source := range responsesToolSources(root) {
|
||||
source.tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
toolType := strings.TrimSpace(tool.Get("type").String())
|
||||
switch toolType {
|
||||
case "", "function":
|
||||
name := responsesToolName(tool)
|
||||
appendDescriptor(tool, name, name, "", "function", source.priority, true)
|
||||
case "custom":
|
||||
name := responsesToolName(tool)
|
||||
appendDescriptor(tool, name, name, "", "custom", source.priority, true)
|
||||
case "namespace":
|
||||
appendNamespaceChildren(tool, source.priority)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return descriptors
|
||||
}
|
||||
|
||||
func responsesToolDescriptorPrecedes(left, right ResponsesToolDescriptor) bool {
|
||||
if left.SourcePriority != right.SourcePriority {
|
||||
return left.SourcePriority < right.SourcePriority
|
||||
}
|
||||
if left.Direct != right.Direct {
|
||||
return left.Direct
|
||||
}
|
||||
return left.Order < right.Order
|
||||
}
|
||||
|
||||
// CollectResponsesToolWinners collects deduplicated winning descriptors for each qualified tool name.
|
||||
func CollectResponsesToolWinners(root gjson.Result) map[string]ResponsesToolDescriptor {
|
||||
winners := map[string]ResponsesToolDescriptor{}
|
||||
for _, descriptor := range CollectResponsesToolDescriptors(root) {
|
||||
current, exists := winners[descriptor.Name]
|
||||
if !exists || responsesToolDescriptorPrecedes(descriptor, current) {
|
||||
winners[descriptor.Name] = descriptor
|
||||
}
|
||||
}
|
||||
return winners
|
||||
}
|
||||
|
||||
func sanitizeResponsesToolNames(names []string) map[string]string {
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
uniqueNames := make(map[string]struct{}, len(names))
|
||||
baseCounts := make(map[string]int, len(names))
|
||||
for _, name := range names {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := uniqueNames[name]; exists {
|
||||
continue
|
||||
}
|
||||
uniqueNames[name] = struct{}{}
|
||||
baseCounts[SanitizeFunctionName(name)]++
|
||||
}
|
||||
|
||||
sortedNames := make([]string, 0, len(uniqueNames))
|
||||
for name := range uniqueNames {
|
||||
sortedNames = append(sortedNames, name)
|
||||
}
|
||||
sort.Strings(sortedNames)
|
||||
|
||||
out := make(map[string]string, len(sortedNames))
|
||||
used := make(map[string]string, len(sortedNames))
|
||||
for _, name := range sortedNames {
|
||||
base := SanitizeFunctionName(name)
|
||||
mapped := base
|
||||
_, baseUsed := used[base]
|
||||
if baseCounts[base] > 1 || baseUsed {
|
||||
mapped = disambiguateResponsesSanitizedName(base, name, used)
|
||||
}
|
||||
out[name] = mapped
|
||||
used[mapped] = name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func disambiguateResponsesSanitizedName(base, original string, used map[string]string) string {
|
||||
for attempt := 0; ; attempt++ {
|
||||
digest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%d", original, attempt)))
|
||||
suffix := "_" + hex.EncodeToString(digest[:6])
|
||||
prefix := base
|
||||
if maxPrefix := 64 - len(suffix); len(prefix) > maxPrefix {
|
||||
prefix = prefix[:maxPrefix]
|
||||
}
|
||||
candidate := prefix + suffix
|
||||
if _, exists := used[candidate]; !exists {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BuildGeminiFunctionDeclarations builds Gemini function declarations, forward name mapping, and reverse identity mapping.
|
||||
func BuildGeminiFunctionDeclarations(root gjson.Result) ([][]byte, map[string]string, map[string]ResponsesToolIdentity) {
|
||||
descriptors := CollectResponsesToolDescriptors(root)
|
||||
winners := CollectResponsesToolWinners(root)
|
||||
|
||||
seenNames := make(map[string]struct{})
|
||||
var winningList []ResponsesToolDescriptor
|
||||
for _, descriptor := range descriptors {
|
||||
winner, ok := winners[descriptor.Name]
|
||||
if !ok || winner.Order != descriptor.Order {
|
||||
continue
|
||||
}
|
||||
if _, seen := seenNames[descriptor.Name]; seen {
|
||||
continue
|
||||
}
|
||||
seenNames[descriptor.Name] = struct{}{}
|
||||
winningList = append(winningList, descriptor)
|
||||
}
|
||||
|
||||
if len(winningList) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
qualifiedNames := make([]string, 0, len(winningList))
|
||||
for _, desc := range winningList {
|
||||
qualifiedNames = append(qualifiedNames, desc.Name)
|
||||
}
|
||||
sanitizedMap := sanitizeResponsesToolNames(qualifiedNames)
|
||||
|
||||
forwardMap := make(map[string]string, len(winningList)*2)
|
||||
reverseMap := make(map[string]ResponsesToolIdentity, len(winningList)*2)
|
||||
var declarations [][]byte
|
||||
|
||||
for _, desc := range winningList {
|
||||
geminiName := desc.Name
|
||||
if mapped, ok := sanitizedMap[desc.Name]; ok && mapped != "" {
|
||||
geminiName = mapped
|
||||
} else {
|
||||
geminiName = SanitizeFunctionName(desc.Name)
|
||||
}
|
||||
|
||||
forwardMap[desc.Name] = geminiName
|
||||
if desc.LocalName != "" && desc.LocalName != desc.Name {
|
||||
if _, exists := forwardMap[desc.LocalName]; !exists {
|
||||
forwardMap[desc.LocalName] = geminiName
|
||||
}
|
||||
}
|
||||
|
||||
identity := ResponsesToolIdentity{
|
||||
Name: desc.LocalName,
|
||||
Namespace: desc.Namespace,
|
||||
Custom: desc.ToolType == "custom",
|
||||
}
|
||||
reverseMap[geminiName] = identity
|
||||
if desc.Name != geminiName {
|
||||
reverseMap[desc.Name] = identity
|
||||
}
|
||||
|
||||
funcDecl := []byte(`{"name":"","description":"","parametersJsonSchema":{}}`)
|
||||
funcDecl, _ = sjson.SetBytes(funcDecl, "name", geminiName)
|
||||
if descStr := responsesToolDescription(desc.Tool); descStr != "" {
|
||||
funcDecl, _ = sjson.SetBytes(funcDecl, "description", descStr)
|
||||
}
|
||||
|
||||
if desc.ToolType == "custom" {
|
||||
funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", []byte(`{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}`))
|
||||
} else {
|
||||
params := responsesToolParameters(desc.Tool)
|
||||
if params.Exists() {
|
||||
funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", []byte(CleanJSONSchemaForGemini(params.Raw)))
|
||||
}
|
||||
}
|
||||
declarations = append(declarations, funcDecl)
|
||||
}
|
||||
|
||||
return declarations, forwardMap, reverseMap
|
||||
}
|
||||
|
||||
// ResponsesToolReverseIdentityMap builds a Gemini function name -> ResponsesToolIdentity map from a Responses request raw JSON.
|
||||
func ResponsesToolReverseIdentityMap(rawJSON []byte) map[string]ResponsesToolIdentity {
|
||||
if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) {
|
||||
return nil
|
||||
}
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
if req := root.Get("request"); req.Exists() && (req.Get("model").Exists() || req.Get("input").Exists() || req.Get("tools").Exists()) {
|
||||
root = req
|
||||
}
|
||||
_, _, reverseMap := BuildGeminiFunctionDeclarations(root)
|
||||
return reverseMap
|
||||
}
|
||||
|
||||
// MapResponsesToolName returns the mapped Gemini function name if present in forwardMap, else sanitized name.
|
||||
func MapResponsesToolName(forwardMap map[string]string, name string) string {
|
||||
if mapped, ok := forwardMap[name]; ok && mapped != "" {
|
||||
return mapped
|
||||
}
|
||||
return SanitizeFunctionName(name)
|
||||
}
|
||||
|
||||
// ConvertResponsesToolChoiceToGemini translates Responses tool_choice into Gemini functionCallingConfig JSON.
|
||||
func ConvertResponsesToolChoiceToGemini(toolChoice gjson.Result, forwardMap map[string]string) ([]byte, bool) {
|
||||
if !toolChoice.Exists() {
|
||||
return nil, false
|
||||
}
|
||||
mode := ""
|
||||
var allowedNames []string
|
||||
if toolChoice.Type == gjson.String {
|
||||
switch strings.ToLower(strings.TrimSpace(toolChoice.String())) {
|
||||
case "none":
|
||||
mode = "NONE"
|
||||
case "auto":
|
||||
mode = "AUTO"
|
||||
case "required", "any":
|
||||
mode = "ANY"
|
||||
}
|
||||
} else if toolChoice.IsObject() {
|
||||
toolType := strings.ToLower(strings.TrimSpace(toolChoice.Get("type").String()))
|
||||
switch toolType {
|
||||
case "none":
|
||||
mode = "NONE"
|
||||
case "auto":
|
||||
mode = "AUTO"
|
||||
case "required", "any":
|
||||
mode = "ANY"
|
||||
case "function", "custom", "tool", "":
|
||||
mode = "ANY"
|
||||
name := strings.TrimSpace(toolChoice.Get("name").String())
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(toolChoice.Get("function.name").String())
|
||||
}
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(toolChoice.Get("custom.name").String())
|
||||
}
|
||||
namespace := strings.TrimSpace(toolChoice.Get("namespace").String())
|
||||
if namespace == "" {
|
||||
namespace = strings.TrimSpace(toolChoice.Get("function.namespace").String())
|
||||
}
|
||||
if namespace == "" {
|
||||
namespace = strings.TrimSpace(toolChoice.Get("custom.namespace").String())
|
||||
}
|
||||
if namespace != "" {
|
||||
name = QualifyResponsesNamespaceToolName(namespace, name)
|
||||
}
|
||||
if name != "" {
|
||||
geminiName := MapResponsesToolName(forwardMap, name)
|
||||
allowedNames = append(allowedNames, geminiName)
|
||||
}
|
||||
}
|
||||
}
|
||||
if mode == "" {
|
||||
return nil, false
|
||||
}
|
||||
cfg := []byte(`{"mode":""}`)
|
||||
cfg, _ = sjson.SetBytes(cfg, "mode", mode)
|
||||
if len(allowedNames) > 0 {
|
||||
cfg, _ = sjson.SetBytes(cfg, "allowedFunctionNames", allowedNames)
|
||||
}
|
||||
return cfg, true
|
||||
}
|
||||
|
||||
// UnwrapResponsesCustomToolInput extracts the raw input string from custom tool arguments JSON or plain string.
|
||||
func UnwrapResponsesCustomToolInput(arguments string) string {
|
||||
arguments = strings.TrimSpace(arguments)
|
||||
if arguments == "" || arguments == "{}" {
|
||||
return ""
|
||||
}
|
||||
if gjson.Valid(arguments) {
|
||||
parsed := gjson.Parse(arguments)
|
||||
if v := parsed.Get("input"); v.Exists() {
|
||||
if v.Type == gjson.String {
|
||||
return v.String()
|
||||
}
|
||||
return v.Raw
|
||||
}
|
||||
if parsed.Type == gjson.String {
|
||||
return parsed.String()
|
||||
}
|
||||
}
|
||||
return arguments
|
||||
}
|
||||
228
backend/internal/util/responses_tools_test.go
Normal file
228
backend/internal/util/responses_tools_test.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestCollectResponsesToolDescriptors_PriorityAndNamespace(t *testing.T) {
|
||||
raw := `{
|
||||
"tools": [
|
||||
{"type": "function", "name": "top_fn", "description": "top function"}
|
||||
],
|
||||
"input": [
|
||||
{
|
||||
"type": "additional_tools",
|
||||
"tools": [
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "ns1",
|
||||
"tools": [
|
||||
{"type": "function", "name": "child_fn", "description": "child function"},
|
||||
{"type": "custom", "name": "child_custom", "description": "child custom"}
|
||||
]
|
||||
},
|
||||
{"type": "custom", "name": "direct_custom"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
root := gjson.Parse(raw)
|
||||
descriptors := CollectResponsesToolDescriptors(root)
|
||||
if len(descriptors) != 4 {
|
||||
t.Fatalf("expected 4 descriptors, got %d", len(descriptors))
|
||||
}
|
||||
|
||||
decls, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(root)
|
||||
if len(decls) != 4 {
|
||||
t.Fatalf("expected 4 declarations, got %d", len(decls))
|
||||
}
|
||||
|
||||
if forwardMap["ns1__child_fn"] != "ns1__child_fn" {
|
||||
t.Fatalf("forwardMap['ns1__child_fn'] = %q, want ns1__child_fn", forwardMap["ns1__child_fn"])
|
||||
}
|
||||
|
||||
childCustomIdentity := reverseMap["ns1__child_custom"]
|
||||
if childCustomIdentity.Name != "child_custom" || childCustomIdentity.Namespace != "ns1" || !childCustomIdentity.Custom {
|
||||
t.Fatalf("unexpected reverseMap for ns1__child_custom: %+v", childCustomIdentity)
|
||||
}
|
||||
|
||||
topFnIdentity := reverseMap["top_fn"]
|
||||
if topFnIdentity.Name != "top_fn" || topFnIdentity.Namespace != "" || topFnIdentity.Custom {
|
||||
t.Fatalf("unexpected reverseMap for top_fn: %+v", topFnIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesToolWinners_TopLevelBeatsAdditionalTools(t *testing.T) {
|
||||
raw := `{
|
||||
"tools": [
|
||||
{"type": "function", "name": "shared_fn", "description": "top level"}
|
||||
],
|
||||
"input": [
|
||||
{
|
||||
"type": "additional_tools",
|
||||
"tools": [
|
||||
{"type": "function", "name": "shared_fn", "description": "additional"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
root := gjson.Parse(raw)
|
||||
winners := CollectResponsesToolWinners(root)
|
||||
winner := winners["shared_fn"]
|
||||
if winner.SourcePriority != 0 {
|
||||
t.Fatalf("winner priority = %d, want 0", winner.SourcePriority)
|
||||
}
|
||||
if winner.Tool.Get("description").String() != "top level" {
|
||||
t.Fatalf("winner description = %q, want 'top level'", winner.Tool.Get("description").String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesToolWinners_DirectBeatsNamespaceChild(t *testing.T) {
|
||||
raw := `{
|
||||
"tools": [
|
||||
{"type": "namespace", "name": "n", "tools": [{"type": "function", "name": "x", "description": "namespace child"}]},
|
||||
{"type": "custom", "name": "n__x", "description": "direct"}
|
||||
]
|
||||
}`
|
||||
|
||||
root := gjson.Parse(raw)
|
||||
winners := CollectResponsesToolWinners(root)
|
||||
winner := winners["n__x"]
|
||||
if !winner.Direct {
|
||||
t.Fatalf("winner direct = %v, want true", winner.Direct)
|
||||
}
|
||||
if winner.ToolType != "custom" {
|
||||
t.Fatalf("winner toolType = %q, want custom", winner.ToolType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertResponsesToolChoiceToGemini(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
choiceJSON string
|
||||
forwardMap map[string]string
|
||||
wantMode string
|
||||
wantNames []string
|
||||
}{
|
||||
{
|
||||
name: "auto string",
|
||||
choiceJSON: `"auto"`,
|
||||
wantMode: "AUTO",
|
||||
},
|
||||
{
|
||||
name: "none string",
|
||||
choiceJSON: `"none"`,
|
||||
wantMode: "NONE",
|
||||
},
|
||||
{
|
||||
name: "required string",
|
||||
choiceJSON: `"required"`,
|
||||
wantMode: "ANY",
|
||||
},
|
||||
{
|
||||
name: "function object with namespace",
|
||||
choiceJSON: `{"type": "function", "name": "my_fn", "namespace": "my_ns"}`,
|
||||
forwardMap: map[string]string{"my_ns__my_fn": "my_ns__my_fn"},
|
||||
wantMode: "ANY",
|
||||
wantNames: []string{"my_ns__my_fn"},
|
||||
},
|
||||
{
|
||||
name: "custom object",
|
||||
choiceJSON: `{"type": "custom", "name": "exec", "namespace": "functions"}`,
|
||||
forwardMap: map[string]string{"functions__exec": "functions__exec"},
|
||||
wantMode: "ANY",
|
||||
wantNames: []string{"functions__exec"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
choice := gjson.Parse(tt.choiceJSON)
|
||||
out, ok := ConvertResponsesToolChoiceToGemini(choice, tt.forwardMap)
|
||||
if !ok {
|
||||
t.Fatalf("ConvertResponsesToolChoiceToGemini returned false")
|
||||
}
|
||||
mode := gjson.GetBytes(out, "mode").String()
|
||||
if mode != tt.wantMode {
|
||||
t.Fatalf("mode = %q, want %q", mode, tt.wantMode)
|
||||
}
|
||||
if len(tt.wantNames) > 0 {
|
||||
names := gjson.GetBytes(out, "allowedFunctionNames").Array()
|
||||
if len(names) != len(tt.wantNames) {
|
||||
t.Fatalf("allowedFunctionNames count = %d, want %d", len(names), len(tt.wantNames))
|
||||
}
|
||||
for i, want := range tt.wantNames {
|
||||
if names[i].String() != want {
|
||||
t.Fatalf("allowedFunctionNames[%d] = %q, want %q", i, names[i].String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapResponsesCustomToolInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: `{"input":"pwd"}`, want: "pwd"},
|
||||
{input: `{"input":{"cmd":"ls"}}`, want: `{"cmd":"ls"}`},
|
||||
{input: `"direct text"`, want: "direct text"},
|
||||
{input: `{}`, want: ""},
|
||||
{input: ``, want: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := UnwrapResponsesCustomToolInput(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("UnwrapResponsesCustomToolInput(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGeminiFunctionDeclarations_DisambiguationAndLongNames(t *testing.T) {
|
||||
// Two tools that genuinely collide after sanitization (e.g. "read/file" vs "read_file"), and one > 64 chars
|
||||
raw := `{
|
||||
"tools": [
|
||||
{"type": "function", "name": "read/file", "description": "tool with slash"},
|
||||
{"type": "function", "name": "read_file", "description": "tool with underscore"},
|
||||
{"type": "custom", "name": "mcp__very_very_very_very_very_very_long_namespace_name__very_very_very_long_custom_tool_name_that_exceeds_sixty_four_chars"}
|
||||
]
|
||||
}`
|
||||
|
||||
root := gjson.Parse(raw)
|
||||
decls, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(root)
|
||||
if len(decls) != 3 {
|
||||
t.Fatalf("expected 3 decls, got %d", len(decls))
|
||||
}
|
||||
|
||||
name1 := forwardMap["read/file"]
|
||||
name2 := forwardMap["read_file"]
|
||||
if name1 == name2 {
|
||||
t.Fatalf("colliding tools mapped to identical name: %q", name1)
|
||||
}
|
||||
|
||||
identity1 := reverseMap[name1]
|
||||
if identity1.Name != "read/file" {
|
||||
t.Fatalf("reverseMap[%q].Name = %q, want read/file", name1, identity1.Name)
|
||||
}
|
||||
identity2 := reverseMap[name2]
|
||||
if identity2.Name != "read_file" {
|
||||
t.Fatalf("reverseMap[%q].Name = %q, want read_file", name2, identity2.Name)
|
||||
}
|
||||
|
||||
longName := forwardMap["mcp__very_very_very_very_very_very_long_namespace_name__very_very_very_long_custom_tool_name_that_exceeds_sixty_four_chars"]
|
||||
if len(longName) > 64 {
|
||||
t.Fatalf("long tool name length = %d > 64: %q", len(longName), longName)
|
||||
}
|
||||
|
||||
identityLong := reverseMap[longName]
|
||||
if !identityLong.Custom || identityLong.Name != "mcp__very_very_very_very_very_very_long_namespace_name__very_very_very_long_custom_tool_name_that_exceeds_sixty_four_chars" {
|
||||
t.Fatalf("unexpected reverse identity for long name: %+v", identityLong)
|
||||
}
|
||||
}
|
||||
215
backend/internal/util/sanitize_test.go
Normal file
215
backend/internal/util/sanitize_test.go
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestSanitizeFunctionName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"Normal", "valid_name", "valid_name"},
|
||||
{"With Dots", "name.with.dots", "name.with.dots"},
|
||||
{"With Colons", "name:with:colons", "name:with:colons"},
|
||||
{"With Dashes", "name-with-dashes", "name-with-dashes"},
|
||||
{"Mixed Allowed", "name.with_dots:colons-dashes", "name.with_dots:colons-dashes"},
|
||||
{"Invalid Characters", "name!with@invalid#chars", "name_with_invalid_chars"},
|
||||
{"Spaces", "name with spaces", "name_with_spaces"},
|
||||
{"Non-ASCII", "name_with_你好_chars", "name_with____chars"},
|
||||
{"Starts with digit", "123name", "_123name"},
|
||||
{"Starts with dot", ".name", "_.name"},
|
||||
{"Starts with colon", ":name", "_:name"},
|
||||
{"Starts with dash", "-name", "_-name"},
|
||||
{"Starts with invalid char", "!name", "_name"},
|
||||
{"Exactly 64 chars", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"},
|
||||
{"Too long (65 chars)", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charactX", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"},
|
||||
{"Very long", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_limit_for_function_names", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_l"},
|
||||
{"Starts with digit (64 chars total)", "1234567890123456789012345678901234567890123456789012345678901234", "_123456789012345678901234567890123456789012345678901234567890123"},
|
||||
{"Starts with invalid char (64 chars total)", "!234567890123456789012345678901234567890123456789012345678901234", "_234567890123456789012345678901234567890123456789012345678901234"},
|
||||
{"Empty", "", ""},
|
||||
{"Single character invalid", "@", "_"},
|
||||
{"Single character valid", "a", "a"},
|
||||
{"Single character digit", "1", "_1"},
|
||||
{"Single character underscore", "_", "_"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := SanitizeFunctionName(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("SanitizeFunctionName(%q) = %v, want %v", tt.input, got, tt.expected)
|
||||
}
|
||||
// Verify Gemini compliance
|
||||
if len(got) > 64 {
|
||||
t.Errorf("SanitizeFunctionName(%q) result too long: %d", tt.input, len(got))
|
||||
}
|
||||
if len(got) > 0 {
|
||||
first := got[0]
|
||||
if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') {
|
||||
t.Errorf("SanitizeFunctionName(%q) result starts with invalid char: %c", tt.input, first)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizedToolNameMap(t *testing.T) {
|
||||
t.Run("returns map for tools needing sanitization", func(t *testing.T) {
|
||||
raw := []byte(`{"tools":[
|
||||
{"name":"valid_tool","input_schema":{}},
|
||||
{"name":"mcp/server/read","input_schema":{}},
|
||||
{"name":"tool@v2","input_schema":{}}
|
||||
]}`)
|
||||
m := SanitizedToolNameMap(raw)
|
||||
if m == nil {
|
||||
t.Fatal("expected non-nil map")
|
||||
}
|
||||
if m["mcp_server_read"] != "mcp/server/read" {
|
||||
t.Errorf("expected mcp_server_read → mcp/server/read, got %q", m["mcp_server_read"])
|
||||
}
|
||||
if m["tool_v2"] != "tool@v2" {
|
||||
t.Errorf("expected tool_v2 → tool@v2, got %q", m["tool_v2"])
|
||||
}
|
||||
if _, exists := m["valid_tool"]; exists {
|
||||
t.Error("valid_tool should not be in the map (no sanitization needed)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns nil when no tools need sanitization", func(t *testing.T) {
|
||||
raw := []byte(`{"tools":[{"name":"Read","input_schema":{}},{"name":"Write","input_schema":{}}]}`)
|
||||
m := SanitizedToolNameMap(raw)
|
||||
if m != nil {
|
||||
t.Errorf("expected nil, got %v", m)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns nil for empty/missing tools", func(t *testing.T) {
|
||||
if m := SanitizedToolNameMap([]byte(`{}`)); m != nil {
|
||||
t.Error("expected nil for no tools")
|
||||
}
|
||||
if m := SanitizedToolNameMap(nil); m != nil {
|
||||
t.Error("expected nil for nil input")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy map ignores nested OpenAI tools", func(t *testing.T) {
|
||||
raw := []byte(`{"tools":[
|
||||
{"type":"function","function":{"name":"web/search"}},
|
||||
{"type":"web_search","name":"web_search"}
|
||||
]}`)
|
||||
if m := SanitizedToolNameMap(raw); m != nil {
|
||||
t.Fatalf("legacy map = %v, want nil", m)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("collision keeps first legacy mapping", func(t *testing.T) {
|
||||
raw := []byte(`{"tools":[
|
||||
{"name":"read/file","input_schema":{}},
|
||||
{"name":"read@file","input_schema":{}}
|
||||
]}`)
|
||||
m := SanitizedToolNameMap(raw)
|
||||
if m == nil {
|
||||
t.Fatal("expected non-nil map")
|
||||
}
|
||||
if got := m["read_file"]; got != "read/file" {
|
||||
t.Errorf("legacy collision mapping = %q, want read/file", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSanitizedFunctionNameMapDisambiguatesCollisions(t *testing.T) {
|
||||
first := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build"
|
||||
second := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build_logs"
|
||||
raw := []byte(`{"tools":[
|
||||
{"name":"` + first + `"},
|
||||
{"name":"` + first + `"},
|
||||
{"name":"` + second + `"}
|
||||
]}`)
|
||||
|
||||
forward := SanitizedFunctionNameMap(raw)
|
||||
firstMapped := forward[first]
|
||||
secondMapped := forward[second]
|
||||
if firstMapped == "" || secondMapped == "" || secondMapped == firstMapped {
|
||||
t.Fatalf("mapped names = %q and %q, want distinct non-empty names", firstMapped, secondMapped)
|
||||
}
|
||||
if len(firstMapped) > 64 || len(secondMapped) > 64 {
|
||||
t.Fatalf("mapped name lengths = %d and %d, want <= 64", len(firstMapped), len(secondMapped))
|
||||
}
|
||||
|
||||
reversed := []byte(`{"tools":[{"name":"` + second + `"},{"name":"` + first + `"}]}`)
|
||||
reversedForward := SanitizedFunctionNameMap(reversed)
|
||||
if reversedForward[first] != firstMapped || reversedForward[second] != secondMapped {
|
||||
t.Fatalf("mapping changed with declaration order: forward=%v reversed=%v", forward, reversedForward)
|
||||
}
|
||||
|
||||
reverse := DisambiguatedToolNameMap(raw)
|
||||
if got := reverse[firstMapped]; got != first {
|
||||
t.Fatalf("reverse[%q] = %q, want %q", firstMapped, got, first)
|
||||
}
|
||||
if got := reverse[secondMapped]; got != second {
|
||||
t.Fatalf("reverse[%q] = %q, want %q", secondMapped, got, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizedFunctionNameMapReadsSupportedToolShapes(t *testing.T) {
|
||||
raw := []byte(`{"tools":[
|
||||
{"type":"function","function":{"name":"nested/name"}},
|
||||
{
|
||||
"functionDeclarations":[{"name":"camel@name"}],
|
||||
"function_declarations":[{"name":"snake name"}]
|
||||
}
|
||||
]}`)
|
||||
forward := SanitizedFunctionNameMap(raw)
|
||||
for original, want := range map[string]string{
|
||||
"nested/name": "nested_name",
|
||||
"camel@name": "camel_name",
|
||||
"snake name": "snake_name",
|
||||
} {
|
||||
if got := forward[original]; got != want {
|
||||
t.Errorf("forward[%q] = %q, want %q", original, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeduplicateFunctionDeclarations(t *testing.T) {
|
||||
raw := []byte(`[
|
||||
{"name":"lookup","description":"first"},
|
||||
{"name":"other"},
|
||||
{"name":"lookup","description":"second"}
|
||||
]`)
|
||||
deduped := DeduplicateFunctionDeclarations(raw)
|
||||
declarations := gjson.ParseBytes(deduped).Array()
|
||||
if len(declarations) != 2 {
|
||||
t.Fatalf("declaration count = %d, want 2: %s", len(declarations), deduped)
|
||||
}
|
||||
if got := declarations[0].Get("description").String(); got != "first" {
|
||||
t.Fatalf("first duplicate description = %q, want first", got)
|
||||
}
|
||||
if got := declarations[1].Get("name").String(); got != "other" {
|
||||
t.Fatalf("second declaration name = %q, want other", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreSanitizedToolName(t *testing.T) {
|
||||
m := map[string]string{
|
||||
"mcp_server_read": "mcp/server/read",
|
||||
"tool_v2": "tool@v2",
|
||||
}
|
||||
|
||||
if got := RestoreSanitizedToolName(m, "mcp_server_read"); got != "mcp/server/read" {
|
||||
t.Errorf("expected mcp/server/read, got %q", got)
|
||||
}
|
||||
if got := RestoreSanitizedToolName(m, "unknown"); got != "unknown" {
|
||||
t.Errorf("expected passthrough for unknown, got %q", got)
|
||||
}
|
||||
if got := RestoreSanitizedToolName(nil, "name"); got != "name" {
|
||||
t.Errorf("expected passthrough for nil map, got %q", got)
|
||||
}
|
||||
if got := RestoreSanitizedToolName(m, ""); got != "" {
|
||||
t.Errorf("expected empty for empty name, got %q", got)
|
||||
}
|
||||
}
|
||||
135
backend/internal/util/ssh_helper.go
Normal file
135
backend/internal/util/ssh_helper.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// Package util provides helper functions for SSH tunnel instructions and network-related tasks.
|
||||
// This includes detecting the appropriate IP address and printing commands
|
||||
// to help users connect to the local server from a remote machine.
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var ipServices = []string{
|
||||
"https://api.ipify.org",
|
||||
"https://ifconfig.me/ip",
|
||||
"https://icanhazip.com",
|
||||
"https://ipinfo.io/ip",
|
||||
}
|
||||
|
||||
// getPublicIP attempts to retrieve the public IP address from a list of external services.
|
||||
// It iterates through the ipServices and returns the first successful response.
|
||||
//
|
||||
// Returns:
|
||||
// - string: The public IP address as a string
|
||||
// - error: An error if all services fail, nil otherwise
|
||||
func getPublicIP() (string, error) {
|
||||
for _, service := range ipServices {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", service, nil)
|
||||
if err != nil {
|
||||
log.Debugf("Failed to create request to %s: %v", service, err)
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
log.Debugf("Failed to get public IP from %s: %v", service, err)
|
||||
continue
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := resp.Body.Close(); closeErr != nil {
|
||||
log.Warnf("Failed to close response body from %s: %v", service, closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Debugf("bad status code from %s: %d", service, resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
ip, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Debugf("Failed to read response body from %s: %v", service, err)
|
||||
continue
|
||||
}
|
||||
return strings.TrimSpace(string(ip)), nil
|
||||
}
|
||||
return "", fmt.Errorf("all IP services failed")
|
||||
}
|
||||
|
||||
// getOutboundIP retrieves the preferred outbound IP address of this machine.
|
||||
// It uses a UDP connection to a public DNS server to determine the local IP
|
||||
// address that would be used for outbound traffic.
|
||||
//
|
||||
// Returns:
|
||||
// - string: The outbound IP address as a string
|
||||
// - error: An error if the IP address cannot be determined, nil otherwise
|
||||
func getOutboundIP() (string, error) {
|
||||
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := conn.Close(); closeErr != nil {
|
||||
log.Warnf("Failed to close UDP connection: %v", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
localAddr, ok := conn.LocalAddr().(*net.UDPAddr)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("could not assert UDP address type")
|
||||
}
|
||||
|
||||
return localAddr.IP.String(), nil
|
||||
}
|
||||
|
||||
// GetIPAddress attempts to find the best-available IP address.
|
||||
// It first tries to get the public IP address, and if that fails,
|
||||
// it falls back to getting the local outbound IP address.
|
||||
//
|
||||
// Returns:
|
||||
// - string: The determined IP address (preferring public IPv4)
|
||||
func GetIPAddress() string {
|
||||
publicIP, err := getPublicIP()
|
||||
if err == nil {
|
||||
log.Debugf("Public IP detected: %s", publicIP)
|
||||
return publicIP
|
||||
}
|
||||
log.Warnf("Failed to get public IP, falling back to outbound IP: %v", err)
|
||||
outboundIP, err := getOutboundIP()
|
||||
if err == nil {
|
||||
log.Debugf("Outbound IP detected: %s", outboundIP)
|
||||
return outboundIP
|
||||
}
|
||||
log.Errorf("Failed to get any IP address: %v", err)
|
||||
return "127.0.0.1" // Fallback
|
||||
}
|
||||
|
||||
// PrintSSHTunnelInstructions detects the IP address and prints SSH tunnel instructions
|
||||
// for the user to connect to the local OAuth callback server from a remote machine.
|
||||
//
|
||||
// Parameters:
|
||||
// - port: The local port number for the SSH tunnel
|
||||
func PrintSSHTunnelInstructions(port int) {
|
||||
ipAddress := GetIPAddress()
|
||||
border := "================================================================================"
|
||||
fmt.Println("To authenticate from a remote machine, an SSH tunnel may be required.")
|
||||
fmt.Println(border)
|
||||
fmt.Println(" Run one of the following commands on your local machine (NOT the server):")
|
||||
fmt.Println()
|
||||
fmt.Printf(" # Standard SSH command (assumes SSH port 22):\n")
|
||||
fmt.Printf(" ssh -L %d:127.0.0.1:%d root@%s -p 22\n", port, port, ipAddress)
|
||||
fmt.Println()
|
||||
fmt.Printf(" # If using an SSH key (assumes SSH port 22):\n")
|
||||
fmt.Printf(" ssh -i <path_to_your_key> -L %d:127.0.0.1:%d root@%s -p 22\n", port, port, ipAddress)
|
||||
fmt.Println()
|
||||
fmt.Println(" NOTE: If your server's SSH port is not 22, please modify the '-p 22' part accordingly.")
|
||||
fmt.Println(border)
|
||||
}
|
||||
496
backend/internal/util/translator.go
Normal file
496
backend/internal/util/translator.go
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
// Package util provides utility functions for the CLI Proxy API server.
|
||||
// It includes helper functions for JSON manipulation, proxy configuration,
|
||||
// and other common operations used across the application.
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// Walk recursively traverses a JSON structure to find all occurrences of a specific field.
|
||||
// It builds paths to each occurrence and adds them to the provided paths slice.
|
||||
//
|
||||
// Parameters:
|
||||
// - value: The gjson.Result object to traverse
|
||||
// - path: The current path in the JSON structure (empty string for root)
|
||||
// - field: The field name to search for
|
||||
// - paths: Pointer to a slice where found paths will be stored
|
||||
//
|
||||
// The function works recursively, building dot-notation paths to each occurrence
|
||||
// of the specified field throughout the JSON structure.
|
||||
func Walk(value gjson.Result, path, field string, paths *[]string) {
|
||||
switch value.Type {
|
||||
case gjson.JSON:
|
||||
// For JSON objects and arrays, iterate through each child
|
||||
value.ForEach(func(key, val gjson.Result) bool {
|
||||
var childPath string
|
||||
// Escape special characters for gjson/sjson path syntax
|
||||
// . -> \.
|
||||
// * -> \*
|
||||
// ? -> \?
|
||||
keyStr := key.String()
|
||||
safeKey := escapeGJSONPathKey(keyStr)
|
||||
|
||||
if path == "" {
|
||||
childPath = safeKey
|
||||
} else {
|
||||
childPath = path + "." + safeKey
|
||||
}
|
||||
if keyStr == field {
|
||||
*paths = append(*paths, childPath)
|
||||
}
|
||||
Walk(val, childPath, field, paths)
|
||||
return true
|
||||
})
|
||||
case gjson.String, gjson.Number, gjson.True, gjson.False, gjson.Null:
|
||||
// Terminal types - no further traversal needed
|
||||
}
|
||||
}
|
||||
|
||||
// RenameKey renames a key in a JSON string by moving its value to a new key path
|
||||
// and then deleting the old key path.
|
||||
//
|
||||
// Parameters:
|
||||
// - jsonStr: The JSON string to modify
|
||||
// - oldKeyPath: The dot-notation path to the key that should be renamed
|
||||
// - newKeyPath: The dot-notation path where the value should be moved to
|
||||
//
|
||||
// Returns:
|
||||
// - string: The modified JSON string with the key renamed
|
||||
// - error: An error if the operation fails
|
||||
//
|
||||
// The function performs the rename in two steps:
|
||||
// 1. Sets the value at the new key path
|
||||
// 2. Deletes the old key path
|
||||
func RenameKey(jsonStr, oldKeyPath, newKeyPath string) (string, error) {
|
||||
value := gjson.Get(jsonStr, oldKeyPath)
|
||||
|
||||
if !value.Exists() {
|
||||
return "", fmt.Errorf("old key '%s' does not exist", oldKeyPath)
|
||||
}
|
||||
|
||||
interimJSON, errSet := sjson.SetRawBytes([]byte(jsonStr), newKeyPath, []byte(value.Raw))
|
||||
if errSet != nil {
|
||||
return "", fmt.Errorf("failed to set new key '%s': %w", newKeyPath, errSet)
|
||||
}
|
||||
|
||||
finalJSON, errDelete := sjson.DeleteBytes(interimJSON, oldKeyPath)
|
||||
if errDelete != nil {
|
||||
return "", fmt.Errorf("failed to delete old key '%s': %w", oldKeyPath, errDelete)
|
||||
}
|
||||
|
||||
return string(finalJSON), nil
|
||||
}
|
||||
|
||||
// FixJSON converts non-standard JSON that uses single quotes for strings into
|
||||
// RFC 8259-compliant JSON by converting those single-quoted strings to
|
||||
// double-quoted strings with proper escaping.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// {'a': 1, 'b': '2'} => {"a": 1, "b": "2"}
|
||||
// {"t": 'He said "hi"'} => {"t": "He said \"hi\""}
|
||||
//
|
||||
// Rules:
|
||||
// - Existing double-quoted JSON strings are preserved as-is.
|
||||
// - Single-quoted strings are converted to double-quoted strings.
|
||||
// - Inside converted strings, any double quote is escaped (\").
|
||||
// - Common backslash escapes (\n, \r, \t, \b, \f, \\) are preserved.
|
||||
// - \' inside single-quoted strings becomes a literal ' in the output (no
|
||||
// escaping needed inside double quotes).
|
||||
// - Unicode escapes (\uXXXX) inside single-quoted strings are forwarded.
|
||||
// - The function does not attempt to fix other non-JSON features beyond quotes.
|
||||
func FixJSON(input string) string {
|
||||
var out bytes.Buffer
|
||||
|
||||
inDouble := false
|
||||
inSingle := false
|
||||
escaped := false // applies within the current string state
|
||||
|
||||
// Helper to write a rune, escaping double quotes when inside a converted
|
||||
// single-quoted string (which becomes a double-quoted string in output).
|
||||
writeConverted := func(r rune) {
|
||||
if r == '"' {
|
||||
out.WriteByte('\\')
|
||||
out.WriteByte('"')
|
||||
return
|
||||
}
|
||||
out.WriteRune(r)
|
||||
}
|
||||
|
||||
runes := []rune(input)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
|
||||
if inDouble {
|
||||
out.WriteRune(r)
|
||||
if escaped {
|
||||
// end of escape sequence in a standard JSON string
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if r == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if r == '"' {
|
||||
inDouble = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if inSingle {
|
||||
if escaped {
|
||||
// Handle common escape sequences after a backslash within a
|
||||
// single-quoted string
|
||||
escaped = false
|
||||
switch r {
|
||||
case 'n', 'r', 't', 'b', 'f', '/', '"':
|
||||
// Keep the backslash and the character (except for '"' which
|
||||
// rarely appears, but if it does, keep as \" to remain valid)
|
||||
out.WriteByte('\\')
|
||||
out.WriteRune(r)
|
||||
case '\\':
|
||||
out.WriteByte('\\')
|
||||
out.WriteByte('\\')
|
||||
case '\'':
|
||||
// \' inside single-quoted becomes a literal '
|
||||
out.WriteRune('\'')
|
||||
case 'u':
|
||||
// Forward \uXXXX if possible
|
||||
out.WriteByte('\\')
|
||||
out.WriteByte('u')
|
||||
// Copy up to next 4 hex digits if present
|
||||
for k := 0; k < 4 && i+1 < len(runes); k++ {
|
||||
peek := runes[i+1]
|
||||
// simple hex check
|
||||
if (peek >= '0' && peek <= '9') || (peek >= 'a' && peek <= 'f') || (peek >= 'A' && peek <= 'F') {
|
||||
out.WriteRune(peek)
|
||||
i++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Unknown escape: preserve the backslash and the char
|
||||
out.WriteByte('\\')
|
||||
out.WriteRune(r)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if r == '\\' { // start escape sequence
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if r == '\'' { // end of single-quoted string
|
||||
out.WriteByte('"')
|
||||
inSingle = false
|
||||
continue
|
||||
}
|
||||
// regular char inside converted string; escape double quotes
|
||||
writeConverted(r)
|
||||
continue
|
||||
}
|
||||
|
||||
// Outside any string
|
||||
if r == '"' {
|
||||
inDouble = true
|
||||
out.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if r == '\'' { // start of non-standard single-quoted string
|
||||
inSingle = true
|
||||
out.WriteByte('"')
|
||||
continue
|
||||
}
|
||||
out.WriteRune(r)
|
||||
}
|
||||
|
||||
// If input ended while still inside a single-quoted string, close it to
|
||||
// produce the best-effort valid JSON.
|
||||
if inSingle {
|
||||
out.WriteByte('"')
|
||||
}
|
||||
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func CanonicalToolName(name string) string {
|
||||
canonical := strings.TrimSpace(name)
|
||||
canonical = strings.TrimLeft(canonical, "_")
|
||||
return strings.ToLower(canonical)
|
||||
}
|
||||
|
||||
// ToolNameMapFromClaudeRequest returns a canonical-name -> original-name map extracted from a Claude request.
|
||||
// It is used to restore exact tool name casing for clients that require strict tool name matching (e.g. Claude Code).
|
||||
func ToolNameMapFromClaudeRequest(rawJSON []byte) map[string]string {
|
||||
if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) {
|
||||
return nil
|
||||
}
|
||||
|
||||
tools := gjson.GetBytes(rawJSON, "tools")
|
||||
if !tools.Exists() || !tools.IsArray() {
|
||||
return nil
|
||||
}
|
||||
|
||||
toolResults := tools.Array()
|
||||
out := make(map[string]string, len(toolResults))
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
name := strings.TrimSpace(tool.Get("name").String())
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(tool.Get("function.name").String())
|
||||
}
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
key := CanonicalToolName(name)
|
||||
if key == "" {
|
||||
return true
|
||||
}
|
||||
if _, exists := out[key]; !exists {
|
||||
out[key] = name
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func MapToolName(toolNameMap map[string]string, name string) string {
|
||||
if name == "" || toolNameMap == nil {
|
||||
return name
|
||||
}
|
||||
if mapped, ok := toolNameMap[CanonicalToolName(name)]; ok && mapped != "" {
|
||||
return mapped
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// SanitizedFunctionNameMap builds an original-name → sanitized-name map from request tools.
|
||||
// Exact duplicate names share a mapping. Distinct names that sanitize to the same value receive
|
||||
// deterministic hash suffixes so every declaration remains addressable within the 64-byte limit.
|
||||
func SanitizedFunctionNameMap(rawJSON []byte) map[string]string {
|
||||
names := functionNamesFromRequest(rawJSON)
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
uniqueNames := make(map[string]struct{}, len(names))
|
||||
baseCounts := make(map[string]int, len(names))
|
||||
for _, name := range names {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := uniqueNames[name]; exists {
|
||||
continue
|
||||
}
|
||||
uniqueNames[name] = struct{}{}
|
||||
baseCounts[SanitizeFunctionName(name)]++
|
||||
}
|
||||
|
||||
sortedNames := make([]string, 0, len(uniqueNames))
|
||||
for name := range uniqueNames {
|
||||
sortedNames = append(sortedNames, name)
|
||||
}
|
||||
sort.Strings(sortedNames)
|
||||
|
||||
out := make(map[string]string, len(sortedNames))
|
||||
used := make(map[string]string, len(sortedNames))
|
||||
for _, name := range sortedNames {
|
||||
base := SanitizeFunctionName(name)
|
||||
mapped := base
|
||||
_, baseUsed := used[base]
|
||||
if baseCounts[base] > 1 || baseUsed {
|
||||
mapped = disambiguateSanitizedFunctionName(base, name, used)
|
||||
}
|
||||
out[name] = mapped
|
||||
used[mapped] = name
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MapSanitizedFunctionName returns the request-specific sanitized name when available.
|
||||
func MapSanitizedFunctionName(nameMap map[string]string, name string) string {
|
||||
if mapped := nameMap[name]; mapped != "" {
|
||||
return mapped
|
||||
}
|
||||
return SanitizeFunctionName(name)
|
||||
}
|
||||
|
||||
// DisambiguatedToolNameMap builds a sanitized-name → original-name map using the
|
||||
// same collision-aware mapping as SanitizedFunctionNameMap.
|
||||
func DisambiguatedToolNameMap(rawJSON []byte) map[string]string {
|
||||
forward := SanitizedFunctionNameMap(rawJSON)
|
||||
if len(forward) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make(map[string]string, len(forward))
|
||||
for original, sanitized := range forward {
|
||||
if sanitized != original {
|
||||
out[sanitized] = original
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SanitizedToolNameMap builds the legacy sanitized-name → original-name map from
|
||||
// top-level Claude-style tools. Collision-aware translators should use
|
||||
// DisambiguatedToolNameMap instead.
|
||||
func SanitizedToolNameMap(rawJSON []byte) map[string]string {
|
||||
if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) {
|
||||
return nil
|
||||
}
|
||||
tools := gjson.GetBytes(rawJSON, "tools")
|
||||
if !tools.IsArray() {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make(map[string]string)
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
name := strings.TrimSpace(tool.Get("name").String())
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
sanitized := SanitizeFunctionName(name)
|
||||
if sanitized == name {
|
||||
return true
|
||||
}
|
||||
if existing, exists := out[sanitized]; !exists {
|
||||
out[sanitized] = name
|
||||
} else {
|
||||
log.Warnf("sanitized tool name collision: %q and %q both map to %q, keeping first", existing, name, sanitized)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func functionNamesFromRequest(rawJSON []byte) []string {
|
||||
if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) {
|
||||
return nil
|
||||
}
|
||||
tools := gjson.GetBytes(rawJSON, "tools")
|
||||
if !tools.IsArray() {
|
||||
return nil
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(tools.Array()))
|
||||
var collectTool func(gjson.Result)
|
||||
collectDeclarations := func(declarations gjson.Result) {
|
||||
if !declarations.IsArray() {
|
||||
return
|
||||
}
|
||||
declarations.ForEach(func(_, declaration gjson.Result) bool {
|
||||
if name := declaration.Get("name").String(); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
collectTool = func(tool gjson.Result) {
|
||||
if nestedTools := tool.Get("tools"); nestedTools.IsArray() {
|
||||
nestedTools.ForEach(func(_, nestedTool gjson.Result) bool {
|
||||
collectTool(nestedTool)
|
||||
return true
|
||||
})
|
||||
return
|
||||
}
|
||||
hasDeclarations := false
|
||||
if declarations := tool.Get("functionDeclarations"); declarations.IsArray() {
|
||||
collectDeclarations(declarations)
|
||||
hasDeclarations = true
|
||||
}
|
||||
if declarations := tool.Get("function_declarations"); declarations.IsArray() {
|
||||
collectDeclarations(declarations)
|
||||
hasDeclarations = true
|
||||
}
|
||||
if hasDeclarations {
|
||||
return
|
||||
}
|
||||
if name := tool.Get("function.name").String(); name != "" {
|
||||
names = append(names, name)
|
||||
return
|
||||
}
|
||||
if name := tool.Get("name").String(); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
collectTool(tool)
|
||||
return true
|
||||
})
|
||||
return names
|
||||
}
|
||||
|
||||
func disambiguateSanitizedFunctionName(base, original string, used map[string]string) string {
|
||||
for attempt := 0; ; attempt++ {
|
||||
digest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%d", original, attempt)))
|
||||
suffix := "_" + hex.EncodeToString(digest[:6])
|
||||
prefix := base
|
||||
if maxPrefix := 64 - len(suffix); len(prefix) > maxPrefix {
|
||||
prefix = prefix[:maxPrefix]
|
||||
}
|
||||
candidate := prefix + suffix
|
||||
if _, exists := used[candidate]; !exists {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeduplicateFunctionDeclarations removes duplicate named declarations while preserving order.
|
||||
func DeduplicateFunctionDeclarations(raw []byte) []byte {
|
||||
result := gjson.ParseBytes(raw)
|
||||
if !result.IsArray() {
|
||||
return raw
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(result.Array()))
|
||||
parts := make([]string, 0, len(result.Array()))
|
||||
for _, declaration := range result.Array() {
|
||||
name := declaration.Get("name").String()
|
||||
if name != "" {
|
||||
if _, exists := seen[name]; exists {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
}
|
||||
parts = append(parts, declaration.Raw)
|
||||
}
|
||||
return []byte("[" + strings.Join(parts, ",") + "]")
|
||||
}
|
||||
|
||||
// RestoreSanitizedToolName looks up a sanitized function name in the provided map
|
||||
// and returns the original client-facing name. If no mapping exists, it returns
|
||||
// the sanitized name unchanged.
|
||||
func RestoreSanitizedToolName(toolNameMap map[string]string, sanitizedName string) string {
|
||||
if sanitizedName == "" || toolNameMap == nil {
|
||||
return sanitizedName
|
||||
}
|
||||
if original, ok := toolNameMap[sanitizedName]; ok {
|
||||
return original
|
||||
}
|
||||
return sanitizedName
|
||||
}
|
||||
128
backend/internal/util/util.go
Normal file
128
backend/internal/util/util.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// Package util provides utility functions for the CLI Proxy API server.
|
||||
// It includes helper functions for logging configuration, file system operations,
|
||||
// and other common utilities used throughout the application.
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var functionNameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_.:-]`)
|
||||
|
||||
// SanitizeFunctionName ensures a function name matches the requirements for Gemini/Vertex AI.
|
||||
// It replaces invalid characters with underscores, ensures it starts with a letter or underscore,
|
||||
// and truncates it to 64 characters if necessary.
|
||||
// Regex Rule: [^a-zA-Z0-9_.:-] replaced with _.
|
||||
func SanitizeFunctionName(name string) string {
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Replace invalid characters with underscore
|
||||
sanitized := functionNameSanitizer.ReplaceAllString(name, "_")
|
||||
|
||||
// Ensure it starts with a letter or underscore
|
||||
// Re-reading requirements: Must start with a letter or an underscore.
|
||||
if len(sanitized) > 0 {
|
||||
first := sanitized[0]
|
||||
if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') {
|
||||
// If it starts with an allowed character but not allowed at the beginning (digit, dot, colon, dash),
|
||||
// we must prepend an underscore.
|
||||
|
||||
// To stay within the 64-character limit while prepending, we must truncate first.
|
||||
if len(sanitized) >= 64 {
|
||||
sanitized = sanitized[:63]
|
||||
}
|
||||
sanitized = "_" + sanitized
|
||||
}
|
||||
} else {
|
||||
sanitized = "_"
|
||||
}
|
||||
|
||||
// Truncate to 64 characters
|
||||
if len(sanitized) > 64 {
|
||||
sanitized = sanitized[:64]
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// SetLogLevel configures the logrus log level based on the configuration.
|
||||
// It sets the log level to DebugLevel if debug mode is enabled, otherwise to InfoLevel.
|
||||
func SetLogLevel(cfg *config.Config) {
|
||||
currentLevel := log.GetLevel()
|
||||
var newLevel log.Level
|
||||
if cfg.Debug {
|
||||
newLevel = log.DebugLevel
|
||||
} else {
|
||||
newLevel = log.InfoLevel
|
||||
}
|
||||
|
||||
if currentLevel != newLevel {
|
||||
log.SetLevel(newLevel)
|
||||
log.Infof("log level changed from %s to %s (debug=%t)", currentLevel, newLevel, cfg.Debug)
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveAuthDir normalizes the auth directory path for consistent reuse throughout the app.
|
||||
// It expands a leading tilde (~) to the user's home directory and returns a cleaned path.
|
||||
// If authDir is empty, it defaults to ~/.cli-proxy-api.
|
||||
func ResolveAuthDir(authDir string) (string, error) {
|
||||
if authDir == "" {
|
||||
authDir = config.DefaultAuthDir
|
||||
}
|
||||
if strings.HasPrefix(authDir, "~") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve auth dir: %w", err)
|
||||
}
|
||||
remainder := strings.TrimPrefix(authDir, "~")
|
||||
remainder = strings.TrimLeft(remainder, "/\\")
|
||||
if remainder == "" {
|
||||
return filepath.Clean(home), nil
|
||||
}
|
||||
normalized := strings.ReplaceAll(remainder, "\\", "/")
|
||||
return filepath.Clean(filepath.Join(home, filepath.FromSlash(normalized))), nil
|
||||
}
|
||||
return filepath.Clean(authDir), nil
|
||||
}
|
||||
|
||||
// CountAuthFiles returns the number of auth records available through the provided Store.
|
||||
// For filesystem-backed stores, this reflects the number of JSON auth files under the configured directory.
|
||||
func CountAuthFiles[T any](ctx context.Context, store interface {
|
||||
List(context.Context) ([]T, error)
|
||||
}) int {
|
||||
if store == nil {
|
||||
return 0
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
entries, err := store.List(ctx)
|
||||
if err != nil {
|
||||
log.Debugf("countAuthFiles: failed to list auth records: %v", err)
|
||||
return 0
|
||||
}
|
||||
return len(entries)
|
||||
}
|
||||
|
||||
// WritablePath returns the cleaned WRITABLE_PATH environment variable when it is set.
|
||||
// It accepts both uppercase and lowercase variants for compatibility with existing conventions.
|
||||
func WritablePath() string {
|
||||
for _, key := range []string{"WRITABLE_PATH", "writable_path"} {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
return filepath.Clean(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Loading…
Reference in a new issue