Parcourir la source

fix: claude has signature field (#424)

* fix: claude has signature field

* fix: go test failed
zijiren il y a 2 mois
Parent
commit
3e4f74b96b

+ 41 - 0
core/common/audio/audio_test.go

@@ -0,0 +1,41 @@
+package audio_test
+
+import (
+	"testing"
+
+	"github.com/labring/aiproxy/core/common/audio"
+	"github.com/smartystreets/goconvey/convey"
+)
+
+func TestParseTimeFromFfmpegOutput(t *testing.T) {
+	convey.Convey("parseTimeFromFfmpegOutput", t, func() {
+		convey.Convey("should parse valid duration", func() {
+			output := "size=N/A time=00:00:05.52 bitrate=N/A speed= 785x"
+			duration, err := audio.ParseTimeFromFfmpegOutput(output)
+			convey.So(err, convey.ShouldBeNil)
+			convey.So(duration, convey.ShouldAlmostEqual, 5.52)
+		})
+
+		convey.Convey("should parse longer duration", func() {
+			output := "frame=  100 fps=0.0 q=-0.0 size=   123kB time=01:02:03.45 bitrate=  10.0kbits/s speed=  10x"
+			duration, err := audio.ParseTimeFromFfmpegOutput(output)
+			convey.So(err, convey.ShouldBeNil)
+			// 1*3600 + 2*60 + 3.45 = 3600 + 120 + 3.45 = 3723.45
+			convey.So(duration, convey.ShouldAlmostEqual, 3723.45)
+		})
+
+		convey.Convey("should use last match", func() {
+			output := "time=00:00:01.00\n... time=00:00:02.00"
+			duration, err := audio.ParseTimeFromFfmpegOutput(output)
+			convey.So(err, convey.ShouldBeNil)
+			convey.So(duration, convey.ShouldAlmostEqual, 2.0)
+		})
+
+		convey.Convey("should return error for no time match", func() {
+			output := "invalid output"
+			_, err := audio.ParseTimeFromFfmpegOutput(output)
+			convey.So(err, convey.ShouldNotBeNil)
+			convey.So(err, convey.ShouldEqual, audio.ErrAudioDurationNAN)
+		})
+	})
+}

+ 3 - 0
core/common/audio/export_test.go

@@ -0,0 +1,3 @@
+package audio
+
+var ParseTimeFromFfmpegOutput = parseTimeFromFfmpegOutput

+ 28 - 0
core/common/conv/any_test.go

@@ -0,0 +1,28 @@
+package conv_test
+
+import (
+	"testing"
+
+	"github.com/labring/aiproxy/core/common/conv"
+	"github.com/smartystreets/goconvey/convey"
+)
+
+func TestBytesToString(t *testing.T) {
+	convey.Convey("BytesToString", t, func() {
+		convey.Convey("should convert bytes to string", func() {
+			b := []byte("hello")
+			s := conv.BytesToString(b)
+			convey.So(s, convey.ShouldEqual, "hello")
+		})
+	})
+}
+
+func TestStringToBytes(t *testing.T) {
+	convey.Convey("StringToBytes", t, func() {
+		convey.Convey("should convert string to bytes", func() {
+			s := "hello"
+			b := conv.StringToBytes(s)
+			convey.So(b, convey.ShouldResemble, []byte("hello"))
+		})
+	})
+}

+ 105 - 0
core/common/gin_test.go

@@ -0,0 +1,105 @@
+package common_test
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"github.com/labring/aiproxy/core/common"
+	"github.com/smartystreets/goconvey/convey"
+)
+
+func TestGetLogFields(t *testing.T) {
+	convey.Convey("GetLogFields", t, func() {
+		fields := common.GetLogFields()
+		convey.So(fields, convey.ShouldNotBeNil)
+		convey.So(len(fields), convey.ShouldEqual, 0)
+
+		fields["test"] = "value"
+		common.PutLogFields(fields)
+
+		// Should get a cleared map (or at least we should be able to reuse it)
+		fields2 := common.GetLogFields()
+		convey.So(fields2, convey.ShouldNotBeNil)
+		convey.So(len(fields2), convey.ShouldEqual, 0)
+	})
+}
+
+func TestLogger(t *testing.T) {
+	convey.Convey("Logger Context", t, func() {
+		convey.Convey("GetLoggerFromReq should create new logger if missing", func() {
+			req := httptest.NewRequest(http.MethodGet, "/", nil)
+			logger := common.GetLoggerFromReq(req)
+			convey.So(logger, convey.ShouldNotBeNil)
+			convey.So(logger.Data, convey.ShouldNotBeNil)
+		})
+
+		convey.Convey("SetLogger should store logger in context", func() {
+			req := httptest.NewRequest(http.MethodGet, "/", nil)
+			entry := common.NewLogger()
+			common.SetLogger(req, entry)
+
+			logger := common.GetLoggerFromReq(req)
+			convey.So(logger, convey.ShouldEqual, entry)
+		})
+
+		convey.Convey("GetLogger should work with Gin context", func() {
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+			c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
+
+			logger := common.GetLogger(c)
+			convey.So(logger, convey.ShouldNotBeNil)
+		})
+	})
+}
+
+func TestTruncateDuration(t *testing.T) {
+	convey.Convey("TruncateDuration", t, func() {
+		convey.Convey("should truncate > 1h to Minute", func() {
+			d := time.Hour + 30*time.Minute + 30*time.Second
+			res := common.TruncateDuration(d)
+			convey.So(res, convey.ShouldEqual, time.Hour+30*time.Minute)
+		})
+
+		convey.Convey("should truncate > 1m to Second", func() {
+			d := time.Minute + 30*time.Second + 500*time.Millisecond
+			res := common.TruncateDuration(d)
+			convey.So(res, convey.ShouldEqual, time.Minute+30*time.Second)
+		})
+
+		convey.Convey("should truncate > 1s to Millisecond", func() {
+			d := time.Second + 500*time.Millisecond + 500*time.Microsecond
+			res := common.TruncateDuration(d)
+			convey.So(res, convey.ShouldEqual, time.Second+500*time.Millisecond)
+		})
+
+		convey.Convey("should truncate > 1ms to Microsecond", func() {
+			d := time.Millisecond + 500*time.Microsecond + 500*time.Nanosecond
+			res := common.TruncateDuration(d)
+			convey.So(res, convey.ShouldEqual, time.Millisecond+500*time.Microsecond)
+		})
+
+		convey.Convey("should keep small durations", func() {
+			d := 500 * time.Nanosecond
+			res := common.TruncateDuration(d)
+			convey.So(res, convey.ShouldEqual, d)
+		})
+
+		convey.Convey("should handle exact boundaries", func() {
+			// 1h -> falls through to > 1m check (since 1h is not > 1h)
+			// returns 1h truncated to Second -> 1h
+			convey.So(common.TruncateDuration(time.Hour), convey.ShouldEqual, time.Hour)
+
+			convey.So(common.TruncateDuration(time.Minute), convey.ShouldEqual, time.Minute)
+			convey.So(common.TruncateDuration(time.Second), convey.ShouldEqual, time.Second)
+			convey.So(
+				common.TruncateDuration(time.Millisecond),
+				convey.ShouldEqual,
+				time.Millisecond,
+			)
+		})
+	})
+}

+ 197 - 0
core/common/image/image_test.go

