mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-30 01:06:39 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbecf5330e | ||
|
|
1c0e102637 | ||
|
|
6b6b343922 | ||
|
|
f7d82fda3f | ||
|
|
25c6b479c7 | ||
|
|
7cf9ff0345 | ||
|
|
209d74062a | ||
|
|
d86b13c9cb | ||
|
|
075e3ab69e | ||
|
|
49ef22ab78 | ||
|
|
ae4638712e | ||
|
|
c1c9483752 | ||
|
|
6c65fdf54b | ||
|
|
4874253d1e | ||
|
|
b72250349f | ||
|
|
116573311f | ||
|
|
4af712544d | ||
|
|
3f9c9591bd | ||
|
|
1548c567ab | ||
|
|
5b23fc570c | ||
|
|
04e1c7a05a | ||
|
|
9181e72204 | ||
|
|
4939865f6d | ||
|
|
3da7f7482e | ||
|
|
9072b029b2 | ||
|
|
c296cfb8c0 | ||
|
|
a275db3fdb |
@@ -40,6 +40,11 @@ api-keys:
|
|||||||
# Enable debug logging
|
# Enable debug logging
|
||||||
debug: false
|
debug: false
|
||||||
|
|
||||||
|
# Enable pprof HTTP debug server (host:port). Keep it bound to localhost for safety.
|
||||||
|
pprof:
|
||||||
|
enable: false
|
||||||
|
addr: "127.0.0.1:8316"
|
||||||
|
|
||||||
# When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency.
|
# When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency.
|
||||||
commercial-mode: false
|
commercial-mode: false
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultPanelGitHubRepository = "https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
|
const (
|
||||||
|
DefaultPanelGitHubRepository = "https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
|
||||||
|
DefaultPprofAddr = "127.0.0.1:8316"
|
||||||
|
)
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -41,6 +44,9 @@ type Config struct {
|
|||||||
// Debug enables or disables debug-level logging and other debug features.
|
// Debug enables or disables debug-level logging and other debug features.
|
||||||
Debug bool `yaml:"debug" json:"debug"`
|
Debug bool `yaml:"debug" json:"debug"`
|
||||||
|
|
||||||
|
// Pprof config controls the optional pprof HTTP debug server.
|
||||||
|
Pprof PprofConfig `yaml:"pprof" json:"pprof"`
|
||||||
|
|
||||||
// CommercialMode disables high-overhead HTTP middleware features to minimize per-request memory usage.
|
// CommercialMode disables high-overhead HTTP middleware features to minimize per-request memory usage.
|
||||||
CommercialMode bool `yaml:"commercial-mode" json:"commercial-mode"`
|
CommercialMode bool `yaml:"commercial-mode" json:"commercial-mode"`
|
||||||
|
|
||||||
@@ -134,6 +140,14 @@ type TLSConfig struct {
|
|||||||
Key string `yaml:"key" json:"key"`
|
Key string `yaml:"key" json:"key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PprofConfig holds pprof HTTP server settings.
|
||||||
|
type PprofConfig struct {
|
||||||
|
// Enable toggles the pprof HTTP debug server.
|
||||||
|
Enable bool `yaml:"enable" json:"enable"`
|
||||||
|
// Addr is the host:port address for the pprof HTTP server.
|
||||||
|
Addr string `yaml:"addr" json:"addr"`
|
||||||
|
}
|
||||||
|
|
||||||
// RemoteManagement holds management API configuration under 'remote-management'.
|
// RemoteManagement holds management API configuration under 'remote-management'.
|
||||||
type RemoteManagement struct {
|
type RemoteManagement struct {
|
||||||
// AllowRemote toggles remote (non-localhost) access to management API.
|
// AllowRemote toggles remote (non-localhost) access to management API.
|
||||||
@@ -556,6 +570,8 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
|||||||
cfg.ErrorLogsMaxFiles = 10
|
cfg.ErrorLogsMaxFiles = 10
|
||||||
cfg.UsageStatisticsEnabled = false
|
cfg.UsageStatisticsEnabled = false
|
||||||
cfg.DisableCooling = false
|
cfg.DisableCooling = false
|
||||||
|
cfg.Pprof.Enable = false
|
||||||
|
cfg.Pprof.Addr = DefaultPprofAddr
|
||||||
cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
|
cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
|
||||||
cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
|
cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
|
||||||
cfg.IncognitoBrowser = false // Default to normal browser (AWS uses incognito by force)
|
cfg.IncognitoBrowser = false // Default to normal browser (AWS uses incognito by force)
|
||||||
@@ -599,6 +615,11 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
|||||||
cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
|
cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg.Pprof.Addr = strings.TrimSpace(cfg.Pprof.Addr)
|
||||||
|
if cfg.Pprof.Addr == "" {
|
||||||
|
cfg.Pprof.Addr = DefaultPprofAddr
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.LogsMaxTotalSizeMB < 0 {
|
if cfg.LogsMaxTotalSizeMB < 0 {
|
||||||
cfg.LogsMaxTotalSizeMB = 0
|
cfg.LogsMaxTotalSizeMB = 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,7 +132,10 @@ func ResolveLogDirectory(cfg *config.Config) string {
|
|||||||
return logDir
|
return logDir
|
||||||
}
|
}
|
||||||
if !isDirWritable(logDir) {
|
if !isDirWritable(logDir) {
|
||||||
authDir := strings.TrimSpace(cfg.AuthDir)
|
authDir, err := util.ResolveAuthDir(cfg.AuthDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("Failed to resolve auth-dir %q for log directory: %v", cfg.AuthDir, err)
|
||||||
|
}
|
||||||
if authDir != "" {
|
if authDir != "" {
|
||||||
logDir = filepath.Join(authDir, "logs")
|
logDir = filepath.Join(authDir, "logs")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -803,6 +803,7 @@ func GetIFlowModels() []*ModelInfo {
|
|||||||
{ID: "minimax-m2", DisplayName: "MiniMax-M2", Description: "MiniMax M2", Created: 1758672000, Thinking: iFlowThinkingSupport},
|
{ID: "minimax-m2", DisplayName: "MiniMax-M2", Description: "MiniMax M2", Created: 1758672000, Thinking: iFlowThinkingSupport},
|
||||||
{ID: "minimax-m2.1", DisplayName: "MiniMax-M2.1", Description: "MiniMax M2.1", Created: 1766448000, Thinking: iFlowThinkingSupport},
|
{ID: "minimax-m2.1", DisplayName: "MiniMax-M2.1", Description: "MiniMax M2.1", Created: 1766448000, Thinking: iFlowThinkingSupport},
|
||||||
{ID: "iflow-rome-30ba3b", DisplayName: "iFlow-ROME", Description: "iFlow Rome 30BA3B model", Created: 1736899200},
|
{ID: "iflow-rome-30ba3b", DisplayName: "iFlow-ROME", Description: "iFlow Rome 30BA3B model", Created: 1736899200},
|
||||||
|
{ID: "kimi-k2.5", DisplayName: "Kimi-K2.5", Description: "Moonshot Kimi K2.5", Created: 1769443200, Thinking: iFlowThinkingSupport},
|
||||||
}
|
}
|
||||||
models := make([]*ModelInfo, 0, len(entries))
|
models := make([]*ModelInfo, 0, len(entries))
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
|
|||||||
@@ -1280,51 +1280,40 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
|
|||||||
payload = geminiToAntigravity(modelName, payload, projectID)
|
payload = geminiToAntigravity(modelName, payload, projectID)
|
||||||
payload, _ = sjson.SetBytes(payload, "model", modelName)
|
payload, _ = sjson.SetBytes(payload, "model", modelName)
|
||||||
|
|
||||||
if strings.Contains(modelName, "claude") || strings.Contains(modelName, "gemini-3-pro-high") {
|
useAntigravitySchema := strings.Contains(modelName, "claude") || strings.Contains(modelName, "gemini-3-pro-high")
|
||||||
strJSON := string(payload)
|
payloadStr := string(payload)
|
||||||
paths := make([]string, 0)
|
paths := make([]string, 0)
|
||||||
util.Walk(gjson.ParseBytes(payload), "", "parametersJsonSchema", &paths)
|
util.Walk(gjson.Parse(payloadStr), "", "parametersJsonSchema", &paths)
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
strJSON, _ = util.RenameKey(strJSON, p, p[:len(p)-len("parametersJsonSchema")]+"parameters")
|
payloadStr, _ = util.RenameKey(payloadStr, p, p[:len(p)-len("parametersJsonSchema")]+"parameters")
|
||||||
}
|
|
||||||
|
|
||||||
// Use the centralized schema cleaner to handle unsupported keywords,
|
|
||||||
// const->enum conversion, and flattening of types/anyOf.
|
|
||||||
strJSON = util.CleanJSONSchemaForAntigravity(strJSON)
|
|
||||||
payload = []byte(strJSON)
|
|
||||||
} else {
|
|
||||||
strJSON := string(payload)
|
|
||||||
paths := make([]string, 0)
|
|
||||||
util.Walk(gjson.Parse(strJSON), "", "parametersJsonSchema", &paths)
|
|
||||||
for _, p := range paths {
|
|
||||||
strJSON, _ = util.RenameKey(strJSON, p, p[:len(p)-len("parametersJsonSchema")]+"parameters")
|
|
||||||
}
|
|
||||||
// Clean tool schemas for Gemini to remove unsupported JSON Schema keywords
|
|
||||||
// without adding empty-schema placeholders.
|
|
||||||
strJSON = util.CleanJSONSchemaForGemini(strJSON)
|
|
||||||
payload = []byte(strJSON)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(modelName, "claude") || strings.Contains(modelName, "gemini-3-pro-high") {
|
if useAntigravitySchema {
|
||||||
systemInstructionPartsResult := gjson.GetBytes(payload, "request.systemInstruction.parts")
|
payloadStr = util.CleanJSONSchemaForAntigravity(payloadStr)
|
||||||
payload, _ = sjson.SetBytes(payload, "request.systemInstruction.role", "user")
|
} else {
|
||||||
payload, _ = sjson.SetBytes(payload, "request.systemInstruction.parts.0.text", systemInstruction)
|
payloadStr = util.CleanJSONSchemaForGemini(payloadStr)
|
||||||
payload, _ = sjson.SetBytes(payload, "request.systemInstruction.parts.1.text", fmt.Sprintf("Please ignore following [ignore]%s[/ignore]", systemInstruction))
|
}
|
||||||
|
|
||||||
|
if useAntigravitySchema {
|
||||||
|
systemInstructionPartsResult := gjson.Get(payloadStr, "request.systemInstruction.parts")
|
||||||
|
payloadStr, _ = sjson.Set(payloadStr, "request.systemInstruction.role", "user")
|
||||||
|
payloadStr, _ = sjson.Set(payloadStr, "request.systemInstruction.parts.0.text", systemInstruction)
|
||||||
|
payloadStr, _ = sjson.Set(payloadStr, "request.systemInstruction.parts.1.text", fmt.Sprintf("Please ignore following [ignore]%s[/ignore]", systemInstruction))
|
||||||
|
|
||||||
if systemInstructionPartsResult.Exists() && systemInstructionPartsResult.IsArray() {
|
if systemInstructionPartsResult.Exists() && systemInstructionPartsResult.IsArray() {
|
||||||
for _, partResult := range systemInstructionPartsResult.Array() {
|
for _, partResult := range systemInstructionPartsResult.Array() {
|
||||||
payload, _ = sjson.SetRawBytes(payload, "request.systemInstruction.parts.-1", []byte(partResult.Raw))
|
payloadStr, _ = sjson.SetRaw(payloadStr, "request.systemInstruction.parts.-1", partResult.Raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(modelName, "claude") {
|
if strings.Contains(modelName, "claude") {
|
||||||
payload, _ = sjson.SetBytes(payload, "request.toolConfig.functionCallingConfig.mode", "VALIDATED")
|
payloadStr, _ = sjson.Set(payloadStr, "request.toolConfig.functionCallingConfig.mode", "VALIDATED")
|
||||||
} else {
|
} else {
|
||||||
payload, _ = sjson.DeleteBytes(payload, "request.generationConfig.maxOutputTokens")
|
payloadStr, _ = sjson.Delete(payloadStr, "request.generationConfig.maxOutputTokens")
|
||||||
}
|
}
|
||||||
|
|
||||||
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload))
|
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), strings.NewReader(payloadStr))
|
||||||
if errReq != nil {
|
if errReq != nil {
|
||||||
return nil, errReq
|
return nil, errReq
|
||||||
}
|
}
|
||||||
@@ -1346,11 +1335,15 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
|
|||||||
authLabel = auth.Label
|
authLabel = auth.Label
|
||||||
authType, authValue = auth.AccountInfo()
|
authType, authValue = auth.AccountInfo()
|
||||||
}
|
}
|
||||||
|
var payloadLog []byte
|
||||||
|
if e.cfg != nil && e.cfg.RequestLog {
|
||||||
|
payloadLog = []byte(payloadStr)
|
||||||
|
}
|
||||||
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
||||||
URL: requestURL.String(),
|
URL: requestURL.String(),
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Headers: httpReq.Header.Clone(),
|
Headers: httpReq.Header.Clone(),
|
||||||
Body: payload,
|
Body: payloadLog,
|
||||||
Provider: e.Identifier(),
|
Provider: e.Identifier(),
|
||||||
AuthID: authID,
|
AuthID: authID,
|
||||||
AuthLabel: authLabel,
|
AuthLabel: authLabel,
|
||||||
|
|||||||
@@ -388,7 +388,12 @@ func extractGeminiConfig(body []byte, provider string) ThinkingConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check thinkingLevel first (Gemini 3 format takes precedence)
|
// Check thinkingLevel first (Gemini 3 format takes precedence)
|
||||||
if level := gjson.GetBytes(body, prefix+".thinkingLevel"); level.Exists() {
|
level := gjson.GetBytes(body, prefix+".thinkingLevel")
|
||||||
|
if !level.Exists() {
|
||||||
|
// Google official Gemini Python SDK sends snake_case field names
|
||||||
|
level = gjson.GetBytes(body, prefix+".thinking_level")
|
||||||
|
}
|
||||||
|
if level.Exists() {
|
||||||
value := level.String()
|
value := level.String()
|
||||||
switch value {
|
switch value {
|
||||||
case "none":
|
case "none":
|
||||||
@@ -401,7 +406,12 @@ func extractGeminiConfig(body []byte, provider string) ThinkingConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check thinkingBudget (Gemini 2.5 format)
|
// Check thinkingBudget (Gemini 2.5 format)
|
||||||
if budget := gjson.GetBytes(body, prefix+".thinkingBudget"); budget.Exists() {
|
budget := gjson.GetBytes(body, prefix+".thinkingBudget")
|
||||||
|
if !budget.Exists() {
|
||||||
|
// Google official Gemini Python SDK sends snake_case field names
|
||||||
|
budget = gjson.GetBytes(body, prefix+".thinking_budget")
|
||||||
|
}
|
||||||
|
if budget.Exists() {
|
||||||
value := int(budget.Int())
|
value := int(budget.Int())
|
||||||
switch value {
|
switch value {
|
||||||
case 0:
|
case 0:
|
||||||
|
|||||||
@@ -94,8 +94,10 @@ func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig, m
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||||
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
|
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
|
||||||
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget")
|
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level")
|
||||||
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
||||||
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
|
||||||
|
|
||||||
@@ -114,28 +116,30 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig)
|
|||||||
|
|
||||||
level := string(config.Level)
|
level := string(config.Level)
|
||||||
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", level)
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", level)
|
||||||
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", true)
|
|
||||||
|
// Respect user's explicit includeThoughts setting from original body; default to true if not set
|
||||||
|
// Support both camelCase and snake_case variants
|
||||||
|
includeThoughts := true
|
||||||
|
if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() {
|
||||||
|
includeThoughts = inc.Bool()
|
||||||
|
} else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() {
|
||||||
|
includeThoughts = inc.Bool()
|
||||||
|
}
|
||||||
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo, isClaude bool) ([]byte, error) {
|
func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo, isClaude bool) ([]byte, error) {
|
||||||
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
|
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
|
||||||
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingLevel")
|
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingLevel")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget")
|
||||||
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
||||||
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
|
||||||
|
|
||||||
budget := config.Budget
|
budget := config.Budget
|
||||||
includeThoughts := false
|
|
||||||
switch config.Mode {
|
|
||||||
case thinking.ModeNone:
|
|
||||||
includeThoughts = false
|
|
||||||
case thinking.ModeAuto:
|
|
||||||
includeThoughts = true
|
|
||||||
default:
|
|
||||||
includeThoughts = budget > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply Claude-specific constraints
|
// Apply Claude-specific constraints first to get the final budget value
|
||||||
if isClaude && modelInfo != nil {
|
if isClaude && modelInfo != nil {
|
||||||
budget, result = a.normalizeClaudeBudget(budget, result, modelInfo)
|
budget, result = a.normalizeClaudeBudget(budget, result, modelInfo)
|
||||||
// Check if budget was removed entirely
|
// Check if budget was removed entirely
|
||||||
@@ -144,6 +148,37 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For ModeNone, always set includeThoughts to false regardless of user setting.
|
||||||
|
// This ensures that when user requests budget=0 (disable thinking output),
|
||||||
|
// the includeThoughts is correctly set to false even if budget is clamped to min.
|
||||||
|
if config.Mode == thinking.ModeNone {
|
||||||
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||||
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine includeThoughts: respect user's explicit setting from original body if provided
|
||||||
|
// Support both camelCase and snake_case variants
|
||||||
|
var includeThoughts bool
|
||||||
|
var userSetIncludeThoughts bool
|
||||||
|
if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() {
|
||||||
|
includeThoughts = inc.Bool()
|
||||||
|
userSetIncludeThoughts = true
|
||||||
|
} else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() {
|
||||||
|
includeThoughts = inc.Bool()
|
||||||
|
userSetIncludeThoughts = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !userSetIncludeThoughts {
|
||||||
|
// No explicit setting, use default logic based on mode
|
||||||
|
switch config.Mode {
|
||||||
|
case thinking.ModeAuto:
|
||||||
|
includeThoughts = true
|
||||||
|
default:
|
||||||
|
includeThoughts = budget > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||||
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
@@ -118,8 +118,10 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig)
|
|||||||
// - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false)
|
// - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false)
|
||||||
// ValidateConfig sets config.Level to the lowest level when ModeNone + Budget > 0.
|
// ValidateConfig sets config.Level to the lowest level when ModeNone + Budget > 0.
|
||||||
|
|
||||||
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
|
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
|
||||||
result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingBudget")
|
result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingBudget")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_budget")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_level")
|
||||||
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
||||||
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts")
|
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts")
|
||||||
|
|
||||||
@@ -138,29 +140,58 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig)
|
|||||||
|
|
||||||
level := string(config.Level)
|
level := string(config.Level)
|
||||||
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", level)
|
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", level)
|
||||||
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", true)
|
|
||||||
|
// Respect user's explicit includeThoughts setting from original body; default to true if not set
|
||||||
|
// Support both camelCase and snake_case variants
|
||||||
|
includeThoughts := true
|
||||||
|
if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.includeThoughts"); inc.Exists() {
|
||||||
|
includeThoughts = inc.Bool()
|
||||||
|
} else if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.include_thoughts"); inc.Exists() {
|
||||||
|
includeThoughts = inc.Bool()
|
||||||
|
}
|
||||||
|
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", includeThoughts)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||||
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
|
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
|
||||||
result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingLevel")
|
result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingLevel")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_level")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_budget")
|
||||||
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
||||||
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts")
|
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts")
|
||||||
|
|
||||||
budget := config.Budget
|
budget := config.Budget
|
||||||
// ModeNone semantics:
|
|
||||||
// - ModeNone + Budget=0: completely disable thinking
|
// For ModeNone, always set includeThoughts to false regardless of user setting.
|
||||||
// - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false)
|
// This ensures that when user requests budget=0 (disable thinking output),
|
||||||
// When ZeroAllowed=false, ValidateConfig clamps Budget to Min while preserving ModeNone.
|
// the includeThoughts is correctly set to false even if budget is clamped to min.
|
||||||
includeThoughts := false
|
if config.Mode == thinking.ModeNone {
|
||||||
switch config.Mode {
|
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||||
case thinking.ModeNone:
|
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", false)
|
||||||
includeThoughts = false
|
return result, nil
|
||||||
case thinking.ModeAuto:
|
}
|
||||||
includeThoughts = true
|
|
||||||
default:
|
// Determine includeThoughts: respect user's explicit setting from original body if provided
|
||||||
includeThoughts = budget > 0
|
// Support both camelCase and snake_case variants
|
||||||
|
var includeThoughts bool
|
||||||
|
var userSetIncludeThoughts bool
|
||||||
|
if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.includeThoughts"); inc.Exists() {
|
||||||
|
includeThoughts = inc.Bool()
|
||||||
|
userSetIncludeThoughts = true
|
||||||
|
} else if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.include_thoughts"); inc.Exists() {
|
||||||
|
includeThoughts = inc.Bool()
|
||||||
|
userSetIncludeThoughts = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !userSetIncludeThoughts {
|
||||||
|
// No explicit setting, use default logic based on mode
|
||||||
|
switch config.Mode {
|
||||||
|
case thinking.ModeAuto:
|
||||||
|
includeThoughts = true
|
||||||
|
default:
|
||||||
|
includeThoughts = budget > 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget)
|
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||||
|
|||||||
@@ -79,8 +79,10 @@ func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||||
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
|
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
|
||||||
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget")
|
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level")
|
||||||
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
||||||
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
|
||||||
|
|
||||||
@@ -99,25 +101,58 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig)
|
|||||||
|
|
||||||
level := string(config.Level)
|
level := string(config.Level)
|
||||||
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", level)
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", level)
|
||||||
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", true)
|
|
||||||
|
// Respect user's explicit includeThoughts setting from original body; default to true if not set
|
||||||
|
// Support both camelCase and snake_case variants
|
||||||
|
includeThoughts := true
|
||||||
|
if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() {
|
||||||
|
includeThoughts = inc.Bool()
|
||||||
|
} else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() {
|
||||||
|
includeThoughts = inc.Bool()
|
||||||
|
}
|
||||||
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||||
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
|
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
|
||||||
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingLevel")
|
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingLevel")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget")
|
||||||
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
||||||
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
|
||||||
|
|
||||||
budget := config.Budget
|
budget := config.Budget
|
||||||
includeThoughts := false
|
|
||||||
switch config.Mode {
|
// For ModeNone, always set includeThoughts to false regardless of user setting.
|
||||||
case thinking.ModeNone:
|
// This ensures that when user requests budget=0 (disable thinking output),
|
||||||
includeThoughts = false
|
// the includeThoughts is correctly set to false even if budget is clamped to min.
|
||||||
case thinking.ModeAuto:
|
if config.Mode == thinking.ModeNone {
|
||||||
includeThoughts = true
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||||
default:
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false)
|
||||||
includeThoughts = budget > 0
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine includeThoughts: respect user's explicit setting from original body if provided
|
||||||
|
// Support both camelCase and snake_case variants
|
||||||
|
var includeThoughts bool
|
||||||
|
var userSetIncludeThoughts bool
|
||||||
|
if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() {
|
||||||
|
includeThoughts = inc.Bool()
|
||||||
|
userSetIncludeThoughts = true
|
||||||
|
} else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() {
|
||||||
|
includeThoughts = inc.Bool()
|
||||||
|
userSetIncludeThoughts = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !userSetIncludeThoughts {
|
||||||
|
// No explicit setting, use default logic based on mode
|
||||||
|
switch config.Mode {
|
||||||
|
case thinking.ModeAuto:
|
||||||
|
includeThoughts = true
|
||||||
|
default:
|
||||||
|
includeThoughts = budget > 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
package chat_completions
|
package chat_completions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ const geminiCLIFunctionThoughtSignature = "skip_thought_signature_validator"
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - []byte: The transformed request data in Gemini CLI API format
|
// - []byte: The transformed request data in Gemini CLI API format
|
||||||
func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {
|
func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {
|
||||||
rawJSON := bytes.Clone(inputRawJSON)
|
rawJSON := inputRawJSON
|
||||||
// Base envelope (no default thinkingConfig)
|
// Base envelope (no default thinkingConfig)
|
||||||
out := []byte(`{"project":"","request":{"contents":[]},"model":"gemini-2.5-pro"}`)
|
out := []byte(`{"project":"","request":{"contents":[]},"model":"gemini-2.5-pro"}`)
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,11 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
|
|||||||
// Include thoughts configuration for reasoning process visibility
|
// Include thoughts configuration for reasoning process visibility
|
||||||
// Translator only does format conversion, ApplyThinking handles model capability validation.
|
// Translator only does format conversion, ApplyThinking handles model capability validation.
|
||||||
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
||||||
if thinkingLevel := thinkingConfig.Get("thinkingLevel"); thinkingLevel.Exists() {
|
thinkingLevel := thinkingConfig.Get("thinkingLevel")
|
||||||
|
if !thinkingLevel.Exists() {
|
||||||
|
thinkingLevel = thinkingConfig.Get("thinking_level")
|
||||||
|
}
|
||||||
|
if thinkingLevel.Exists() {
|
||||||
level := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
|
level := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
|
||||||
switch level {
|
switch level {
|
||||||
case "":
|
case "":
|
||||||
@@ -132,23 +136,29 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
|
|||||||
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
|
} else {
|
||||||
budget := int(thinkingBudget.Int())
|
thinkingBudget := thinkingConfig.Get("thinkingBudget")
|
||||||
switch budget {
|
if !thinkingBudget.Exists() {
|
||||||
case 0:
|
thinkingBudget = thinkingConfig.Get("thinking_budget")
|
||||||
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
}
|
||||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
if thinkingBudget.Exists() {
|
||||||
case -1:
|
budget := int(thinkingBudget.Int())
|
||||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
switch budget {
|
||||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
case 0:
|
||||||
default:
|
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
||||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||||
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
case -1:
|
||||||
|
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||||
|
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||||
|
default:
|
||||||
|
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||||
|
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
||||||
|
}
|
||||||
|
} else if includeThoughts := thinkingConfig.Get("includeThoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {
|
||||||
|
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||||
|
} else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {
|
||||||
|
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||||
}
|
}
|
||||||
} else if includeThoughts := thinkingConfig.Get("includeThoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {
|
|
||||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
|
||||||
} else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {
|
|
||||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,19 +243,30 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
out, _ = sjson.Set(out, "parallel_tool_calls", true)
|
out, _ = sjson.Set(out, "parallel_tool_calls", true)
|
||||||
|
|
||||||
// Convert Gemini thinkingConfig to Codex reasoning.effort.
|
// Convert Gemini thinkingConfig to Codex reasoning.effort.
|
||||||
|
// Note: Google official Python SDK sends snake_case fields (thinking_level/thinking_budget).
|
||||||
effortSet := false
|
effortSet := false
|
||||||
if genConfig := root.Get("generationConfig"); genConfig.Exists() {
|
if genConfig := root.Get("generationConfig"); genConfig.Exists() {
|
||||||
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
||||||
if thinkingLevel := thinkingConfig.Get("thinkingLevel"); thinkingLevel.Exists() {
|
thinkingLevel := thinkingConfig.Get("thinkingLevel")
|
||||||
|
if !thinkingLevel.Exists() {
|
||||||
|
thinkingLevel = thinkingConfig.Get("thinking_level")
|
||||||
|
}
|
||||||
|
if thinkingLevel.Exists() {
|
||||||
effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
|
effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
|
||||||
if effort != "" {
|
if effort != "" {
|
||||||
out, _ = sjson.Set(out, "reasoning.effort", effort)
|
out, _ = sjson.Set(out, "reasoning.effort", effort)
|
||||||
effortSet = true
|
effortSet = true
|
||||||
}
|
}
|
||||||
} else if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
|
} else {
|
||||||
if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {
|
thinkingBudget := thinkingConfig.Get("thinkingBudget")
|
||||||
out, _ = sjson.Set(out, "reasoning.effort", effort)
|
if !thinkingBudget.Exists() {
|
||||||
effortSet = true
|
thinkingBudget = thinkingConfig.Get("thinking_budget")
|
||||||
|
}
|
||||||
|
if thinkingBudget.Exists() {
|
||||||
|
if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {
|
||||||
|
out, _ = sjson.Set(out, "reasoning.effort", effort)
|
||||||
|
effortSet = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,19 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
|
|||||||
part, _ = sjson.Set(part, "functionResponse.name", funcName)
|
part, _ = sjson.Set(part, "functionResponse.name", funcName)
|
||||||
part, _ = sjson.Set(part, "functionResponse.response.result", responseData)
|
part, _ = sjson.Set(part, "functionResponse.response.result", responseData)
|
||||||
contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part)
|
contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part)
|
||||||
|
|
||||||
|
case "image":
|
||||||
|
source := contentResult.Get("source")
|
||||||
|
if source.Get("type").String() == "base64" {
|
||||||
|
mimeType := source.Get("media_type").String()
|
||||||
|
data := source.Get("data").String()
|
||||||
|
if mimeType != "" && data != "" {
|
||||||
|
part := `{"inlineData":{"mime_type":"","data":""}}`
|
||||||
|
part, _ = sjson.Set(part, "inlineData.mime_type", mimeType)
|
||||||
|
part, _ = sjson.Set(part, "inlineData.data", data)
|
||||||
|
contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -659,13 +659,36 @@ func buildAssistantMessageFromOpenAI(msg gjson.Result) KiroAssistantResponseMess
|
|||||||
contentBuilder.WriteString(content.String())
|
contentBuilder.WriteString(content.String())
|
||||||
} else if content.IsArray() {
|
} else if content.IsArray() {
|
||||||
for _, part := range content.Array() {
|
for _, part := range content.Array() {
|
||||||
if part.Get("type").String() == "text" {
|
partType := part.Get("type").String()
|
||||||
|
switch partType {
|
||||||
|
case "text":
|
||||||
contentBuilder.WriteString(part.Get("text").String())
|
contentBuilder.WriteString(part.Get("text").String())
|
||||||
|
case "tool_use":
|
||||||
|
// Handle tool_use in content array (Anthropic/OpenCode format)
|
||||||
|
// This is different from OpenAI's tool_calls format
|
||||||
|
toolUseID := part.Get("id").String()
|
||||||
|
toolName := part.Get("name").String()
|
||||||
|
inputData := part.Get("input")
|
||||||
|
|
||||||
|
inputMap := make(map[string]interface{})
|
||||||
|
if inputData.Exists() && inputData.IsObject() {
|
||||||
|
inputData.ForEach(func(key, value gjson.Result) bool {
|
||||||
|
inputMap[key.String()] = value.Value()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
toolUses = append(toolUses, KiroToolUse{
|
||||||
|
ToolUseID: toolUseID,
|
||||||
|
Name: toolName,
|
||||||
|
Input: inputMap,
|
||||||
|
})
|
||||||
|
log.Debugf("kiro-openai: extracted tool_use from content array: %s", toolName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle tool_calls
|
// Handle tool_calls (OpenAI format)
|
||||||
toolCalls := msg.Get("tool_calls")
|
toolCalls := msg.Get("tool_calls")
|
||||||
if toolCalls.IsArray() {
|
if toolCalls.IsArray() {
|
||||||
for _, tc := range toolCalls.Array() {
|
for _, tc := range toolCalls.Array() {
|
||||||
|
|||||||
@@ -83,16 +83,27 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map Gemini thinkingConfig to OpenAI reasoning_effort.
|
// Map Gemini thinkingConfig to OpenAI reasoning_effort.
|
||||||
// Always perform conversion to support allowCompat models that may not be in registry
|
// Always perform conversion to support allowCompat models that may not be in registry.
|
||||||
|
// Note: Google official Python SDK sends snake_case fields (thinking_level/thinking_budget).
|
||||||
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
||||||
if thinkingLevel := thinkingConfig.Get("thinkingLevel"); thinkingLevel.Exists() {
|
thinkingLevel := thinkingConfig.Get("thinkingLevel")
|
||||||
|
if !thinkingLevel.Exists() {
|
||||||
|
thinkingLevel = thinkingConfig.Get("thinking_level")
|
||||||
|
}
|
||||||
|
if thinkingLevel.Exists() {
|
||||||
effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
|
effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
|
||||||
if effort != "" {
|
if effort != "" {
|
||||||
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
||||||
}
|
}
|
||||||
} else if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
|
} else {
|
||||||
if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {
|
thinkingBudget := thinkingConfig.Get("thinkingBudget")
|
||||||
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
if !thinkingBudget.Exists() {
|
||||||
|
thinkingBudget = thinkingConfig.Get("thinking_budget")
|
||||||
|
}
|
||||||
|
if thinkingBudget.Exists() {
|
||||||
|
if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {
|
||||||
|
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -667,6 +667,9 @@ func orDefault(val, def string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func escapeGJSONPathKey(key string) string {
|
func escapeGJSONPathKey(key string) string {
|
||||||
|
if strings.IndexAny(key, ".*?") == -1 {
|
||||||
|
return key
|
||||||
|
}
|
||||||
return gjsonPathKeyReplacer.Replace(key)
|
return gjsonPathKeyReplacer.Replace(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package util
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
@@ -33,15 +32,15 @@ func Walk(value gjson.Result, path, field string, paths *[]string) {
|
|||||||
// . -> \.
|
// . -> \.
|
||||||
// * -> \*
|
// * -> \*
|
||||||
// ? -> \?
|
// ? -> \?
|
||||||
var keyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?")
|
keyStr := key.String()
|
||||||
safeKey := keyReplacer.Replace(key.String())
|
safeKey := escapeGJSONPathKey(keyStr)
|
||||||
|
|
||||||
if path == "" {
|
if path == "" {
|
||||||
childPath = safeKey
|
childPath = safeKey
|
||||||
} else {
|
} else {
|
||||||
childPath = path + "." + safeKey
|
childPath = path + "." + safeKey
|
||||||
}
|
}
|
||||||
if key.String() == field {
|
if keyStr == field {
|
||||||
*paths = append(*paths, childPath)
|
*paths = append(*paths, childPath)
|
||||||
}
|
}
|
||||||
Walk(val, childPath, field, paths)
|
Walk(val, childPath, field, paths)
|
||||||
@@ -87,15 +86,6 @@ func RenameKey(jsonStr, oldKeyPath, newKeyPath string) (string, error) {
|
|||||||
return finalJson, nil
|
return finalJson, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteKey(jsonStr, keyName string) string {
|
|
||||||
paths := make([]string, 0)
|
|
||||||
Walk(gjson.Parse(jsonStr), "", keyName, &paths)
|
|
||||||
for _, p := range paths {
|
|
||||||
jsonStr, _ = sjson.Delete(jsonStr, p)
|
|
||||||
}
|
|
||||||
return jsonStr
|
|
||||||
}
|
|
||||||
|
|
||||||
// FixJSON converts non-standard JSON that uses single quotes for strings into
|
// FixJSON converts non-standard JSON that uses single quotes for strings into
|
||||||
// RFC 8259-compliant JSON by converting those single-quoted strings to
|
// RFC 8259-compliant JSON by converting those single-quoted strings to
|
||||||
// double-quoted strings with proper escaping.
|
// double-quoted strings with proper escaping.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
|
||||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -72,6 +74,7 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
|
|||||||
w.clientsMutex.Lock()
|
w.clientsMutex.Lock()
|
||||||
|
|
||||||
w.lastAuthHashes = make(map[string]string)
|
w.lastAuthHashes = make(map[string]string)
|
||||||
|
w.lastAuthContents = make(map[string]*coreauth.Auth)
|
||||||
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {
|
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {
|
||||||
log.Errorf("failed to resolve auth directory for hash cache: %v", errResolveAuthDir)
|
log.Errorf("failed to resolve auth directory for hash cache: %v", errResolveAuthDir)
|
||||||
} else if resolvedAuthDir != "" {
|
} else if resolvedAuthDir != "" {
|
||||||
@@ -84,6 +87,11 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
|
|||||||
sum := sha256.Sum256(data)
|
sum := sha256.Sum256(data)
|
||||||
normalizedPath := w.normalizeAuthPath(path)
|
normalizedPath := w.normalizeAuthPath(path)
|
||||||
w.lastAuthHashes[normalizedPath] = hex.EncodeToString(sum[:])
|
w.lastAuthHashes[normalizedPath] = hex.EncodeToString(sum[:])
|
||||||
|
// Parse and cache auth content for future diff comparisons
|
||||||
|
var auth coreauth.Auth
|
||||||
|
if errParse := json.Unmarshal(data, &auth); errParse == nil {
|
||||||
|
w.lastAuthContents[normalizedPath] = &auth
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -127,6 +135,13 @@ func (w *Watcher) addOrUpdateClient(path string) {
|
|||||||
curHash := hex.EncodeToString(sum[:])
|
curHash := hex.EncodeToString(sum[:])
|
||||||
normalized := w.normalizeAuthPath(path)
|
normalized := w.normalizeAuthPath(path)
|
||||||
|
|
||||||
|
// Parse new auth content for diff comparison
|
||||||
|
var newAuth coreauth.Auth
|
||||||
|
if errParse := json.Unmarshal(data, &newAuth); errParse != nil {
|
||||||
|
log.Errorf("failed to parse auth file %s: %v", filepath.Base(path), errParse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
w.clientsMutex.Lock()
|
w.clientsMutex.Lock()
|
||||||
|
|
||||||
cfg := w.config
|
cfg := w.config
|
||||||
@@ -141,7 +156,26 @@ func (w *Watcher) addOrUpdateClient(path string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get old auth for diff comparison
|
||||||
|
var oldAuth *coreauth.Auth
|
||||||
|
if w.lastAuthContents != nil {
|
||||||
|
oldAuth = w.lastAuthContents[normalized]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute and log field changes
|
||||||
|
if changes := diff.BuildAuthChangeDetails(oldAuth, &newAuth); len(changes) > 0 {
|
||||||
|
log.Debugf("auth field changes for %s:", filepath.Base(path))
|
||||||
|
for _, c := range changes {
|
||||||
|
log.Debugf(" %s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update caches
|
||||||
w.lastAuthHashes[normalized] = curHash
|
w.lastAuthHashes[normalized] = curHash
|
||||||
|
if w.lastAuthContents == nil {
|
||||||
|
w.lastAuthContents = make(map[string]*coreauth.Auth)
|
||||||
|
}
|
||||||
|
w.lastAuthContents[normalized] = &newAuth
|
||||||
|
|
||||||
w.clientsMutex.Unlock() // Unlock before the callback
|
w.clientsMutex.Unlock() // Unlock before the callback
|
||||||
|
|
||||||
@@ -160,6 +194,7 @@ func (w *Watcher) removeClient(path string) {
|
|||||||
|
|
||||||
cfg := w.config
|
cfg := w.config
|
||||||
delete(w.lastAuthHashes, normalized)
|
delete(w.lastAuthHashes, normalized)
|
||||||
|
delete(w.lastAuthContents, normalized)
|
||||||
|
|
||||||
w.clientsMutex.Unlock() // Release the lock before the callback
|
w.clientsMutex.Unlock() // Release the lock before the callback
|
||||||
|
|
||||||
|
|||||||
44
internal/watcher/diff/auth_diff.go
Normal file
44
internal/watcher/diff/auth_diff.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// auth_diff.go computes human-readable diffs for auth file field changes.
|
||||||
|
package diff
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildAuthChangeDetails computes a redacted, human-readable list of auth field changes.
|
||||||
|
// Only prefix, proxy_url, and disabled fields are tracked; sensitive data is never printed.
|
||||||
|
func BuildAuthChangeDetails(oldAuth, newAuth *coreauth.Auth) []string {
|
||||||
|
changes := make([]string, 0, 3)
|
||||||
|
|
||||||
|
// Handle nil cases by using empty Auth as default
|
||||||
|
if oldAuth == nil {
|
||||||
|
oldAuth = &coreauth.Auth{}
|
||||||
|
}
|
||||||
|
if newAuth == nil {
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare prefix
|
||||||
|
oldPrefix := strings.TrimSpace(oldAuth.Prefix)
|
||||||
|
newPrefix := strings.TrimSpace(newAuth.Prefix)
|
||||||
|
if oldPrefix != newPrefix {
|
||||||
|
changes = append(changes, fmt.Sprintf("prefix: %s -> %s", oldPrefix, newPrefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare proxy_url (redacted)
|
||||||
|
oldProxy := strings.TrimSpace(oldAuth.ProxyURL)
|
||||||
|
newProxy := strings.TrimSpace(newAuth.ProxyURL)
|
||||||
|
if oldProxy != newProxy {
|
||||||
|
changes = append(changes, fmt.Sprintf("proxy_url: %s -> %s", formatProxyURL(oldProxy), formatProxyURL(newProxy)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare disabled
|
||||||
|
if oldAuth.Disabled != newAuth.Disabled {
|
||||||
|
changes = append(changes, fmt.Sprintf("disabled: %t -> %t", oldAuth.Disabled, newAuth.Disabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes
|
||||||
|
}
|
||||||
@@ -27,6 +27,12 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
|
|||||||
if oldCfg.Debug != newCfg.Debug {
|
if oldCfg.Debug != newCfg.Debug {
|
||||||
changes = append(changes, fmt.Sprintf("debug: %t -> %t", oldCfg.Debug, newCfg.Debug))
|
changes = append(changes, fmt.Sprintf("debug: %t -> %t", oldCfg.Debug, newCfg.Debug))
|
||||||
}
|
}
|
||||||
|
if oldCfg.Pprof.Enable != newCfg.Pprof.Enable {
|
||||||
|
changes = append(changes, fmt.Sprintf("pprof.enable: %t -> %t", oldCfg.Pprof.Enable, newCfg.Pprof.Enable))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(oldCfg.Pprof.Addr) != strings.TrimSpace(newCfg.Pprof.Addr) {
|
||||||
|
changes = append(changes, fmt.Sprintf("pprof.addr: %s -> %s", strings.TrimSpace(oldCfg.Pprof.Addr), strings.TrimSpace(newCfg.Pprof.Addr)))
|
||||||
|
}
|
||||||
if oldCfg.LoggingToFile != newCfg.LoggingToFile {
|
if oldCfg.LoggingToFile != newCfg.LoggingToFile {
|
||||||
changes = append(changes, fmt.Sprintf("logging-to-file: %t -> %t", oldCfg.LoggingToFile, newCfg.LoggingToFile))
|
changes = append(changes, fmt.Sprintf("logging-to-file: %t -> %t", oldCfg.LoggingToFile, newCfg.LoggingToFile))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type Watcher struct {
|
|||||||
reloadCallback func(*config.Config)
|
reloadCallback func(*config.Config)
|
||||||
watcher *fsnotify.Watcher
|
watcher *fsnotify.Watcher
|
||||||
lastAuthHashes map[string]string
|
lastAuthHashes map[string]string
|
||||||
|
lastAuthContents map[string]*coreauth.Auth
|
||||||
lastRemoveTimes map[string]time.Time
|
lastRemoveTimes map[string]time.Time
|
||||||
lastConfigHash string
|
lastConfigHash string
|
||||||
authQueue chan<- AuthUpdate
|
authQueue chan<- AuthUpdate
|
||||||
|
|||||||
@@ -155,20 +155,6 @@ func requestExecutionMetadata(ctx context.Context) map[string]any {
|
|||||||
return map[string]any{idempotencyKeyMetadataKey: key}
|
return map[string]any{idempotencyKeyMetadataKey: key}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeMetadata(base, overlay map[string]any) map[string]any {
|
|
||||||
if len(base) == 0 && len(overlay) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make(map[string]any, len(base)+len(overlay))
|
|
||||||
for k, v := range base {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
for k, v := range overlay {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// BaseAPIHandler contains the handlers for API endpoints.
|
// BaseAPIHandler contains the handlers for API endpoints.
|
||||||
// It holds a pool of clients to interact with the backend service and manages
|
// It holds a pool of clients to interact with the backend service and manages
|
||||||
// load balancing, client selection, and configuration.
|
// load balancing, client selection, and configuration.
|
||||||
@@ -399,7 +385,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
|
|||||||
opts := coreexecutor.Options{
|
opts := coreexecutor.Options{
|
||||||
Stream: false,
|
Stream: false,
|
||||||
Alt: alt,
|
Alt: alt,
|
||||||
OriginalRequest: cloneBytes(rawJSON),
|
OriginalRequest: rawJSON,
|
||||||
SourceFormat: sdktranslator.FromString(handlerType),
|
SourceFormat: sdktranslator.FromString(handlerType),
|
||||||
}
|
}
|
||||||
opts.Metadata = reqMeta
|
opts.Metadata = reqMeta
|
||||||
@@ -438,7 +424,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
|
|||||||
opts := coreexecutor.Options{
|
opts := coreexecutor.Options{
|
||||||
Stream: false,
|
Stream: false,
|
||||||
Alt: alt,
|
Alt: alt,
|
||||||
OriginalRequest: cloneBytes(rawJSON),
|
OriginalRequest: rawJSON,
|
||||||
SourceFormat: sdktranslator.FromString(handlerType),
|
SourceFormat: sdktranslator.FromString(handlerType),
|
||||||
}
|
}
|
||||||
opts.Metadata = reqMeta
|
opts.Metadata = reqMeta
|
||||||
@@ -480,7 +466,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
|
|||||||
opts := coreexecutor.Options{
|
opts := coreexecutor.Options{
|
||||||
Stream: true,
|
Stream: true,
|
||||||
Alt: alt,
|
Alt: alt,
|
||||||
OriginalRequest: cloneBytes(rawJSON),
|
OriginalRequest: rawJSON,
|
||||||
SourceFormat: sdktranslator.FromString(handlerType),
|
SourceFormat: sdktranslator.FromString(handlerType),
|
||||||
}
|
}
|
||||||
opts.Metadata = reqMeta
|
opts.Metadata = reqMeta
|
||||||
@@ -669,17 +655,6 @@ func cloneBytes(src []byte) []byte {
|
|||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneMetadata(src map[string]any) map[string]any {
|
|
||||||
if len(src) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
dst := make(map[string]any, len(src))
|
|
||||||
for k, v := range src {
|
|
||||||
dst[k] = v
|
|
||||||
}
|
|
||||||
return dst
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteErrorResponse writes an error message to the response writer using the HTTP status embedded in the message.
|
// WriteErrorResponse writes an error message to the response writer using the HTTP status embedded in the message.
|
||||||
func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) {
|
func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) {
|
||||||
status := http.StatusInternalServerError
|
status := http.StatusInternalServerError
|
||||||
|
|||||||
163
sdk/cliproxy/pprof_server.go
Normal file
163
sdk/cliproxy/pprof_server.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package cliproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/pprof"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pprofServer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
server *http.Server
|
||||||
|
addr string
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPprofServer() *pprofServer {
|
||||||
|
return &pprofServer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) applyPprofConfig(cfg *config.Config) {
|
||||||
|
if s == nil || cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.pprofServer == nil {
|
||||||
|
s.pprofServer = newPprofServer()
|
||||||
|
}
|
||||||
|
s.pprofServer.Apply(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) shutdownPprof(ctx context.Context) error {
|
||||||
|
if s == nil || s.pprofServer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.pprofServer.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pprofServer) Apply(cfg *config.Config) {
|
||||||
|
if p == nil || cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addr := strings.TrimSpace(cfg.Pprof.Addr)
|
||||||
|
if addr == "" {
|
||||||
|
addr = config.DefaultPprofAddr
|
||||||
|
}
|
||||||
|
enabled := cfg.Pprof.Enable
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
currentServer := p.server
|
||||||
|
currentAddr := p.addr
|
||||||
|
p.addr = addr
|
||||||
|
p.enabled = enabled
|
||||||
|
if !enabled {
|
||||||
|
p.server = nil
|
||||||
|
p.mu.Unlock()
|
||||||
|
if currentServer != nil {
|
||||||
|
p.stopServer(currentServer, currentAddr, "disabled")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if currentServer != nil && currentAddr == addr {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.server = nil
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if currentServer != nil {
|
||||||
|
p.stopServer(currentServer, currentAddr, "restarted")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.startServer(addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pprofServer) Shutdown(ctx context.Context) error {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
currentServer := p.server
|
||||||
|
currentAddr := p.addr
|
||||||
|
p.server = nil
|
||||||
|
p.enabled = false
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if currentServer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.stopServerWithContext(ctx, currentServer, currentAddr, "shutdown")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pprofServer) startServer(addr string) {
|
||||||
|
mux := newPprofMux()
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: mux,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
if !p.enabled || p.addr != addr || p.server != nil {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.server = server
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
log.Infof("pprof server starting on %s", addr)
|
||||||
|
go func() {
|
||||||
|
if errServe := server.ListenAndServe(); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) {
|
||||||
|
log.Errorf("pprof server failed on %s: %v", addr, errServe)
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.server == server {
|
||||||
|
p.server = nil
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pprofServer) stopServer(server *http.Server, addr string, reason string) {
|
||||||
|
_ = p.stopServerWithContext(context.Background(), server, addr, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pprofServer) stopServerWithContext(ctx context.Context, server *http.Server, addr string, reason string) error {
|
||||||
|
if server == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stopCtx := ctx
|
||||||
|
if stopCtx == nil {
|
||||||
|
stopCtx = context.Background()
|
||||||
|
}
|
||||||
|
stopCtx, cancel := context.WithTimeout(stopCtx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if errStop := server.Shutdown(stopCtx); errStop != nil {
|
||||||
|
log.Errorf("pprof server stop failed on %s: %v", addr, errStop)
|
||||||
|
return errStop
|
||||||
|
}
|
||||||
|
log.Infof("pprof server stopped on %s (%s)", addr, reason)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPprofMux() *http.ServeMux {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||||
|
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||||
|
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||||
|
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||||
|
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||||
|
mux.Handle("/debug/pprof/allocs", pprof.Handler("allocs"))
|
||||||
|
mux.Handle("/debug/pprof/block", pprof.Handler("block"))
|
||||||
|
mux.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine"))
|
||||||
|
mux.Handle("/debug/pprof/heap", pprof.Handler("heap"))
|
||||||
|
mux.Handle("/debug/pprof/mutex", pprof.Handler("mutex"))
|
||||||
|
mux.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate"))
|
||||||
|
return mux
|
||||||
|
}
|
||||||
@@ -58,6 +58,9 @@ type Service struct {
|
|||||||
// server is the HTTP API server instance.
|
// server is the HTTP API server instance.
|
||||||
server *api.Server
|
server *api.Server
|
||||||
|
|
||||||
|
// pprofServer manages the optional pprof HTTP debug server.
|
||||||
|
pprofServer *pprofServer
|
||||||
|
|
||||||
// serverErr channel for server startup/shutdown errors.
|
// serverErr channel for server startup/shutdown errors.
|
||||||
serverErr chan error
|
serverErr chan error
|
||||||
|
|
||||||
@@ -281,27 +284,42 @@ func (s *Service) wsOnDisconnected(channelID string, reason error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) applyCoreAuthAddOrUpdate(ctx context.Context, auth *coreauth.Auth) {
|
func (s *Service) applyCoreAuthAddOrUpdate(ctx context.Context, auth *coreauth.Auth) {
|
||||||
if s == nil || auth == nil || auth.ID == "" {
|
if s == nil || s.coreManager == nil || auth == nil || auth.ID == "" {
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.coreManager == nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
auth = auth.Clone()
|
auth = auth.Clone()
|
||||||
s.ensureExecutorsForAuth(auth)
|
s.ensureExecutorsForAuth(auth)
|
||||||
s.registerModelsForAuth(auth)
|
|
||||||
if existing, ok := s.coreManager.GetByID(auth.ID); ok && existing != nil {
|
// IMPORTANT: Update coreManager FIRST, before model registration.
|
||||||
|
// This ensures that configuration changes (proxy_url, prefix, etc.) take effect
|
||||||
|
// immediately for API calls, rather than waiting for model registration to complete.
|
||||||
|
// Model registration may involve network calls (e.g., FetchAntigravityModels) that
|
||||||
|
// could timeout if the new proxy_url is unreachable.
|
||||||
|
op := "register"
|
||||||
|
var err error
|
||||||
|
if existing, ok := s.coreManager.GetByID(auth.ID); ok {
|
||||||
auth.CreatedAt = existing.CreatedAt
|
auth.CreatedAt = existing.CreatedAt
|
||||||
auth.LastRefreshedAt = existing.LastRefreshedAt
|
auth.LastRefreshedAt = existing.LastRefreshedAt
|
||||||
auth.NextRefreshAfter = existing.NextRefreshAfter
|
auth.NextRefreshAfter = existing.NextRefreshAfter
|
||||||
if _, err := s.coreManager.Update(ctx, auth); err != nil {
|
op = "update"
|
||||||
log.Errorf("failed to update auth %s: %v", auth.ID, err)
|
_, err = s.coreManager.Update(ctx, auth)
|
||||||
|
} else {
|
||||||
|
_, err = s.coreManager.Register(ctx, auth)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to %s auth %s: %v", op, auth.ID, err)
|
||||||
|
current, ok := s.coreManager.GetByID(auth.ID)
|
||||||
|
if !ok || current.Disabled {
|
||||||
|
GlobalModelRegistry().UnregisterClient(auth.ID)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return
|
auth = current
|
||||||
}
|
|
||||||
if _, err := s.coreManager.Register(ctx, auth); err != nil {
|
|
||||||
log.Errorf("failed to register auth %s: %v", auth.ID, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register models after auth is updated in coreManager.
|
||||||
|
// This operation may block on network calls, but the auth configuration
|
||||||
|
// is already effective at this point.
|
||||||
|
s.registerModelsForAuth(auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) applyCoreAuthRemoval(ctx context.Context, id string) {
|
func (s *Service) applyCoreAuthRemoval(ctx context.Context, id string) {
|
||||||
@@ -516,6 +534,8 @@ func (s *Service) Run(ctx context.Context) error {
|
|||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
fmt.Printf("API server started successfully on: %s:%d\n", s.cfg.Host, s.cfg.Port)
|
fmt.Printf("API server started successfully on: %s:%d\n", s.cfg.Host, s.cfg.Port)
|
||||||
|
|
||||||
|
s.applyPprofConfig(s.cfg)
|
||||||
|
|
||||||
if s.hooks.OnAfterStart != nil {
|
if s.hooks.OnAfterStart != nil {
|
||||||
s.hooks.OnAfterStart(s)
|
s.hooks.OnAfterStart(s)
|
||||||
}
|
}
|
||||||
@@ -561,6 +581,7 @@ func (s *Service) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.applyRetryConfig(newCfg)
|
s.applyRetryConfig(newCfg)
|
||||||
|
s.applyPprofConfig(newCfg)
|
||||||
if s.server != nil {
|
if s.server != nil {
|
||||||
s.server.UpdateClients(newCfg)
|
s.server.UpdateClients(newCfg)
|
||||||
}
|
}
|
||||||
@@ -666,6 +687,13 @@ func (s *Service) Shutdown(ctx context.Context) error {
|
|||||||
s.authQueueStop = nil
|
s.authQueueStop = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errShutdownPprof := s.shutdownPprof(ctx); errShutdownPprof != nil {
|
||||||
|
log.Errorf("failed to stop pprof server: %v", errShutdownPprof)
|
||||||
|
if shutdownErr == nil {
|
||||||
|
shutdownErr = errShutdownPprof
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// no legacy clients to persist
|
// no legacy clients to persist
|
||||||
|
|
||||||
if s.server != nil {
|
if s.server != nil {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestOpenAIToCodex_PreservesBuiltinTools(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenAIResponsesToOpenAI_PreservesBuiltinTools(t *testing.T) {
|
func TestOpenAIResponsesToOpenAI_IgnoresBuiltinTools(t *testing.T) {
|
||||||
in := []byte(`{
|
in := []byte(`{
|
||||||
"model":"gpt-5",
|
"model":"gpt-5",
|
||||||
"input":[{"role":"user","content":[{"type":"input_text","text":"hi"}]}],
|
"input":[{"role":"user","content":[{"type":"input_text","text":"hi"}]}],
|
||||||
@@ -42,13 +42,7 @@ func TestOpenAIResponsesToOpenAI_PreservesBuiltinTools(t *testing.T) {
|
|||||||
|
|
||||||
out := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "gpt-5", in, false)
|
out := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "gpt-5", in, false)
|
||||||
|
|
||||||
if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 {
|
if got := gjson.GetBytes(out, "tools.#").Int(); got != 0 {
|
||||||
t.Fatalf("expected 1 tool, got %d: %s", got, string(out))
|
t.Fatalf("expected 0 tools (builtin tools not supported in Chat Completions), got %d: %s", got, string(out))
|
||||||
}
|
|
||||||
if got := gjson.GetBytes(out, "tools.0.type").String(); got != "web_search" {
|
|
||||||
t.Fatalf("expected tools[0].type=web_search, got %q: %s", got, string(out))
|
|
||||||
}
|
|
||||||
if got := gjson.GetBytes(out, "tools.0.search_context_size").String(); got != "low" {
|
|
||||||
t.Fatalf("expected tools[0].search_context_size=low, got %q: %s", got, string(out))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user