mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-23 11:23:17 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db491c8f9b | ||
|
|
f6d625114c | ||
|
|
fcd6475377 | ||
|
|
3fc410a253 | ||
|
|
293cc8c1a3 | ||
|
|
453e744abf | ||
|
|
653439698e | ||
|
|
5418bbc338 | ||
|
|
89254cfc97 | ||
|
|
6bd9a034f7 | ||
|
|
26fc65b051 | ||
|
|
ed5ec5b55c | ||
|
|
df777650ac | ||
|
|
8fac6b147a | ||
|
|
10f8c795ac | ||
|
|
3e4858a624 | ||
|
|
1231dc9cda | ||
|
|
c84ff42bcd | ||
|
|
40d78908ed | ||
|
|
8a5db02165 | ||
|
|
56fa81f3c6 | ||
|
|
d7afb6eb0c | ||
|
|
03209b35c0 | ||
|
|
bbd1fe890a | ||
|
|
843316ea7a | ||
|
|
f607231efa | ||
|
|
2039062845 | ||
|
|
1b8cb7b77b | ||
|
|
39597267ae |
@@ -434,7 +434,7 @@ func main() {
|
|||||||
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
||||||
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
||||||
|
|
||||||
if err = logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil {
|
if err = logging.ConfigureLogOutput(cfg.LoggingToFile, cfg.LogsMaxTotalSizeMB); err != nil {
|
||||||
log.Errorf("failed to configure log output: %v", err)
|
log.Errorf("failed to configure log output: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ incognito-browser: true
|
|||||||
# When true, write application logs to rotating files instead of stdout
|
# When true, write application logs to rotating files instead of stdout
|
||||||
logging-to-file: false
|
logging-to-file: false
|
||||||
|
|
||||||
|
# Maximum total size (MB) of log files under the logs directory. When exceeded, the oldest log
|
||||||
|
# files are deleted until within the limit. Set to 0 to disable.
|
||||||
|
logs-max-total-size-mb: 0
|
||||||
|
|
||||||
# When false, disable in-memory usage statistics aggregation
|
# When false, disable in-memory usage statistics aggregation
|
||||||
usage-statistics-enabled: false
|
usage-statistics-enabled: false
|
||||||
|
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
|
||||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
||||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/logging"
|
||||||
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,43 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Normalize model (handles dynamic thinking suffixes)
|
// Normalize model (handles dynamic thinking suffixes)
|
||||||
normalizedModel, _ := util.NormalizeThinkingModel(modelName)
|
normalizedModel, thinkingMetadata := util.NormalizeThinkingModel(modelName)
|
||||||
|
thinkingSuffix := ""
|
||||||
|
if thinkingMetadata != nil && strings.HasPrefix(modelName, normalizedModel) {
|
||||||
|
thinkingSuffix = modelName[len(normalizedModel):]
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveMappedModel := func() (string, []string) {
|
||||||
|
if fh.modelMapper == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mappedModel := fh.modelMapper.MapModel(modelName)
|
||||||
|
if mappedModel == "" {
|
||||||
|
mappedModel = fh.modelMapper.MapModel(normalizedModel)
|
||||||
|
}
|
||||||
|
mappedModel = strings.TrimSpace(mappedModel)
|
||||||
|
if mappedModel == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve dynamic thinking suffix (e.g. "(xhigh)") when mapping applies, unless the target
|
||||||
|
// already specifies its own thinking suffix.
|
||||||
|
if thinkingSuffix != "" {
|
||||||
|
_, mappedThinkingMetadata := util.NormalizeThinkingModel(mappedModel)
|
||||||
|
if mappedThinkingMetadata == nil {
|
||||||
|
mappedModel += thinkingSuffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mappedBaseModel, _ := util.NormalizeThinkingModel(mappedModel)
|
||||||
|
mappedProviders := util.GetProviderName(mappedBaseModel)
|
||||||
|
if len(mappedProviders) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mappedModel, mappedProviders
|
||||||
|
}
|
||||||
|
|
||||||
// Track resolved model for logging (may change if mapping is applied)
|
// Track resolved model for logging (may change if mapping is applied)
|
||||||
resolvedModel := normalizedModel
|
resolvedModel := normalizedModel
|
||||||
@@ -147,21 +183,15 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
|
|||||||
if forceMappings {
|
if forceMappings {
|
||||||
// FORCE MODE: Check model mappings FIRST (takes precedence over local API keys)
|
// FORCE MODE: Check model mappings FIRST (takes precedence over local API keys)
|
||||||
// This allows users to route Amp requests to their preferred OAuth providers
|
// This allows users to route Amp requests to their preferred OAuth providers
|
||||||
if fh.modelMapper != nil {
|
if mappedModel, mappedProviders := resolveMappedModel(); mappedModel != "" {
|
||||||
if mappedModel := fh.modelMapper.MapModel(normalizedModel); mappedModel != "" {
|
// Mapping found and provider available - rewrite the model in request body
|
||||||
// Mapping found - check if we have a provider for the mapped model
|
bodyBytes = rewriteModelInRequest(bodyBytes, mappedModel)
|
||||||
mappedProviders := util.GetProviderName(mappedModel)
|
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
if len(mappedProviders) > 0 {
|
// Store mapped model in context for handlers that check it (like gemini bridge)
|
||||||
// Mapping found and provider available - rewrite the model in request body
|
c.Set(MappedModelContextKey, mappedModel)
|
||||||
bodyBytes = rewriteModelInRequest(bodyBytes, mappedModel)
|
resolvedModel = mappedModel
|
||||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
usedMapping = true
|
||||||
// Store mapped model in context for handlers that check it (like gemini bridge)
|
providers = mappedProviders
|
||||||
c.Set(MappedModelContextKey, mappedModel)
|
|
||||||
resolvedModel = mappedModel
|
|
||||||
usedMapping = true
|
|
||||||
providers = mappedProviders
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no mapping applied, check for local providers
|
// If no mapping applied, check for local providers
|
||||||
@@ -174,21 +204,15 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
|
|||||||
|
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
// No providers configured - check if we have a model mapping
|
// No providers configured - check if we have a model mapping
|
||||||
if fh.modelMapper != nil {
|
if mappedModel, mappedProviders := resolveMappedModel(); mappedModel != "" {
|
||||||
if mappedModel := fh.modelMapper.MapModel(normalizedModel); mappedModel != "" {
|
// Mapping found and provider available - rewrite the model in request body
|
||||||
// Mapping found - check if we have a provider for the mapped model
|
bodyBytes = rewriteModelInRequest(bodyBytes, mappedModel)
|
||||||
mappedProviders := util.GetProviderName(mappedModel)
|
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
if len(mappedProviders) > 0 {
|
// Store mapped model in context for handlers that check it (like gemini bridge)
|
||||||
// Mapping found and provider available - rewrite the model in request body
|
c.Set(MappedModelContextKey, mappedModel)
|
||||||
bodyBytes = rewriteModelInRequest(bodyBytes, mappedModel)
|
resolvedModel = mappedModel
|
||||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
usedMapping = true
|
||||||
// Store mapped model in context for handlers that check it (like gemini bridge)
|
providers = mappedProviders
|
||||||
c.Set(MappedModelContextKey, mappedModel)
|
|
||||||
resolvedModel = mappedModel
|
|
||||||
usedMapping = true
|
|
||||||
providers = mappedProviders
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,14 +246,14 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
|
|||||||
// Log: Model was mapped to another model
|
// Log: Model was mapped to another model
|
||||||
log.Debugf("amp model mapping: request %s -> %s", normalizedModel, resolvedModel)
|
log.Debugf("amp model mapping: request %s -> %s", normalizedModel, resolvedModel)
|
||||||
logAmpRouting(RouteTypeModelMapping, modelName, resolvedModel, providerName, requestPath)
|
logAmpRouting(RouteTypeModelMapping, modelName, resolvedModel, providerName, requestPath)
|
||||||
rewriter := NewResponseRewriter(c.Writer, normalizedModel)
|
rewriter := NewResponseRewriter(c.Writer, modelName)
|
||||||
c.Writer = rewriter
|
c.Writer = rewriter
|
||||||
// Filter Anthropic-Beta header only for local handling paths
|
// Filter Anthropic-Beta header only for local handling paths
|
||||||
filterAntropicBetaHeader(c)
|
filterAntropicBetaHeader(c)
|
||||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
handler(c)
|
handler(c)
|
||||||
rewriter.Flush()
|
rewriter.Flush()
|
||||||
log.Debugf("amp model mapping: response %s -> %s", resolvedModel, normalizedModel)
|
log.Debugf("amp model mapping: response %s -> %s", resolvedModel, modelName)
|
||||||
} else if len(providers) > 0 {
|
} else if len(providers) > 0 {
|
||||||
// Log: Using local provider (free)
|
// Log: Using local provider (free)
|
||||||
logAmpRouting(RouteTypeLocalProvider, modelName, resolvedModel, providerName, requestPath)
|
logAmpRouting(RouteTypeLocalProvider, modelName, resolvedModel, providerName, requestPath)
|
||||||
|
|||||||
73
internal/api/modules/amp/fallback_handlers_test.go
Normal file
73
internal/api/modules/amp/fallback_handlers_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package amp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/http/httputil"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFallbackHandler_ModelMapping_PreservesThinkingSuffixAndRewritesResponse(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
reg := registry.GetGlobalRegistry()
|
||||||
|
reg.RegisterClient("test-client-amp-fallback", "codex", []*registry.ModelInfo{
|
||||||
|
{ID: "test/gpt-5.2", OwnedBy: "openai", Type: "codex"},
|
||||||
|
})
|
||||||
|
defer reg.UnregisterClient("test-client-amp-fallback")
|
||||||
|
|
||||||
|
mapper := NewModelMapper([]config.AmpModelMapping{
|
||||||
|
{From: "gpt-5.2", To: "test/gpt-5.2"},
|
||||||
|
})
|
||||||
|
|
||||||
|
fallback := NewFallbackHandlerWithMapper(func() *httputil.ReverseProxy { return nil }, mapper, nil)
|
||||||
|
|
||||||
|
handler := func(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"model": req.Model,
|
||||||
|
"seen_model": req.Model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/chat/completions", fallback.WrapHandler(handler))
|
||||||
|
|
||||||
|
reqBody := []byte(`{"model":"gpt-5.2(xhigh)"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/chat/completions", bytes.NewReader(reqBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
SeenModel string `json:"seen_model"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Failed to parse response JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Model != "gpt-5.2(xhigh)" {
|
||||||
|
t.Errorf("Expected response model gpt-5.2(xhigh), got %s", resp.Model)
|
||||||
|
}
|
||||||
|
if resp.SeenModel != "test/gpt-5.2(xhigh)" {
|
||||||
|
t.Errorf("Expected handler to see test/gpt-5.2(xhigh), got %s", resp.SeenModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,8 @@ func (m *DefaultModelMapper) MapModel(requestedModel string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify target model has available providers
|
// Verify target model has available providers
|
||||||
providers := util.GetProviderName(targetModel)
|
normalizedTarget, _ := util.NormalizeThinkingModel(targetModel)
|
||||||
|
providers := util.GetProviderName(normalizedTarget)
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
log.Debugf("amp model mapping: target model %s has no available providers, skipping mapping", targetModel)
|
log.Debugf("amp model mapping: target model %s has no available providers, skipping mapping", targetModel)
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -71,6 +71,25 @@ func TestModelMapper_MapModel_WithProvider(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModelMapper_MapModel_TargetWithThinkingSuffix(t *testing.T) {
|
||||||
|
reg := registry.GetGlobalRegistry()
|
||||||
|
reg.RegisterClient("test-client-thinking", "codex", []*registry.ModelInfo{
|
||||||
|
{ID: "gpt-5.2", OwnedBy: "openai", Type: "codex"},
|
||||||
|
})
|
||||||
|
defer reg.UnregisterClient("test-client-thinking")
|
||||||
|
|
||||||
|
mappings := []config.AmpModelMapping{
|
||||||
|
{From: "gpt-5.2-alias", To: "gpt-5.2(xhigh)"},
|
||||||
|
}
|
||||||
|
|
||||||
|
mapper := NewModelMapper(mappings)
|
||||||
|
|
||||||
|
result := mapper.MapModel("gpt-5.2-alias")
|
||||||
|
if result != "gpt-5.2(xhigh)" {
|
||||||
|
t.Errorf("Expected gpt-5.2(xhigh), got %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestModelMapper_MapModel_CaseInsensitive(t *testing.T) {
|
func TestModelMapper_MapModel_CaseInsensitive(t *testing.T) {
|
||||||
reg := registry.GetGlobalRegistry()
|
reg := registry.GetGlobalRegistry()
|
||||||
reg.RegisterClient("test-client2", "claude", []*registry.ModelInfo{
|
reg.RegisterClient("test-client2", "claude", []*registry.ModelInfo{
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
|
|||||||
var authWithBypass gin.HandlerFunc
|
var authWithBypass gin.HandlerFunc
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
ampAPI.Use(auth)
|
ampAPI.Use(auth)
|
||||||
authWithBypass = wrapManagementAuth(auth, "/threads", "/auth")
|
authWithBypass = wrapManagementAuth(auth, "/threads", "/auth", "/docs", "/settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic proxy handler that uses m.getProxy() for hot-reload support
|
// Dynamic proxy handler that uses m.getProxy() for hot-reload support
|
||||||
@@ -175,7 +175,13 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
|
|||||||
if authWithBypass != nil {
|
if authWithBypass != nil {
|
||||||
rootMiddleware = append(rootMiddleware, authWithBypass)
|
rootMiddleware = append(rootMiddleware, authWithBypass)
|
||||||
}
|
}
|
||||||
|
engine.GET("/threads", append(rootMiddleware, proxyHandler)...)
|
||||||
engine.GET("/threads/*path", append(rootMiddleware, proxyHandler)...)
|
engine.GET("/threads/*path", append(rootMiddleware, proxyHandler)...)
|
||||||
|
engine.GET("/docs", append(rootMiddleware, proxyHandler)...)
|
||||||
|
engine.GET("/docs/*path", append(rootMiddleware, proxyHandler)...)
|
||||||
|
engine.GET("/settings", append(rootMiddleware, proxyHandler)...)
|
||||||
|
engine.GET("/settings/*path", append(rootMiddleware, proxyHandler)...)
|
||||||
|
|
||||||
engine.GET("/threads.rss", append(rootMiddleware, proxyHandler)...)
|
engine.GET("/threads.rss", append(rootMiddleware, proxyHandler)...)
|
||||||
engine.GET("/news.rss", append(rootMiddleware, proxyHandler)...)
|
engine.GET("/news.rss", append(rootMiddleware, proxyHandler)...)
|
||||||
|
|
||||||
|
|||||||
@@ -865,11 +865,20 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if oldCfg != nil && oldCfg.LoggingToFile != cfg.LoggingToFile {
|
if oldCfg == nil || oldCfg.LoggingToFile != cfg.LoggingToFile || oldCfg.LogsMaxTotalSizeMB != cfg.LogsMaxTotalSizeMB {
|
||||||
if err := logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil {
|
if err := logging.ConfigureLogOutput(cfg.LoggingToFile, cfg.LogsMaxTotalSizeMB); err != nil {
|
||||||
log.Errorf("failed to reconfigure log output: %v", err)
|
log.Errorf("failed to reconfigure log output: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Debugf("logging_to_file updated from %t to %t", oldCfg.LoggingToFile, cfg.LoggingToFile)
|
if oldCfg == nil {
|
||||||
|
log.Debug("log output configuration refreshed")
|
||||||
|
} else {
|
||||||
|
if oldCfg.LoggingToFile != cfg.LoggingToFile {
|
||||||
|
log.Debugf("logging_to_file updated from %t to %t", oldCfg.LoggingToFile, cfg.LoggingToFile)
|
||||||
|
}
|
||||||
|
if oldCfg.LogsMaxTotalSizeMB != cfg.LogsMaxTotalSizeMB {
|
||||||
|
log.Debugf("logs_max_total_size_mb updated from %d to %d", oldCfg.LogsMaxTotalSizeMB, cfg.LogsMaxTotalSizeMB)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -21,7 +20,7 @@ const DefaultPanelGitHubRepository = "https://github.com/router-for-me/Cli-Proxy
|
|||||||
|
|
||||||
// Config represents the application's configuration, loaded from a YAML file.
|
// Config represents the application's configuration, loaded from a YAML file.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
config.SDKConfig `yaml:",inline"`
|
SDKConfig `yaml:",inline"`
|
||||||
// Host is the network host/interface on which the API server will bind.
|
// Host is the network host/interface on which the API server will bind.
|
||||||
// Default is empty ("") to bind all interfaces (IPv4 + IPv6). Use "127.0.0.1" or "localhost" for local-only access.
|
// Default is empty ("") to bind all interfaces (IPv4 + IPv6). Use "127.0.0.1" or "localhost" for local-only access.
|
||||||
Host string `yaml:"host" json:"-"`
|
Host string `yaml:"host" json:"-"`
|
||||||
@@ -43,6 +42,10 @@ type Config struct {
|
|||||||
// 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"`
|
||||||
|
|
||||||
|
// LogsMaxTotalSizeMB limits the total size (in MB) of log files under the logs directory.
|
||||||
|
// When exceeded, the oldest log files are deleted until within the limit. Set to 0 to disable.
|
||||||
|
LogsMaxTotalSizeMB int `yaml:"logs-max-total-size-mb" json:"logs-max-total-size-mb"`
|
||||||
|
|
||||||
// UsageStatisticsEnabled toggles in-memory usage aggregation; when false, usage data is discarded.
|
// UsageStatisticsEnabled toggles in-memory usage aggregation; when false, usage data is discarded.
|
||||||
UsageStatisticsEnabled bool `yaml:"usage-statistics-enabled" json:"usage-statistics-enabled"`
|
UsageStatisticsEnabled bool `yaml:"usage-statistics-enabled" json:"usage-statistics-enabled"`
|
||||||
|
|
||||||
@@ -383,6 +386,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
|||||||
// Set defaults before unmarshal so that absent keys keep defaults.
|
// Set defaults before unmarshal so that absent keys keep defaults.
|
||||||
cfg.Host = "" // Default empty: binds to all interfaces (IPv4 + IPv6)
|
cfg.Host = "" // Default empty: binds to all interfaces (IPv4 + IPv6)
|
||||||
cfg.LoggingToFile = false
|
cfg.LoggingToFile = false
|
||||||
|
cfg.LogsMaxTotalSizeMB = 0
|
||||||
cfg.UsageStatisticsEnabled = false
|
cfg.UsageStatisticsEnabled = false
|
||||||
cfg.DisableCooling = false
|
cfg.DisableCooling = false
|
||||||
cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
|
cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
|
||||||
@@ -428,6 +432,10 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
|||||||
cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
|
cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.LogsMaxTotalSizeMB < 0 {
|
||||||
|
cfg.LogsMaxTotalSizeMB = 0
|
||||||
|
}
|
||||||
|
|
||||||
// Sync request authentication providers with inline API keys for backwards compatibility.
|
// Sync request authentication providers with inline API keys for backwards compatibility.
|
||||||
syncInlineAccessProvider(&cfg)
|
syncInlineAccessProvider(&cfg)
|
||||||
|
|
||||||
@@ -754,7 +762,7 @@ func sanitizeConfigForPersist(cfg *Config) *Config {
|
|||||||
}
|
}
|
||||||
clone := *cfg
|
clone := *cfg
|
||||||
clone.SDKConfig = cfg.SDKConfig
|
clone.SDKConfig = cfg.SDKConfig
|
||||||
clone.SDKConfig.Access = config.AccessConfig{}
|
clone.SDKConfig.Access = AccessConfig{}
|
||||||
return &clone
|
return &clone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
87
internal/config/sdk_config.go
Normal file
87
internal/config/sdk_config.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// Package config provides configuration management for the CLI Proxy API server.
|
||||||
|
// It handles loading and parsing YAML configuration files, and provides structured
|
||||||
|
// access to application settings including server port, authentication directory,
|
||||||
|
// debug settings, proxy configuration, and API keys.
|
||||||
|
package config
|
||||||
|
|
||||||
|
// SDKConfig represents the application's configuration, loaded from a YAML file.
|
||||||
|
type SDKConfig struct {
|
||||||
|
// ProxyURL is the URL of an optional proxy server to use for outbound requests.
|
||||||
|
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
|
||||||
|
|
||||||
|
// ForceModelPrefix requires explicit model prefixes (e.g., "teamA/gemini-3-pro-preview")
|
||||||
|
// to target prefixed credentials. When false, unprefixed model requests may use prefixed
|
||||||
|
// credentials as well.
|
||||||
|
ForceModelPrefix bool `yaml:"force-model-prefix" json:"force-model-prefix"`
|
||||||
|
|
||||||
|
// RequestLog enables or disables detailed request logging functionality.
|
||||||
|
RequestLog bool `yaml:"request-log" json:"request-log"`
|
||||||
|
|
||||||
|
// APIKeys is a list of keys for authenticating clients to this proxy server.
|
||||||
|
APIKeys []string `yaml:"api-keys" json:"api-keys"`
|
||||||
|
|
||||||
|
// Access holds request authentication provider configuration.
|
||||||
|
Access AccessConfig `yaml:"auth,omitempty" json:"auth,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessConfig groups request authentication providers.
|
||||||
|
type AccessConfig struct {
|
||||||
|
// Providers lists configured authentication providers.
|
||||||
|
Providers []AccessProvider `yaml:"providers,omitempty" json:"providers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessProvider describes a request authentication provider entry.
|
||||||
|
type AccessProvider struct {
|
||||||
|
// Name is the instance identifier for the provider.
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
|
||||||
|
// Type selects the provider implementation registered via the SDK.
|
||||||
|
Type string `yaml:"type" json:"type"`
|
||||||
|
|
||||||
|
// SDK optionally names a third-party SDK module providing this provider.
|
||||||
|
SDK string `yaml:"sdk,omitempty" json:"sdk,omitempty"`
|
||||||
|
|
||||||
|
// APIKeys lists inline keys for providers that require them.
|
||||||
|
APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"`
|
||||||
|
|
||||||
|
// Config passes provider-specific options to the implementation.
|
||||||
|
Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AccessProviderTypeConfigAPIKey is the built-in provider validating inline API keys.
|
||||||
|
AccessProviderTypeConfigAPIKey = "config-api-key"
|
||||||
|
|
||||||
|
// DefaultAccessProviderName is applied when no provider name is supplied.
|
||||||
|
DefaultAccessProviderName = "config-inline"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigAPIKeyProvider returns the first inline API key provider if present.
|
||||||
|
func (c *SDKConfig) ConfigAPIKeyProvider() *AccessProvider {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := range c.Access.Providers {
|
||||||
|
if c.Access.Providers[i].Type == AccessProviderTypeConfigAPIKey {
|
||||||
|
if c.Access.Providers[i].Name == "" {
|
||||||
|
c.Access.Providers[i].Name = DefaultAccessProviderName
|
||||||
|
}
|
||||||
|
return &c.Access.Providers[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MakeInlineAPIKeyProvider constructs an inline API key provider configuration.
|
||||||
|
// It returns nil when no keys are supplied.
|
||||||
|
func MakeInlineAPIKeyProvider(keys []string) *AccessProvider {
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
provider := &AccessProvider{
|
||||||
|
Name: DefaultAccessProviderName,
|
||||||
|
Type: AccessProviderTypeConfigAPIKey,
|
||||||
|
APIKeys: append([]string(nil), keys...),
|
||||||
|
}
|
||||||
|
return provider
|
||||||
|
}
|
||||||
@@ -76,39 +76,45 @@ func SetupBaseLogger() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConfigureLogOutput switches the global log destination between rotating files and stdout.
|
// ConfigureLogOutput switches the global log destination between rotating files and stdout.
|
||||||
func ConfigureLogOutput(loggingToFile bool) error {
|
// When logsMaxTotalSizeMB > 0, a background cleaner removes the oldest log files in the logs directory
|
||||||
|
// until the total size is within the limit.
|
||||||
|
func ConfigureLogOutput(loggingToFile bool, logsMaxTotalSizeMB int) error {
|
||||||
SetupBaseLogger()
|
SetupBaseLogger()
|
||||||
|
|
||||||
writerMu.Lock()
|
writerMu.Lock()
|
||||||
defer writerMu.Unlock()
|
defer writerMu.Unlock()
|
||||||
|
|
||||||
|
logDir := "logs"
|
||||||
|
if base := util.WritablePath(); base != "" {
|
||||||
|
logDir = filepath.Join(base, "logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
protectedPath := ""
|
||||||
if loggingToFile {
|
if loggingToFile {
|
||||||
logDir := "logs"
|
|
||||||
if base := util.WritablePath(); base != "" {
|
|
||||||
logDir = filepath.Join(base, "logs")
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("logging: failed to create log directory: %w", err)
|
return fmt.Errorf("logging: failed to create log directory: %w", err)
|
||||||
}
|
}
|
||||||
if logWriter != nil {
|
if logWriter != nil {
|
||||||
_ = logWriter.Close()
|
_ = logWriter.Close()
|
||||||
}
|
}
|
||||||
|
protectedPath = filepath.Join(logDir, "main.log")
|
||||||
logWriter = &lumberjack.Logger{
|
logWriter = &lumberjack.Logger{
|
||||||
Filename: filepath.Join(logDir, "main.log"),
|
Filename: protectedPath,
|
||||||
MaxSize: 10,
|
MaxSize: 10,
|
||||||
MaxBackups: 0,
|
MaxBackups: 0,
|
||||||
MaxAge: 0,
|
MaxAge: 0,
|
||||||
Compress: false,
|
Compress: false,
|
||||||
}
|
}
|
||||||
log.SetOutput(logWriter)
|
log.SetOutput(logWriter)
|
||||||
return nil
|
} else {
|
||||||
|
if logWriter != nil {
|
||||||
|
_ = logWriter.Close()
|
||||||
|
logWriter = nil
|
||||||
|
}
|
||||||
|
log.SetOutput(os.Stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
if logWriter != nil {
|
configureLogDirCleanerLocked(logDir, logsMaxTotalSizeMB, protectedPath)
|
||||||
_ = logWriter.Close()
|
|
||||||
logWriter = nil
|
|
||||||
}
|
|
||||||
log.SetOutput(os.Stdout)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +122,8 @@ func closeLogOutputs() {
|
|||||||
writerMu.Lock()
|
writerMu.Lock()
|
||||||
defer writerMu.Unlock()
|
defer writerMu.Unlock()
|
||||||
|
|
||||||
|
stopLogDirCleanerLocked()
|
||||||
|
|
||||||
if logWriter != nil {
|
if logWriter != nil {
|
||||||
_ = logWriter.Close()
|
_ = logWriter.Close()
|
||||||
logWriter = nil
|
logWriter = nil
|
||||||
|
|||||||
166
internal/logging/log_dir_cleaner.go
Normal file
166
internal/logging/log_dir_cleaner.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const logDirCleanerInterval = time.Minute
|
||||||
|
|
||||||
|
var logDirCleanerCancel context.CancelFunc
|
||||||
|
|
||||||
|
func configureLogDirCleanerLocked(logDir string, maxTotalSizeMB int, protectedPath string) {
|
||||||
|
stopLogDirCleanerLocked()
|
||||||
|
|
||||||
|
if maxTotalSizeMB <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxBytes := int64(maxTotalSizeMB) * 1024 * 1024
|
||||||
|
if maxBytes <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := strings.TrimSpace(logDir)
|
||||||
|
if dir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
logDirCleanerCancel = cancel
|
||||||
|
go runLogDirCleaner(ctx, filepath.Clean(dir), maxBytes, strings.TrimSpace(protectedPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopLogDirCleanerLocked() {
|
||||||
|
if logDirCleanerCancel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logDirCleanerCancel()
|
||||||
|
logDirCleanerCancel = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLogDirCleaner(ctx context.Context, logDir string, maxBytes int64, protectedPath string) {
|
||||||
|
ticker := time.NewTicker(logDirCleanerInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
cleanOnce := func() {
|
||||||
|
deleted, errClean := enforceLogDirSizeLimit(logDir, maxBytes, protectedPath)
|
||||||
|
if errClean != nil {
|
||||||
|
log.WithError(errClean).Warn("logging: failed to enforce log directory size limit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if deleted > 0 {
|
||||||
|
log.Debugf("logging: removed %d old log file(s) to enforce log directory size limit", deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanOnce()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
cleanOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enforceLogDirSizeLimit(logDir string, maxBytes int64, protectedPath string) (int, error) {
|
||||||
|
if maxBytes <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := strings.TrimSpace(logDir)
|
||||||
|
if dir == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
dir = filepath.Clean(dir)
|
||||||
|
|
||||||
|
entries, errRead := os.ReadDir(dir)
|
||||||
|
if errRead != nil {
|
||||||
|
if os.IsNotExist(errRead) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, errRead
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := strings.TrimSpace(protectedPath)
|
||||||
|
if protected != "" {
|
||||||
|
protected = filepath.Clean(protected)
|
||||||
|
}
|
||||||
|
|
||||||
|
type logFile struct {
|
||||||
|
path string
|
||||||
|
size int64
|
||||||
|
modTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
files []logFile
|
||||||
|
total int64
|
||||||
|
)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if !isLogFileName(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, errInfo := entry.Info()
|
||||||
|
if errInfo != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
files = append(files, logFile{
|
||||||
|
path: path,
|
||||||
|
size: info.Size(),
|
||||||
|
modTime: info.ModTime(),
|
||||||
|
})
|
||||||
|
total += info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
if total <= maxBytes {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
return files[i].modTime.Before(files[j].modTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
deleted := 0
|
||||||
|
for _, file := range files {
|
||||||
|
if total <= maxBytes {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if protected != "" && filepath.Clean(file.path) == protected {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errRemove := os.Remove(file.path); errRemove != nil {
|
||||||
|
log.WithError(errRemove).Warnf("logging: failed to remove old log file: %s", filepath.Base(file.path))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total -= file.size
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLogFileName(name string) bool {
|
||||||
|
trimmed := strings.TrimSpace(name)
|
||||||
|
if trimmed == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
return strings.HasSuffix(lower, ".log") || strings.HasSuffix(lower, ".log.gz")
|
||||||
|
}
|
||||||
70
internal/logging/log_dir_cleaner_test.go
Normal file
70
internal/logging/log_dir_cleaner_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnforceLogDirSizeLimitDeletesOldest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
writeLogFile(t, filepath.Join(dir, "old.log"), 60, time.Unix(1, 0))
|
||||||
|
writeLogFile(t, filepath.Join(dir, "mid.log"), 60, time.Unix(2, 0))
|
||||||
|
protected := filepath.Join(dir, "main.log")
|
||||||
|
writeLogFile(t, protected, 60, time.Unix(3, 0))
|
||||||
|
|
||||||
|
deleted, err := enforceLogDirSizeLimit(dir, 120, protected)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if deleted != 1 {
|
||||||
|
t.Fatalf("expected 1 deleted file, got %d", deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "old.log")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected old.log to be removed, stat error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "mid.log")); err != nil {
|
||||||
|
t.Fatalf("expected mid.log to remain, stat error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(protected); err != nil {
|
||||||
|
t.Fatalf("expected protected main.log to remain, stat error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnforceLogDirSizeLimitSkipsProtected(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
protected := filepath.Join(dir, "main.log")
|
||||||
|
writeLogFile(t, protected, 200, time.Unix(1, 0))
|
||||||
|
writeLogFile(t, filepath.Join(dir, "other.log"), 50, time.Unix(2, 0))
|
||||||
|
|
||||||
|
deleted, err := enforceLogDirSizeLimit(dir, 100, protected)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if deleted != 1 {
|
||||||
|
t.Fatalf("expected 1 deleted file, got %d", deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(protected); err != nil {
|
||||||
|
t.Fatalf("expected protected main.log to remain, stat error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "other.log")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected other.log to be removed, stat error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLogFile(t *testing.T, path string, size int, modTime time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
data := make([]byte, size)
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
t.Fatalf("write file: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chtimes(path, modTime, modTime); err != nil {
|
||||||
|
t.Fatalf("set times: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/andybalholm/brotli"
|
"github.com/andybalholm/brotli"
|
||||||
@@ -25,6 +26,8 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var requestLogID atomic.Uint64
|
||||||
|
|
||||||
// RequestLogger defines the interface for logging HTTP requests and responses.
|
// RequestLogger defines the interface for logging HTTP requests and responses.
|
||||||
// It provides methods for logging both regular and streaming HTTP request/response cycles.
|
// It provides methods for logging both regular and streaming HTTP request/response cycles.
|
||||||
type RequestLogger interface {
|
type RequestLogger interface {
|
||||||
@@ -204,19 +207,52 @@ func (l *FileRequestLogger) logRequest(url, method string, requestHeaders map[st
|
|||||||
}
|
}
|
||||||
filePath := filepath.Join(l.logsDir, filename)
|
filePath := filepath.Join(l.logsDir, filename)
|
||||||
|
|
||||||
// Decompress response if needed
|
requestBodyPath, errTemp := l.writeRequestBodyTempFile(body)
|
||||||
decompressedResponse, err := l.decompressResponse(responseHeaders, response)
|
if errTemp != nil {
|
||||||
if err != nil {
|
log.WithError(errTemp).Warn("failed to create request body temp file, falling back to direct write")
|
||||||
// If decompression fails, log the error but continue with original response
|
}
|
||||||
decompressedResponse = append(response, []byte(fmt.Sprintf("\n[DECOMPRESSION ERROR: %v]", err))...)
|
if requestBodyPath != "" {
|
||||||
|
defer func() {
|
||||||
|
if errRemove := os.Remove(requestBodyPath); errRemove != nil {
|
||||||
|
log.WithError(errRemove).Warn("failed to remove request body temp file")
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create log content
|
responseToWrite, decompressErr := l.decompressResponse(responseHeaders, response)
|
||||||
content := l.formatLogContent(url, method, requestHeaders, body, apiRequest, apiResponse, decompressedResponse, statusCode, responseHeaders, apiResponseErrors)
|
if decompressErr != nil {
|
||||||
|
// If decompression fails, continue with original response and annotate the log output.
|
||||||
|
responseToWrite = response
|
||||||
|
}
|
||||||
|
|
||||||
// Write to file
|
logFile, errOpen := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||||
if err = os.WriteFile(filePath, []byte(content), 0644); err != nil {
|
if errOpen != nil {
|
||||||
return fmt.Errorf("failed to write log file: %w", err)
|
return fmt.Errorf("failed to create log file: %w", errOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeErr := l.writeNonStreamingLog(
|
||||||
|
logFile,
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
requestHeaders,
|
||||||
|
body,
|
||||||
|
requestBodyPath,
|
||||||
|
apiRequest,
|
||||||
|
apiResponse,
|
||||||
|
apiResponseErrors,
|
||||||
|
statusCode,
|
||||||
|
responseHeaders,
|
||||||
|
responseToWrite,
|
||||||
|
decompressErr,
|
||||||
|
)
|
||||||
|
if errClose := logFile.Close(); errClose != nil {
|
||||||
|
log.WithError(errClose).Warn("failed to close request log file")
|
||||||
|
if writeErr == nil {
|
||||||
|
return errClose
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if writeErr != nil {
|
||||||
|
return fmt.Errorf("failed to write log file: %w", writeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if force && !l.enabled {
|
if force && !l.enabled {
|
||||||
@@ -253,26 +289,38 @@ func (l *FileRequestLogger) LogStreamingRequest(url, method string, headers map[
|
|||||||
filename := l.generateFilename(url)
|
filename := l.generateFilename(url)
|
||||||
filePath := filepath.Join(l.logsDir, filename)
|
filePath := filepath.Join(l.logsDir, filename)
|
||||||
|
|
||||||
// Create and open file
|
requestHeaders := make(map[string][]string, len(headers))
|
||||||
file, err := os.Create(filePath)
|
for key, values := range headers {
|
||||||
if err != nil {
|
headerValues := make([]string, len(values))
|
||||||
return nil, fmt.Errorf("failed to create log file: %w", err)
|
copy(headerValues, values)
|
||||||
|
requestHeaders[key] = headerValues
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write initial request information
|
requestBodyPath, errTemp := l.writeRequestBodyTempFile(body)
|
||||||
requestInfo := l.formatRequestInfo(url, method, headers, body)
|
if errTemp != nil {
|
||||||
if _, err = file.WriteString(requestInfo); err != nil {
|
return nil, fmt.Errorf("failed to create request body temp file: %w", errTemp)
|
||||||
_ = file.Close()
|
|
||||||
return nil, fmt.Errorf("failed to write request info: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
responseBodyFile, errCreate := os.CreateTemp(l.logsDir, "response-body-*.tmp")
|
||||||
|
if errCreate != nil {
|
||||||
|
_ = os.Remove(requestBodyPath)
|
||||||
|
return nil, fmt.Errorf("failed to create response body temp file: %w", errCreate)
|
||||||
|
}
|
||||||
|
responseBodyPath := responseBodyFile.Name()
|
||||||
|
|
||||||
// Create streaming writer
|
// Create streaming writer
|
||||||
writer := &FileStreamingLogWriter{
|
writer := &FileStreamingLogWriter{
|
||||||
file: file,
|
logFilePath: filePath,
|
||||||
chunkChan: make(chan []byte, 100), // Buffered channel for async writes
|
url: url,
|
||||||
closeChan: make(chan struct{}),
|
method: method,
|
||||||
errorChan: make(chan error, 1),
|
timestamp: time.Now(),
|
||||||
bufferedChunks: &bytes.Buffer{},
|
requestHeaders: requestHeaders,
|
||||||
|
requestBodyPath: requestBodyPath,
|
||||||
|
responseBodyPath: responseBodyPath,
|
||||||
|
responseBodyFile: responseBodyFile,
|
||||||
|
chunkChan: make(chan []byte, 100), // Buffered channel for async writes
|
||||||
|
closeChan: make(chan struct{}),
|
||||||
|
errorChan: make(chan error, 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start async writer goroutine
|
// Start async writer goroutine
|
||||||
@@ -323,7 +371,9 @@ func (l *FileRequestLogger) generateFilename(url string) string {
|
|||||||
timestamp := time.Now().Format("2006-01-02T150405-.000000000")
|
timestamp := time.Now().Format("2006-01-02T150405-.000000000")
|
||||||
timestamp = strings.Replace(timestamp, ".", "", -1)
|
timestamp = strings.Replace(timestamp, ".", "", -1)
|
||||||
|
|
||||||
return fmt.Sprintf("%s-%s.log", sanitized, timestamp)
|
id := requestLogID.Add(1)
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s-%s-%d.log", sanitized, timestamp, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sanitizeForFilename replaces characters that are not safe for filenames.
|
// sanitizeForFilename replaces characters that are not safe for filenames.
|
||||||
@@ -405,6 +455,220 @@ func (l *FileRequestLogger) cleanupOldErrorLogs() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *FileRequestLogger) writeRequestBodyTempFile(body []byte) (string, error) {
|
||||||
|
tmpFile, errCreate := os.CreateTemp(l.logsDir, "request-body-*.tmp")
|
||||||
|
if errCreate != nil {
|
||||||
|
return "", errCreate
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
|
||||||
|
if _, errCopy := io.Copy(tmpFile, bytes.NewReader(body)); errCopy != nil {
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return "", errCopy
|
||||||
|
}
|
||||||
|
if errClose := tmpFile.Close(); errClose != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return "", errClose
|
||||||
|
}
|
||||||
|
return tmpPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *FileRequestLogger) writeNonStreamingLog(
|
||||||
|
w io.Writer,
|
||||||
|
url, method string,
|
||||||
|
requestHeaders map[string][]string,
|
||||||
|
requestBody []byte,
|
||||||
|
requestBodyPath string,
|
||||||
|
apiRequest []byte,
|
||||||
|
apiResponse []byte,
|
||||||
|
apiResponseErrors []*interfaces.ErrorMessage,
|
||||||
|
statusCode int,
|
||||||
|
responseHeaders map[string][]string,
|
||||||
|
response []byte,
|
||||||
|
decompressErr error,
|
||||||
|
) error {
|
||||||
|
if errWrite := writeRequestInfoWithBody(w, url, method, requestHeaders, requestBody, requestBodyPath, time.Now()); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if errWrite := writeAPISection(w, "=== API REQUEST ===\n", "=== API REQUEST", apiRequest); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if errWrite := writeAPIErrorResponses(w, apiResponseErrors); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if errWrite := writeAPISection(w, "=== API RESPONSE ===\n", "=== API RESPONSE", apiResponse); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
return writeResponseSection(w, statusCode, true, responseHeaders, bytes.NewReader(response), decompressErr, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRequestInfoWithBody(
|
||||||
|
w io.Writer,
|
||||||
|
url, method string,
|
||||||
|
headers map[string][]string,
|
||||||
|
body []byte,
|
||||||
|
bodyPath string,
|
||||||
|
timestamp time.Time,
|
||||||
|
) error {
|
||||||
|
if _, errWrite := io.WriteString(w, "=== REQUEST INFO ===\n"); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("Version: %s\n", buildinfo.Version)); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("URL: %s\n", url)); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("Method: %s\n", method)); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("Timestamp: %s\n", timestamp.Format(time.RFC3339Nano))); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, errWrite := io.WriteString(w, "=== HEADERS ===\n"); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
for key, values := range headers {
|
||||||
|
for _, value := range values {
|
||||||
|
masked := util.MaskSensitiveHeaderValue(key, value)
|
||||||
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("%s: %s\n", key, masked)); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, errWrite := io.WriteString(w, "=== REQUEST BODY ===\n"); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
if bodyPath != "" {
|
||||||
|
bodyFile, errOpen := os.Open(bodyPath)
|
||||||
|
if errOpen != nil {
|
||||||
|
return errOpen
|
||||||
|
}
|
||||||
|
if _, errCopy := io.Copy(w, bodyFile); errCopy != nil {
|
||||||
|
_ = bodyFile.Close()
|
||||||
|
return errCopy
|
||||||
|
}
|
||||||
|
if errClose := bodyFile.Close(); errClose != nil {
|
||||||
|
log.WithError(errClose).Warn("failed to close request body temp file")
|
||||||
|
}
|
||||||
|
} else if _, errWrite := w.Write(body); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, errWrite := io.WriteString(w, "\n\n"); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAPISection(w io.Writer, sectionHeader string, sectionPrefix string, payload []byte) error {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.HasPrefix(payload, []byte(sectionPrefix)) {
|
||||||
|
if _, errWrite := w.Write(payload); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if !bytes.HasSuffix(payload, []byte("\n")) {
|
||||||
|
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, errWrite := io.WriteString(w, sectionHeader); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if _, errWrite := w.Write(payload); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAPIErrorResponses(w io.Writer, apiResponseErrors []*interfaces.ErrorMessage) error {
|
||||||
|
for i := 0; i < len(apiResponseErrors); i++ {
|
||||||
|
if apiResponseErrors[i] == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, errWrite := io.WriteString(w, "=== API ERROR RESPONSE ===\n"); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("HTTP Status: %d\n", apiResponseErrors[i].StatusCode)); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if apiResponseErrors[i].Error != nil {
|
||||||
|
if _, errWrite := io.WriteString(w, apiResponseErrors[i].Error.Error()); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, errWrite := io.WriteString(w, "\n\n"); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeResponseSection(w io.Writer, statusCode int, statusWritten bool, responseHeaders map[string][]string, responseReader io.Reader, decompressErr error, trailingNewline bool) error {
|
||||||
|
if _, errWrite := io.WriteString(w, "=== RESPONSE ===\n"); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if statusWritten {
|
||||||
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("Status: %d\n", statusCode)); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if responseHeaders != nil {
|
||||||
|
for key, values := range responseHeaders {
|
||||||
|
for _, value := range values {
|
||||||
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("%s: %s\n", key, value)); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
if responseReader != nil {
|
||||||
|
if _, errCopy := io.Copy(w, responseReader); errCopy != nil {
|
||||||
|
return errCopy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if decompressErr != nil {
|
||||||
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("\n[DECOMPRESSION ERROR: %v]", decompressErr)); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if trailingNewline {
|
||||||
|
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// formatLogContent creates the complete log content for non-streaming requests.
|
// formatLogContent creates the complete log content for non-streaming requests.
|
||||||
//
|
//
|
||||||
// Parameters:
|
// Parameters:
|
||||||
@@ -648,13 +912,34 @@ func (l *FileRequestLogger) formatRequestInfo(url, method string, headers map[st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FileStreamingLogWriter implements StreamingLogWriter for file-based streaming logs.
|
// FileStreamingLogWriter implements StreamingLogWriter for file-based streaming logs.
|
||||||
// It handles asynchronous writing of streaming response chunks to a file.
|
// It spools streaming response chunks to a temporary file to avoid retaining large responses in memory.
|
||||||
// All data is buffered and written in the correct order when Close is called.
|
// The final log file is assembled when Close is called.
|
||||||
type FileStreamingLogWriter struct {
|
type FileStreamingLogWriter struct {
|
||||||
// file is the file where log data is written.
|
// logFilePath is the final log file path.
|
||||||
file *os.File
|
logFilePath string
|
||||||
|
|
||||||
// chunkChan is a channel for receiving response chunks to buffer.
|
// url is the request URL (masked upstream in middleware).
|
||||||
|
url string
|
||||||
|
|
||||||
|
// method is the HTTP method.
|
||||||
|
method string
|
||||||
|
|
||||||
|
// timestamp is captured when the streaming log is initialized.
|
||||||
|
timestamp time.Time
|
||||||
|
|
||||||
|
// requestHeaders stores the request headers.
|
||||||
|
requestHeaders map[string][]string
|
||||||
|
|
||||||
|
// requestBodyPath is a temporary file path holding the request body.
|
||||||
|
requestBodyPath string
|
||||||
|
|
||||||
|
// responseBodyPath is a temporary file path holding the streaming response body.
|
||||||
|
responseBodyPath string
|
||||||
|
|
||||||
|
// responseBodyFile is the temp file where chunks are appended by the async writer.
|
||||||
|
responseBodyFile *os.File
|
||||||
|
|
||||||
|
// chunkChan is a channel for receiving response chunks to spool.
|
||||||
chunkChan chan []byte
|
chunkChan chan []byte
|
||||||
|
|
||||||
// closeChan is a channel for signaling when the writer is closed.
|
// closeChan is a channel for signaling when the writer is closed.
|
||||||
@@ -663,9 +948,6 @@ type FileStreamingLogWriter struct {
|
|||||||
// errorChan is a channel for reporting errors during writing.
|
// errorChan is a channel for reporting errors during writing.
|
||||||
errorChan chan error
|
errorChan chan error
|
||||||
|
|
||||||
// bufferedChunks stores the response chunks in order.
|
|
||||||
bufferedChunks *bytes.Buffer
|
|
||||||
|
|
||||||
// responseStatus stores the HTTP status code.
|
// responseStatus stores the HTTP status code.
|
||||||
responseStatus int
|
responseStatus int
|
||||||
|
|
||||||
@@ -770,85 +1052,115 @@ func (w *FileStreamingLogWriter) Close() error {
|
|||||||
close(w.chunkChan)
|
close(w.chunkChan)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for async writer to finish buffering chunks
|
// Wait for async writer to finish spooling chunks
|
||||||
if w.closeChan != nil {
|
if w.closeChan != nil {
|
||||||
<-w.closeChan
|
<-w.closeChan
|
||||||
w.chunkChan = nil
|
w.chunkChan = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.file == nil {
|
select {
|
||||||
|
case errWrite := <-w.errorChan:
|
||||||
|
w.cleanupTempFiles()
|
||||||
|
return errWrite
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.logFilePath == "" {
|
||||||
|
w.cleanupTempFiles()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write all content in the correct order
|
logFile, errOpen := os.OpenFile(w.logFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||||
var content strings.Builder
|
if errOpen != nil {
|
||||||
|
w.cleanupTempFiles()
|
||||||
// 1. Write API REQUEST section
|
return fmt.Errorf("failed to create log file: %w", errOpen)
|
||||||
if len(w.apiRequest) > 0 {
|
|
||||||
if bytes.HasPrefix(w.apiRequest, []byte("=== API REQUEST")) {
|
|
||||||
content.Write(w.apiRequest)
|
|
||||||
if !bytes.HasSuffix(w.apiRequest, []byte("\n")) {
|
|
||||||
content.WriteString("\n")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content.WriteString("=== API REQUEST ===\n")
|
|
||||||
content.Write(w.apiRequest)
|
|
||||||
content.WriteString("\n")
|
|
||||||
}
|
|
||||||
content.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Write API RESPONSE section
|
writeErr := w.writeFinalLog(logFile)
|
||||||
if len(w.apiResponse) > 0 {
|
if errClose := logFile.Close(); errClose != nil {
|
||||||
if bytes.HasPrefix(w.apiResponse, []byte("=== API RESPONSE")) {
|
log.WithError(errClose).Warn("failed to close request log file")
|
||||||
content.Write(w.apiResponse)
|
if writeErr == nil {
|
||||||
if !bytes.HasSuffix(w.apiResponse, []byte("\n")) {
|
writeErr = errClose
|
||||||
content.WriteString("\n")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content.WriteString("=== API RESPONSE ===\n")
|
|
||||||
content.Write(w.apiResponse)
|
|
||||||
content.WriteString("\n")
|
|
||||||
}
|
|
||||||
content.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Write RESPONSE section (status, headers, buffered chunks)
|
|
||||||
content.WriteString("=== RESPONSE ===\n")
|
|
||||||
if w.statusWritten {
|
|
||||||
content.WriteString(fmt.Sprintf("Status: %d\n", w.responseStatus))
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, values := range w.responseHeaders {
|
|
||||||
for _, value := range values {
|
|
||||||
content.WriteString(fmt.Sprintf("%s: %s\n", key, value))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
content.WriteString("\n")
|
|
||||||
|
|
||||||
// Write buffered response body chunks
|
w.cleanupTempFiles()
|
||||||
if w.bufferedChunks != nil && w.bufferedChunks.Len() > 0 {
|
return writeErr
|
||||||
content.Write(w.bufferedChunks.Bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the complete content to file
|
|
||||||
if _, err := w.file.WriteString(content.String()); err != nil {
|
|
||||||
_ = w.file.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return w.file.Close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// asyncWriter runs in a goroutine to buffer chunks from the channel.
|
// asyncWriter runs in a goroutine to buffer chunks from the channel.
|
||||||
// It continuously reads chunks from the channel and buffers them for later writing.
|
// It continuously reads chunks from the channel and appends them to a temp file for later assembly.
|
||||||
func (w *FileStreamingLogWriter) asyncWriter() {
|
func (w *FileStreamingLogWriter) asyncWriter() {
|
||||||
defer close(w.closeChan)
|
defer close(w.closeChan)
|
||||||
|
|
||||||
for chunk := range w.chunkChan {
|
for chunk := range w.chunkChan {
|
||||||
if w.bufferedChunks != nil {
|
if w.responseBodyFile == nil {
|
||||||
w.bufferedChunks.Write(chunk)
|
continue
|
||||||
}
|
}
|
||||||
|
if _, errWrite := w.responseBodyFile.Write(chunk); errWrite != nil {
|
||||||
|
select {
|
||||||
|
case w.errorChan <- errWrite:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if errClose := w.responseBodyFile.Close(); errClose != nil {
|
||||||
|
select {
|
||||||
|
case w.errorChan <- errClose:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.responseBodyFile = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.responseBodyFile == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errClose := w.responseBodyFile.Close(); errClose != nil {
|
||||||
|
select {
|
||||||
|
case w.errorChan <- errClose:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.responseBodyFile = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *FileStreamingLogWriter) writeFinalLog(logFile *os.File) error {
|
||||||
|
if errWrite := writeRequestInfoWithBody(logFile, w.url, w.method, w.requestHeaders, nil, w.requestBodyPath, w.timestamp); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if errWrite := writeAPISection(logFile, "=== API REQUEST ===\n", "=== API REQUEST", w.apiRequest); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
if errWrite := writeAPISection(logFile, "=== API RESPONSE ===\n", "=== API RESPONSE", w.apiResponse); errWrite != nil {
|
||||||
|
return errWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
responseBodyFile, errOpen := os.Open(w.responseBodyPath)
|
||||||
|
if errOpen != nil {
|
||||||
|
return errOpen
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := responseBodyFile.Close(); errClose != nil {
|
||||||
|
log.WithError(errClose).Warn("failed to close response body temp file")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return writeResponseSection(logFile, w.responseStatus, w.statusWritten, w.responseHeaders, responseBodyFile, nil, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *FileStreamingLogWriter) cleanupTempFiles() {
|
||||||
|
if w.requestBodyPath != "" {
|
||||||
|
if errRemove := os.Remove(w.requestBodyPath); errRemove != nil {
|
||||||
|
log.WithError(errRemove).Warn("failed to remove request body temp file")
|
||||||
|
}
|
||||||
|
w.requestBodyPath = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.responseBodyPath != "" {
|
||||||
|
if errRemove := os.Remove(w.responseBodyPath); errRemove != nil {
|
||||||
|
log.WithError(errRemove).Warn("failed to remove response body temp file")
|
||||||
|
}
|
||||||
|
w.responseBodyPath = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,21 @@ func GetGeminiModels() []*ModelInfo {
|
|||||||
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
||||||
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}},
|
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "gemini-3-flash-preview",
|
||||||
|
Object: "model",
|
||||||
|
Created: 1765929600,
|
||||||
|
OwnedBy: "google",
|
||||||
|
Type: "gemini",
|
||||||
|
Name: "models/gemini-3-flash-preview",
|
||||||
|
Version: "3.0",
|
||||||
|
DisplayName: "Gemini 3 Flash Preview",
|
||||||
|
Description: "Gemini 3 Flash Preview",
|
||||||
|
InputTokenLimit: 1048576,
|
||||||
|
OutputTokenLimit: 65536,
|
||||||
|
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
||||||
|
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: "gemini-3-pro-image-preview",
|
ID: "gemini-3-pro-image-preview",
|
||||||
Object: "model",
|
Object: "model",
|
||||||
|
|||||||
@@ -325,8 +325,8 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c
|
|||||||
payload = ApplyThinkingMetadata(payload, req.Metadata, req.Model)
|
payload = ApplyThinkingMetadata(payload, req.Metadata, req.Model)
|
||||||
payload = util.ApplyGemini3ThinkingLevelFromMetadata(req.Model, req.Metadata, payload)
|
payload = util.ApplyGemini3ThinkingLevelFromMetadata(req.Model, req.Metadata, payload)
|
||||||
payload = util.ApplyDefaultThinkingIfNeeded(req.Model, payload)
|
payload = util.ApplyDefaultThinkingIfNeeded(req.Model, payload)
|
||||||
payload = util.ConvertThinkingLevelToBudget(payload, req.Model)
|
payload = util.ConvertThinkingLevelToBudget(payload, req.Model, true)
|
||||||
payload = util.NormalizeGeminiThinkingBudget(req.Model, payload)
|
payload = util.NormalizeGeminiThinkingBudget(req.Model, payload, true)
|
||||||
payload = util.StripThinkingConfigIfUnsupported(req.Model, payload)
|
payload = util.StripThinkingConfigIfUnsupported(req.Model, payload)
|
||||||
payload = fixGeminiImageAspectRatio(req.Model, payload)
|
payload = fixGeminiImageAspectRatio(req.Model, payload)
|
||||||
payload = applyPayloadConfig(e.cfg, req.Model, payload)
|
payload = applyPayloadConfig(e.cfg, req.Model, payload)
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
|
translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
|
||||||
translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated)
|
translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated)
|
||||||
translated = normalizeAntigravityThinking(req.Model, translated)
|
translated = normalizeAntigravityThinking(req.Model, translated)
|
||||||
|
translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated)
|
||||||
|
|
||||||
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
@@ -191,6 +192,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
|
|||||||
translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
|
translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
|
||||||
translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated)
|
translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated)
|
||||||
translated = normalizeAntigravityThinking(req.Model, translated)
|
translated = normalizeAntigravityThinking(req.Model, translated)
|
||||||
|
translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated)
|
||||||
|
|
||||||
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
@@ -524,6 +526,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
|
|||||||
translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
|
translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
|
||||||
translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated)
|
translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated)
|
||||||
translated = normalizeAntigravityThinking(req.Model, translated)
|
translated = normalizeAntigravityThinking(req.Model, translated)
|
||||||
|
translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated)
|
||||||
|
|
||||||
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema)
|
tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema)
|
||||||
tool, _ = sjson.Delete(tool, "strict")
|
tool, _ = sjson.Delete(tool, "strict")
|
||||||
tool, _ = sjson.Delete(tool, "input_examples")
|
tool, _ = sjson.Delete(tool, "input_examples")
|
||||||
|
tool, _ = sjson.Delete(tool, "type")
|
||||||
|
tool, _ = sjson.Delete(tool, "cache_control")
|
||||||
toolsJSON, _ = sjson.SetRaw(toolsJSON, "0.functionDeclarations.-1", tool)
|
toolsJSON, _ = sjson.SetRaw(toolsJSON, "0.functionDeclarations.-1", tool)
|
||||||
toolDeclCount++
|
toolDeclCount++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,23 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
// Note: OpenAI official fields take precedence over extra_body.google.thinking_config
|
// Note: OpenAI official fields take precedence over extra_body.google.thinking_config
|
||||||
re := gjson.GetBytes(rawJSON, "reasoning_effort")
|
re := gjson.GetBytes(rawJSON, "reasoning_effort")
|
||||||
hasOfficialThinking := re.Exists()
|
hasOfficialThinking := re.Exists()
|
||||||
if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
|
if hasOfficialThinking && util.ModelSupportsThinking(modelName) {
|
||||||
out = util.ApplyReasoningEffortToGeminiCLI(out, re.String())
|
effort := strings.ToLower(strings.TrimSpace(re.String()))
|
||||||
|
if util.IsGemini3Model(modelName) {
|
||||||
|
switch effort {
|
||||||
|
case "none":
|
||||||
|
out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingConfig")
|
||||||
|
case "auto":
|
||||||
|
includeThoughts := true
|
||||||
|
out = util.ApplyGeminiCLIThinkingLevel(out, "", &includeThoughts)
|
||||||
|
default:
|
||||||
|
if level, ok := util.ValidateGemini3ThinkingLevel(modelName, effort); ok {
|
||||||
|
out = util.ApplyGeminiCLIThinkingLevel(out, level, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !util.ModelUsesThinkingLevels(modelName) {
|
||||||
|
out = util.ApplyReasoningEffortToGeminiCLI(out, effort)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent)
|
// Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent)
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// response.created
|
// response.created
|
||||||
created := `{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"instructions":""}}`
|
created := `{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"output":[]}}`
|
||||||
created, _ = sjson.Set(created, "sequence_number", nextSeq())
|
created, _ = sjson.Set(created, "sequence_number", nextSeq())
|
||||||
created, _ = sjson.Set(created, "response.id", st.ResponseID)
|
created, _ = sjson.Set(created, "response.id", st.ResponseID)
|
||||||
created, _ = sjson.Set(created, "response.created_at", st.CreatedAt)
|
created, _ = sjson.Set(created, "response.created_at", st.CreatedAt)
|
||||||
@@ -197,11 +197,11 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
|||||||
if st.ReasoningActive {
|
if st.ReasoningActive {
|
||||||
if t := d.Get("thinking"); t.Exists() {
|
if t := d.Get("thinking"); t.Exists() {
|
||||||
st.ReasoningBuf.WriteString(t.String())
|
st.ReasoningBuf.WriteString(t.String())
|
||||||
msg := `{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`
|
msg := `{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`
|
||||||
msg, _ = sjson.Set(msg, "sequence_number", nextSeq())
|
msg, _ = sjson.Set(msg, "sequence_number", nextSeq())
|
||||||
msg, _ = sjson.Set(msg, "item_id", st.ReasoningItemID)
|
msg, _ = sjson.Set(msg, "item_id", st.ReasoningItemID)
|
||||||
msg, _ = sjson.Set(msg, "output_index", st.ReasoningIndex)
|
msg, _ = sjson.Set(msg, "output_index", st.ReasoningIndex)
|
||||||
msg, _ = sjson.Set(msg, "text", t.String())
|
msg, _ = sjson.Set(msg, "delta", t.String())
|
||||||
out = append(out, emitEvent("response.reasoning_summary_text.delta", msg))
|
out = append(out, emitEvent("response.reasoning_summary_text.delta", msg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
|
|||||||
tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema)
|
tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema)
|
||||||
tool, _ = sjson.Delete(tool, "strict")
|
tool, _ = sjson.Delete(tool, "strict")
|
||||||
tool, _ = sjson.Delete(tool, "input_examples")
|
tool, _ = sjson.Delete(tool, "input_examples")
|
||||||
|
tool, _ = sjson.Delete(tool, "type")
|
||||||
|
tool, _ = sjson.Delete(tool, "cache_control")
|
||||||
var toolDeclaration any
|
var toolDeclaration any
|
||||||
if err := json.Unmarshal([]byte(tool), &toolDeclaration); err == nil {
|
if err := json.Unmarshal([]byte(tool), &toolDeclaration); err == nil {
|
||||||
tools[0].FunctionDeclarations = append(tools[0].FunctionDeclarations, toolDeclaration)
|
tools[0].FunctionDeclarations = append(tools[0].FunctionDeclarations, toolDeclaration)
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema)
|
tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema)
|
||||||
tool, _ = sjson.Delete(tool, "strict")
|
tool, _ = sjson.Delete(tool, "strict")
|
||||||
tool, _ = sjson.Delete(tool, "input_examples")
|
tool, _ = sjson.Delete(tool, "input_examples")
|
||||||
|
tool, _ = sjson.Delete(tool, "type")
|
||||||
|
tool, _ = sjson.Delete(tool, "cache_control")
|
||||||
var toolDeclaration any
|
var toolDeclaration any
|
||||||
if err := json.Unmarshal([]byte(tool), &toolDeclaration); err == nil {
|
if err := json.Unmarshal([]byte(tool), &toolDeclaration); err == nil {
|
||||||
tools[0].FunctionDeclarations = append(tools[0].FunctionDeclarations, toolDeclaration)
|
tools[0].FunctionDeclarations = append(tools[0].FunctionDeclarations, toolDeclaration)
|
||||||
|
|||||||
@@ -37,12 +37,28 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
|
|
||||||
// Reasoning effort -> thinkingBudget/include_thoughts
|
// Reasoning effort -> thinkingBudget/include_thoughts
|
||||||
// Note: OpenAI official fields take precedence over extra_body.google.thinking_config
|
// Note: OpenAI official fields take precedence over extra_body.google.thinking_config
|
||||||
// Only convert for models that use numeric budgets (not discrete levels) to avoid
|
// Only apply numeric budgets for models that use budgets (not discrete levels) to avoid
|
||||||
// incorrectly applying thinkingBudget for level-based models like gpt-5.
|
// incorrectly applying thinkingBudget for level-based models like gpt-5. Gemini 3 models
|
||||||
|
// use thinkingLevel/includeThoughts instead.
|
||||||
re := gjson.GetBytes(rawJSON, "reasoning_effort")
|
re := gjson.GetBytes(rawJSON, "reasoning_effort")
|
||||||
hasOfficialThinking := re.Exists()
|
hasOfficialThinking := re.Exists()
|
||||||
if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
|
if hasOfficialThinking && util.ModelSupportsThinking(modelName) {
|
||||||
out = util.ApplyReasoningEffortToGemini(out, re.String())
|
effort := strings.ToLower(strings.TrimSpace(re.String()))
|
||||||
|
if util.IsGemini3Model(modelName) {
|
||||||
|
switch effort {
|
||||||
|
case "none":
|
||||||
|
out, _ = sjson.DeleteBytes(out, "generationConfig.thinkingConfig")
|
||||||
|
case "auto":
|
||||||
|
includeThoughts := true
|
||||||
|
out = util.ApplyGeminiThinkingLevel(out, "", &includeThoughts)
|
||||||
|
default:
|
||||||
|
if level, ok := util.ValidateGemini3ThinkingLevel(modelName, effort); ok {
|
||||||
|
out = util.ApplyGeminiThinkingLevel(out, level, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !util.ModelUsesThinkingLevels(modelName) {
|
||||||
|
out = util.ApplyReasoningEffortToGemini(out, effort)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent)
|
// Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent)
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
|
|||||||
st.CreatedAt = time.Now().Unix()
|
st.CreatedAt = time.Now().Unix()
|
||||||
}
|
}
|
||||||
|
|
||||||
created := `{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null}}`
|
created := `{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"output":[]}}`
|
||||||
created, _ = sjson.Set(created, "sequence_number", nextSeq())
|
created, _ = sjson.Set(created, "sequence_number", nextSeq())
|
||||||
created, _ = sjson.Set(created, "response.id", st.ResponseID)
|
created, _ = sjson.Set(created, "response.id", st.ResponseID)
|
||||||
created, _ = sjson.Set(created, "response.created_at", st.CreatedAt)
|
created, _ = sjson.Set(created, "response.created_at", st.CreatedAt)
|
||||||
@@ -160,11 +160,11 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
|
|||||||
}
|
}
|
||||||
if t := part.Get("text"); t.Exists() && t.String() != "" {
|
if t := part.Get("text"); t.Exists() && t.String() != "" {
|
||||||
st.ReasoningBuf.WriteString(t.String())
|
st.ReasoningBuf.WriteString(t.String())
|
||||||
msg := `{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`
|
msg := `{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`
|
||||||
msg, _ = sjson.Set(msg, "sequence_number", nextSeq())
|
msg, _ = sjson.Set(msg, "sequence_number", nextSeq())
|
||||||
msg, _ = sjson.Set(msg, "item_id", st.ReasoningItemID)
|
msg, _ = sjson.Set(msg, "item_id", st.ReasoningItemID)
|
||||||
msg, _ = sjson.Set(msg, "output_index", st.ReasoningIndex)
|
msg, _ = sjson.Set(msg, "output_index", st.ReasoningIndex)
|
||||||
msg, _ = sjson.Set(msg, "text", t.String())
|
msg, _ = sjson.Set(msg, "delta", t.String())
|
||||||
out = append(out, emitEvent("response.reasoning_summary_text.delta", msg))
|
out = append(out, emitEvent("response.reasoning_summary_text.delta", msg))
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
|||||||
st.ReasoningTokens = 0
|
st.ReasoningTokens = 0
|
||||||
st.UsageSeen = false
|
st.UsageSeen = false
|
||||||
// response.created
|
// response.created
|
||||||
created := `{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null}}`
|
created := `{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"output":[]}}`
|
||||||
created, _ = sjson.Set(created, "sequence_number", nextSeq())
|
created, _ = sjson.Set(created, "sequence_number", nextSeq())
|
||||||
created, _ = sjson.Set(created, "response.id", st.ResponseID)
|
created, _ = sjson.Set(created, "response.id", st.ResponseID)
|
||||||
created, _ = sjson.Set(created, "response.created_at", st.Created)
|
created, _ = sjson.Set(created, "response.created_at", st.Created)
|
||||||
@@ -216,11 +216,11 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
|||||||
}
|
}
|
||||||
// Append incremental text to reasoning buffer
|
// Append incremental text to reasoning buffer
|
||||||
st.ReasoningBuf.WriteString(rc.String())
|
st.ReasoningBuf.WriteString(rc.String())
|
||||||
msg := `{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`
|
msg := `{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`
|
||||||
msg, _ = sjson.Set(msg, "sequence_number", nextSeq())
|
msg, _ = sjson.Set(msg, "sequence_number", nextSeq())
|
||||||
msg, _ = sjson.Set(msg, "item_id", st.ReasoningID)
|
msg, _ = sjson.Set(msg, "item_id", st.ReasoningID)
|
||||||
msg, _ = sjson.Set(msg, "output_index", st.ReasoningIndex)
|
msg, _ = sjson.Set(msg, "output_index", st.ReasoningIndex)
|
||||||
msg, _ = sjson.Set(msg, "text", rc.String())
|
msg, _ = sjson.Set(msg, "delta", rc.String())
|
||||||
out = append(out, emitRespEvent("response.reasoning_summary_text.delta", msg))
|
out = append(out, emitRespEvent("response.reasoning_summary_text.delta", msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ func flattenTypeArrays(jsonStr string) string {
|
|||||||
func removeUnsupportedKeywords(jsonStr string) string {
|
func removeUnsupportedKeywords(jsonStr string) string {
|
||||||
keywords := append(unsupportedConstraints,
|
keywords := append(unsupportedConstraints,
|
||||||
"$schema", "$defs", "definitions", "const", "$ref", "additionalProperties",
|
"$schema", "$defs", "definitions", "const", "$ref", "additionalProperties",
|
||||||
|
"propertyNames", // Gemini doesn't support property name validation
|
||||||
)
|
)
|
||||||
for _, key := range keywords {
|
for _, key := range keywords {
|
||||||
for _, p := range findPaths(jsonStr, key) {
|
for _, p := range findPaths(jsonStr, key) {
|
||||||
|
|||||||
@@ -596,6 +596,71 @@ func TestCleanJSONSchemaForGemini_MultipleNonNullTypes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_PropertyNamesRemoval(t *testing.T) {
|
||||||
|
// propertyNames is used to validate object property names (e.g., must match a pattern)
|
||||||
|
// Gemini doesn't support this keyword and will reject requests containing it
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"metadata": {
|
||||||
|
"type": "object",
|
||||||
|
"propertyNames": {
|
||||||
|
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$"
|
||||||
|
},
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
expected := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"metadata": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
compareJSON(t, expected, result)
|
||||||
|
|
||||||
|
// Verify propertyNames is completely removed
|
||||||
|
if strings.Contains(result, "propertyNames") {
|
||||||
|
t.Errorf("propertyNames keyword should be removed, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_PropertyNamesRemoval_Nested(t *testing.T) {
|
||||||
|
// Test deeply nested propertyNames (as seen in real Claude tool schemas)
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"config": {
|
||||||
|
"type": "object",
|
||||||
|
"propertyNames": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
|
||||||
|
if strings.Contains(result, "propertyNames") {
|
||||||
|
t.Errorf("Nested propertyNames should be removed, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func compareJSON(t *testing.T, expectedJSON, actualJSON string) {
|
func compareJSON(t *testing.T, expectedJSON, actualJSON string) {
|
||||||
var expMap, actMap map[string]interface{}
|
var expMap, actMap map[string]interface{}
|
||||||
errExp := json.Unmarshal([]byte(expectedJSON), &expMap)
|
errExp := json.Unmarshal([]byte(expectedJSON), &expMap)
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ func ApplyGeminiThinkingLevel(body []byte, level string, includeThoughts *bool)
|
|||||||
updated = rewritten
|
updated = rewritten
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if it := gjson.GetBytes(body, "generationConfig.thinkingConfig.include_thoughts"); it.Exists() {
|
||||||
|
updated, _ = sjson.DeleteBytes(updated, "generationConfig.thinkingConfig.include_thoughts")
|
||||||
|
}
|
||||||
|
if tb := gjson.GetBytes(body, "generationConfig.thinkingConfig.thinkingBudget"); tb.Exists() {
|
||||||
|
updated, _ = sjson.DeleteBytes(updated, "generationConfig.thinkingConfig.thinkingBudget")
|
||||||
|
}
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +173,12 @@ func ApplyGeminiCLIThinkingLevel(body []byte, level string, includeThoughts *boo
|
|||||||
updated = rewritten
|
updated = rewritten
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if it := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); it.Exists() {
|
||||||
|
updated, _ = sjson.DeleteBytes(updated, "request.generationConfig.thinkingConfig.include_thoughts")
|
||||||
|
}
|
||||||
|
if tb := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget"); tb.Exists() {
|
||||||
|
updated, _ = sjson.DeleteBytes(updated, "request.generationConfig.thinkingConfig.thinkingBudget")
|
||||||
|
}
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,8 +364,9 @@ func StripThinkingConfigIfUnsupported(model string, body []byte) []byte {
|
|||||||
|
|
||||||
// NormalizeGeminiThinkingBudget normalizes the thinkingBudget value in a standard Gemini
|
// NormalizeGeminiThinkingBudget normalizes the thinkingBudget value in a standard Gemini
|
||||||
// request body (generationConfig.thinkingConfig.thinkingBudget path).
|
// request body (generationConfig.thinkingConfig.thinkingBudget path).
|
||||||
// For Gemini 3 models, converts thinkingBudget to thinkingLevel per Google's documentation.
|
// For Gemini 3 models, converts thinkingBudget to thinkingLevel per Google's documentation,
|
||||||
func NormalizeGeminiThinkingBudget(model string, body []byte) []byte {
|
// unless skipGemini3Check is provided and true.
|
||||||
|
func NormalizeGeminiThinkingBudget(model string, body []byte, skipGemini3Check ...bool) []byte {
|
||||||
const budgetPath = "generationConfig.thinkingConfig.thinkingBudget"
|
const budgetPath = "generationConfig.thinkingConfig.thinkingBudget"
|
||||||
const levelPath = "generationConfig.thinkingConfig.thinkingLevel"
|
const levelPath = "generationConfig.thinkingConfig.thinkingLevel"
|
||||||
|
|
||||||
@@ -363,7 +376,8 @@ func NormalizeGeminiThinkingBudget(model string, body []byte) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For Gemini 3 models, convert thinkingBudget to thinkingLevel
|
// For Gemini 3 models, convert thinkingBudget to thinkingLevel
|
||||||
if IsGemini3Model(model) {
|
skipGemini3 := len(skipGemini3Check) > 0 && skipGemini3Check[0]
|
||||||
|
if IsGemini3Model(model) && !skipGemini3 {
|
||||||
if level, ok := ThinkingBudgetToGemini3Level(model, int(budget.Int())); ok {
|
if level, ok := ThinkingBudgetToGemini3Level(model, int(budget.Int())); ok {
|
||||||
updated, _ := sjson.SetBytes(body, levelPath, level)
|
updated, _ := sjson.SetBytes(body, levelPath, level)
|
||||||
updated, _ = sjson.DeleteBytes(updated, budgetPath)
|
updated, _ = sjson.DeleteBytes(updated, budgetPath)
|
||||||
@@ -382,8 +396,9 @@ func NormalizeGeminiThinkingBudget(model string, body []byte) []byte {
|
|||||||
|
|
||||||
// NormalizeGeminiCLIThinkingBudget normalizes the thinkingBudget value in a Gemini CLI
|
// NormalizeGeminiCLIThinkingBudget normalizes the thinkingBudget value in a Gemini CLI
|
||||||
// request body (request.generationConfig.thinkingConfig.thinkingBudget path).
|
// request body (request.generationConfig.thinkingConfig.thinkingBudget path).
|
||||||
// For Gemini 3 models, converts thinkingBudget to thinkingLevel per Google's documentation.
|
// For Gemini 3 models, converts thinkingBudget to thinkingLevel per Google's documentation,
|
||||||
func NormalizeGeminiCLIThinkingBudget(model string, body []byte) []byte {
|
// unless skipGemini3Check is provided and true.
|
||||||
|
func NormalizeGeminiCLIThinkingBudget(model string, body []byte, skipGemini3Check ...bool) []byte {
|
||||||
const budgetPath = "request.generationConfig.thinkingConfig.thinkingBudget"
|
const budgetPath = "request.generationConfig.thinkingConfig.thinkingBudget"
|
||||||
const levelPath = "request.generationConfig.thinkingConfig.thinkingLevel"
|
const levelPath = "request.generationConfig.thinkingConfig.thinkingLevel"
|
||||||
|
|
||||||
@@ -393,7 +408,8 @@ func NormalizeGeminiCLIThinkingBudget(model string, body []byte) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For Gemini 3 models, convert thinkingBudget to thinkingLevel
|
// For Gemini 3 models, convert thinkingBudget to thinkingLevel
|
||||||
if IsGemini3Model(model) {
|
skipGemini3 := len(skipGemini3Check) > 0 && skipGemini3Check[0]
|
||||||
|
if IsGemini3Model(model) && !skipGemini3 {
|
||||||
if level, ok := ThinkingBudgetToGemini3Level(model, int(budget.Int())); ok {
|
if level, ok := ThinkingBudgetToGemini3Level(model, int(budget.Int())); ok {
|
||||||
updated, _ := sjson.SetBytes(body, levelPath, level)
|
updated, _ := sjson.SetBytes(body, levelPath, level)
|
||||||
updated, _ = sjson.DeleteBytes(updated, budgetPath)
|
updated, _ = sjson.DeleteBytes(updated, budgetPath)
|
||||||
@@ -477,7 +493,7 @@ func ApplyReasoningEffortToGeminiCLI(body []byte, effort string) []byte {
|
|||||||
|
|
||||||
// ConvertThinkingLevelToBudget checks for "generationConfig.thinkingConfig.thinkingLevel"
|
// ConvertThinkingLevelToBudget checks for "generationConfig.thinkingConfig.thinkingLevel"
|
||||||
// and converts it to "thinkingBudget" for Gemini 2.5 models.
|
// and converts it to "thinkingBudget" for Gemini 2.5 models.
|
||||||
// For Gemini 3 models, preserves thinkingLevel as-is (does not convert).
|
// For Gemini 3 models, preserves thinkingLevel unless skipGemini3Check is provided and true.
|
||||||
// Mappings for Gemini 2.5:
|
// Mappings for Gemini 2.5:
|
||||||
// - "high" -> 32768
|
// - "high" -> 32768
|
||||||
// - "medium" -> 8192
|
// - "medium" -> 8192
|
||||||
@@ -485,43 +501,31 @@ func ApplyReasoningEffortToGeminiCLI(body []byte, effort string) []byte {
|
|||||||
// - "minimal" -> 512
|
// - "minimal" -> 512
|
||||||
//
|
//
|
||||||
// It removes "thinkingLevel" after conversion (for Gemini 2.5 only).
|
// It removes "thinkingLevel" after conversion (for Gemini 2.5 only).
|
||||||
func ConvertThinkingLevelToBudget(body []byte, model string) []byte {
|
func ConvertThinkingLevelToBudget(body []byte, model string, skipGemini3Check ...bool) []byte {
|
||||||
levelPath := "generationConfig.thinkingConfig.thinkingLevel"
|
levelPath := "generationConfig.thinkingConfig.thinkingLevel"
|
||||||
res := gjson.GetBytes(body, levelPath)
|
res := gjson.GetBytes(body, levelPath)
|
||||||
if !res.Exists() {
|
if !res.Exists() {
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gemini 3 models, preserve thinkingLevel - don't convert to budget
|
// For Gemini 3 models, preserve thinkingLevel unless explicitly skipped
|
||||||
if IsGemini3Model(model) {
|
skipGemini3 := len(skipGemini3Check) > 0 && skipGemini3Check[0]
|
||||||
|
if IsGemini3Model(model) && !skipGemini3 {
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
level := strings.ToLower(res.String())
|
budget, ok := ThinkingLevelToBudget(res.String())
|
||||||
var budget int
|
if !ok {
|
||||||
switch level {
|
|
||||||
case "high":
|
|
||||||
budget = 32768
|
|
||||||
case "medium":
|
|
||||||
budget = 8192
|
|
||||||
case "low":
|
|
||||||
budget = 1024
|
|
||||||
case "minimal":
|
|
||||||
budget = 512
|
|
||||||
default:
|
|
||||||
// Unknown level - remove it and let the API use defaults
|
|
||||||
updated, _ := sjson.DeleteBytes(body, levelPath)
|
updated, _ := sjson.DeleteBytes(body, levelPath)
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set budget
|
|
||||||
budgetPath := "generationConfig.thinkingConfig.thinkingBudget"
|
budgetPath := "generationConfig.thinkingConfig.thinkingBudget"
|
||||||
updated, err := sjson.SetBytes(body, budgetPath, budget)
|
updated, err := sjson.SetBytes(body, budgetPath, budget)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove level
|
|
||||||
updated, err = sjson.DeleteBytes(updated, levelPath)
|
updated, err = sjson.DeleteBytes(updated, levelPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return body
|
return body
|
||||||
@@ -544,31 +548,18 @@ func ConvertThinkingLevelToBudgetCLI(body []byte, model string) []byte {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
level := strings.ToLower(res.String())
|
budget, ok := ThinkingLevelToBudget(res.String())
|
||||||
var budget int
|
if !ok {
|
||||||
switch level {
|
|
||||||
case "high":
|
|
||||||
budget = 32768
|
|
||||||
case "medium":
|
|
||||||
budget = 8192
|
|
||||||
case "low":
|
|
||||||
budget = 1024
|
|
||||||
case "minimal":
|
|
||||||
budget = 512
|
|
||||||
default:
|
|
||||||
// Unknown level - remove it and let the API use defaults
|
|
||||||
updated, _ := sjson.DeleteBytes(body, levelPath)
|
updated, _ := sjson.DeleteBytes(body, levelPath)
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set budget
|
|
||||||
budgetPath := "request.generationConfig.thinkingConfig.thinkingBudget"
|
budgetPath := "request.generationConfig.thinkingConfig.thinkingBudget"
|
||||||
updated, err := sjson.SetBytes(body, budgetPath, budget)
|
updated, err := sjson.SetBytes(body, budgetPath, budget)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove level
|
|
||||||
updated, err = sjson.DeleteBytes(updated, levelPath)
|
updated, err = sjson.DeleteBytes(updated, levelPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return body
|
return body
|
||||||
|
|||||||
@@ -160,6 +160,34 @@ func ThinkingEffortToBudget(model, effort string) (int, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThinkingLevelToBudget maps a Gemini thinkingLevel to a numeric thinking budget (tokens).
|
||||||
|
//
|
||||||
|
// Mappings:
|
||||||
|
// - "minimal" -> 512
|
||||||
|
// - "low" -> 1024
|
||||||
|
// - "medium" -> 8192
|
||||||
|
// - "high" -> 32768
|
||||||
|
//
|
||||||
|
// Returns false when the level is empty or unsupported.
|
||||||
|
func ThinkingLevelToBudget(level string) (int, bool) {
|
||||||
|
if level == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(level))
|
||||||
|
switch normalized {
|
||||||
|
case "minimal":
|
||||||
|
return 512, true
|
||||||
|
case "low":
|
||||||
|
return 1024, true
|
||||||
|
case "medium":
|
||||||
|
return 8192, true
|
||||||
|
case "high":
|
||||||
|
return 32768, true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ThinkingBudgetToEffort maps a numeric thinking budget (tokens)
|
// ThinkingBudgetToEffort maps a numeric thinking budget (tokens)
|
||||||
// to a reasoning effort level for level-based models.
|
// to a reasoning effort level for level-based models.
|
||||||
//
|
//
|
||||||
|
|||||||
46
sdk/api/options.go
Normal file
46
sdk/api/options.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Package api exposes server option helpers for embedding CLIProxyAPI.
|
||||||
|
//
|
||||||
|
// It wraps internal server option types so external projects can configure the embedded
|
||||||
|
// HTTP server without importing internal packages.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
internalapi "github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerOption customises HTTP server construction.
|
||||||
|
type ServerOption = internalapi.ServerOption
|
||||||
|
|
||||||
|
// WithMiddleware appends additional Gin middleware during server construction.
|
||||||
|
func WithMiddleware(mw ...gin.HandlerFunc) ServerOption { return internalapi.WithMiddleware(mw...) }
|
||||||
|
|
||||||
|
// WithEngineConfigurator allows callers to mutate the Gin engine prior to middleware setup.
|
||||||
|
func WithEngineConfigurator(fn func(*gin.Engine)) ServerOption {
|
||||||
|
return internalapi.WithEngineConfigurator(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRouterConfigurator appends a callback after default routes are registered.
|
||||||
|
func WithRouterConfigurator(fn func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)) ServerOption {
|
||||||
|
return internalapi.WithRouterConfigurator(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLocalManagementPassword stores a runtime-only management password accepted for localhost requests.
|
||||||
|
func WithLocalManagementPassword(password string) ServerOption {
|
||||||
|
return internalapi.WithLocalManagementPassword(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithKeepAliveEndpoint enables a keep-alive endpoint with the provided timeout and callback.
|
||||||
|
func WithKeepAliveEndpoint(timeout time.Duration, onTimeout func()) ServerOption {
|
||||||
|
return internalapi.WithKeepAliveEndpoint(timeout, onTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRequestLoggerFactory customises request logger creation.
|
||||||
|
func WithRequestLoggerFactory(factory func(*config.Config, string) logging.RequestLogger) ServerOption {
|
||||||
|
return internalapi.WithRequestLoggerFactory(factory)
|
||||||
|
}
|
||||||
@@ -72,7 +72,9 @@ func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (str
|
|||||||
return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal)
|
return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal)
|
||||||
}
|
}
|
||||||
if existing, errRead := os.ReadFile(path); errRead == nil {
|
if existing, errRead := os.ReadFile(path); errRead == nil {
|
||||||
if jsonEqual(existing, raw) {
|
// Use metadataEqualIgnoringTimestamps to skip writes when only timestamp fields change.
|
||||||
|
// This prevents the token refresh loop caused by timestamp/expired/expires_in changes.
|
||||||
|
if metadataEqualIgnoringTimestamps(existing, raw) {
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
} else if errRead != nil && !os.IsNotExist(errRead) {
|
} else if errRead != nil && !os.IsNotExist(errRead) {
|
||||||
@@ -264,6 +266,8 @@ func (s *FileTokenStore) baseDirSnapshot() string {
|
|||||||
return s.baseDir
|
return s.baseDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DEPRECATED: Use metadataEqualIgnoringTimestamps for comparing auth metadata.
|
||||||
|
// This function is kept for backward compatibility but can cause refresh loops.
|
||||||
func jsonEqual(a, b []byte) bool {
|
func jsonEqual(a, b []byte) bool {
|
||||||
var objA any
|
var objA any
|
||||||
var objB any
|
var objB any
|
||||||
@@ -276,6 +280,32 @@ func jsonEqual(a, b []byte) bool {
|
|||||||
return deepEqualJSON(objA, objB)
|
return deepEqualJSON(objA, objB)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// metadataEqualIgnoringTimestamps compares two metadata JSON blobs,
|
||||||
|
// ignoring fields that change on every refresh but don't affect functionality.
|
||||||
|
// This prevents unnecessary file writes that would trigger watcher events and
|
||||||
|
// create refresh loops.
|
||||||
|
func metadataEqualIgnoringTimestamps(a, b []byte) bool {
|
||||||
|
var objA, objB map[string]any
|
||||||
|
if err := json.Unmarshal(a, &objA); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &objB); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fields to ignore: these change on every refresh but don't affect authentication logic.
|
||||||
|
// - timestamp, expired, expires_in, last_refresh: time-related fields that change on refresh
|
||||||
|
// - access_token: Google OAuth returns a new access_token on each refresh, this is expected
|
||||||
|
// and shouldn't trigger file writes (the new token will be fetched again when needed)
|
||||||
|
ignoredFields := []string{"timestamp", "expired", "expires_in", "last_refresh", "access_token"}
|
||||||
|
for _, field := range ignoredFields {
|
||||||
|
delete(objA, field)
|
||||||
|
delete(objB, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return deepEqualJSON(objA, objB)
|
||||||
|
}
|
||||||
|
|
||||||
func deepEqualJSON(a, b any) bool {
|
func deepEqualJSON(a, b any) bool {
|
||||||
switch valA := a.(type) {
|
switch valA := a.(type) {
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
||||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Builder constructs a Service instance with customizable providers.
|
// Builder constructs a Service instance with customizable providers.
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package cliproxy
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewFileTokenClientProvider returns the default token-backed client loader.
|
// NewFileTokenClientProvider returns the default token-backed client loader.
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
|
||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||||
@@ -23,6 +22,7 @@ import (
|
|||||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ package cliproxy
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
|
||||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TokenClientProvider loads clients backed by stored authentication tokens.
|
// TokenClientProvider loads clients backed by stored authentication tokens.
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package cliproxy
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
|
||||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func defaultWatcherFactory(configPath, authDir string, reload func(*config.Config)) (*WatcherWrapper, error) {
|
func defaultWatcherFactory(configPath, authDir string, reload func(*config.Config)) (*WatcherWrapper, error) {
|
||||||
|
|||||||
@@ -1,87 +1,59 @@
|
|||||||
// Package config provides configuration management for the CLI Proxy API server.
|
// Package config provides the public SDK configuration API.
|
||||||
// It handles loading and parsing YAML configuration files, and provides structured
|
//
|
||||||
// access to application settings including server port, authentication directory,
|
// It re-exports the server configuration types and helpers so external projects can
|
||||||
// debug settings, proxy configuration, and API keys.
|
// embed CLIProxyAPI without importing internal packages.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
// SDKConfig represents the application's configuration, loaded from a YAML file.
|
import internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
type SDKConfig struct {
|
|
||||||
// ProxyURL is the URL of an optional proxy server to use for outbound requests.
|
|
||||||
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
|
|
||||||
|
|
||||||
// ForceModelPrefix requires explicit model prefixes (e.g., "teamA/gemini-3-pro-preview")
|
type SDKConfig = internalconfig.SDKConfig
|
||||||
// to target prefixed credentials. When false, unprefixed model requests may use prefixed
|
type AccessConfig = internalconfig.AccessConfig
|
||||||
// credentials as well.
|
type AccessProvider = internalconfig.AccessProvider
|
||||||
ForceModelPrefix bool `yaml:"force-model-prefix" json:"force-model-prefix"`
|
|
||||||
|
|
||||||
// RequestLog enables or disables detailed request logging functionality.
|
type Config = internalconfig.Config
|
||||||
RequestLog bool `yaml:"request-log" json:"request-log"`
|
|
||||||
|
|
||||||
// APIKeys is a list of keys for authenticating clients to this proxy server.
|
type TLSConfig = internalconfig.TLSConfig
|
||||||
APIKeys []string `yaml:"api-keys" json:"api-keys"`
|
type RemoteManagement = internalconfig.RemoteManagement
|
||||||
|
type AmpCode = internalconfig.AmpCode
|
||||||
|
type PayloadConfig = internalconfig.PayloadConfig
|
||||||
|
type PayloadRule = internalconfig.PayloadRule
|
||||||
|
type PayloadModelRule = internalconfig.PayloadModelRule
|
||||||
|
|
||||||
// Access holds request authentication provider configuration.
|
type GeminiKey = internalconfig.GeminiKey
|
||||||
Access AccessConfig `yaml:"auth,omitempty" json:"auth,omitempty"`
|
type CodexKey = internalconfig.CodexKey
|
||||||
}
|
type ClaudeKey = internalconfig.ClaudeKey
|
||||||
|
type VertexCompatKey = internalconfig.VertexCompatKey
|
||||||
|
type VertexCompatModel = internalconfig.VertexCompatModel
|
||||||
|
type OpenAICompatibility = internalconfig.OpenAICompatibility
|
||||||
|
type OpenAICompatibilityAPIKey = internalconfig.OpenAICompatibilityAPIKey
|
||||||
|
type OpenAICompatibilityModel = internalconfig.OpenAICompatibilityModel
|
||||||
|
|
||||||
// AccessConfig groups request authentication providers.
|
type TLS = internalconfig.TLSConfig
|
||||||
type AccessConfig struct {
|
|
||||||
// Providers lists configured authentication providers.
|
|
||||||
Providers []AccessProvider `yaml:"providers,omitempty" json:"providers,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AccessProvider describes a request authentication provider entry.
|
|
||||||
type AccessProvider struct {
|
|
||||||
// Name is the instance identifier for the provider.
|
|
||||||
Name string `yaml:"name" json:"name"`
|
|
||||||
|
|
||||||
// Type selects the provider implementation registered via the SDK.
|
|
||||||
Type string `yaml:"type" json:"type"`
|
|
||||||
|
|
||||||
// SDK optionally names a third-party SDK module providing this provider.
|
|
||||||
SDK string `yaml:"sdk,omitempty" json:"sdk,omitempty"`
|
|
||||||
|
|
||||||
// APIKeys lists inline keys for providers that require them.
|
|
||||||
APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"`
|
|
||||||
|
|
||||||
// Config passes provider-specific options to the implementation.
|
|
||||||
Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// AccessProviderTypeConfigAPIKey is the built-in provider validating inline API keys.
|
AccessProviderTypeConfigAPIKey = internalconfig.AccessProviderTypeConfigAPIKey
|
||||||
AccessProviderTypeConfigAPIKey = "config-api-key"
|
DefaultAccessProviderName = internalconfig.DefaultAccessProviderName
|
||||||
|
DefaultPanelGitHubRepository = internalconfig.DefaultPanelGitHubRepository
|
||||||
// DefaultAccessProviderName is applied when no provider name is supplied.
|
|
||||||
DefaultAccessProviderName = "config-inline"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConfigAPIKeyProvider returns the first inline API key provider if present.
|
func MakeInlineAPIKeyProvider(keys []string) *AccessProvider {
|
||||||
func (c *SDKConfig) ConfigAPIKeyProvider() *AccessProvider {
|
return internalconfig.MakeInlineAPIKeyProvider(keys)
|
||||||
if c == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for i := range c.Access.Providers {
|
|
||||||
if c.Access.Providers[i].Type == AccessProviderTypeConfigAPIKey {
|
|
||||||
if c.Access.Providers[i].Name == "" {
|
|
||||||
c.Access.Providers[i].Name = DefaultAccessProviderName
|
|
||||||
}
|
|
||||||
return &c.Access.Providers[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MakeInlineAPIKeyProvider constructs an inline API key provider configuration.
|
func LoadConfig(configFile string) (*Config, error) { return internalconfig.LoadConfig(configFile) }
|
||||||
// It returns nil when no keys are supplied.
|
|
||||||
func MakeInlineAPIKeyProvider(keys []string) *AccessProvider {
|
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
||||||
if len(keys) == 0 {
|
return internalconfig.LoadConfigOptional(configFile, optional)
|
||||||
return nil
|
}
|
||||||
}
|
|
||||||
provider := &AccessProvider{
|
func SaveConfigPreserveComments(configFile string, cfg *Config) error {
|
||||||
Name: DefaultAccessProviderName,
|
return internalconfig.SaveConfigPreserveComments(configFile, cfg)
|
||||||
Type: AccessProviderTypeConfigAPIKey,
|
}
|
||||||
APIKeys: append([]string(nil), keys...),
|
|
||||||
}
|
func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {
|
||||||
return provider
|
return internalconfig.SaveConfigPreserveCommentsUpdateNestedScalar(configFile, path, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeCommentIndentation(data []byte) []byte {
|
||||||
|
return internalconfig.NormalizeCommentIndentation(data)
|
||||||
}
|
}
|
||||||
|
|||||||
18
sdk/logging/request_logger.go
Normal file
18
sdk/logging/request_logger.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Package logging re-exports request logging primitives for SDK consumers.
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||||
|
|
||||||
|
// RequestLogger defines the interface for logging HTTP requests and responses.
|
||||||
|
type RequestLogger = internallogging.RequestLogger
|
||||||
|
|
||||||
|
// StreamingLogWriter handles real-time logging of streaming response chunks.
|
||||||
|
type StreamingLogWriter = internallogging.StreamingLogWriter
|
||||||
|
|
||||||
|
// FileRequestLogger implements RequestLogger using file-based storage.
|
||||||
|
type FileRequestLogger = internallogging.FileRequestLogger
|
||||||
|
|
||||||
|
// NewFileRequestLogger creates a new file-based request logger.
|
||||||
|
func NewFileRequestLogger(enabled bool, logsDir string, configDir string) *FileRequestLogger {
|
||||||
|
return internallogging.NewFileRequestLogger(enabled, logsDir, configDir)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user