@@ -0,0 +1,197 @@
+package image_test
+
+import (
+	"context"
+	"encoding/base64"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/labring/aiproxy/core/common/image"
+	"github.com/smartystreets/goconvey/convey"
+)
+
+func TestIsImageURL(t *testing.T) {
+	convey.Convey("IsImageURL", t, func() {
+		convey.Convey("should return true for image content type", func() {
+			convey.So(image.IsImageURL("image/jpeg"), convey.ShouldBeTrue)
+			convey.So(image.IsImageURL("image/png"), convey.ShouldBeTrue)
+		})
+
+		convey.Convey("should return false for non-image content type", func() {
+			convey.So(image.IsImageURL("text/plain"), convey.ShouldBeFalse)
+			convey.So(image.IsImageURL("application/json"), convey.ShouldBeFalse)
+		})
+	})
+}
+
+func TestTrimImageContentType(t *testing.T) {
+	convey.Convey("TrimImageContentType", t, func() {
+		convey.Convey("should trim content type", func() {
+			convey.So(
+				image.TrimImageContentType("image/jpeg; charset=utf-8"),
+				convey.ShouldEqual,
+				"image/jpeg",
+			)
+			convey.So(image.TrimImageContentType("image/png"), convey.ShouldEqual, "image/png")
+		})
+	})
+}
+
+func TestGetImageSizeFromBase64(t *testing.T) {
+	convey.Convey("GetImageSizeFromBase64", t, func() {
+		// 1x1 pixel red dot png
+		base64Img := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
+
+		convey.Convey("should get size from base64", func() {
+			w, h, err := image.GetImageSizeFromBase64(base64Img)
+			convey.So(err, convey.ShouldBeNil)
+			convey.So(w, convey.ShouldEqual, 1)
+			convey.So(h, convey.ShouldEqual, 1)
+		})
+
+		convey.Convey("should return error for invalid base64", func() {
+			_, _, err := image.GetImageSizeFromBase64("invalid")
+			convey.So(err, convey.ShouldNotBeNil)
+		})
+	})
+}
+
+func TestGetImageFromURL(t *testing.T) {
+	convey.Convey("GetImageFromURL", t, func() {
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			switch r.URL.Path {
+			case "/image.png":
+				w.Header().Set("Content-Type", "image/png")
+				// 1x1 pixel red dot png
+				data, _ := base64.StdEncoding.DecodeString(
+					"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+				)
+				_, _ = w.Write(data)
+			case "/text":
+				w.Header().Set("Content-Type", "text/plain")
+				_, _ = w.Write([]byte("hello"))
+			default:
+				w.WriteHeader(http.StatusNotFound)
+			}
+		}))
+		defer ts.Close()
+
+		convey.Convey("should get image from URL", func() {
+			mime, data, err := image.GetImageFromURL(context.Background(), ts.URL+"/image.png")
+			convey.So(err, convey.ShouldBeNil)
+			convey.So(mime, convey.ShouldEqual, "image/png")
+			convey.So(
+				data,
+				convey.ShouldEqual,
+				"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+			)
+		})
+
+		convey.Convey("should return error for non-image URL", func() {
+			_, _, err := image.GetImageFromURL(context.Background(), ts.URL+"/text")
+			convey.So(err, convey.ShouldNotBeNil)
+		})
+
+		convey.Convey("should return error for 404", func() {
+			_, _, err := image.GetImageFromURL(context.Background(), ts.URL+"/404")
+			convey.So(err, convey.ShouldNotBeNil)
+		})
+
+		convey.Convey("should handle data URL", func() {
+			dataURL := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
+			mime, data, err := image.GetImageFromURL(context.Background(), dataURL)
+			convey.So(err, convey.ShouldBeNil)
+			convey.So(mime, convey.ShouldEqual, "image/png")
+			convey.So(
+				data,
+				convey.ShouldEqual,
+				"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+			)
+		})
+	})
+}
+
+func TestGetImageSizeFromURL(t *testing.T) {
+	convey.Convey("GetImageSizeFromURL", t, func() {
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			if r.URL.Path == "/image.png" {
+				w.Header().Set("Content-Type", "image/png")
+				// 1x1 pixel red dot png
+				data, _ := base64.StdEncoding.DecodeString(
+					"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+				)
+				_, _ = w.Write(data)
+			} else {
+				w.WriteHeader(http.StatusNotFound)
+			}
+		}))
+		defer ts.Close()
+
+		convey.Convey("should get image size from URL", func() {
+			w, h, err := image.GetImageSizeFromURL(ts.URL + "/image.png")
+			convey.So(err, convey.ShouldBeNil)
+			convey.So(w, convey.ShouldEqual, 1)
+			convey.So(h, convey.ShouldEqual, 1)
+		})
+
+		convey.Convey("should return error for 404", func() {
+			_, _, err := image.GetImageSizeFromURL(ts.URL + "/404")
+			convey.So(err, convey.ShouldNotBeNil)
+		})
+	})
+}
+
+func TestGetImageSize(t *testing.T) {
+	convey.Convey("GetImageSize", t, func() {
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			if r.URL.Path == "/image.png" {
+				w.Header().Set("Content-Type", "image/png")
+				// 1x1 pixel red dot png
+				data, _ := base64.StdEncoding.DecodeString(
+					"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+				)
+				_, _ = w.Write(data)
+			}
+		}))
+		defer ts.Close()
+
+		convey.Convey("should get size from data URL", func() {
+			dataURL := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
+			w, h, err := image.GetImageSize(dataURL)
+			convey.So(err, convey.ShouldBeNil)
+			convey.So(w, convey.ShouldEqual, 1)
+			convey.So(h, convey.ShouldEqual, 1)
+		})
+
+		convey.Convey("should get size from HTTP URL", func() {
+			w, h, err := image.GetImageSize(ts.URL + "/image.png")
+			convey.So(err, convey.ShouldBeNil)
+			convey.So(w, convey.ShouldEqual, 1)
+			convey.So(h, convey.ShouldEqual, 1)
+		})
+	})
+}
+
+func TestSVG(t *testing.T) {
+	convey.Convey("SVG Decode", t, func() {
+		svgContent := `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
+  <circle cx="50" cy="50" r="40" stroke="green" stroke-width="4" fill="yellow" />
+</svg>`
+
+		// Helper to simulate reading from response body or file
+		reader := strings.NewReader(svgContent)
+
+		convey.Convey("should decode SVG config", func() {
+			// Reset reader
+			_, err := reader.Seek(0, 0)
+			convey.So(err, convey.ShouldBeNil)
+			config, err := image.DecodeConfig(reader)
+			convey.So(err, convey.ShouldBeNil)
+			convey.So(config.Width, convey.ShouldEqual, 100)
+			convey.So(config.Height, convey.ShouldEqual, 100)
+		})
+	})
+}

+ 52 - 0
core/common/tiktoken/tiktoken_test.go

@@ -0,0 +1,52 @@
+package tiktoken_test
+
+import (
+	"testing"
+
+	"github.com/labring/aiproxy/core/common/tiktoken"
+	"github.com/smartystreets/goconvey/convey"
+)
+
+func TestGetTokenEncoder(t *testing.T) {
+	convey.Convey("GetTokenEncoder", t, func() {
+		convey.Convey("should get encoder for gpt-4o", func() {
+			enc := tiktoken.GetTokenEncoder("gpt-4o")
+			convey.So(enc, convey.ShouldNotBeNil)
+		})
+
+		convey.Convey("should get encoder for gpt-3.5-turbo", func() {
+			enc := tiktoken.GetTokenEncoder("gpt-3.5-turbo")
+			convey.So(enc, convey.ShouldNotBeNil)
+		})
+
+		convey.Convey("should return default encoder for unknown model", func() {
+			enc := tiktoken.GetTokenEncoder("unknown-model")
+			convey.So(enc, convey.ShouldNotBeNil)
+			// Should default to gpt-4o encoder (o200k_base)
+			ids, _, _ := enc.Encode("hello")
+			convey.So(len(ids), convey.ShouldBeGreaterThan, 0)
+		})
+
+		convey.Convey("should cache encoders", func() {
+			enc1 := tiktoken.GetTokenEncoder("gpt-4")
+			enc2 := tiktoken.GetTokenEncoder("gpt-4")
+			convey.So(enc1, convey.ShouldEqual, enc2)
+		})
+	})
+}
+
+func TestEncoding(t *testing.T) {
+	convey.Convey("Encoding", t, func() {
+		convey.Convey("should encode correctly", func() {
+			enc := tiktoken.GetTokenEncoder("gpt-3.5-turbo")
+			text := "hello world"
+			ids, _, err := enc.Encode(text)
+			convey.So(err, convey.ShouldBeNil)
+			convey.So(len(ids), convey.ShouldBeGreaterThan, 0)
+
+			decoded, err := enc.Decode(ids)
+			convey.So(err, convey.ShouldBeNil)
+			convey.So(decoded, convey.ShouldEqual, text)
+		})
+	})
+}

+ 73 - 0
core/common/trunc_test.go

