mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-23 21:42:39 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60936b5185 | ||
|
|
72274099aa | ||
|
|
b7f7b3a1d8 | ||
|
|
dcae098e23 | ||
|
|
618606966f | ||
|
|
05f249d77f | ||
|
|
2eb05ec640 | ||
|
|
3ce0d76aa4 | ||
|
|
a00b79d9be | ||
|
|
9fe6a215e6 | ||
|
|
33e53a2a56 | ||
|
|
cd5b80785f | ||
|
|
54f71aa273 | ||
|
|
3f949b7f84 | ||
|
|
cf8b2dcc85 | ||
|
|
8e24d9dc34 | ||
|
|
443c4538bb | ||
|
|
a7fc2ee4cf | ||
|
|
8e749ac22d | ||
|
|
69e09d9bc7 | ||
|
|
671558a822 |
@@ -39,6 +39,9 @@ api-keys:
|
|||||||
# Enable debug logging
|
# Enable debug logging
|
||||||
debug: false
|
debug: false
|
||||||
|
|
||||||
|
# When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency.
|
||||||
|
commercial-mode: false
|
||||||
|
|
||||||
# Open OAuth URLs in incognito/private browser mode.
|
# Open OAuth URLs in incognito/private browser mode.
|
||||||
# Useful when you want to login with a different account without logging out from your current session.
|
# Useful when you want to login with a different account without logging out from your current session.
|
||||||
# Default: false (but Kiro auth defaults to true for multi-account support)
|
# Default: false (but Kiro auth defaults to true for multi-account support)
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
package management
|
package management
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type usageExportPayload struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
ExportedAt time.Time `json:"exported_at"`
|
||||||
|
Usage usage.StatisticsSnapshot `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type usageImportPayload struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Usage usage.StatisticsSnapshot `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
// GetUsageStatistics returns the in-memory request statistics snapshot.
|
// GetUsageStatistics returns the in-memory request statistics snapshot.
|
||||||
func (h *Handler) GetUsageStatistics(c *gin.Context) {
|
func (h *Handler) GetUsageStatistics(c *gin.Context) {
|
||||||
var snapshot usage.StatisticsSnapshot
|
var snapshot usage.StatisticsSnapshot
|
||||||
@@ -18,3 +31,49 @@ func (h *Handler) GetUsageStatistics(c *gin.Context) {
|
|||||||
"failed_requests": snapshot.FailureCount,
|
"failed_requests": snapshot.FailureCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportUsageStatistics returns a complete usage snapshot for backup/migration.
|
||||||
|
func (h *Handler) ExportUsageStatistics(c *gin.Context) {
|
||||||
|
var snapshot usage.StatisticsSnapshot
|
||||||
|
if h != nil && h.usageStats != nil {
|
||||||
|
snapshot = h.usageStats.Snapshot()
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, usageExportPayload{
|
||||||
|
Version: 1,
|
||||||
|
ExportedAt: time.Now().UTC(),
|
||||||
|
Usage: snapshot,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportUsageStatistics merges a previously exported usage snapshot into memory.
|
||||||
|
func (h *Handler) ImportUsageStatistics(c *gin.Context) {
|
||||||
|
if h == nil || h.usageStats == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "usage statistics unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := c.GetRawData()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload usageImportPayload
|
||||||
|
if err := json.Unmarshal(data, &payload); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if payload.Version != 0 && payload.Version != 1 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported version"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.usageStats.MergeSnapshot(payload.Usage)
|
||||||
|
snapshot := h.usageStats.Snapshot()
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"added": result.Added,
|
||||||
|
"skipped": result.Skipped,
|
||||||
|
"total_requests": snapshot.TotalRequests,
|
||||||
|
"failed_requests": snapshot.FailureCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -209,13 +209,15 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
|||||||
// Resolve logs directory relative to the configuration file directory.
|
// Resolve logs directory relative to the configuration file directory.
|
||||||
var requestLogger logging.RequestLogger
|
var requestLogger logging.RequestLogger
|
||||||
var toggle func(bool)
|
var toggle func(bool)
|
||||||
if optionState.requestLoggerFactory != nil {
|
if !cfg.CommercialMode {
|
||||||
requestLogger = optionState.requestLoggerFactory(cfg, configFilePath)
|
if optionState.requestLoggerFactory != nil {
|
||||||
}
|
requestLogger = optionState.requestLoggerFactory(cfg, configFilePath)
|
||||||
if requestLogger != nil {
|
}
|
||||||
engine.Use(middleware.RequestLoggingMiddleware(requestLogger))
|
if requestLogger != nil {
|
||||||
if setter, ok := requestLogger.(interface{ SetEnabled(bool) }); ok {
|
engine.Use(middleware.RequestLoggingMiddleware(requestLogger))
|
||||||
toggle = setter.SetEnabled
|
if setter, ok := requestLogger.(interface{ SetEnabled(bool) }); ok {
|
||||||
|
toggle = setter.SetEnabled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,6 +496,8 @@ func (s *Server) registerManagementRoutes() {
|
|||||||
mgmt.Use(s.managementAvailabilityMiddleware(), s.mgmt.Middleware())
|
mgmt.Use(s.managementAvailabilityMiddleware(), s.mgmt.Middleware())
|
||||||
{
|
{
|
||||||
mgmt.GET("/usage", s.mgmt.GetUsageStatistics)
|
mgmt.GET("/usage", s.mgmt.GetUsageStatistics)
|
||||||
|
mgmt.GET("/usage/export", s.mgmt.ExportUsageStatistics)
|
||||||
|
mgmt.POST("/usage/import", s.mgmt.ImportUsageStatistics)
|
||||||
mgmt.GET("/config", s.mgmt.GetConfig)
|
mgmt.GET("/config", s.mgmt.GetConfig)
|
||||||
mgmt.GET("/config.yaml", s.mgmt.GetConfigYAML)
|
mgmt.GET("/config.yaml", s.mgmt.GetConfigYAML)
|
||||||
mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML)
|
mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML)
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ type Config struct {
|
|||||||
// Debug enables or disables debug-level logging and other debug features.
|
// Debug enables or disables debug-level logging and other debug features.
|
||||||
Debug bool `yaml:"debug" json:"debug"`
|
Debug bool `yaml:"debug" json:"debug"`
|
||||||
|
|
||||||
|
// CommercialMode disables high-overhead HTTP middleware features to minimize per-request memory usage.
|
||||||
|
CommercialMode bool `yaml:"commercial-mode" json:"commercial-mode"`
|
||||||
|
|
||||||
// LoggingToFile controls whether application logs are written to rotating files or stdout.
|
// LoggingToFile controls whether application logs are written to rotating files or stdout.
|
||||||
LoggingToFile bool `yaml:"logging-to-file" json:"logging-to-file"`
|
LoggingToFile bool `yaml:"logging-to-file" json:"logging-to-file"`
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type usageReporter struct {
|
|||||||
provider string
|
provider string
|
||||||
model string
|
model string
|
||||||
authID string
|
authID string
|
||||||
authIndex uint64
|
authIndex string
|
||||||
apiKey string
|
apiKey string
|
||||||
source string
|
source string
|
||||||
requestedAt time.Time
|
requestedAt time.Time
|
||||||
|
|||||||
@@ -249,8 +249,28 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
p := 0
|
p := 0
|
||||||
if content.Type == gjson.String {
|
if content.Type == gjson.String {
|
||||||
node, _ = sjson.SetBytes(node, "parts.-1.text", content.String())
|
node, _ = sjson.SetBytes(node, "parts.-1.text", content.String())
|
||||||
out, _ = sjson.SetRawBytes(out, "request.contents.-1", node)
|
|
||||||
p++
|
p++
|
||||||
|
} else if content.IsArray() {
|
||||||
|
// Assistant multimodal content (e.g. text + image) -> single model content with parts
|
||||||
|
for _, item := range content.Array() {
|
||||||
|
switch item.Get("type").String() {
|
||||||
|
case "text":
|
||||||
|
p++
|
||||||
|
case "image_url":
|
||||||
|
// If the assistant returned an inline data URL, preserve it for history fidelity.
|
||||||
|
imageURL := item.Get("image_url.url").String()
|
||||||
|
if len(imageURL) > 5 { // expect data:...
|
||||||
|
pieces := strings.SplitN(imageURL[5:], ";", 2)
|
||||||
|
if len(pieces) == 2 && len(pieces[1]) > 7 {
|
||||||
|
mime := pieces[0]
|
||||||
|
data := pieces[1][7:]
|
||||||
|
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.mime_type", mime)
|
||||||
|
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.data", data)
|
||||||
|
p++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool calls -> single model content with functionCall parts
|
// Tool calls -> single model content with functionCall parts
|
||||||
@@ -305,6 +325,8 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
if pp > 0 {
|
if pp > 0 {
|
||||||
out, _ = sjson.SetRawBytes(out, "request.contents.-1", toolNode)
|
out, _ = sjson.SetRawBytes(out, "request.contents.-1", toolNode)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
out, _ = sjson.SetRawBytes(out, "request.contents.-1", node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,12 +181,14 @@ func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalReq
|
|||||||
mimeType = "image/png"
|
mimeType = "image/png"
|
||||||
}
|
}
|
||||||
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
|
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
|
||||||
imagePayload := `{"image_url":{"url":""},"type":"image_url"}`
|
|
||||||
imagePayload, _ = sjson.Set(imagePayload, "image_url.url", imageURL)
|
|
||||||
imagesResult := gjson.Get(template, "choices.0.delta.images")
|
imagesResult := gjson.Get(template, "choices.0.delta.images")
|
||||||
if !imagesResult.Exists() || !imagesResult.IsArray() {
|
if !imagesResult.Exists() || !imagesResult.IsArray() {
|
||||||
template, _ = sjson.SetRaw(template, "choices.0.delta.images", `[]`)
|
template, _ = sjson.SetRaw(template, "choices.0.delta.images", `[]`)
|
||||||
}
|
}
|
||||||
|
imageIndex := len(gjson.Get(template, "choices.0.delta.images").Array())
|
||||||
|
imagePayload := `{"type":"image_url","image_url":{"url":""}}`
|
||||||
|
imagePayload, _ = sjson.Set(imagePayload, "index", imageIndex)
|
||||||
|
imagePayload, _ = sjson.Set(imagePayload, "image_url.url", imageURL)
|
||||||
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
||||||
template, _ = sjson.SetRaw(template, "choices.0.delta.images.-1", imagePayload)
|
template, _ = sjson.SetRaw(template, "choices.0.delta.images.-1", imagePayload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,9 +209,12 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
|
|||||||
if usage := root.Get("usage"); usage.Exists() {
|
if usage := root.Get("usage"); usage.Exists() {
|
||||||
inputTokens := usage.Get("input_tokens").Int()
|
inputTokens := usage.Get("input_tokens").Int()
|
||||||
outputTokens := usage.Get("output_tokens").Int()
|
outputTokens := usage.Get("output_tokens").Int()
|
||||||
template, _ = sjson.Set(template, "usage.prompt_tokens", inputTokens)
|
cacheReadInputTokens := usage.Get("cache_read_input_tokens").Int()
|
||||||
|
cacheCreationInputTokens := usage.Get("cache_creation_input_tokens").Int()
|
||||||
|
template, _ = sjson.Set(template, "usage.prompt_tokens", inputTokens+cacheCreationInputTokens)
|
||||||
template, _ = sjson.Set(template, "usage.completion_tokens", outputTokens)
|
template, _ = sjson.Set(template, "usage.completion_tokens", outputTokens)
|
||||||
template, _ = sjson.Set(template, "usage.total_tokens", inputTokens+outputTokens)
|
template, _ = sjson.Set(template, "usage.total_tokens", inputTokens+outputTokens)
|
||||||
|
template, _ = sjson.Set(template, "usage.prompt_tokens_details.cached_tokens", cacheReadInputTokens)
|
||||||
}
|
}
|
||||||
return []string{template}
|
return []string{template}
|
||||||
|
|
||||||
@@ -285,8 +288,6 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
|
|||||||
var messageID string
|
var messageID string
|
||||||
var model string
|
var model string
|
||||||
var createdAt int64
|
var createdAt int64
|
||||||
var inputTokens, outputTokens int64
|
|
||||||
var reasoningTokens int64
|
|
||||||
var stopReason string
|
var stopReason string
|
||||||
var contentParts []string
|
var contentParts []string
|
||||||
var reasoningParts []string
|
var reasoningParts []string
|
||||||
@@ -303,9 +304,6 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
|
|||||||
messageID = message.Get("id").String()
|
messageID = message.Get("id").String()
|
||||||
model = message.Get("model").String()
|
model = message.Get("model").String()
|
||||||
createdAt = time.Now().Unix()
|
createdAt = time.Now().Unix()
|
||||||
if usage := message.Get("usage"); usage.Exists() {
|
|
||||||
inputTokens = usage.Get("input_tokens").Int()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "content_block_start":
|
case "content_block_start":
|
||||||
@@ -368,11 +366,14 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if usage := root.Get("usage"); usage.Exists() {
|
if usage := root.Get("usage"); usage.Exists() {
|
||||||
outputTokens = usage.Get("output_tokens").Int()
|
inputTokens := usage.Get("input_tokens").Int()
|
||||||
// Estimate reasoning tokens from accumulated thinking content
|
outputTokens := usage.Get("output_tokens").Int()
|
||||||
if len(reasoningParts) > 0 {
|
cacheReadInputTokens := usage.Get("cache_read_input_tokens").Int()
|
||||||
reasoningTokens = int64(len(strings.Join(reasoningParts, "")) / 4) // Rough estimation
|
cacheCreationInputTokens := usage.Get("cache_creation_input_tokens").Int()
|
||||||
}
|
out, _ = sjson.Set(out, "usage.prompt_tokens", inputTokens+cacheCreationInputTokens)
|
||||||
|
out, _ = sjson.Set(out, "usage.completion_tokens", outputTokens)
|
||||||
|
out, _ = sjson.Set(out, "usage.total_tokens", inputTokens+outputTokens)
|
||||||
|
out, _ = sjson.Set(out, "usage.prompt_tokens_details.cached_tokens", cacheReadInputTokens)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,16 +432,5 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
|
|||||||
out, _ = sjson.Set(out, "choices.0.finish_reason", mapAnthropicStopReasonToOpenAI(stopReason))
|
out, _ = sjson.Set(out, "choices.0.finish_reason", mapAnthropicStopReasonToOpenAI(stopReason))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set usage information including prompt tokens, completion tokens, and total tokens
|
|
||||||
totalTokens := inputTokens + outputTokens
|
|
||||||
out, _ = sjson.Set(out, "usage.prompt_tokens", inputTokens)
|
|
||||||
out, _ = sjson.Set(out, "usage.completion_tokens", outputTokens)
|
|
||||||
out, _ = sjson.Set(out, "usage.total_tokens", totalTokens)
|
|
||||||
|
|
||||||
// Add reasoning tokens to usage details if any reasoning content was processed
|
|
||||||
if reasoningTokens > 0 {
|
|
||||||
out, _ = sjson.Set(out, "usage.completion_tokens_details.reasoning_tokens", reasoningTokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,13 +114,16 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
|
|||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
if parts := item.Get("content"); parts.Exists() && parts.IsArray() {
|
if parts := item.Get("content"); parts.Exists() && parts.IsArray() {
|
||||||
parts.ForEach(func(_, part gjson.Result) bool {
|
parts.ForEach(func(_, part gjson.Result) bool {
|
||||||
text := part.Get("text").String()
|
textResult := part.Get("text")
|
||||||
|
text := textResult.String()
|
||||||
if builder.Len() > 0 && text != "" {
|
if builder.Len() > 0 && text != "" {
|
||||||
builder.WriteByte('\n')
|
builder.WriteByte('\n')
|
||||||
}
|
}
|
||||||
builder.WriteString(text)
|
builder.WriteString(text)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
} else if parts.Type == gjson.String {
|
||||||
|
builder.WriteString(parts.String())
|
||||||
}
|
}
|
||||||
instructionsText = builder.String()
|
instructionsText = builder.String()
|
||||||
if instructionsText != "" {
|
if instructionsText != "" {
|
||||||
@@ -207,6 +210,8 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
} else if parts.Type == gjson.String {
|
||||||
|
textAggregate.WriteString(parts.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to given role if content types not decisive
|
// Fallback to given role if content types not decisive
|
||||||
|
|||||||
@@ -218,8 +218,29 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
|
|||||||
if content.Type == gjson.String {
|
if content.Type == gjson.String {
|
||||||
// Assistant text -> single model content
|
// Assistant text -> single model content
|
||||||
node, _ = sjson.SetBytes(node, "parts.-1.text", content.String())
|
node, _ = sjson.SetBytes(node, "parts.-1.text", content.String())
|
||||||
out, _ = sjson.SetRawBytes(out, "request.contents.-1", node)
|
|
||||||
p++
|
p++
|
||||||
|
} else if content.IsArray() {
|
||||||
|
// Assistant multimodal content (e.g. text + image) -> single model content with parts
|
||||||
|
for _, item := range content.Array() {
|
||||||
|
switch item.Get("type").String() {
|
||||||
|
case "text":
|
||||||
|
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".text", item.Get("text").String())
|
||||||
|
p++
|
||||||
|
case "image_url":
|
||||||
|
// If the assistant returned an inline data URL, preserve it for history fidelity.
|
||||||
|
imageURL := item.Get("image_url.url").String()
|
||||||
|
if len(imageURL) > 5 { // expect data:...
|
||||||
|
pieces := strings.SplitN(imageURL[5:], ";", 2)
|
||||||
|
if len(pieces) == 2 && len(pieces[1]) > 7 {
|
||||||
|
mime := pieces[0]
|
||||||
|
data := pieces[1][7:]
|
||||||
|
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.mime_type", mime)
|
||||||
|
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.data", data)
|
||||||
|
p++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool calls -> single model content with functionCall parts
|
// Tool calls -> single model content with functionCall parts
|
||||||
@@ -260,6 +281,8 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
|
|||||||
if pp > 0 {
|
if pp > 0 {
|
||||||
out, _ = sjson.SetRawBytes(out, "request.contents.-1", toolNode)
|
out, _ = sjson.SetRawBytes(out, "request.contents.-1", toolNode)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
out, _ = sjson.SetRawBytes(out, "request.contents.-1", node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,12 +170,14 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
|
|||||||
mimeType = "image/png"
|
mimeType = "image/png"
|
||||||
}
|
}
|
||||||
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
|
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
|
||||||
imagePayload := `{"image_url":{"url":""},"type":"image_url"}`
|
|
||||||
imagePayload, _ = sjson.Set(imagePayload, "image_url.url", imageURL)
|
|
||||||
imagesResult := gjson.Get(template, "choices.0.delta.images")
|
imagesResult := gjson.Get(template, "choices.0.delta.images")
|
||||||
if !imagesResult.Exists() || !imagesResult.IsArray() {
|
if !imagesResult.Exists() || !imagesResult.IsArray() {
|
||||||
template, _ = sjson.SetRaw(template, "choices.0.delta.images", `[]`)
|
template, _ = sjson.SetRaw(template, "choices.0.delta.images", `[]`)
|
||||||
}
|
}
|
||||||
|
imageIndex := len(gjson.Get(template, "choices.0.delta.images").Array())
|
||||||
|
imagePayload := `{"type":"image_url","image_url":{"url":""}}`
|
||||||
|
imagePayload, _ = sjson.Set(imagePayload, "index", imageIndex)
|
||||||
|
imagePayload, _ = sjson.Set(imagePayload, "image_url.url", imageURL)
|
||||||
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
||||||
template, _ = sjson.SetRaw(template, "choices.0.delta.images.-1", imagePayload)
|
template, _ = sjson.SetRaw(template, "choices.0.delta.images.-1", imagePayload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,18 +233,15 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
} else if role == "assistant" {
|
} else if role == "assistant" {
|
||||||
node := []byte(`{"role":"model","parts":[]}`)
|
node := []byte(`{"role":"model","parts":[]}`)
|
||||||
p := 0
|
p := 0
|
||||||
|
|
||||||
if content.Type == gjson.String {
|
if content.Type == gjson.String {
|
||||||
// Assistant text -> single model content
|
// Assistant text -> single model content
|
||||||
node, _ = sjson.SetBytes(node, "parts.-1.text", content.String())
|
node, _ = sjson.SetBytes(node, "parts.-1.text", content.String())
|
||||||
out, _ = sjson.SetRawBytes(out, "contents.-1", node)
|
|
||||||
p++
|
p++
|
||||||
} else if content.IsArray() {
|
} else if content.IsArray() {
|
||||||
// Assistant multimodal content (e.g. text + image) -> single model content with parts
|
// Assistant multimodal content (e.g. text + image) -> single model content with parts
|
||||||
for _, item := range content.Array() {
|
for _, item := range content.Array() {
|
||||||
switch item.Get("type").String() {
|
switch item.Get("type").String() {
|
||||||
case "text":
|
case "text":
|
||||||
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".text", item.Get("text").String())
|
|
||||||
p++
|
p++
|
||||||
case "image_url":
|
case "image_url":
|
||||||
// If the assistant returned an inline data URL, preserve it for history fidelity.
|
// If the assistant returned an inline data URL, preserve it for history fidelity.
|
||||||
@@ -261,7 +258,6 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out, _ = sjson.SetRawBytes(out, "contents.-1", node)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool calls -> single model content with functionCall parts
|
// Tool calls -> single model content with functionCall parts
|
||||||
@@ -302,6 +298,8 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
if pp > 0 {
|
if pp > 0 {
|
||||||
out, _ = sjson.SetRawBytes(out, "contents.-1", toolNode)
|
out, _ = sjson.SetRawBytes(out, "contents.-1", toolNode)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
out, _ = sjson.SetRawBytes(out, "contents.-1", node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,12 +182,14 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
|
|||||||
mimeType = "image/png"
|
mimeType = "image/png"
|
||||||
}
|
}
|
||||||
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
|
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
|
||||||
imagePayload := `{"image_url":{"url":""},"type":"image_url"}`
|
|
||||||
imagePayload, _ = sjson.Set(imagePayload, "image_url.url", imageURL)
|
|
||||||
imagesResult := gjson.Get(template, "choices.0.delta.images")
|
imagesResult := gjson.Get(template, "choices.0.delta.images")
|
||||||
if !imagesResult.Exists() || !imagesResult.IsArray() {
|
if !imagesResult.Exists() || !imagesResult.IsArray() {
|
||||||
template, _ = sjson.SetRaw(template, "choices.0.delta.images", `[]`)
|
template, _ = sjson.SetRaw(template, "choices.0.delta.images", `[]`)
|
||||||
}
|
}
|
||||||
|
imageIndex := len(gjson.Get(template, "choices.0.delta.images").Array())
|
||||||
|
imagePayload := `{"type":"image_url","image_url":{"url":""}}`
|
||||||
|
imagePayload, _ = sjson.Set(imagePayload, "index", imageIndex)
|
||||||
|
imagePayload, _ = sjson.Set(imagePayload, "image_url.url", imageURL)
|
||||||
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
||||||
template, _ = sjson.SetRaw(template, "choices.0.delta.images.-1", imagePayload)
|
template, _ = sjson.SetRaw(template, "choices.0.delta.images.-1", imagePayload)
|
||||||
}
|
}
|
||||||
@@ -316,12 +318,14 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina
|
|||||||
mimeType = "image/png"
|
mimeType = "image/png"
|
||||||
}
|
}
|
||||||
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
|
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
|
||||||
imagePayload := `{"image_url":{"url":""},"type":"image_url"}`
|
|
||||||
imagePayload, _ = sjson.Set(imagePayload, "image_url.url", imageURL)
|
|
||||||
imagesResult := gjson.Get(template, "choices.0.message.images")
|
imagesResult := gjson.Get(template, "choices.0.message.images")
|
||||||
if !imagesResult.Exists() || !imagesResult.IsArray() {
|
if !imagesResult.Exists() || !imagesResult.IsArray() {
|
||||||
template, _ = sjson.SetRaw(template, "choices.0.message.images", `[]`)
|
template, _ = sjson.SetRaw(template, "choices.0.message.images", `[]`)
|
||||||
}
|
}
|
||||||
|
imageIndex := len(gjson.Get(template, "choices.0.message.images").Array())
|
||||||
|
imagePayload := `{"type":"image_url","image_url":{"url":""}}`
|
||||||
|
imagePayload, _ = sjson.Set(imagePayload, "index", imageIndex)
|
||||||
|
imagePayload, _ = sjson.Set(imagePayload, "image_url.url", imageURL)
|
||||||
template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
|
template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
|
||||||
template, _ = sjson.SetRaw(template, "choices.0.message.images.-1", imagePayload)
|
template, _ = sjson.SetRaw(template, "choices.0.message.images.-1", imagePayload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package usage
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -90,7 +91,7 @@ type modelStats struct {
|
|||||||
type RequestDetail struct {
|
type RequestDetail struct {
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
AuthIndex uint64 `json:"auth_index"`
|
AuthIndex string `json:"auth_index"`
|
||||||
Tokens TokenStats `json:"tokens"`
|
Tokens TokenStats `json:"tokens"`
|
||||||
Failed bool `json:"failed"`
|
Failed bool `json:"failed"`
|
||||||
}
|
}
|
||||||
@@ -281,6 +282,118 @@ func (s *RequestStatistics) Snapshot() StatisticsSnapshot {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MergeResult struct {
|
||||||
|
Added int64 `json:"added"`
|
||||||
|
Skipped int64 `json:"skipped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSnapshot merges an exported statistics snapshot into the current store.
|
||||||
|
// Existing data is preserved and duplicate request details are skipped.
|
||||||
|
func (s *RequestStatistics) MergeSnapshot(snapshot StatisticsSnapshot) MergeResult {
|
||||||
|
result := MergeResult{}
|
||||||
|
if s == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for apiName, stats := range s.apis {
|
||||||
|
if stats == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for modelName, modelStatsValue := range stats.Models {
|
||||||
|
if modelStatsValue == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, detail := range modelStatsValue.Details {
|
||||||
|
seen[dedupKey(apiName, modelName, detail)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for apiName, apiSnapshot := range snapshot.APIs {
|
||||||
|
apiName = strings.TrimSpace(apiName)
|
||||||
|
if apiName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats, ok := s.apis[apiName]
|
||||||
|
if !ok || stats == nil {
|
||||||
|
stats = &apiStats{Models: make(map[string]*modelStats)}
|
||||||
|
s.apis[apiName] = stats
|
||||||
|
} else if stats.Models == nil {
|
||||||
|
stats.Models = make(map[string]*modelStats)
|
||||||
|
}
|
||||||
|
for modelName, modelSnapshot := range apiSnapshot.Models {
|
||||||
|
modelName = strings.TrimSpace(modelName)
|
||||||
|
if modelName == "" {
|
||||||
|
modelName = "unknown"
|
||||||
|
}
|
||||||
|
for _, detail := range modelSnapshot.Details {
|
||||||
|
detail.Tokens = normaliseTokenStats(detail.Tokens)
|
||||||
|
if detail.Timestamp.IsZero() {
|
||||||
|
detail.Timestamp = time.Now()
|
||||||
|
}
|
||||||
|
key := dedupKey(apiName, modelName, detail)
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
result.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
s.recordImported(apiName, modelName, stats, detail)
|
||||||
|
result.Added++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RequestStatistics) recordImported(apiName, modelName string, stats *apiStats, detail RequestDetail) {
|
||||||
|
totalTokens := detail.Tokens.TotalTokens
|
||||||
|
if totalTokens < 0 {
|
||||||
|
totalTokens = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
s.totalRequests++
|
||||||
|
if detail.Failed {
|
||||||
|
s.failureCount++
|
||||||
|
} else {
|
||||||
|
s.successCount++
|
||||||
|
}
|
||||||
|
s.totalTokens += totalTokens
|
||||||
|
|
||||||
|
s.updateAPIStats(stats, modelName, detail)
|
||||||
|
|
||||||
|
dayKey := detail.Timestamp.Format("2006-01-02")
|
||||||
|
hourKey := detail.Timestamp.Hour()
|
||||||
|
|
||||||
|
s.requestsByDay[dayKey]++
|
||||||
|
s.requestsByHour[hourKey]++
|
||||||
|
s.tokensByDay[dayKey] += totalTokens
|
||||||
|
s.tokensByHour[hourKey] += totalTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupKey(apiName, modelName string, detail RequestDetail) string {
|
||||||
|
timestamp := detail.Timestamp.UTC().Format(time.RFC3339Nano)
|
||||||
|
tokens := normaliseTokenStats(detail.Tokens)
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%s|%s|%s|%s|%s|%t|%d|%d|%d|%d|%d",
|
||||||
|
apiName,
|
||||||
|
modelName,
|
||||||
|
timestamp,
|
||||||
|
detail.Source,
|
||||||
|
detail.AuthIndex,
|
||||||
|
detail.Failed,
|
||||||
|
tokens.InputTokens,
|
||||||
|
tokens.OutputTokens,
|
||||||
|
tokens.ReasoningTokens,
|
||||||
|
tokens.CachedTokens,
|
||||||
|
tokens.TotalTokens,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func resolveAPIIdentifier(ctx context.Context, record coreusage.Record) string {
|
func resolveAPIIdentifier(ctx context.Context, record coreusage.Record) string {
|
||||||
if ctx != nil {
|
if ctx != nil {
|
||||||
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil {
|
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil {
|
||||||
@@ -340,6 +453,16 @@ func normaliseDetail(detail coreusage.Detail) TokenStats {
|
|||||||
return tokens
|
return tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normaliseTokenStats(tokens TokenStats) TokenStats {
|
||||||
|
if tokens.TotalTokens == 0 {
|
||||||
|
tokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens
|
||||||
|
}
|
||||||
|
if tokens.TotalTokens == 0 {
|
||||||
|
tokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens + tokens.CachedTokens
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
func formatHour(hour int) string {
|
func formatHour(hour int) string {
|
||||||
if hour < 0 {
|
if hour < 0 {
|
||||||
hour = 0
|
hour = 0
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func TestExecuteStreamWithAuthManager_RetriesBeforeFirstByte(t *testing.T) {
|
|||||||
Streaming: sdkconfig.StreamingConfig{
|
Streaming: sdkconfig.StreamingConfig{
|
||||||
BootstrapRetries: &bootstrapRetries,
|
BootstrapRetries: &bootstrapRetries,
|
||||||
},
|
},
|
||||||
}, manager, nil)
|
}, manager)
|
||||||
dataChan, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), "openai", "test-model", []byte(`{"model":"test-model"}`), "")
|
dataChan, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), "openai", "test-model", []byte(`{"model":"test-model"}`), "")
|
||||||
if dataChan == nil || errChan == nil {
|
if dataChan == nil || errChan == nil {
|
||||||
t.Fatalf("expected non-nil channels")
|
t.Fatalf("expected non-nil channels")
|
||||||
|
|||||||
@@ -203,10 +203,10 @@ func (m *Manager) Register(ctx context.Context, auth *Auth) (*Auth, error) {
|
|||||||
if auth == nil {
|
if auth == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
auth.EnsureIndex()
|
|
||||||
if auth.ID == "" {
|
if auth.ID == "" {
|
||||||
auth.ID = uuid.NewString()
|
auth.ID = uuid.NewString()
|
||||||
}
|
}
|
||||||
|
auth.EnsureIndex()
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.auths[auth.ID] = auth.Clone()
|
m.auths[auth.ID] = auth.Clone()
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
@@ -221,7 +221,7 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
if existing, ok := m.auths[auth.ID]; ok && existing != nil && !auth.indexAssigned && auth.Index == 0 {
|
if existing, ok := m.auths[auth.ID]; ok && existing != nil && !auth.indexAssigned && auth.Index == "" {
|
||||||
auth.Index = existing.Index
|
auth.Index = existing.Index
|
||||||
auth.indexAssigned = existing.indexAssigned
|
auth.indexAssigned = existing.indexAssigned
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
baseauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth"
|
baseauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth"
|
||||||
@@ -15,8 +16,8 @@ import (
|
|||||||
type Auth struct {
|
type Auth struct {
|
||||||
// ID uniquely identifies the auth record across restarts.
|
// ID uniquely identifies the auth record across restarts.
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
// Index is a monotonically increasing runtime identifier used for diagnostics.
|
// Index is a stable runtime identifier derived from auth metadata (not persisted).
|
||||||
Index uint64 `json:"-"`
|
Index string `json:"-"`
|
||||||
// Provider is the upstream provider key (e.g. "gemini", "claude").
|
// Provider is the upstream provider key (e.g. "gemini", "claude").
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
// Prefix optionally namespaces models for routing (e.g., "teamA/gemini-3-pro-preview").
|
// Prefix optionally namespaces models for routing (e.g., "teamA/gemini-3-pro-preview").
|
||||||
@@ -94,12 +95,6 @@ type ModelState struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var authIndexCounter atomic.Uint64
|
|
||||||
|
|
||||||
func nextAuthIndex() uint64 {
|
|
||||||
return authIndexCounter.Add(1) - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone shallow copies the Auth structure, duplicating maps to avoid accidental mutation.
|
// Clone shallow copies the Auth structure, duplicating maps to avoid accidental mutation.
|
||||||
func (a *Auth) Clone() *Auth {
|
func (a *Auth) Clone() *Auth {
|
||||||
if a == nil {
|
if a == nil {
|
||||||
@@ -128,15 +123,41 @@ func (a *Auth) Clone() *Auth {
|
|||||||
return ©Auth
|
return ©Auth
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureIndex returns the global index, assigning one if it was not set yet.
|
func stableAuthIndex(seed string) string {
|
||||||
func (a *Auth) EnsureIndex() uint64 {
|
seed = strings.TrimSpace(seed)
|
||||||
if a == nil {
|
if seed == "" {
|
||||||
return 0
|
return ""
|
||||||
}
|
}
|
||||||
if a.indexAssigned {
|
sum := sha256.Sum256([]byte(seed))
|
||||||
|
return hex.EncodeToString(sum[:8])
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureIndex returns a stable index derived from the auth file name or API key.
|
||||||
|
func (a *Auth) EnsureIndex() string {
|
||||||
|
if a == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if a.indexAssigned && a.Index != "" {
|
||||||
return a.Index
|
return a.Index
|
||||||
}
|
}
|
||||||
idx := nextAuthIndex()
|
|
||||||
|
seed := strings.TrimSpace(a.FileName)
|
||||||
|
if seed != "" {
|
||||||
|
seed = "file:" + seed
|
||||||
|
} else if a.Attributes != nil {
|
||||||
|
if apiKey := strings.TrimSpace(a.Attributes["api_key"]); apiKey != "" {
|
||||||
|
seed = "api_key:" + apiKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seed == "" {
|
||||||
|
if id := strings.TrimSpace(a.ID); id != "" {
|
||||||
|
seed = "id:" + id
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := stableAuthIndex(seed)
|
||||||
a.Index = idx
|
a.Index = idx
|
||||||
a.indexAssigned = true
|
a.indexAssigned = true
|
||||||
return idx
|
return idx
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type Record struct {
|
|||||||
Model string
|
Model string
|
||||||
APIKey string
|
APIKey string
|
||||||
AuthID string
|
AuthID string
|
||||||
AuthIndex uint64
|
AuthIndex string
|
||||||
Source string
|
Source string
|
||||||
RequestedAt time.Time
|
RequestedAt time.Time
|
||||||
Failed bool
|
Failed bool
|
||||||
|
|||||||
Reference in New Issue
Block a user