Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View 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
}

View 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)
}
})
}
}

View 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")
}

View 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)
}
})
}
}

View 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
}
}

View 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))
})
}
}

View 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
}

View 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")
}
}

View 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
}

View 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)
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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)))
}

View 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)
}
}

View 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)
}
}

View 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")
}
})
}

View 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
}

View 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
}
}

View 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
}

View 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
}

View 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
}

View 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)
}
}

View 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)
}
}

View 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)
}

View 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
}

View 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 ""
}