@@ -0,0 +1,73 @@
+package common_test
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/labring/aiproxy/core/common"
+	"github.com/smartystreets/goconvey/convey"
+)
+
+func TestTruncateByRune(t *testing.T) {
+	convey.Convey("TruncateByRune", t, func() {
+		convey.Convey("should truncate normal string", func() {
+			s := "hello world"
+			convey.So(common.TruncateByRune(s, 5), convey.ShouldEqual, "hello")
+		})
+
+		convey.Convey("should handle chinese characters", func() {
+			s := "你好世界"
+			// Each chinese char is 3 bytes
+			// 5 bytes is not enough for 2 chars (6 bytes), so it should return "你好" which is 6 bytes?
+			// Wait, TruncateByRune implementation:
+			// for _, r := range s { runeLen := utf8.RuneLen(r) ... total += runeLen }
+			// It truncates based on byte length but respecting rune boundaries.
+
+			// "你" (3 bytes)
+			// "你好" (6 bytes)
+			// TruncateByRune("你好世界", 5)
+			// 1. r='你', len=3. total=3 <= 5. ok.
+			// 2. r='好', len=3. total=6 > 5. return s[:3] -> "你"
+			convey.So(common.TruncateByRune(s, 5), convey.ShouldEqual, "你")
+			convey.So(common.TruncateByRune(s, 6), convey.ShouldEqual, "你好")
+		})
+
+		convey.Convey("should handle string shorter than length", func() {
+			s := "abc"
+			convey.So(common.TruncateByRune(s, 10), convey.ShouldEqual, "abc")
+		})
+
+		convey.Convey("should handle empty string", func() {
+			s := ""
+			convey.So(common.TruncateByRune(s, 5), convey.ShouldEqual, "")
+		})
+
+		convey.Convey("should handle mixed string", func() {
+			s := "a你好"
+			// 'a' (1), '你' (3), '好' (3)
+			// len 4: 'a' (1) + '你' (3) = 4. Exact.
+			convey.So(common.TruncateByRune(s, 4), convey.ShouldEqual, "a你")
+			// len 3: 'a' (1) + '你' (3) = 4 > 3. Returns 'a'
+			convey.So(common.TruncateByRune(s, 3), convey.ShouldEqual, "a")
+		})
+	})
+}
+
+func TestTruncateBytesByRune(t *testing.T) {
+	convey.Convey("TruncateBytesByRune", t, func() {
+		convey.Convey("should truncate bytes respecting runes", func() {
+			s := "你好世界"
+			b := []byte(s)
+			// Same logic as string
+			convey.So(string(common.TruncateBytesByRune(b, 5)), convey.ShouldEqual, "你")
+			convey.So(string(common.TruncateBytesByRune(b, 6)), convey.ShouldEqual, "你好")
+		})
+
+		convey.Convey("should handle long string", func() {
+			// Generate a long string
+			s := strings.Repeat("a", 1000)
+			b := []byte(s)
+			convey.So(len(common.TruncateBytesByRune(b, 500)), convey.ShouldEqual, 500)
+		})
+	})
+}

+ 23 - 0
core/common/utils_test.go

@@ -0,0 +1,23 @@
+package common_test
+
+import (
+	"testing"
+
+	"github.com/labring/aiproxy/core/common"
+	"github.com/smartystreets/goconvey/convey"
+)
+
+func TestShortUUID(t *testing.T) {
+	convey.Convey("ShortUUID", t, func() {
+		convey.Convey("should return a 32-character hex string", func() {
+			uid := common.ShortUUID()
+			convey.So(len(uid), convey.ShouldEqual, 32)
+		})
+
+		convey.Convey("should be unique", func() {
+			uid1 := common.ShortUUID()
+			uid2 := common.ShortUUID()
+			convey.So(uid1, convey.ShouldNotEqual, uid2)
+		})
+	})
+}

+ 132 - 0
core/controller/utils/utils_test.go

@@ -0,0 +1,132 @@
+package utils_test
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strconv"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"github.com/labring/aiproxy/core/controller/utils"
+	"github.com/smartystreets/goconvey/convey"
+)
+
+func TestParsePageParams(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+
+	convey.Convey("ParsePageParams", t, func() {
+		convey.Convey("should parse valid page and per_page", func() {
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+			c.Request = &http.Request{
+				URL: &url.URL{
+					RawQuery: "page=2&per_page=20",
+				},
+			}
+
+			page, perPage := utils.ParsePageParams(c)
+			convey.So(page, convey.ShouldEqual, 2)
+			convey.So(perPage, convey.ShouldEqual, 20)
+		})
+
+		convey.Convey("should parse 'p' alias for page", func() {
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+			c.Request = &http.Request{
+				URL: &url.URL{
+					RawQuery: "p=3&per_page=15",
+				},
+			}
+
+			page, perPage := utils.ParsePageParams(c)
+			convey.So(page, convey.ShouldEqual, 3)
+			convey.So(perPage, convey.ShouldEqual, 15)
+		})
+
+		convey.Convey("should return 0 for missing params", func() {
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+			c.Request = &http.Request{
+				URL: &url.URL{},
+			}
+
+			page, perPage := utils.ParsePageParams(c)
+			convey.So(page, convey.ShouldEqual, 0)
+			convey.So(perPage, convey.ShouldEqual, 0)
+		})
+	})
+}
+
+func TestParseTimeRange(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+
+	convey.Convey("ParseTimeRange", t, func() {
+		now := time.Now()
+		startTimeStr := strconv.FormatInt(now.Add(-time.Hour).Unix(), 10)
+		endTimeStr := strconv.FormatInt(now.Unix(), 10)
+
+		convey.Convey("should parse valid timestamps", func() {
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+			c.Request = &http.Request{
+				URL: &url.URL{
+					RawQuery: "start_timestamp=" + startTimeStr + "&end_timestamp=" + endTimeStr,
+				},
+			}
+
+			start, end := utils.ParseTimeRange(c, 0)
+			// Timestamps are in seconds, so we check if they are roughly equal (ignoring sub-second differences lost in conversion)
+			convey.So(start.Unix(), convey.ShouldEqual, now.Add(-time.Hour).Unix())
+			convey.So(end.Unix(), convey.ShouldEqual, now.Unix())
+		})
+
+		convey.Convey("should use default max span if start time is too old", func() {
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+
+			// Start time 10 days ago
+			oldStartStr := strconv.FormatInt(now.Add(-24*10*time.Hour).Unix(), 10)
+
+			c.Request = &http.Request{
+				URL: &url.URL{
+					RawQuery: "start_timestamp=" + oldStartStr,
+				},
+			}
+
+			start, end := utils.ParseTimeRange(c, time.Hour*24*7) // Max 7 days
+
+			// End should be effectively Now() (since not provided)
+			// Start should be End - 7 days
+			expectedStart := end.Add(-time.Hour * 24 * 7)
+
+			convey.So(end.Unix(), convey.ShouldAlmostEqual, now.Unix(), 5) // Allow 5s diff
+			convey.So(start.Unix(), convey.ShouldAlmostEqual, expectedStart.Unix(), 5)
+		})
+
+		convey.Convey("should handle millisecond timestamps", func() {
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+
+			milliStart := now.Add(-time.Hour).UnixMilli()
+			milliEnd := now.UnixMilli()
+
+			c.Request = &http.Request{
+				URL: &url.URL{
+					RawQuery: "start_timestamp=" + strconv.FormatInt(
+						milliStart,
+						10,
+					) + "&end_timestamp=" + strconv.FormatInt(
+						milliEnd,
+						10,
+					),
+				},
+			}
+
+			start, end := utils.ParseTimeRange(c, 0)
+			convey.So(start.Unix(), convey.ShouldEqual, now.Add(-time.Hour).Unix())
+			convey.So(end.Unix(), convey.ShouldEqual, now.Unix())
+		})
+	})
+}

+ 4 - 0
core/model/export_test.go

@@ -0,0 +1,4 @@
+package model
+
+// Export for testing
+var ToLimitOffset = toLimitOffset

+ 236 - 0
core/model/utils_test.go

@@ -0,0 +1,236 @@
+package model_test
+
+import (
+	"errors"
+	"go/constant"
+	"testing"
+
+	"github.com/labring/aiproxy/core/model"
+	"github.com/smartystreets/goconvey/convey"
+	"gorm.io/gorm"
+)
+
+func TestString2Int(t *testing.T) {
+	convey.Convey("String2Int", t, func() {
+		convey.Convey("should convert valid string", func() {
+			convey.So(model.String2Int("123"), convey.ShouldEqual, 123)
+		})
+		convey.Convey("should return 0 for empty string", func() {
+			convey.So(model.String2Int(""), convey.ShouldEqual, 0)
+		})
+		convey.Convey("should return 0 for invalid string", func() {
+			convey.So(model.String2Int("abc"), convey.ShouldEqual, 0)
+		})
+	})
+}
+
+func TestToLimitOffset(t *testing.T) {
+	convey.Convey("toLimitOffset", t, func() {
+		convey.Convey("should calculate correct offset", func() {
+			limit, offset := model.ToLimitOffset(1, 10)
+			convey.So(limit, convey.ShouldEqual, 10)
+			convey.So(offset, convey.ShouldEqual, 0)
+
+			limit, offset = model.ToLimitOffset(2, 10)
+			convey.So(limit, convey.ShouldEqual, 10)
+			convey.So(offset, convey.ShouldEqual, 10)
+		})
+
+		convey.Convey("should handle page < 1", func() {
+			_, offset := model.ToLimitOffset(0, 10)
+			convey.So(offset, convey.ShouldEqual, 0)
+		})
+
+		convey.Convey("should clamp perPage", func() {
+			// Default 10 if <= 0
+			limit, _ := model.ToLimitOffset(1, 0)
+			convey.So(limit, convey.ShouldEqual, 10)
+
+			// Max 100
+			limit, _ = model.ToLimitOffset(1, 101)
+			convey.So(limit, convey.ShouldEqual, 100)
+		})
+	})
+}
+
+func TestErrors(t *testing.T) {
+	convey.Convey("Errors", t, func() {
+		convey.Convey("NotFoundError", func() {
+			err := model.NotFoundError("user")
+			convey.So(errors.Is(err, gorm.ErrRecordNotFound), convey.ShouldBeTrue)
+			convey.So(err.Error(), convey.ShouldContainSubstring, "user")
+		})
+
+		convey.Convey("HandleNotFound", func() {
+			// Should wrap ErrRecordNotFound
+			err := model.HandleNotFound(gorm.ErrRecordNotFound, "user")
+			convey.So(errors.Is(err, gorm.ErrRecordNotFound), convey.ShouldBeTrue)
+			convey.So(err.Error(), convey.ShouldContainSubstring, "user")
+
+			// Should pass through other errors
+			otherErr := errors.New("other error")
+			err = model.HandleNotFound(otherErr, "user")
+			convey.So(err, convey.ShouldEqual, otherErr)
+
+			// Should return nil for nil
+			err = model.HandleNotFound(nil, "user")
+			convey.So(err, convey.ShouldBeNil)
+		})
+
+		convey.Convey("IgnoreNotFound", func() {
+			// Should ignore ErrRecordNotFound
+			err := model.IgnoreNotFound(gorm.ErrRecordNotFound)
+			convey.So(err, convey.ShouldBeNil)
+
+			// Should pass through other errors
+			otherErr := errors.New("other error")
+			err = model.IgnoreNotFound(otherErr)
+			convey.So(err, convey.ShouldEqual, otherErr)
+
+			// Should return nil for nil
+			err = model.IgnoreNotFound(nil)
+			convey.So(err, convey.ShouldBeNil)
+		})
+
+		convey.Convey("HandleUpdateResult", func() {
+			// Error case
+			res := &gorm.DB{Error: errors.New("db error")}
+			err := model.HandleUpdateResult(res, "user")
+			convey.So(err, convey.ShouldNotBeNil)
+			convey.So(err.Error(), convey.ShouldEqual, "db error")
+
+			// RowsAffected == 0 case (should be NotFoundError)
+			res = &gorm.DB{Error: nil, RowsAffected: 0}
+			err = model.HandleUpdateResult(res, "user")
+			convey.So(errors.Is(err, gorm.ErrRecordNotFound), convey.ShouldBeTrue)
+			convey.So(err.Error(), convey.ShouldContainSubstring, "user")
+
+			// Success case
+			res = &gorm.DB{Error: nil, RowsAffected: 1}
+			err = model.HandleUpdateResult(res, "user")
+			convey.So(err, convey.ShouldBeNil)
+		})
+	})
+}
+
+func TestZeroNullTypes(t *testing.T) {
+	convey.Convey("ZeroNullInt64", t, func() {
+		convey.Convey("Value", func() {
+			var z model.ZeroNullInt64 = 0
+
+			v, _ := z.Value()
+			convey.So(v, convey.ShouldBeNil)
+
+			z = 10
+			v, _ = z.Value()
+			convey.So(v, convey.ShouldEqual, 10)
+		})
+
+		convey.Convey("Scan", func() {
+			var z model.ZeroNullInt64
+
+			// Nil
+			convey.So(z.Scan(nil), convey.ShouldBeNil)
+			convey.So(z, convey.ShouldEqual, 0)
+
+			// Int
+			convey.So(z.Scan(int(123)), convey.ShouldBeNil)
+			convey.So(z, convey.ShouldEqual, 123)
+
+			// Int64
+			convey.So(z.Scan(int64(456)), convey.ShouldBeNil)
+			convey.So(z, convey.ShouldEqual, 456)
+
+			// String
+			convey.So(z.Scan("789"), convey.ShouldBeNil)
+			convey.So(z, convey.ShouldEqual, 789)
+
+			// Invalid type
+			err := z.Scan(true)
+			convey.So(err, convey.ShouldNotBeNil)
+			convey.So(err.Error(), convey.ShouldContainSubstring, "unsupported type")
+
+			// Invalid string
+			err = z.Scan("abc")
+			convey.So(err, convey.ShouldNotBeNil)
+		})
+	})
+
+	convey.Convey("ZeroNullFloat64", t, func() {
+		convey.Convey("Value", func() {
+			var z model.ZeroNullFloat64 = 0
+
+			v, _ := z.Value()
+			convey.So(v, convey.ShouldBeNil)
+
+			z = 10.5
+			v, _ = z.Value()
+			convey.So(v, convey.ShouldEqual, 10.5)
+		})
+
+		convey.Convey("Scan", func() {
+			var z model.ZeroNullFloat64
+
+			// Nil
+			convey.So(z.Scan(nil), convey.ShouldBeNil)
+			convey.So(z, convey.ShouldEqual, 0)
+
+			// Float64
+			convey.So(z.Scan(10.5), convey.ShouldBeNil)
+			convey.So(z, convey.ShouldEqual, 10.5)
+
+			// String
+			convey.So(z.Scan("20.5"), convey.ShouldBeNil)
+			convey.So(z, convey.ShouldEqual, 20.5)
+
+			// Int (implicit conversion in switch)
+			convey.So(z.Scan(int(30)), convey.ShouldBeNil)
+			convey.So(z, convey.ShouldEqual, 30.0)
+
+			// Invalid type
+			err := z.Scan(true)
+			convey.So(err, convey.ShouldNotBeNil)
+			convey.So(err.Error(), convey.ShouldContainSubstring, "unsupported type")
+
+			// Invalid string
+			err = z.Scan("abc")
+			convey.So(err, convey.ShouldNotBeNil)
+		})
+	})
+
+	convey.Convey("EmptyNullString", t, func() {
+		convey.Convey("Value", func() {
+			var s model.EmptyNullString = ""
+
+			v, _ := s.Value()
+			convey.So(v, convey.ShouldBeNil)
+
+			s = "test"
+			v, _ = s.Value()
+			convey.So(v, convey.ShouldEqual, "test")
+		})
+
+		convey.Convey("Scan", func() {
+			var s model.EmptyNullString
+
+			convey.So(s.Scan(nil), convey.ShouldBeNil)
+			convey.So(string(s), convey.ShouldEqual, "")
+
+			convey.So(s.Scan("test"), convey.ShouldBeNil)
+			convey.So(string(s), convey.ShouldEqual, "test")
+
+			convey.So(s.Scan([]byte("bytes")), convey.ShouldBeNil)
+			convey.So(string(s), convey.ShouldEqual, "bytes")
+
+			// Invalid type
+			err := s.Scan(123)
+			convey.So(err, convey.ShouldNotBeNil)
+			convey.So(err.Error(), convey.ShouldContainSubstring, "unsupported type")
+		})
+
+		convey.Convey(constant.String.String(), func() {
+			var s model.EmptyNullString = "test"
+			convey.So(s.String(), convey.ShouldEqual, "test")
+		})
+	})
+}

+ 8 - 2
core/relay/adaptor/anthropic/openai.go

@@ -342,6 +342,7 @@ func (s *StreamState) StreamResponse2OpenAI(
 		usage      *relaymodel.ChatUsage
 		content    string
 		thinking   string
+		signature  string
 		stopReason string
 	)
 
@@ -396,6 +397,7 @@ func (s *StreamState) StreamResponse2OpenAI(
 			case "thinking_delta":
 				thinking = claudeResponse.Delta.Thinking
 			case "signature_delta":
+				signature = claudeResponse.Delta.Signature
 			default:
 				content = claudeResponse.Delta.Text
 			}
@@ -422,6 +424,7 @@ func (s *StreamState) StreamResponse2OpenAI(
 		Delta: relaymodel.Message{
 			Content:          content,
 			ReasoningContent: thinking,
+			Signature:        signature,
 			ToolCalls:        tools,
 			Role:             "assistant",
 		},
@@ -464,8 +467,9 @@ func Response2OpenAI(
 	}
 
 	var (
-		content  string
-		thinking string
+		content   string
+		thinking  string
+		signature string
 	)
 
 	tools := make([]relaymodel.ToolCall, 0)
@@ -475,6 +479,7 @@ func Response2OpenAI(
 			content = v.Text
 		case conetentTypeThinking:
 			thinking = v.Thinking
+			signature = v.Signature
 		case toolUseType:
 			args, _ := sonic.MarshalString(v.Input)
 			tools = append(tools, relaymodel.ToolCall{
@@ -498,6 +503,7 @@ func Response2OpenAI(
 			Role:             "assistant",
 			Content:          content,
 			ReasoningContent: thinking,
+			Signature:        signature,
 			Name:             nil,
 			ToolCalls:        tools,
 		},

+ 90 - 0
core/relay/adaptor/anthropic/openai_test.go

@@ -0,0 +1,90 @@
+package anthropic_test
+
+import (
+	"testing"
+
+	"github.com/labring/aiproxy/core/relay/adaptor/anthropic"
+	"github.com/labring/aiproxy/core/relay/meta"
+	"github.com/smartystreets/goconvey/convey"
+)
+
+func TestStreamResponse2OpenAI(t *testing.T) {
+	convey.Convey("StreamResponse2OpenAI", t, func() {
+		streamState := anthropic.NewStreamState()
+		m := &meta.Meta{
+			OriginModel: "claude-3-7-sonnet-20250219",
+		}
+
+		convey.Convey("should handle signature_delta", func() {
+			data := []byte(`{
+				"type": "content_block_delta",
+				"index": 0,
+				"delta": {
+					"type": "signature_delta",
+					"signature": "test_signature"
+				}
+			}`)
+
+			resp, err := streamState.StreamResponse2OpenAI(m, data)
+			convey.So(err, convey.ShouldBeNil)
+			convey.So(resp.Choices[0].Delta.Signature, convey.ShouldEqual, "test_signature")
+		})
+
+		convey.Convey("should handle thinking_delta", func() {
+			data := []byte(`{
+				"type": "content_block_delta",
+				"index": 0,
+				"delta": {
+					"type": "thinking_delta",
+					"thinking": "I am thinking"
+				}
+			}`)
+
+			resp, err := streamState.StreamResponse2OpenAI(m, data)
+			convey.So(err, convey.ShouldBeNil)
+			convey.So(resp.Choices[0].Delta.ReasoningContent, convey.ShouldEqual, "I am thinking")
+		})
+	})
+}
+
+func TestResponse2OpenAI(t *testing.T) {
+	convey.Convey("Response2OpenAI", t, func() {
+		m := &meta.Meta{
+			OriginModel: "claude-3-7-sonnet-20250219",
+		}
+
+		convey.Convey("should handle thinking and signature in content", func() {
+			data := []byte(`{
+				"id": "msg_123",
+				"type": "message",
+				"role": "assistant",
+				"model": "claude-3-7-sonnet-20250219",
+				"content": [
+					{
+						"type": "thinking",
+						"thinking": "I am thinking...",
+						"signature": "test_signature_block"
+					},
+					{
+						"type": "text",
+						"text": "Hello"
+					}
+				],
+				"usage": {
+					"input_tokens": 10,
+					"output_tokens": 20
+				}
+			}`)
+
+			resp, err := anthropic.Response2OpenAI(m, data)
+			convey.So(err, convey.ShouldBeNil)
+			convey.So(
+				resp.Choices[0].Message.ReasoningContent,
+				convey.ShouldEqual,
+				"I am thinking...",
+			)
+			convey.So(resp.Choices[0].Message.Signature, convey.ShouldEqual, "test_signature_block")
+			convey.So(resp.Choices[0].Message.Content, convey.ShouldEqual, "Hello")
+		})
+	})
+}

+ 24 - 12
core/relay/adaptor/gemini/claude.go

@@ -240,6 +240,17 @@ func ClaudeStreamHandler(
 								Thinking: "",
 							},
 						})
+
+						if part.ThoughtSignature != "" {
+							_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
+								Type:  "content_block_delta",
+								Index: currentContentIndex,
+								ContentBlock: &relaymodel.ClaudeContent{
+									Type:      "signature_delta",
+									Signature: part.ThoughtSignature,
+								},
+							})
+						}
 					}
 
 					thinkingText.WriteString(part.Text)
@@ -288,11 +299,11 @@ func ClaudeStreamHandler(
 					currentContentType = "tool_use"
 
 					toolContent := &relaymodel.ClaudeContent{
-						Type:             "tool_use",
-						ID:               openai.CallID(),
-						Name:             part.FunctionCall.Name,
-						Input:            part.FunctionCall.Args,
-						ThoughtSignature: part.ThoughtSignature,
+						Type:      "tool_use",
+						ID:        openai.CallID(),
+						Name:      part.FunctionCall.Name,
+						Input:     part.FunctionCall.Args,
+						Signature: part.ThoughtSignature,
 					}
 					toolCallsBuffer[currentContentIndex] = toolContent
 
@@ -390,18 +401,19 @@ func geminiResponse2Claude(meta *meta.Meta, response *ChatResponse) *relaymodel.
 			if part.FunctionCall != nil {
 				// Convert function call to tool use
 				claudeResponse.Content = append(claudeResponse.Content, relaymodel.ClaudeContent{
-					Type:             "tool_use",
-					ID:               openai.CallID(),
-					Name:             part.FunctionCall.Name,
-					Input:            part.FunctionCall.Args,
-					ThoughtSignature: part.ThoughtSignature,
+					Type:      "tool_use",
+					ID:        openai.CallID(),
+					Name:      part.FunctionCall.Name,
+					Input:     part.FunctionCall.Args,
+					Signature: part.ThoughtSignature,
 				})
 			} else if part.Text != "" {
 				if part.Thought {
 					// Add thinking content
 					claudeResponse.Content = append(claudeResponse.Content, relaymodel.ClaudeContent{
-						Type:     "thinking",
-						Thinking: part.Text,
+						Type:      "thinking",
+						Thinking:  part.Text,
+						Signature: part.ThoughtSignature,
 					})
 				} else {
 					// Add text content

+ 178 - 0
core/relay/adaptor/gemini/claude_test.go

@@ -0,0 +1,178 @@
+package gemini_test
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/labring/aiproxy/core/relay/adaptor/gemini"
+	"github.com/labring/aiproxy/core/relay/meta"
+	relaymodel "github.com/labring/aiproxy/core/relay/model"
+	"github.com/smartystreets/goconvey/convey"
+)
+
+func TestClaudeHandler(t *testing.T) {
+	convey.Convey("ClaudeHandler", t, func() {
+		convey.Convey("should handle thinking with signature", func() {
+			meta := &meta.Meta{
+				OriginModel: "claude-3-5-sonnet-20240620",
+			}
+
+			response := &gemini.ChatResponse{
+				Candidates: []*gemini.ChatCandidate{
+					{
+						Content: gemini.ChatContent{
+							Parts: []*gemini.Part{
+								{
+									Text:             "Thinking process...",
+									Thought:          true,
+									ThoughtSignature: "signature_123",
+								},
+								{
+									Text: "Final answer",
+								},
+							},
+						},
+						FinishReason: "STOP",
+					},
+				},
+			}
+
+			respBody, _ := json.Marshal(response)
+			httpResp := &http.Response{
+				StatusCode: http.StatusOK,
+				Body:       io.NopCloser(bytes.NewReader(respBody)),
+			}
+
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+			c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
+
+			usage, handlerErr := gemini.ClaudeHandler(meta, c, httpResp)
+			convey.So(handlerErr, convey.ShouldBeNil)
+			convey.So(usage, convey.ShouldNotBeNil)
+
+			var claudeResponse relaymodel.ClaudeResponse
+
+			err := json.Unmarshal(w.Body.Bytes(), &claudeResponse)
+			convey.So(err, convey.ShouldBeNil)
+
+			convey.So(len(claudeResponse.Content), convey.ShouldEqual, 2)
+
+			// Check thinking block
+			convey.So(claudeResponse.Content[0].Type, convey.ShouldEqual, "thinking")
+			convey.So(claudeResponse.Content[0].Thinking, convey.ShouldEqual, "Thinking process...")
+			convey.So(claudeResponse.Content[0].Signature, convey.ShouldEqual, "signature_123")
+
+			// Check text block
+			convey.So(claudeResponse.Content[1].Type, convey.ShouldEqual, "text")
+			convey.So(claudeResponse.Content[1].Text, convey.ShouldEqual, "Final answer")
+		})
+
+		convey.Convey("should handle tool call with signature", func() {
+			meta := &meta.Meta{
+				OriginModel: "claude-3-5-sonnet-20240620",
+			}
+
+			response := &gemini.ChatResponse{
+				Candidates: []*gemini.ChatCandidate{
+					{
+						Content: gemini.ChatContent{
+							Parts: []*gemini.Part{
+								{
+									FunctionCall: &gemini.FunctionCall{
+										Name: "get_weather",
+										Args: map[string]any{"location": "London"},
+									},
+									ThoughtSignature: "tool_signature_456",
+								},
+							},
+						},
+						FinishReason: "TOOL_CALLS",
+					},
+				},
+			}
+
+			respBody, _ := json.Marshal(response)
+			httpResp := &http.Response{
+				StatusCode: http.StatusOK,
+				Body:       io.NopCloser(bytes.NewReader(respBody)),
+			}
+
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+			c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
+
+			usage, handlerErr := gemini.ClaudeHandler(meta, c, httpResp)
+			convey.So(handlerErr, convey.ShouldBeNil)
+			convey.So(usage, convey.ShouldNotBeNil)
+
+			var claudeResponse relaymodel.ClaudeResponse
+
+			err := json.Unmarshal(w.Body.Bytes(), &claudeResponse)
+			convey.So(err, convey.ShouldBeNil)
+
+			convey.So(len(claudeResponse.Content), convey.ShouldEqual, 1)
+
+			// Check tool use block
+			convey.So(claudeResponse.Content[0].Type, convey.ShouldEqual, "tool_use")
+			convey.So(claudeResponse.Content[0].Name, convey.ShouldEqual, "get_weather")
+			convey.So(claudeResponse.Content[0].Signature, convey.ShouldEqual, "tool_signature_456")
+		})
+	})
+}
+
+func TestClaudeStreamHandler(t *testing.T) {
+	convey.Convey("ClaudeStreamHandler", t, func() {
+		convey.Convey("should handle thinking with signature in stream", func() {
+			meta := &meta.Meta{
+				OriginModel: "claude-3-5-sonnet-20240620",
+			}
+
+			// Prepare SSE stream response
+			response := &gemini.ChatResponse{
+				Candidates: []*gemini.ChatCandidate{
+					{
+						Content: gemini.ChatContent{
+							Parts: []*gemini.Part{
+								{
+									Text:             "Thinking process...",
+									Thought:          true,
+									ThoughtSignature: "signature_stream_123",
+								},
+							},
+						},
+					},
+				},
+			}
+
+			respData, _ := json.Marshal(response)
+			streamBody := "data: " + string(respData) + "\n\n" + "data: [DONE]\n\n"
+
+			httpResp := &http.Response{
+				StatusCode: http.StatusOK,
+				Body:       io.NopCloser(bytes.NewReader([]byte(streamBody))),
+			}
+
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+			c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
+
+			_, err := gemini.ClaudeStreamHandler(meta, c, httpResp)
+			convey.So(err, convey.ShouldBeNil)
+
+			// Check response body for signature
+			body := w.Body.String()
+			convey.So(body, convey.ShouldContainSubstring, `"type":"thinking"`)
+			convey.So(
+				body,
+				convey.ShouldContainSubstring,
+				`"signature":"signature_stream_123"`,
+			)
+		})
+	})
+}

+ 12 - 4
core/relay/adaptor/gemini/main.go

@@ -287,10 +287,10 @@ func buildContents(
 				}
 
 				// Restore Gemini thought signature if present in extra_content (OpenAI format)
-				if toolCall.ExtraContent != nil && toolCall.ExtraContent.Google != nil {
-					if toolCall.ExtraContent.Google.ThoughtSignature != "" {
-						part.ThoughtSignature = toolCall.ExtraContent.Google.ThoughtSignature
-					}
+				if toolCall.ExtraContent != nil &&
+					toolCall.ExtraContent.Google != nil &&
+					toolCall.ExtraContent.Google.ThoughtSignature != "" {
+					part.ThoughtSignature = toolCall.ExtraContent.Google.ThoughtSignature
 				} else {
 					// If thought signature is missing (e.g., from non-Gemini sources or clients that don't preserve it),
 					// use a dummy signature to skip Gemini's validation as per their FAQ:
@@ -680,6 +680,10 @@ func responseChat2OpenAI(meta *meta.Meta, response *ChatResponse) *relaymodel.Te
 					} else {
 						if part.Thought {
 							reasoningContent.WriteString(part.Text)
+
+							if part.ThoughtSignature != "" {
+								choice.Message.Signature = part.ThoughtSignature
+							}
 						} else {
 							builder.WriteString(part.Text)
 						}
@@ -778,6 +782,10 @@ func streamResponseChat2OpenAI(
 					} else {
 						if part.Thought {
 							reasoningContent.WriteString(part.Text)
+
+							if part.ThoughtSignature != "" {
+								choice.Delta.Signature = part.ThoughtSignature
+							}
 						} else {
 							builder.WriteString(part.Text)
 						}

+ 167 - 0
core/relay/adaptor/gemini/main_test.go

@@ -0,0 +1,167 @@
+package gemini_test
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/labring/aiproxy/core/relay/adaptor/gemini"
+	"github.com/labring/aiproxy/core/relay/meta"
+	relaymodel "github.com/labring/aiproxy/core/relay/model"
+	"github.com/smartystreets/goconvey/convey"
+)
+
+func TestHandler(t *testing.T) {
+	convey.Convey("Handler", t, func() {
+		convey.Convey("should handle thinking with signature in OpenAI format", func() {
+			meta := &meta.Meta{
+				OriginModel: "gemini-1.5-pro",
+			}
+
+			response := &gemini.ChatResponse{
+				Candidates: []*gemini.ChatCandidate{
+					{
+						Content: gemini.ChatContent{
+							Parts: []*gemini.Part{
+								{
+									Text:             "Thinking process...",
+									Thought:          true,
+									ThoughtSignature: "signature_openai_123",
+								},
+								{
+									Text: "Final answer",
+								},
+							},
+						},
+						FinishReason: "STOP",
+					},
+				},
+			}
+
+			respBody, _ := json.Marshal(response)
+			httpResp := &http.Response{
+				StatusCode: http.StatusOK,
+				Body:       io.NopCloser(bytes.NewReader(respBody)),
+			}
+
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+			c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
+
+			usage, handlerErr := gemini.Handler(meta, c, httpResp)
+			convey.So(handlerErr, convey.ShouldBeNil)
+			convey.So(usage, convey.ShouldNotBeNil)
+
+			var textResponse relaymodel.TextResponse
+
+			err := json.Unmarshal(w.Body.Bytes(), &textResponse)
+			convey.So(err, convey.ShouldBeNil)
+
+			convey.So(len(textResponse.Choices), convey.ShouldEqual, 1)
+
+			// Check message content and signature
+			convey.So(
+				textResponse.Choices[0].Message.ReasoningContent,
+				convey.ShouldEqual,
+				"Thinking process...",
+			)
+			convey.So(textResponse.Choices[0].Message.Content, convey.ShouldEqual, "Final answer")
+			convey.So(
+				textResponse.Choices[0].Message.Signature,
+				convey.ShouldEqual,
+				"signature_openai_123",
+			)
+		})
+	})
+}
+
+func TestStreamHandler(t *testing.T) {
+	convey.Convey("StreamHandler", t, func() {
+		convey.Convey("should handle thinking with signature in OpenAI stream format", func() {
+			meta := &meta.Meta{
+				OriginModel: "gemini-1.5-pro",
+			}
+
+			// Prepare SSE stream response
+			response := &gemini.ChatResponse{
+				Candidates: []*gemini.ChatCandidate{
+					{
+						Content: gemini.ChatContent{
+							Parts: []*gemini.Part{
+								{
+									Text:             "Thinking chunk...",
+									Thought:          true,
+									ThoughtSignature: "signature_stream_openai_456",
+								},
+							},
+						},
+					},
+				},
+			}
+
+			respData, _ := json.Marshal(response)
+			streamBody := "data: " + string(respData) + "\n\n" + "data: [DONE]\n\n"
+
+			httpResp := &http.Response{
+				StatusCode: http.StatusOK,
+				Body:       io.NopCloser(bytes.NewReader([]byte(streamBody))),
+			}
+
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+			c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
+
+			_, err := gemini.StreamHandler(meta, c, httpResp)
+			convey.So(err, convey.ShouldBeNil)
+
+			// Check response body for signature
+			body := w.Body.String()
+
+			// Parse the SSE output manually to verify structure
+			lines := strings.Split(body, "\n")
+			foundSignature := false
+			foundReasoning := false
+
+			for _, line := range lines {
+				if strings.HasPrefix(line, "data: {") {
+					jsonStr := strings.TrimPrefix(line, "data: ")
+
+					var streamResp relaymodel.ChatCompletionsStreamResponse
+
+					_ = json.Unmarshal([]byte(jsonStr), &streamResp)
+
+					if len(streamResp.Choices) > 0 {
+						delta := streamResp.Choices[0].Delta
+						if delta.ReasoningContent != "" {
+							convey.So(
+								delta.ReasoningContent,
+								convey.ShouldEqual,
+								"Thinking chunk...",
+							)
+
+							foundReasoning = true
+						}
+
+						if delta.Signature != "" {
+							convey.So(
+								delta.Signature,
+								convey.ShouldEqual,
+								"signature_stream_openai_456",
+							)
+
+							foundSignature = true
+						}
+					}
+				}
+			}
+
+			convey.So(foundReasoning, convey.ShouldBeTrue)
+			convey.So(foundSignature, convey.ShouldBeTrue)
+		})
+	})
+}

+ 2 - 2
core/relay/adaptor/openai/claude.go

@@ -207,10 +207,10 @@ func convertClaudeContent(content any) convertClaudeContentResult {
 					},
 				}
 				// Preserve Gemini thought signature if present (OpenAI format)
-				if content.ThoughtSignature != "" {
+				if content.Signature != "" {
 					toolCall.ExtraContent = &relaymodel.ExtraContent{
 						Google: &relaymodel.GoogleExtraContent{
-							ThoughtSignature: content.ThoughtSignature,
+							ThoughtSignature: content.Signature,
 						},
 					}
 				}

+ 108 - 0
core/relay/model/chat_test.go

@@ -0,0 +1,108 @@
+package model_test
+
+import (
+	"errors"
+	"net/http"
+	"testing"
+
+	"github.com/labring/aiproxy/core/relay/model"
+	"github.com/smartystreets/goconvey/convey"
+)
+
+func TestChatUsage(t *testing.T) {
+	convey.Convey("ChatUsage", t, func() {
+		convey.Convey("ToModelUsage", func() {
+			u := model.ChatUsage{
+				PromptTokens:     10,
+				CompletionTokens: 20,
+				TotalTokens:      30,
+				WebSearchCount:   5,
+				PromptTokensDetails: &model.PromptTokensDetails{
+					CachedTokens:        5,
+					CacheCreationTokens: 2,
+				},
+				CompletionTokensDetails: &model.CompletionTokensDetails{
+					ReasoningTokens: 10,
+				},
+			}
+
+			modelUsage := u.ToModelUsage()
+			convey.So(int64(modelUsage.InputTokens), convey.ShouldEqual, 10)
+			convey.So(int64(modelUsage.OutputTokens), convey.ShouldEqual, 20)
+			convey.So(int64(modelUsage.TotalTokens), convey.ShouldEqual, 30)
+			convey.So(int64(modelUsage.WebSearchCount), convey.ShouldEqual, 5)
+			convey.So(int64(modelUsage.CachedTokens), convey.ShouldEqual, 5)
+			convey.So(int64(modelUsage.CacheCreationTokens), convey.ShouldEqual, 2)
+			convey.So(int64(modelUsage.ReasoningTokens), convey.ShouldEqual, 10)
+		})
+
+		convey.Convey("Add", func() {
+			u1 := model.ChatUsage{
+				PromptTokens:     10,
+				CompletionTokens: 20,
+				TotalTokens:      30,
+				PromptTokensDetails: &model.PromptTokensDetails{
+					CachedTokens: 5,
+				},
+			}
+			u2 := model.ChatUsage{
+				PromptTokens:     5,
+				CompletionTokens: 5,
+				TotalTokens:      10,
+				PromptTokensDetails: &model.PromptTokensDetails{
+					CachedTokens: 2,
+				},
+			}
+
+			u1.Add(&u2)
+			convey.So(u1.PromptTokens, convey.ShouldEqual, 15)
+			convey.So(u1.CompletionTokens, convey.ShouldEqual, 25)
+			convey.So(u1.TotalTokens, convey.ShouldEqual, 40)
+			convey.So(u1.PromptTokensDetails.CachedTokens, convey.ShouldEqual, 7)
+
+			// Add nil
+			u1.Add(nil)
+			convey.So(u1.TotalTokens, convey.ShouldEqual, 40)
+		})
+
+		convey.Convey("ToClaudeUsage", func() {
+			u := model.ChatUsage{
+				PromptTokens:     10,
+				CompletionTokens: 20,
+				PromptTokensDetails: &model.PromptTokensDetails{
+					CachedTokens:        5,
+					CacheCreationTokens: 2,
+				},
+			}
+
+			cu := u.ToClaudeUsage()
+			convey.So(cu.InputTokens, convey.ShouldEqual, 10)
+			convey.So(cu.OutputTokens, convey.ShouldEqual, 20)
+			convey.So(cu.CacheReadInputTokens, convey.ShouldEqual, 5)
+			convey.So(cu.CacheCreationInputTokens, convey.ShouldEqual, 2)
+		})
+	})
+}
+
+func TestOpenAIError(t *testing.T) {
+	convey.Convey("OpenAIError", t, func() {
+		convey.Convey("NewOpenAIError", func() {
+			err := model.OpenAIError{
+				Message: "test error",
+				Type:    "test_type",
+				Code:    "test_code",
+			}
+			resp := model.NewOpenAIError(http.StatusBadRequest, err)
+			convey.So(resp.StatusCode(), convey.ShouldEqual, http.StatusBadRequest)
+			// The Error field is unexported or nested, but NewOpenAIError returns adaptor.Error interface (or struct?)
+			// Let's check what adaptor.Error exposes.
+			// It usually exposes Error() string.
+		})
+
+		convey.Convey("WrapperOpenAIError", func() {
+			err := errors.New("base error")
+			resp := model.WrapperOpenAIError(err, "code_123", http.StatusInternalServerError)
+			convey.So(resp.StatusCode(), convey.ShouldEqual, http.StatusInternalServerError)
+		})
+	})
+}

+ 2 - 2
core/relay/model/claude.go

@@ -58,8 +58,7 @@ type ClaudeContent struct {
 	Content      any                 `json:"content,omitempty"`
 	ToolUseID    string              `json:"tool_use_id,omitempty"`
 	CacheControl *ClaudeCacheControl `json:"cache_control,omitempty"`
-	// Gemini-specific field to store thought signature for function calls
-	ThoughtSignature string `json:"thought_signature,omitempty"`
+	Signature    string              `json:"signature,omitempty"`
 }
 
 type ClaudeAnyContentMessage struct {
@@ -220,6 +219,7 @@ type ClaudeDelta struct {
 	StopSequence *string `json:"stop_sequence,omitempty"`
 	Type         string  `json:"type,omitempty"`
 	Thinking     string  `json:"thinking,omitempty"`
+	Signature    string  `json:"signature,omitempty"`
 	Text         string  `json:"text,omitempty"`
 	PartialJSON  string  `json:"partial_json,omitempty"`
 }

+ 66 - 0
core/relay/model/claude_test.go

@@ -0,0 +1,66 @@
+package model_test
+
+import (
+	"testing"
+
+	"github.com/labring/aiproxy/core/relay/model"
+	"github.com/smartystreets/goconvey/convey"
+)
+
+func TestClaudeUsage(t *testing.T) {
+	convey.Convey("ClaudeUsage", t, func() {
+		convey.Convey("ToOpenAIUsage", func() {
+			u := model.ClaudeUsage{
+				InputTokens:              10,
+				OutputTokens:             20,
+				CacheCreationInputTokens: 5,
+				CacheReadInputTokens:     3,
+				ServerToolUse: &model.ClaudeServerToolUse{
+					WebSearchRequests: 2,
+				},
+			}
+
+			usage := u.ToOpenAIUsage()
+			// PromptTokens = Input + Read + Creation = 10 + 3 + 5 = 18
+			convey.So(usage.PromptTokens, convey.ShouldEqual, 18)
+			convey.So(usage.CompletionTokens, convey.ShouldEqual, 20)
+			convey.So(usage.TotalTokens, convey.ShouldEqual, 38)
+			convey.So(usage.WebSearchCount, convey.ShouldEqual, 2)
+			convey.So(usage.PromptTokensDetails.CachedTokens, convey.ShouldEqual, 3)
+			convey.So(usage.PromptTokensDetails.CacheCreationTokens, convey.ShouldEqual, 5)
+		})
+
+		convey.Convey("ToOpenAIUsage without details", func() {
+			u := model.ClaudeUsage{
+				InputTokens:  10,
+				OutputTokens: 20,
+			}
+			usage := u.ToOpenAIUsage()
+			convey.So(usage.PromptTokens, convey.ShouldEqual, 10)
+			convey.So(usage.CompletionTokens, convey.ShouldEqual, 20)
+			convey.So(usage.TotalTokens, convey.ShouldEqual, 30)
+			convey.So(usage.PromptTokensDetails.CachedTokens, convey.ShouldEqual, 0)
+		})
+	})
+}
+
+func TestClaudeCacheControl(t *testing.T) {
+	convey.Convey("ClaudeCacheControl", t, func() {
+		convey.Convey("ResetTTL", func() {
+			cc := &model.ClaudeCacheControl{
+				Type: "ephemeral",
+				TTL:  "5m",
+			}
+			cc.ResetTTL()
+			convey.So(cc.TTL, convey.ShouldEqual, "")
+			convey.So(cc.Type, convey.ShouldEqual, "ephemeral")
+		})
+
+		convey.Convey("ResetTTL nil", func() {
+			var cc *model.ClaudeCacheControl
+
+			res := cc.ResetTTL()
+			convey.So(res, convey.ShouldBeNil)
+		})
+	})
+}

+ 1 - 0
core/relay/model/completions.go

@@ -119,6 +119,7 @@ type TextResponse struct {
 type Message struct {
 	Content          any        `json:"content,omitempty"`
 	ReasoningContent string     `json:"reasoning_content,omitempty"`
+	Signature        string     `json:"signature,omitempty"`
 	Name             *string    `json:"name,omitempty"`
 	Role             string     `json:"role,omitempty"`
 	ToolCallID       string     `json:"tool_call_id,omitempty"`

+ 3 - 0
core/relay/utils/export_test.go

@@ -0,0 +1,3 @@
+package utils
+
+var ScannerBufferSize = scannerBufferSize

+ 104 - 0
core/relay/utils/utils_test.go

@@ -0,0 +1,104 @@
+package utils_test
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/labring/aiproxy/core/relay/utils"
+	"github.com/smartystreets/goconvey/convey"
+)
+
+func TestIsStreamResponseWithHeader(t *testing.T) {
+	convey.Convey("IsStreamResponseWithHeader", t, func() {
+		convey.Convey("should return true for text/event-stream", func() {
+			header := http.Header{}
+			header.Set("Content-Type", "text/event-stream")
+			convey.So(utils.IsStreamResponseWithHeader(header), convey.ShouldBeTrue)
+		})
+
+		convey.Convey("should return true for application/x-ndjson", func() {
+			header := http.Header{}
+			header.Set("Content-Type", "application/x-ndjson")
+			convey.So(utils.IsStreamResponseWithHeader(header), convey.ShouldBeTrue)
+		})
+
+		convey.Convey("should return false for application/json", func() {
+			header := http.Header{}
+			header.Set("Content-Type", "application/json")
+			convey.So(utils.IsStreamResponseWithHeader(header), convey.ShouldBeFalse)
+		})
+
+		convey.Convey("should return false for empty content type", func() {
+			header := http.Header{}
+			convey.So(utils.IsStreamResponseWithHeader(header), convey.ShouldBeFalse)
+		})
+	})
+}
+
+func TestScannerBuffer(t *testing.T) {
+	convey.Convey("ScannerBuffer", t, func() {
+		convey.Convey("should get buffer of correct size", func() {
+			buf := utils.GetScannerBuffer()
+			convey.So(len(*buf), convey.ShouldEqual, utils.ScannerBufferSize)
+			convey.So(cap(*buf), convey.ShouldEqual, utils.ScannerBufferSize)
+			utils.PutScannerBuffer(buf)
+		})
+	})
+}
+
+func TestDoRequest(t *testing.T) {
+	convey.Convey("DoRequest", t, func() {
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(http.StatusOK)
+			_, _ = w.Write([]byte("ok"))
+		}))
+		defer ts.Close()
+
+		convey.Convey("should make request successfully", func() {
+			req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL, nil)
+			resp, err := utils.DoRequest(req, time.Second)
+			convey.So(err, convey.ShouldBeNil)
+
+			defer resp.Body.Close()
+
+			convey.So(resp.StatusCode, convey.ShouldEqual, http.StatusOK)
+			body, _ := io.ReadAll(resp.Body)
+			convey.So(string(body), convey.ShouldEqual, "ok")
+		})
+	})
+}
+
+func TestUnmarshalGeneralOpenAIRequest(t *testing.T) {
+	convey.Convey("UnmarshalGeneralOpenAIRequest", t, func() {
+		convey.Convey("should unmarshal valid request", func() {
+			reqBody := map[string]any{
+				"model": "gpt-3.5-turbo",
+				"messages": []map[string]string{
+					{"role": "user", "content": "hello"},
+				},
+				"stream": true,
+			}
+			bodyBytes, _ := json.Marshal(reqBody)
+			req, _ := http.NewRequestWithContext(
+				context.Background(),
+				http.MethodPost,
+				"/",
+				bytes.NewBuffer(bodyBytes),
+			)
+			req.Header.Set("Content-Type", "application/json")
+
+			parsedReq, err := utils.UnmarshalGeneralOpenAIRequest(req)
+			convey.So(err, convey.ShouldBeNil)
+			convey.So(parsedReq.Model, convey.ShouldEqual, "gpt-3.5-turbo")
+			convey.So(parsedReq.Stream, convey.ShouldBeTrue)
+			convey.So(len(parsedReq.Messages), convey.ShouldEqual, 1)
+			convey.So(parsedReq.Messages[0].Role, convey.ShouldEqual, "user")
+		})
+	})
+}

+ 4 - 0
mcp-servers/go.sum

@@ -107,6 +107,7 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
@@ -127,6 +128,7 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
 github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -188,6 +190,8 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp
 github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
 github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
 github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
+github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
 github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
 github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=