mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-18 20:30:51 +08:00
Merge pull request #1033 from router-for-me/reasoning
Refactor thinking
This commit is contained in:
@@ -201,12 +201,27 @@ nonstream-keepalive-interval: 0
|
|||||||
# - from: "claude-haiku-4-5-20251001"
|
# - from: "claude-haiku-4-5-20251001"
|
||||||
# to: "gemini-2.5-flash"
|
# to: "gemini-2.5-flash"
|
||||||
|
|
||||||
# Global OAuth model name mappings (per channel)
|
# Global OAuth model name aliases (per channel)
|
||||||
# These mappings rename model IDs for both model listing and request routing.
|
# These aliases rename model IDs for both model listing and request routing.
|
||||||
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow.
|
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow.
|
||||||
# NOTE: Mappings do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.
|
# NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.
|
||||||
# You can repeat the same name with different aliases to expose multiple client model names.
|
# You can repeat the same name with different aliases to expose multiple client model names.
|
||||||
# oauth-model-mappings:
|
oauth-model-alias:
|
||||||
|
antigravity:
|
||||||
|
- name: "rev19-uic3-1p"
|
||||||
|
alias: "gemini-2.5-computer-use-preview-10-2025"
|
||||||
|
- name: "gemini-3-pro-image"
|
||||||
|
alias: "gemini-3-pro-image-preview"
|
||||||
|
- name: "gemini-3-pro-high"
|
||||||
|
alias: "gemini-3-pro-preview"
|
||||||
|
- name: "gemini-3-flash"
|
||||||
|
alias: "gemini-3-flash-preview"
|
||||||
|
- name: "claude-sonnet-4-5"
|
||||||
|
alias: "gemini-claude-sonnet-4-5"
|
||||||
|
- name: "claude-sonnet-4-5-thinking"
|
||||||
|
alias: "gemini-claude-sonnet-4-5-thinking"
|
||||||
|
- name: "claude-opus-4-5-thinking"
|
||||||
|
alias: "gemini-claude-opus-4-5-thinking"
|
||||||
# gemini-cli:
|
# gemini-cli:
|
||||||
# - name: "gemini-2.5-pro" # original model name under this channel
|
# - name: "gemini-2.5-pro" # original model name under this channel
|
||||||
# alias: "g2.5p" # client-visible alias
|
# alias: "g2.5p" # client-visible alias
|
||||||
@@ -217,9 +232,6 @@ nonstream-keepalive-interval: 0
|
|||||||
# aistudio:
|
# aistudio:
|
||||||
# - name: "gemini-2.5-pro"
|
# - name: "gemini-2.5-pro"
|
||||||
# alias: "g2.5p"
|
# alias: "g2.5p"
|
||||||
# antigravity:
|
|
||||||
# - name: "gemini-3-pro-preview"
|
|
||||||
# alias: "g3p"
|
|
||||||
# claude:
|
# claude:
|
||||||
# - name: "claude-sonnet-4-5-20250929"
|
# - name: "claude-sonnet-4-5-20250929"
|
||||||
# alias: "cs4.5"
|
# alias: "cs4.5"
|
||||||
|
|||||||
@@ -703,21 +703,21 @@ func (h *Handler) DeleteOAuthExcludedModels(c *gin.Context) {
|
|||||||
h.persist(c)
|
h.persist(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// oauth-model-mappings: map[string][]ModelNameMapping
|
// oauth-model-alias: map[string][]OAuthModelAlias
|
||||||
func (h *Handler) GetOAuthModelMappings(c *gin.Context) {
|
func (h *Handler) GetOAuthModelAlias(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"oauth-model-mappings": sanitizedOAuthModelMappings(h.cfg.OAuthModelMappings)})
|
c.JSON(200, gin.H{"oauth-model-alias": sanitizedOAuthModelAlias(h.cfg.OAuthModelAlias)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) PutOAuthModelMappings(c *gin.Context) {
|
func (h *Handler) PutOAuthModelAlias(c *gin.Context) {
|
||||||
data, err := c.GetRawData()
|
data, err := c.GetRawData()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(400, gin.H{"error": "failed to read body"})
|
c.JSON(400, gin.H{"error": "failed to read body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var entries map[string][]config.ModelNameMapping
|
var entries map[string][]config.OAuthModelAlias
|
||||||
if err = json.Unmarshal(data, &entries); err != nil {
|
if err = json.Unmarshal(data, &entries); err != nil {
|
||||||
var wrapper struct {
|
var wrapper struct {
|
||||||
Items map[string][]config.ModelNameMapping `json:"items"`
|
Items map[string][]config.OAuthModelAlias `json:"items"`
|
||||||
}
|
}
|
||||||
if err2 := json.Unmarshal(data, &wrapper); err2 != nil {
|
if err2 := json.Unmarshal(data, &wrapper); err2 != nil {
|
||||||
c.JSON(400, gin.H{"error": "invalid body"})
|
c.JSON(400, gin.H{"error": "invalid body"})
|
||||||
@@ -725,15 +725,15 @@ func (h *Handler) PutOAuthModelMappings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
entries = wrapper.Items
|
entries = wrapper.Items
|
||||||
}
|
}
|
||||||
h.cfg.OAuthModelMappings = sanitizedOAuthModelMappings(entries)
|
h.cfg.OAuthModelAlias = sanitizedOAuthModelAlias(entries)
|
||||||
h.persist(c)
|
h.persist(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) PatchOAuthModelMappings(c *gin.Context) {
|
func (h *Handler) PatchOAuthModelAlias(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Provider *string `json:"provider"`
|
Provider *string `json:"provider"`
|
||||||
Channel *string `json:"channel"`
|
Channel *string `json:"channel"`
|
||||||
Mappings []config.ModelNameMapping `json:"mappings"`
|
Aliases []config.OAuthModelAlias `json:"aliases"`
|
||||||
}
|
}
|
||||||
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {
|
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {
|
||||||
c.JSON(400, gin.H{"error": "invalid body"})
|
c.JSON(400, gin.H{"error": "invalid body"})
|
||||||
@@ -751,32 +751,32 @@ func (h *Handler) PatchOAuthModelMappings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizedMap := sanitizedOAuthModelMappings(map[string][]config.ModelNameMapping{channel: body.Mappings})
|
normalizedMap := sanitizedOAuthModelAlias(map[string][]config.OAuthModelAlias{channel: body.Aliases})
|
||||||
normalized := normalizedMap[channel]
|
normalized := normalizedMap[channel]
|
||||||
if len(normalized) == 0 {
|
if len(normalized) == 0 {
|
||||||
if h.cfg.OAuthModelMappings == nil {
|
if h.cfg.OAuthModelAlias == nil {
|
||||||
c.JSON(404, gin.H{"error": "channel not found"})
|
c.JSON(404, gin.H{"error": "channel not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := h.cfg.OAuthModelMappings[channel]; !ok {
|
if _, ok := h.cfg.OAuthModelAlias[channel]; !ok {
|
||||||
c.JSON(404, gin.H{"error": "channel not found"})
|
c.JSON(404, gin.H{"error": "channel not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
delete(h.cfg.OAuthModelMappings, channel)
|
delete(h.cfg.OAuthModelAlias, channel)
|
||||||
if len(h.cfg.OAuthModelMappings) == 0 {
|
if len(h.cfg.OAuthModelAlias) == 0 {
|
||||||
h.cfg.OAuthModelMappings = nil
|
h.cfg.OAuthModelAlias = nil
|
||||||
}
|
}
|
||||||
h.persist(c)
|
h.persist(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if h.cfg.OAuthModelMappings == nil {
|
if h.cfg.OAuthModelAlias == nil {
|
||||||
h.cfg.OAuthModelMappings = make(map[string][]config.ModelNameMapping)
|
h.cfg.OAuthModelAlias = make(map[string][]config.OAuthModelAlias)
|
||||||
}
|
}
|
||||||
h.cfg.OAuthModelMappings[channel] = normalized
|
h.cfg.OAuthModelAlias[channel] = normalized
|
||||||
h.persist(c)
|
h.persist(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) DeleteOAuthModelMappings(c *gin.Context) {
|
func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) {
|
||||||
channel := strings.ToLower(strings.TrimSpace(c.Query("channel")))
|
channel := strings.ToLower(strings.TrimSpace(c.Query("channel")))
|
||||||
if channel == "" {
|
if channel == "" {
|
||||||
channel = strings.ToLower(strings.TrimSpace(c.Query("provider")))
|
channel = strings.ToLower(strings.TrimSpace(c.Query("provider")))
|
||||||
@@ -785,17 +785,17 @@ func (h *Handler) DeleteOAuthModelMappings(c *gin.Context) {
|
|||||||
c.JSON(400, gin.H{"error": "missing channel"})
|
c.JSON(400, gin.H{"error": "missing channel"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if h.cfg.OAuthModelMappings == nil {
|
if h.cfg.OAuthModelAlias == nil {
|
||||||
c.JSON(404, gin.H{"error": "channel not found"})
|
c.JSON(404, gin.H{"error": "channel not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := h.cfg.OAuthModelMappings[channel]; !ok {
|
if _, ok := h.cfg.OAuthModelAlias[channel]; !ok {
|
||||||
c.JSON(404, gin.H{"error": "channel not found"})
|
c.JSON(404, gin.H{"error": "channel not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
delete(h.cfg.OAuthModelMappings, channel)
|
delete(h.cfg.OAuthModelAlias, channel)
|
||||||
if len(h.cfg.OAuthModelMappings) == 0 {
|
if len(h.cfg.OAuthModelAlias) == 0 {
|
||||||
h.cfg.OAuthModelMappings = nil
|
h.cfg.OAuthModelAlias = nil
|
||||||
}
|
}
|
||||||
h.persist(c)
|
h.persist(c)
|
||||||
}
|
}
|
||||||
@@ -1042,26 +1042,26 @@ func normalizeVertexCompatKey(entry *config.VertexCompatKey) {
|
|||||||
entry.Models = normalized
|
entry.Models = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizedOAuthModelMappings(entries map[string][]config.ModelNameMapping) map[string][]config.ModelNameMapping {
|
func sanitizedOAuthModelAlias(entries map[string][]config.OAuthModelAlias) map[string][]config.OAuthModelAlias {
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
copied := make(map[string][]config.ModelNameMapping, len(entries))
|
copied := make(map[string][]config.OAuthModelAlias, len(entries))
|
||||||
for channel, mappings := range entries {
|
for channel, aliases := range entries {
|
||||||
if len(mappings) == 0 {
|
if len(aliases) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
copied[channel] = append([]config.ModelNameMapping(nil), mappings...)
|
copied[channel] = append([]config.OAuthModelAlias(nil), aliases...)
|
||||||
}
|
}
|
||||||
if len(copied) == 0 {
|
if len(copied) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
cfg := config.Config{OAuthModelMappings: copied}
|
cfg := config.Config{OAuthModelAlias: copied}
|
||||||
cfg.SanitizeOAuthModelMappings()
|
cfg.SanitizeOAuthModelAlias()
|
||||||
if len(cfg.OAuthModelMappings) == 0 {
|
if len(cfg.OAuthModelAlias) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return cfg.OAuthModelMappings
|
return cfg.OAuthModelAlias
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAmpCode returns the complete ampcode configuration.
|
// GetAmpCode returns the complete ampcode configuration.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
@@ -134,10 +135,11 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Normalize model (handles dynamic thinking suffixes)
|
// Normalize model (handles dynamic thinking suffixes)
|
||||||
normalizedModel, thinkingMetadata := util.NormalizeThinkingModel(modelName)
|
suffixResult := thinking.ParseSuffix(modelName)
|
||||||
|
normalizedModel := suffixResult.ModelName
|
||||||
thinkingSuffix := ""
|
thinkingSuffix := ""
|
||||||
if thinkingMetadata != nil && strings.HasPrefix(modelName, normalizedModel) {
|
if suffixResult.HasSuffix {
|
||||||
thinkingSuffix = modelName[len(normalizedModel):]
|
thinkingSuffix = "(" + suffixResult.RawSuffix + ")"
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveMappedModel := func() (string, []string) {
|
resolveMappedModel := func() (string, []string) {
|
||||||
@@ -157,13 +159,13 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
|
|||||||
// Preserve dynamic thinking suffix (e.g. "(xhigh)") when mapping applies, unless the target
|
// Preserve dynamic thinking suffix (e.g. "(xhigh)") when mapping applies, unless the target
|
||||||
// already specifies its own thinking suffix.
|
// already specifies its own thinking suffix.
|
||||||
if thinkingSuffix != "" {
|
if thinkingSuffix != "" {
|
||||||
_, mappedThinkingMetadata := util.NormalizeThinkingModel(mappedModel)
|
mappedSuffixResult := thinking.ParseSuffix(mappedModel)
|
||||||
if mappedThinkingMetadata == nil {
|
if !mappedSuffixResult.HasSuffix {
|
||||||
mappedModel += thinkingSuffix
|
mappedModel += thinkingSuffix
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mappedBaseModel, _ := util.NormalizeThinkingModel(mappedModel)
|
mappedBaseModel := thinking.ParseSuffix(mappedModel).ModelName
|
||||||
mappedProviders := util.GetProviderName(mappedBaseModel)
|
mappedProviders := util.GetProviderName(mappedBaseModel)
|
||||||
if len(mappedProviders) == 0 {
|
if len(mappedProviders) == 0 {
|
||||||
return "", nil
|
return "", nil
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"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/thinking"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -44,6 +45,11 @@ func NewModelMapper(mappings []config.AmpModelMapping) *DefaultModelMapper {
|
|||||||
// MapModel checks if a mapping exists for the requested model and if the
|
// MapModel checks if a mapping exists for the requested model and if the
|
||||||
// target model has available local providers. Returns the mapped model name
|
// target model has available local providers. Returns the mapped model name
|
||||||
// or empty string if no valid mapping exists.
|
// or empty string if no valid mapping exists.
|
||||||
|
//
|
||||||
|
// If the requested model contains a thinking suffix (e.g., "g25p(8192)"),
|
||||||
|
// the suffix is preserved in the returned model name (e.g., "gemini-2.5-pro(8192)").
|
||||||
|
// However, if the mapping target already contains a suffix, the config suffix
|
||||||
|
// takes priority over the user's suffix.
|
||||||
func (m *DefaultModelMapper) MapModel(requestedModel string) string {
|
func (m *DefaultModelMapper) MapModel(requestedModel string) string {
|
||||||
if requestedModel == "" {
|
if requestedModel == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -52,16 +58,20 @@ func (m *DefaultModelMapper) MapModel(requestedModel string) string {
|
|||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
// Normalize the requested model for lookup
|
// Extract thinking suffix from requested model using ParseSuffix
|
||||||
normalizedRequest := strings.ToLower(strings.TrimSpace(requestedModel))
|
requestResult := thinking.ParseSuffix(requestedModel)
|
||||||
|
baseModel := requestResult.ModelName
|
||||||
|
|
||||||
// Check for direct mapping
|
// Normalize the base model for lookup (case-insensitive)
|
||||||
targetModel, exists := m.mappings[normalizedRequest]
|
normalizedBase := strings.ToLower(strings.TrimSpace(baseModel))
|
||||||
|
|
||||||
|
// Check for direct mapping using base model name
|
||||||
|
targetModel, exists := m.mappings[normalizedBase]
|
||||||
if !exists {
|
if !exists {
|
||||||
// Try regex mappings in order
|
// Try regex mappings in order using base model only
|
||||||
base, _ := util.NormalizeThinkingModel(requestedModel)
|
// (suffix is handled separately via ParseSuffix)
|
||||||
for _, rm := range m.regexps {
|
for _, rm := range m.regexps {
|
||||||
if rm.re.MatchString(requestedModel) || (base != "" && rm.re.MatchString(base)) {
|
if rm.re.MatchString(baseModel) {
|
||||||
targetModel = rm.to
|
targetModel = rm.to
|
||||||
exists = true
|
exists = true
|
||||||
break
|
break
|
||||||
@@ -72,14 +82,28 @@ func (m *DefaultModelMapper) MapModel(requestedModel string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify target model has available providers
|
// Check if target model already has a thinking suffix (config priority)
|
||||||
normalizedTarget, _ := util.NormalizeThinkingModel(targetModel)
|
targetResult := thinking.ParseSuffix(targetModel)
|
||||||
providers := util.GetProviderName(normalizedTarget)
|
|
||||||
|
// Verify target model has available providers (use base model for lookup)
|
||||||
|
providers := util.GetProviderName(targetResult.ModelName)
|
||||||
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 ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suffix handling: config suffix takes priority, otherwise preserve user suffix
|
||||||
|
if targetResult.HasSuffix {
|
||||||
|
// Config's "to" already contains a suffix - use it as-is (config priority)
|
||||||
|
return targetModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve user's thinking suffix on the mapped model
|
||||||
|
// (skip empty suffixes to avoid returning "model()")
|
||||||
|
if requestResult.HasSuffix && requestResult.RawSuffix != "" {
|
||||||
|
return targetModel + "(" + requestResult.RawSuffix + ")"
|
||||||
|
}
|
||||||
|
|
||||||
// Note: Detailed routing log is handled by logAmpRouting in fallback_handlers.go
|
// Note: Detailed routing log is handled by logAmpRouting in fallback_handlers.go
|
||||||
return targetModel
|
return targetModel
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,10 +217,10 @@ func TestModelMapper_Regex_MatchBaseWithoutParens(t *testing.T) {
|
|||||||
|
|
||||||
mapper := NewModelMapper(mappings)
|
mapper := NewModelMapper(mappings)
|
||||||
|
|
||||||
// Incoming model has reasoning suffix but should match base via regex
|
// Incoming model has reasoning suffix, regex matches base, suffix is preserved
|
||||||
result := mapper.MapModel("gpt-5(high)")
|
result := mapper.MapModel("gpt-5(high)")
|
||||||
if result != "gemini-2.5-pro" {
|
if result != "gemini-2.5-pro(high)" {
|
||||||
t.Errorf("Expected gemini-2.5-pro, got %s", result)
|
t.Errorf("Expected gemini-2.5-pro(high), got %s", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,3 +281,95 @@ func TestModelMapper_Regex_CaseInsensitive(t *testing.T) {
|
|||||||
t.Errorf("Expected claude-sonnet-4, got %s", result)
|
t.Errorf("Expected claude-sonnet-4, got %s", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModelMapper_SuffixPreservation(t *testing.T) {
|
||||||
|
reg := registry.GetGlobalRegistry()
|
||||||
|
|
||||||
|
// Register test models
|
||||||
|
reg.RegisterClient("test-client-suffix", "gemini", []*registry.ModelInfo{
|
||||||
|
{ID: "gemini-2.5-pro", OwnedBy: "google", Type: "gemini"},
|
||||||
|
})
|
||||||
|
reg.RegisterClient("test-client-suffix-2", "claude", []*registry.ModelInfo{
|
||||||
|
{ID: "claude-sonnet-4", OwnedBy: "anthropic", Type: "claude"},
|
||||||
|
})
|
||||||
|
defer reg.UnregisterClient("test-client-suffix")
|
||||||
|
defer reg.UnregisterClient("test-client-suffix-2")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mappings []config.AmpModelMapping
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "numeric suffix preserved",
|
||||||
|
mappings: []config.AmpModelMapping{{From: "g25p", To: "gemini-2.5-pro"}},
|
||||||
|
input: "g25p(8192)",
|
||||||
|
want: "gemini-2.5-pro(8192)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "level suffix preserved",
|
||||||
|
mappings: []config.AmpModelMapping{{From: "g25p", To: "gemini-2.5-pro"}},
|
||||||
|
input: "g25p(high)",
|
||||||
|
want: "gemini-2.5-pro(high)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no suffix unchanged",
|
||||||
|
mappings: []config.AmpModelMapping{{From: "g25p", To: "gemini-2.5-pro"}},
|
||||||
|
input: "g25p",
|
||||||
|
want: "gemini-2.5-pro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "config suffix takes priority",
|
||||||
|
mappings: []config.AmpModelMapping{{From: "alias", To: "gemini-2.5-pro(medium)"}},
|
||||||
|
input: "alias(high)",
|
||||||
|
want: "gemini-2.5-pro(medium)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regex with suffix preserved",
|
||||||
|
mappings: []config.AmpModelMapping{{From: "^g25.*", To: "gemini-2.5-pro", Regex: true}},
|
||||||
|
input: "g25p(8192)",
|
||||||
|
want: "gemini-2.5-pro(8192)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "auto suffix preserved",
|
||||||
|
mappings: []config.AmpModelMapping{{From: "g25p", To: "gemini-2.5-pro"}},
|
||||||
|
input: "g25p(auto)",
|
||||||
|
want: "gemini-2.5-pro(auto)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "none suffix preserved",
|
||||||
|
mappings: []config.AmpModelMapping{{From: "g25p", To: "gemini-2.5-pro"}},
|
||||||
|
input: "g25p(none)",
|
||||||
|
want: "gemini-2.5-pro(none)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case insensitive base lookup with suffix",
|
||||||
|
mappings: []config.AmpModelMapping{{From: "G25P", To: "gemini-2.5-pro"}},
|
||||||
|
input: "g25p(high)",
|
||||||
|
want: "gemini-2.5-pro(high)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty suffix filtered out",
|
||||||
|
mappings: []config.AmpModelMapping{{From: "g25p", To: "gemini-2.5-pro"}},
|
||||||
|
input: "g25p()",
|
||||||
|
want: "gemini-2.5-pro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "incomplete suffix treated as no suffix",
|
||||||
|
mappings: []config.AmpModelMapping{{From: "g25p(high", To: "gemini-2.5-pro"}},
|
||||||
|
input: "g25p(high",
|
||||||
|
want: "gemini-2.5-pro",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
mapper := NewModelMapper(tt.mappings)
|
||||||
|
got := mapper.MapModel(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("MapModel(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -601,10 +601,10 @@ func (s *Server) registerManagementRoutes() {
|
|||||||
mgmt.PATCH("/oauth-excluded-models", s.mgmt.PatchOAuthExcludedModels)
|
mgmt.PATCH("/oauth-excluded-models", s.mgmt.PatchOAuthExcludedModels)
|
||||||
mgmt.DELETE("/oauth-excluded-models", s.mgmt.DeleteOAuthExcludedModels)
|
mgmt.DELETE("/oauth-excluded-models", s.mgmt.DeleteOAuthExcludedModels)
|
||||||
|
|
||||||
mgmt.GET("/oauth-model-mappings", s.mgmt.GetOAuthModelMappings)
|
mgmt.GET("/oauth-model-alias", s.mgmt.GetOAuthModelAlias)
|
||||||
mgmt.PUT("/oauth-model-mappings", s.mgmt.PutOAuthModelMappings)
|
mgmt.PUT("/oauth-model-alias", s.mgmt.PutOAuthModelAlias)
|
||||||
mgmt.PATCH("/oauth-model-mappings", s.mgmt.PatchOAuthModelMappings)
|
mgmt.PATCH("/oauth-model-alias", s.mgmt.PatchOAuthModelAlias)
|
||||||
mgmt.DELETE("/oauth-model-mappings", s.mgmt.DeleteOAuthModelMappings)
|
mgmt.DELETE("/oauth-model-alias", s.mgmt.DeleteOAuthModelAlias)
|
||||||
|
|
||||||
mgmt.GET("/auth-files", s.mgmt.ListAuthFiles)
|
mgmt.GET("/auth-files", s.mgmt.ListAuthFiles)
|
||||||
mgmt.GET("/auth-files/models", s.mgmt.GetAuthFileModels)
|
mgmt.GET("/auth-files/models", s.mgmt.GetAuthFileModels)
|
||||||
|
|||||||
@@ -91,13 +91,13 @@ type Config struct {
|
|||||||
// OAuthExcludedModels defines per-provider global model exclusions applied to OAuth/file-backed auth entries.
|
// OAuthExcludedModels defines per-provider global model exclusions applied to OAuth/file-backed auth entries.
|
||||||
OAuthExcludedModels map[string][]string `yaml:"oauth-excluded-models,omitempty" json:"oauth-excluded-models,omitempty"`
|
OAuthExcludedModels map[string][]string `yaml:"oauth-excluded-models,omitempty" json:"oauth-excluded-models,omitempty"`
|
||||||
|
|
||||||
// OAuthModelMappings defines global model name mappings for OAuth/file-backed auth channels.
|
// OAuthModelAlias defines global model name aliases for OAuth/file-backed auth channels.
|
||||||
// These mappings affect both model listing and model routing for supported channels:
|
// These aliases affect both model listing and model routing for supported channels:
|
||||||
// gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow.
|
// gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow.
|
||||||
//
|
//
|
||||||
// NOTE: This does not apply to existing per-credential model alias features under:
|
// NOTE: This does not apply to existing per-credential model alias features under:
|
||||||
// gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, and ampcode.
|
// gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, and ampcode.
|
||||||
OAuthModelMappings map[string][]ModelNameMapping `yaml:"oauth-model-mappings,omitempty" json:"oauth-model-mappings,omitempty"`
|
OAuthModelAlias map[string][]OAuthModelAlias `yaml:"oauth-model-alias,omitempty" json:"oauth-model-alias,omitempty"`
|
||||||
|
|
||||||
// Payload defines default and override rules for provider payload parameters.
|
// Payload defines default and override rules for provider payload parameters.
|
||||||
Payload PayloadConfig `yaml:"payload" json:"payload"`
|
Payload PayloadConfig `yaml:"payload" json:"payload"`
|
||||||
@@ -145,11 +145,11 @@ type RoutingConfig struct {
|
|||||||
Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"`
|
Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelNameMapping defines a model ID mapping for a specific channel.
|
// OAuthModelAlias defines a model ID alias for a specific channel.
|
||||||
// It maps the upstream model name (Name) to the client-visible alias (Alias).
|
// It maps the upstream model name (Name) to the client-visible alias (Alias).
|
||||||
// When Fork is true, the alias is added as an additional model in listings while
|
// When Fork is true, the alias is added as an additional model in listings while
|
||||||
// keeping the original model ID available.
|
// keeping the original model ID available.
|
||||||
type ModelNameMapping struct {
|
type OAuthModelAlias struct {
|
||||||
Name string `yaml:"name" json:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Alias string `yaml:"alias" json:"alias"`
|
Alias string `yaml:"alias" json:"alias"`
|
||||||
Fork bool `yaml:"fork,omitempty" json:"fork,omitempty"`
|
Fork bool `yaml:"fork,omitempty" json:"fork,omitempty"`
|
||||||
@@ -266,6 +266,9 @@ type ClaudeKey struct {
|
|||||||
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
|
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (k ClaudeKey) GetAPIKey() string { return k.APIKey }
|
||||||
|
func (k ClaudeKey) GetBaseURL() string { return k.BaseURL }
|
||||||
|
|
||||||
// ClaudeModel describes a mapping between an alias and the actual upstream model name.
|
// ClaudeModel describes a mapping between an alias and the actual upstream model name.
|
||||||
type ClaudeModel struct {
|
type ClaudeModel struct {
|
||||||
// Name is the upstream model identifier used when issuing requests.
|
// Name is the upstream model identifier used when issuing requests.
|
||||||
@@ -308,6 +311,9 @@ type CodexKey struct {
|
|||||||
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
|
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (k CodexKey) GetAPIKey() string { return k.APIKey }
|
||||||
|
func (k CodexKey) GetBaseURL() string { return k.BaseURL }
|
||||||
|
|
||||||
// CodexModel describes a mapping between an alias and the actual upstream model name.
|
// CodexModel describes a mapping between an alias and the actual upstream model name.
|
||||||
type CodexModel struct {
|
type CodexModel struct {
|
||||||
// Name is the upstream model identifier used when issuing requests.
|
// Name is the upstream model identifier used when issuing requests.
|
||||||
@@ -349,6 +355,9 @@ type GeminiKey struct {
|
|||||||
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
|
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (k GeminiKey) GetAPIKey() string { return k.APIKey }
|
||||||
|
func (k GeminiKey) GetBaseURL() string { return k.BaseURL }
|
||||||
|
|
||||||
// GeminiModel describes a mapping between an alias and the actual upstream model name.
|
// GeminiModel describes a mapping between an alias and the actual upstream model name.
|
||||||
type GeminiModel struct {
|
type GeminiModel struct {
|
||||||
// Name is the upstream model identifier used when issuing requests.
|
// Name is the upstream model identifier used when issuing requests.
|
||||||
@@ -406,6 +415,9 @@ type OpenAICompatibilityModel struct {
|
|||||||
Alias string `yaml:"alias" json:"alias"`
|
Alias string `yaml:"alias" json:"alias"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m OpenAICompatibilityModel) GetName() string { return m.Name }
|
||||||
|
func (m OpenAICompatibilityModel) GetAlias() string { return m.Alias }
|
||||||
|
|
||||||
// LoadConfig reads a YAML configuration file from the given path,
|
// LoadConfig reads a YAML configuration file from the given path,
|
||||||
// unmarshals it into a Config struct, applies environment variable overrides,
|
// unmarshals it into a Config struct, applies environment variable overrides,
|
||||||
// and returns it.
|
// and returns it.
|
||||||
@@ -424,6 +436,15 @@ func LoadConfig(configFile string) (*Config, error) {
|
|||||||
// If optional is true and the file is missing, it returns an empty Config.
|
// If optional is true and the file is missing, it returns an empty Config.
|
||||||
// If optional is true and the file is empty or invalid, it returns an empty Config.
|
// If optional is true and the file is empty or invalid, it returns an empty Config.
|
||||||
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
||||||
|
// Perform oauth-model-alias migration before loading config.
|
||||||
|
// This migrates oauth-model-mappings to oauth-model-alias if needed.
|
||||||
|
if migrated, err := MigrateOAuthModelAlias(configFile); err != nil {
|
||||||
|
// Log warning but don't fail - config loading should still work
|
||||||
|
fmt.Printf("Warning: oauth-model-alias migration failed: %v\n", err)
|
||||||
|
} else if migrated {
|
||||||
|
fmt.Println("Migrated oauth-model-mappings to oauth-model-alias")
|
||||||
|
}
|
||||||
|
|
||||||
// Read the entire configuration file into memory.
|
// Read the entire configuration file into memory.
|
||||||
data, err := os.ReadFile(configFile)
|
data, err := os.ReadFile(configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -516,8 +537,8 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
|||||||
// Normalize OAuth provider model exclusion map.
|
// Normalize OAuth provider model exclusion map.
|
||||||
cfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels)
|
cfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels)
|
||||||
|
|
||||||
// Normalize global OAuth model name mappings.
|
// Normalize global OAuth model name aliases.
|
||||||
cfg.SanitizeOAuthModelMappings()
|
cfg.SanitizeOAuthModelAlias()
|
||||||
|
|
||||||
if cfg.legacyMigrationPending {
|
if cfg.legacyMigrationPending {
|
||||||
fmt.Println("Detected legacy configuration keys, attempting to persist the normalized config...")
|
fmt.Println("Detected legacy configuration keys, attempting to persist the normalized config...")
|
||||||
@@ -535,24 +556,24 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
|||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SanitizeOAuthModelMappings normalizes and deduplicates global OAuth model name mappings.
|
// SanitizeOAuthModelAlias normalizes and deduplicates global OAuth model name aliases.
|
||||||
// It trims whitespace, normalizes channel keys to lower-case, drops empty entries,
|
// It trims whitespace, normalizes channel keys to lower-case, drops empty entries,
|
||||||
// allows multiple aliases per upstream name, and ensures aliases are unique within each channel.
|
// allows multiple aliases per upstream name, and ensures aliases are unique within each channel.
|
||||||
func (cfg *Config) SanitizeOAuthModelMappings() {
|
func (cfg *Config) SanitizeOAuthModelAlias() {
|
||||||
if cfg == nil || len(cfg.OAuthModelMappings) == 0 {
|
if cfg == nil || len(cfg.OAuthModelAlias) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make(map[string][]ModelNameMapping, len(cfg.OAuthModelMappings))
|
out := make(map[string][]OAuthModelAlias, len(cfg.OAuthModelAlias))
|
||||||
for rawChannel, mappings := range cfg.OAuthModelMappings {
|
for rawChannel, aliases := range cfg.OAuthModelAlias {
|
||||||
channel := strings.ToLower(strings.TrimSpace(rawChannel))
|
channel := strings.ToLower(strings.TrimSpace(rawChannel))
|
||||||
if channel == "" || len(mappings) == 0 {
|
if channel == "" || len(aliases) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seenAlias := make(map[string]struct{}, len(mappings))
|
seenAlias := make(map[string]struct{}, len(aliases))
|
||||||
clean := make([]ModelNameMapping, 0, len(mappings))
|
clean := make([]OAuthModelAlias, 0, len(aliases))
|
||||||
for _, mapping := range mappings {
|
for _, entry := range aliases {
|
||||||
name := strings.TrimSpace(mapping.Name)
|
name := strings.TrimSpace(entry.Name)
|
||||||
alias := strings.TrimSpace(mapping.Alias)
|
alias := strings.TrimSpace(entry.Alias)
|
||||||
if name == "" || alias == "" {
|
if name == "" || alias == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -564,13 +585,13 @@ func (cfg *Config) SanitizeOAuthModelMappings() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seenAlias[aliasKey] = struct{}{}
|
seenAlias[aliasKey] = struct{}{}
|
||||||
clean = append(clean, ModelNameMapping{Name: name, Alias: alias, Fork: mapping.Fork})
|
clean = append(clean, OAuthModelAlias{Name: name, Alias: alias, Fork: entry.Fork})
|
||||||
}
|
}
|
||||||
if len(clean) > 0 {
|
if len(clean) > 0 {
|
||||||
out[channel] = clean
|
out[channel] = clean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cfg.OAuthModelMappings = out
|
cfg.OAuthModelAlias = out
|
||||||
}
|
}
|
||||||
|
|
||||||
// SanitizeOpenAICompatibility removes OpenAI-compatibility provider entries that are
|
// SanitizeOpenAICompatibility removes OpenAI-compatibility provider entries that are
|
||||||
|
|||||||
258
internal/config/oauth_model_alias_migration.go
Normal file
258
internal/config/oauth_model_alias_migration.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// antigravityModelConversionTable maps old built-in aliases to actual model names
|
||||||
|
// for the antigravity channel during migration.
|
||||||
|
var antigravityModelConversionTable = map[string]string{
|
||||||
|
"gemini-2.5-computer-use-preview-10-2025": "rev19-uic3-1p",
|
||||||
|
"gemini-3-pro-image-preview": "gemini-3-pro-image",
|
||||||
|
"gemini-3-pro-preview": "gemini-3-pro-high",
|
||||||
|
"gemini-3-flash-preview": "gemini-3-flash",
|
||||||
|
"gemini-claude-sonnet-4-5": "claude-sonnet-4-5",
|
||||||
|
"gemini-claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
|
||||||
|
"gemini-claude-opus-4-5-thinking": "claude-opus-4-5-thinking",
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultAntigravityAliases returns the default oauth-model-alias configuration
|
||||||
|
// for the antigravity channel when neither field exists.
|
||||||
|
func defaultAntigravityAliases() []OAuthModelAlias {
|
||||||
|
return []OAuthModelAlias{
|
||||||
|
{Name: "rev19-uic3-1p", Alias: "gemini-2.5-computer-use-preview-10-2025"},
|
||||||
|
{Name: "gemini-3-pro-image", Alias: "gemini-3-pro-image-preview"},
|
||||||
|
{Name: "gemini-3-pro-high", Alias: "gemini-3-pro-preview"},
|
||||||
|
{Name: "gemini-3-flash", Alias: "gemini-3-flash-preview"},
|
||||||
|
{Name: "claude-sonnet-4-5", Alias: "gemini-claude-sonnet-4-5"},
|
||||||
|
{Name: "claude-sonnet-4-5-thinking", Alias: "gemini-claude-sonnet-4-5-thinking"},
|
||||||
|
{Name: "claude-opus-4-5-thinking", Alias: "gemini-claude-opus-4-5-thinking"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrateOAuthModelAlias checks for and performs migration from oauth-model-mappings
|
||||||
|
// to oauth-model-alias at startup. Returns true if migration was performed.
|
||||||
|
//
|
||||||
|
// Migration flow:
|
||||||
|
// 1. Check if oauth-model-alias exists -> skip migration
|
||||||
|
// 2. Check if oauth-model-mappings exists -> convert and migrate
|
||||||
|
// - For antigravity channel, convert old built-in aliases to actual model names
|
||||||
|
//
|
||||||
|
// 3. Neither exists -> add default antigravity config
|
||||||
|
func MigrateOAuthModelAlias(configFile string) (bool, error) {
|
||||||
|
data, err := os.ReadFile(configFile)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse YAML into node tree to preserve structure
|
||||||
|
var root yaml.Node
|
||||||
|
if err := yaml.Unmarshal(data, &root); err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if root.Kind != yaml.DocumentNode || len(root.Content) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
rootMap := root.Content[0]
|
||||||
|
if rootMap == nil || rootMap.Kind != yaml.MappingNode {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if oauth-model-alias already exists
|
||||||
|
if findMapKeyIndex(rootMap, "oauth-model-alias") >= 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if oauth-model-mappings exists
|
||||||
|
oldIdx := findMapKeyIndex(rootMap, "oauth-model-mappings")
|
||||||
|
if oldIdx >= 0 {
|
||||||
|
// Migrate from old field
|
||||||
|
return migrateFromOldField(configFile, &root, rootMap, oldIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither field exists - add default antigravity config
|
||||||
|
return addDefaultAntigravityConfig(configFile, &root, rootMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateFromOldField converts oauth-model-mappings to oauth-model-alias
|
||||||
|
func migrateFromOldField(configFile string, root *yaml.Node, rootMap *yaml.Node, oldIdx int) (bool, error) {
|
||||||
|
if oldIdx+1 >= len(rootMap.Content) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
oldValue := rootMap.Content[oldIdx+1]
|
||||||
|
if oldValue == nil || oldValue.Kind != yaml.MappingNode {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the old aliases
|
||||||
|
oldAliases := parseOldAliasNode(oldValue)
|
||||||
|
if len(oldAliases) == 0 {
|
||||||
|
// Remove the old field and write
|
||||||
|
removeMapKeyByIndex(rootMap, oldIdx)
|
||||||
|
return writeYAMLNode(configFile, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert model names for antigravity channel
|
||||||
|
newAliases := make(map[string][]OAuthModelAlias, len(oldAliases))
|
||||||
|
for channel, entries := range oldAliases {
|
||||||
|
converted := make([]OAuthModelAlias, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
newEntry := OAuthModelAlias{
|
||||||
|
Name: entry.Name,
|
||||||
|
Alias: entry.Alias,
|
||||||
|
Fork: entry.Fork,
|
||||||
|
}
|
||||||
|
// Convert model names for antigravity channel
|
||||||
|
if strings.EqualFold(channel, "antigravity") {
|
||||||
|
if actual, ok := antigravityModelConversionTable[entry.Name]; ok {
|
||||||
|
newEntry.Name = actual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
converted = append(converted, newEntry)
|
||||||
|
}
|
||||||
|
newAliases[channel] = converted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new node
|
||||||
|
newNode := buildOAuthModelAliasNode(newAliases)
|
||||||
|
|
||||||
|
// Replace old key with new key and value
|
||||||
|
rootMap.Content[oldIdx].Value = "oauth-model-alias"
|
||||||
|
rootMap.Content[oldIdx+1] = newNode
|
||||||
|
|
||||||
|
return writeYAMLNode(configFile, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addDefaultAntigravityConfig adds the default antigravity configuration
|
||||||
|
func addDefaultAntigravityConfig(configFile string, root *yaml.Node, rootMap *yaml.Node) (bool, error) {
|
||||||
|
defaults := map[string][]OAuthModelAlias{
|
||||||
|
"antigravity": defaultAntigravityAliases(),
|
||||||
|
}
|
||||||
|
newNode := buildOAuthModelAliasNode(defaults)
|
||||||
|
|
||||||
|
// Add new key-value pair
|
||||||
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "oauth-model-alias"}
|
||||||
|
rootMap.Content = append(rootMap.Content, keyNode, newNode)
|
||||||
|
|
||||||
|
return writeYAMLNode(configFile, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOldAliasNode parses the old oauth-model-mappings node structure
|
||||||
|
func parseOldAliasNode(node *yaml.Node) map[string][]OAuthModelAlias {
|
||||||
|
if node == nil || node.Kind != yaml.MappingNode {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make(map[string][]OAuthModelAlias)
|
||||||
|
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||||
|
channelNode := node.Content[i]
|
||||||
|
entriesNode := node.Content[i+1]
|
||||||
|
if channelNode == nil || entriesNode == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
channel := strings.ToLower(strings.TrimSpace(channelNode.Value))
|
||||||
|
if channel == "" || entriesNode.Kind != yaml.SequenceNode {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries := make([]OAuthModelAlias, 0, len(entriesNode.Content))
|
||||||
|
for _, entryNode := range entriesNode.Content {
|
||||||
|
if entryNode == nil || entryNode.Kind != yaml.MappingNode {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry := parseAliasEntry(entryNode)
|
||||||
|
if entry.Name != "" && entry.Alias != "" {
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(entries) > 0 {
|
||||||
|
result[channel] = entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAliasEntry parses a single alias entry node
|
||||||
|
func parseAliasEntry(node *yaml.Node) OAuthModelAlias {
|
||||||
|
var entry OAuthModelAlias
|
||||||
|
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||||
|
keyNode := node.Content[i]
|
||||||
|
valNode := node.Content[i+1]
|
||||||
|
if keyNode == nil || valNode == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(keyNode.Value)) {
|
||||||
|
case "name":
|
||||||
|
entry.Name = strings.TrimSpace(valNode.Value)
|
||||||
|
case "alias":
|
||||||
|
entry.Alias = strings.TrimSpace(valNode.Value)
|
||||||
|
case "fork":
|
||||||
|
entry.Fork = strings.ToLower(strings.TrimSpace(valNode.Value)) == "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOAuthModelAliasNode creates a YAML node for oauth-model-alias
|
||||||
|
func buildOAuthModelAliasNode(aliases map[string][]OAuthModelAlias) *yaml.Node {
|
||||||
|
node := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||||
|
for channel, entries := range aliases {
|
||||||
|
channelNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: channel}
|
||||||
|
entriesNode := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
||||||
|
for _, entry := range entries {
|
||||||
|
entryNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||||
|
entryNode.Content = append(entryNode.Content,
|
||||||
|
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "name"},
|
||||||
|
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: entry.Name},
|
||||||
|
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "alias"},
|
||||||
|
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: entry.Alias},
|
||||||
|
)
|
||||||
|
if entry.Fork {
|
||||||
|
entryNode.Content = append(entryNode.Content,
|
||||||
|
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "fork"},
|
||||||
|
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "true"},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entriesNode.Content = append(entriesNode.Content, entryNode)
|
||||||
|
}
|
||||||
|
node.Content = append(node.Content, channelNode, entriesNode)
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeMapKeyByIndex removes a key-value pair from a mapping node by index
|
||||||
|
func removeMapKeyByIndex(mapNode *yaml.Node, keyIdx int) {
|
||||||
|
if mapNode == nil || mapNode.Kind != yaml.MappingNode {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if keyIdx < 0 || keyIdx+1 >= len(mapNode.Content) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mapNode.Content = append(mapNode.Content[:keyIdx], mapNode.Content[keyIdx+2:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeYAMLNode writes the YAML node tree back to file
|
||||||
|
func writeYAMLNode(configFile string, root *yaml.Node) (bool, error) {
|
||||||
|
f, err := os.Create(configFile)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
enc := yaml.NewEncoder(f)
|
||||||
|
enc.SetIndent(2)
|
||||||
|
if err := enc.Encode(root); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := enc.Close(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
225
internal/config/oauth_model_alias_migration_test.go
Normal file
225
internal/config/oauth_model_alias_migration_test.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMigrateOAuthModelAlias_SkipsIfNewFieldExists(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configFile := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
content := `oauth-model-alias:
|
||||||
|
gemini-cli:
|
||||||
|
- name: "gemini-2.5-pro"
|
||||||
|
alias: "g2.5p"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrated, err := MigrateOAuthModelAlias(configFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if migrated {
|
||||||
|
t.Fatal("expected no migration when oauth-model-alias already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file unchanged
|
||||||
|
data, _ := os.ReadFile(configFile)
|
||||||
|
if !strings.Contains(string(data), "oauth-model-alias:") {
|
||||||
|
t.Fatal("file should still contain oauth-model-alias")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateOAuthModelAlias_MigratesOldField(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configFile := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
content := `oauth-model-mappings:
|
||||||
|
gemini-cli:
|
||||||
|
- name: "gemini-2.5-pro"
|
||||||
|
alias: "g2.5p"
|
||||||
|
fork: true
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrated, err := MigrateOAuthModelAlias(configFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !migrated {
|
||||||
|
t.Fatal("expected migration to occur")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify new field exists and old field removed
|
||||||
|
data, _ := os.ReadFile(configFile)
|
||||||
|
if strings.Contains(string(data), "oauth-model-mappings:") {
|
||||||
|
t.Fatal("old field should be removed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "oauth-model-alias:") {
|
||||||
|
t.Fatal("new field should exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and verify structure
|
||||||
|
var root yaml.Node
|
||||||
|
if err := yaml.Unmarshal(data, &root); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateOAuthModelAlias_ConvertsAntigravityModels(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configFile := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
// Use old model names that should be converted
|
||||||
|
content := `oauth-model-mappings:
|
||||||
|
antigravity:
|
||||||
|
- name: "gemini-2.5-computer-use-preview-10-2025"
|
||||||
|
alias: "computer-use"
|
||||||
|
- name: "gemini-3-pro-preview"
|
||||||
|
alias: "g3p"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrated, err := MigrateOAuthModelAlias(configFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !migrated {
|
||||||
|
t.Fatal("expected migration to occur")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify model names were converted
|
||||||
|
data, _ := os.ReadFile(configFile)
|
||||||
|
content = string(data)
|
||||||
|
if !strings.Contains(content, "rev19-uic3-1p") {
|
||||||
|
t.Fatal("expected gemini-2.5-computer-use-preview-10-2025 to be converted to rev19-uic3-1p")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "gemini-3-pro-high") {
|
||||||
|
t.Fatal("expected gemini-3-pro-preview to be converted to gemini-3-pro-high")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateOAuthModelAlias_AddsDefaultIfNeitherExists(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configFile := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
content := `debug: true
|
||||||
|
port: 8080
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrated, err := MigrateOAuthModelAlias(configFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !migrated {
|
||||||
|
t.Fatal("expected migration to add default config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify default antigravity config was added
|
||||||
|
data, _ := os.ReadFile(configFile)
|
||||||
|
content = string(data)
|
||||||
|
if !strings.Contains(content, "oauth-model-alias:") {
|
||||||
|
t.Fatal("expected oauth-model-alias to be added")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "antigravity:") {
|
||||||
|
t.Fatal("expected antigravity channel to be added")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "rev19-uic3-1p") {
|
||||||
|
t.Fatal("expected default antigravity aliases to include rev19-uic3-1p")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateOAuthModelAlias_PreservesOtherConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configFile := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
content := `debug: true
|
||||||
|
port: 8080
|
||||||
|
oauth-model-mappings:
|
||||||
|
gemini-cli:
|
||||||
|
- name: "test"
|
||||||
|
alias: "t"
|
||||||
|
api-keys:
|
||||||
|
- "key1"
|
||||||
|
- "key2"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrated, err := MigrateOAuthModelAlias(configFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !migrated {
|
||||||
|
t.Fatal("expected migration to occur")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify other config preserved
|
||||||
|
data, _ := os.ReadFile(configFile)
|
||||||
|
content = string(data)
|
||||||
|
if !strings.Contains(content, "debug: true") {
|
||||||
|
t.Fatal("expected debug field to be preserved")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "port: 8080") {
|
||||||
|
t.Fatal("expected port field to be preserved")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "api-keys:") {
|
||||||
|
t.Fatal("expected api-keys field to be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateOAuthModelAlias_NonexistentFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
migrated, err := MigrateOAuthModelAlias("/nonexistent/path/config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for nonexistent file: %v", err)
|
||||||
|
}
|
||||||
|
if migrated {
|
||||||
|
t.Fatal("expected no migration for nonexistent file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateOAuthModelAlias_EmptyFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configFile := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
if err := os.WriteFile(configFile, []byte(""), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrated, err := MigrateOAuthModelAlias(configFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if migrated {
|
||||||
|
t.Fatal("expected no migration for empty file")
|
||||||
|
}
|
||||||
|
}
|
||||||
56
internal/config/oauth_model_alias_test.go
Normal file
56
internal/config/oauth_model_alias_test.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSanitizeOAuthModelAlias_PreservesForkFlag(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
OAuthModelAlias: map[string][]OAuthModelAlias{
|
||||||
|
" CoDeX ": {
|
||||||
|
{Name: " gpt-5 ", Alias: " g5 ", Fork: true},
|
||||||
|
{Name: "gpt-6", Alias: "g6"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.SanitizeOAuthModelAlias()
|
||||||
|
|
||||||
|
aliases := cfg.OAuthModelAlias["codex"]
|
||||||
|
if len(aliases) != 2 {
|
||||||
|
t.Fatalf("expected 2 sanitized aliases, got %d", len(aliases))
|
||||||
|
}
|
||||||
|
if aliases[0].Name != "gpt-5" || aliases[0].Alias != "g5" || !aliases[0].Fork {
|
||||||
|
t.Fatalf("expected first alias to be gpt-5->g5 fork=true, got name=%q alias=%q fork=%v", aliases[0].Name, aliases[0].Alias, aliases[0].Fork)
|
||||||
|
}
|
||||||
|
if aliases[1].Name != "gpt-6" || aliases[1].Alias != "g6" || aliases[1].Fork {
|
||||||
|
t.Fatalf("expected second alias to be gpt-6->g6 fork=false, got name=%q alias=%q fork=%v", aliases[1].Name, aliases[1].Alias, aliases[1].Fork)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeOAuthModelAlias_AllowsMultipleAliasesForSameName(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
OAuthModelAlias: map[string][]OAuthModelAlias{
|
||||||
|
"antigravity": {
|
||||||
|
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101", Fork: true},
|
||||||
|
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101-thinking", Fork: true},
|
||||||
|
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5", Fork: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.SanitizeOAuthModelAlias()
|
||||||
|
|
||||||
|
aliases := cfg.OAuthModelAlias["antigravity"]
|
||||||
|
expected := []OAuthModelAlias{
|
||||||
|
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101", Fork: true},
|
||||||
|
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101-thinking", Fork: true},
|
||||||
|
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5", Fork: true},
|
||||||
|
}
|
||||||
|
if len(aliases) != len(expected) {
|
||||||
|
t.Fatalf("expected %d sanitized aliases, got %d", len(expected), len(aliases))
|
||||||
|
}
|
||||||
|
for i, exp := range expected {
|
||||||
|
if aliases[i].Name != exp.Name || aliases[i].Alias != exp.Alias || aliases[i].Fork != exp.Fork {
|
||||||
|
t.Fatalf("expected alias %d to be name=%q alias=%q fork=%v, got name=%q alias=%q fork=%v", i, exp.Name, exp.Alias, exp.Fork, aliases[i].Name, aliases[i].Alias, aliases[i].Fork)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestSanitizeOAuthModelMappings_PreservesForkFlag(t *testing.T) {
|
|
||||||
cfg := &Config{
|
|
||||||
OAuthModelMappings: map[string][]ModelNameMapping{
|
|
||||||
" CoDeX ": {
|
|
||||||
{Name: " gpt-5 ", Alias: " g5 ", Fork: true},
|
|
||||||
{Name: "gpt-6", Alias: "g6"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.SanitizeOAuthModelMappings()
|
|
||||||
|
|
||||||
mappings := cfg.OAuthModelMappings["codex"]
|
|
||||||
if len(mappings) != 2 {
|
|
||||||
t.Fatalf("expected 2 sanitized mappings, got %d", len(mappings))
|
|
||||||
}
|
|
||||||
if mappings[0].Name != "gpt-5" || mappings[0].Alias != "g5" || !mappings[0].Fork {
|
|
||||||
t.Fatalf("expected first mapping to be gpt-5->g5 fork=true, got name=%q alias=%q fork=%v", mappings[0].Name, mappings[0].Alias, mappings[0].Fork)
|
|
||||||
}
|
|
||||||
if mappings[1].Name != "gpt-6" || mappings[1].Alias != "g6" || mappings[1].Fork {
|
|
||||||
t.Fatalf("expected second mapping to be gpt-6->g6 fork=false, got name=%q alias=%q fork=%v", mappings[1].Name, mappings[1].Alias, mappings[1].Fork)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSanitizeOAuthModelMappings_AllowsMultipleAliasesForSameName(t *testing.T) {
|
|
||||||
cfg := &Config{
|
|
||||||
OAuthModelMappings: map[string][]ModelNameMapping{
|
|
||||||
"antigravity": {
|
|
||||||
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101", Fork: true},
|
|
||||||
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101-thinking", Fork: true},
|
|
||||||
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5", Fork: true},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.SanitizeOAuthModelMappings()
|
|
||||||
|
|
||||||
mappings := cfg.OAuthModelMappings["antigravity"]
|
|
||||||
expected := []ModelNameMapping{
|
|
||||||
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101", Fork: true},
|
|
||||||
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101-thinking", Fork: true},
|
|
||||||
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5", Fork: true},
|
|
||||||
}
|
|
||||||
if len(mappings) != len(expected) {
|
|
||||||
t.Fatalf("expected %d sanitized mappings, got %d", len(expected), len(mappings))
|
|
||||||
}
|
|
||||||
for i, exp := range expected {
|
|
||||||
if mappings[i].Name != exp.Name || mappings[i].Alias != exp.Alias || mappings[i].Fork != exp.Fork {
|
|
||||||
t.Fatalf("expected mapping %d to be name=%q alias=%q fork=%v, got name=%q alias=%q fork=%v", i, exp.Name, exp.Alias, exp.Fork, mappings[i].Name, mappings[i].Alias, mappings[i].Fork)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,6 +36,9 @@ type VertexCompatKey struct {
|
|||||||
Models []VertexCompatModel `yaml:"models,omitempty" json:"models,omitempty"`
|
Models []VertexCompatModel `yaml:"models,omitempty" json:"models,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (k VertexCompatKey) GetAPIKey() string { return k.APIKey }
|
||||||
|
func (k VertexCompatKey) GetBaseURL() string { return k.BaseURL }
|
||||||
|
|
||||||
// VertexCompatModel represents a model configuration for Vertex compatibility,
|
// VertexCompatModel represents a model configuration for Vertex compatibility,
|
||||||
// including the actual model name and its alias for API routing.
|
// including the actual model name and its alias for API routing.
|
||||||
type VertexCompatModel struct {
|
type VertexCompatModel struct {
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ var (
|
|||||||
// Format: [2025-12-23 20:14:04] [debug] [manager.go:524] | a1b2c3d4 | Use API key sk-9...0RHO for model gpt-5.2
|
// Format: [2025-12-23 20:14:04] [debug] [manager.go:524] | a1b2c3d4 | Use API key sk-9...0RHO for model gpt-5.2
|
||||||
type LogFormatter struct{}
|
type LogFormatter struct{}
|
||||||
|
|
||||||
|
// logFieldOrder defines the display order for common log fields.
|
||||||
|
var logFieldOrder = []string{"provider", "model", "mode", "budget", "level", "original_value", "min", "max", "clamped_to", "error"}
|
||||||
|
|
||||||
// Format renders a single log entry with custom formatting.
|
// Format renders a single log entry with custom formatting.
|
||||||
func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
|
func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
|
||||||
var buffer *bytes.Buffer
|
var buffer *bytes.Buffer
|
||||||
@@ -52,11 +55,25 @@ func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
levelStr := fmt.Sprintf("%-5s", level)
|
levelStr := fmt.Sprintf("%-5s", level)
|
||||||
|
|
||||||
|
// Build fields string (only print fields in logFieldOrder)
|
||||||
|
var fieldsStr string
|
||||||
|
if len(entry.Data) > 0 {
|
||||||
|
var fields []string
|
||||||
|
for _, k := range logFieldOrder {
|
||||||
|
if v, ok := entry.Data[k]; ok {
|
||||||
|
fields = append(fields, fmt.Sprintf("%s=%v", k, v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fields) > 0 {
|
||||||
|
fieldsStr = " " + strings.Join(fields, " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var formatted string
|
var formatted string
|
||||||
if entry.Caller != nil {
|
if entry.Caller != nil {
|
||||||
formatted = fmt.Sprintf("[%s] [%s] [%s] [%s:%d] %s\n", timestamp, reqID, levelStr, filepath.Base(entry.Caller.File), entry.Caller.Line, message)
|
formatted = fmt.Sprintf("[%s] [%s] [%s] [%s:%d] %s%s\n", timestamp, reqID, levelStr, filepath.Base(entry.Caller.File), entry.Caller.Line, message, fieldsStr)
|
||||||
} else {
|
} else {
|
||||||
formatted = fmt.Sprintf("[%s] [%s] [%s] %s\n", timestamp, reqID, levelStr, message)
|
formatted = fmt.Sprintf("[%s] [%s] [%s] %s%s\n", timestamp, reqID, levelStr, message, fieldsStr)
|
||||||
}
|
}
|
||||||
buffer.WriteString(formatted)
|
buffer.WriteString(formatted)
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func GetClaudeModels() []*ModelInfo {
|
|||||||
DisplayName: "Claude 4.5 Sonnet",
|
DisplayName: "Claude 4.5 Sonnet",
|
||||||
ContextLength: 200000,
|
ContextLength: 200000,
|
||||||
MaxCompletionTokens: 64000,
|
MaxCompletionTokens: 64000,
|
||||||
Thinking: &ThinkingSupport{Min: 1024, Max: 100000, ZeroAllowed: false, DynamicAllowed: true},
|
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "claude-opus-4-5-20251101",
|
ID: "claude-opus-4-5-20251101",
|
||||||
@@ -39,7 +39,7 @@ func GetClaudeModels() []*ModelInfo {
|
|||||||
Description: "Premium model combining maximum intelligence with practical performance",
|
Description: "Premium model combining maximum intelligence with practical performance",
|
||||||
ContextLength: 200000,
|
ContextLength: 200000,
|
||||||
MaxCompletionTokens: 64000,
|
MaxCompletionTokens: 64000,
|
||||||
Thinking: &ThinkingSupport{Min: 1024, Max: 100000, ZeroAllowed: false, DynamicAllowed: true},
|
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "claude-opus-4-1-20250805",
|
ID: "claude-opus-4-1-20250805",
|
||||||
@@ -50,7 +50,7 @@ func GetClaudeModels() []*ModelInfo {
|
|||||||
DisplayName: "Claude 4.1 Opus",
|
DisplayName: "Claude 4.1 Opus",
|
||||||
ContextLength: 200000,
|
ContextLength: 200000,
|
||||||
MaxCompletionTokens: 32000,
|
MaxCompletionTokens: 32000,
|
||||||
Thinking: &ThinkingSupport{Min: 1024, Max: 100000, ZeroAllowed: false, DynamicAllowed: true},
|
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: false, DynamicAllowed: false},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "claude-opus-4-20250514",
|
ID: "claude-opus-4-20250514",
|
||||||
@@ -61,7 +61,7 @@ func GetClaudeModels() []*ModelInfo {
|
|||||||
DisplayName: "Claude 4 Opus",
|
DisplayName: "Claude 4 Opus",
|
||||||
ContextLength: 200000,
|
ContextLength: 200000,
|
||||||
MaxCompletionTokens: 32000,
|
MaxCompletionTokens: 32000,
|
||||||
Thinking: &ThinkingSupport{Min: 1024, Max: 100000, ZeroAllowed: false, DynamicAllowed: true},
|
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: false, DynamicAllowed: false},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "claude-sonnet-4-20250514",
|
ID: "claude-sonnet-4-20250514",
|
||||||
@@ -72,7 +72,7 @@ func GetClaudeModels() []*ModelInfo {
|
|||||||
DisplayName: "Claude 4 Sonnet",
|
DisplayName: "Claude 4 Sonnet",
|
||||||
ContextLength: 200000,
|
ContextLength: 200000,
|
||||||
MaxCompletionTokens: 64000,
|
MaxCompletionTokens: 64000,
|
||||||
Thinking: &ThinkingSupport{Min: 1024, Max: 100000, ZeroAllowed: false, DynamicAllowed: true},
|
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: false, DynamicAllowed: false},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "claude-3-7-sonnet-20250219",
|
ID: "claude-3-7-sonnet-20250219",
|
||||||
@@ -83,7 +83,7 @@ func GetClaudeModels() []*ModelInfo {
|
|||||||
DisplayName: "Claude 3.7 Sonnet",
|
DisplayName: "Claude 3.7 Sonnet",
|
||||||
ContextLength: 128000,
|
ContextLength: 128000,
|
||||||
MaxCompletionTokens: 8192,
|
MaxCompletionTokens: 8192,
|
||||||
Thinking: &ThinkingSupport{Min: 1024, Max: 100000, ZeroAllowed: false, DynamicAllowed: true},
|
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: false, DynamicAllowed: false},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "claude-3-5-haiku-20241022",
|
ID: "claude-3-5-haiku-20241022",
|
||||||
@@ -432,7 +432,7 @@ func GetAIStudioModels() []*ModelInfo {
|
|||||||
InputTokenLimit: 1048576,
|
InputTokenLimit: 1048576,
|
||||||
OutputTokenLimit: 65536,
|
OutputTokenLimit: 65536,
|
||||||
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},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "gemini-3-flash-preview",
|
ID: "gemini-3-flash-preview",
|
||||||
@@ -447,7 +447,7 @@ func GetAIStudioModels() []*ModelInfo {
|
|||||||
InputTokenLimit: 1048576,
|
InputTokenLimit: 1048576,
|
||||||
OutputTokenLimit: 65536,
|
OutputTokenLimit: 65536,
|
||||||
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
||||||
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}},
|
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "gemini-pro-latest",
|
ID: "gemini-pro-latest",
|
||||||
@@ -742,6 +742,7 @@ func GetIFlowModels() []*ModelInfo {
|
|||||||
{ID: "qwen3-235b", DisplayName: "Qwen3-235B-A22B", Description: "Qwen3 235B A22B", Created: 1753401600},
|
{ID: "qwen3-235b", DisplayName: "Qwen3-235B-A22B", Description: "Qwen3 235B A22B", Created: 1753401600},
|
||||||
{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},
|
||||||
}
|
}
|
||||||
models := make([]*ModelInfo, 0, len(entries))
|
models := make([]*ModelInfo, 0, len(entries))
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
@@ -768,17 +769,17 @@ type AntigravityModelConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetAntigravityModelConfig returns static configuration for antigravity models.
|
// GetAntigravityModelConfig returns static configuration for antigravity models.
|
||||||
// Keys use the ALIASED model names (after modelName2Alias conversion) for direct lookup.
|
// Keys use upstream model names returned by the Antigravity models endpoint.
|
||||||
func GetAntigravityModelConfig() map[string]*AntigravityModelConfig {
|
func GetAntigravityModelConfig() map[string]*AntigravityModelConfig {
|
||||||
return map[string]*AntigravityModelConfig{
|
return map[string]*AntigravityModelConfig{
|
||||||
"gemini-2.5-flash": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash"},
|
"gemini-2.5-flash": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash"},
|
||||||
"gemini-2.5-flash-lite": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash-lite"},
|
"gemini-2.5-flash-lite": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash-lite"},
|
||||||
"gemini-2.5-computer-use-preview-10-2025": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, Name: "models/gemini-2.5-computer-use-preview-10-2025"},
|
"rev19-uic3-1p": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, Name: "models/rev19-uic3-1p"},
|
||||||
"gemini-3-pro-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-preview"},
|
"gemini-3-pro-high": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-high"},
|
||||||
"gemini-3-pro-image-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-image-preview"},
|
"gemini-3-pro-image": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-image"},
|
||||||
"gemini-3-flash-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}, Name: "models/gemini-3-flash-preview"},
|
"gemini-3-flash": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}, Name: "models/gemini-3-flash"},
|
||||||
"gemini-claude-sonnet-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 200000, ZeroAllowed: false, DynamicAllowed: true}, MaxCompletionTokens: 64000},
|
"claude-sonnet-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000},
|
||||||
"gemini-claude-opus-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 200000, ZeroAllowed: false, DynamicAllowed: true}, MaxCompletionTokens: 64000},
|
"claude-opus-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,6 +789,7 @@ func LookupStaticModelInfo(modelID string) *ModelInfo {
|
|||||||
if modelID == "" {
|
if modelID == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
allModels := [][]*ModelInfo{
|
allModels := [][]*ModelInfo{
|
||||||
GetClaudeModels(),
|
GetClaudeModels(),
|
||||||
GetGeminiModels(),
|
GetGeminiModels(),
|
||||||
@@ -805,5 +807,16 @@ func LookupStaticModelInfo(modelID string) *ModelInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check Antigravity static config
|
||||||
|
if cfg := GetAntigravityModelConfig()[modelID]; cfg != nil && cfg.Thinking != nil {
|
||||||
|
return &ModelInfo{
|
||||||
|
ID: modelID,
|
||||||
|
Name: cfg.Name,
|
||||||
|
Thinking: cfg.Thinking,
|
||||||
|
MaxCompletionTokens: cfg.MaxCompletionTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ type ModelInfo struct {
|
|||||||
// Thinking holds provider-specific reasoning/thinking budget capabilities.
|
// Thinking holds provider-specific reasoning/thinking budget capabilities.
|
||||||
// This is optional and currently used for Gemini thinking budget normalization.
|
// This is optional and currently used for Gemini thinking budget normalization.
|
||||||
Thinking *ThinkingSupport `json:"thinking,omitempty"`
|
Thinking *ThinkingSupport `json:"thinking,omitempty"`
|
||||||
|
|
||||||
|
// UserDefined indicates this model was defined through config file's models[]
|
||||||
|
// array (e.g., openai-compatibility.*.models[], *-api-key.models[]).
|
||||||
|
// UserDefined models have thinking configuration passed through without validation.
|
||||||
|
UserDefined bool `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ThinkingSupport describes a model family's supported internal reasoning budget range.
|
// ThinkingSupport describes a model family's supported internal reasoning budget range.
|
||||||
@@ -127,6 +132,21 @@ func GetGlobalRegistry() *ModelRegistry {
|
|||||||
return globalRegistry
|
return globalRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LookupModelInfo searches the dynamic registry first, then falls back to static model definitions.
|
||||||
|
//
|
||||||
|
// This helper exists because some code paths only have a model ID and still need Thinking and
|
||||||
|
// max completion token metadata even when the dynamic registry hasn't been populated.
|
||||||
|
func LookupModelInfo(modelID string) *ModelInfo {
|
||||||
|
modelID = strings.TrimSpace(modelID)
|
||||||
|
if modelID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info := GetGlobalRegistry().GetModelInfo(modelID); info != nil {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
return LookupStaticModelInfo(modelID)
|
||||||
|
}
|
||||||
|
|
||||||
// SetHook sets an optional hook for observing model registration changes.
|
// SetHook sets an optional hook for observing model registration changes.
|
||||||
func (r *ModelRegistry) SetHook(hook ModelRegistryHook) {
|
func (r *ModelRegistry) SetHook(hook ModelRegistryHook) {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"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/thinking"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/wsrelay"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/wsrelay"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
@@ -111,7 +111,8 @@ func (e *AIStudioExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.A
|
|||||||
|
|
||||||
// Execute performs a non-streaming request to the AI Studio API.
|
// Execute performs a non-streaming request to the AI Studio API.
|
||||||
func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
translatedReq, body, err := e.translateRequest(req, opts, false)
|
translatedReq, body, err := e.translateRequest(req, opts, false)
|
||||||
@@ -119,7 +120,7 @@ func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth,
|
|||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := e.buildEndpoint(req.Model, body.action, opts.Alt)
|
endpoint := e.buildEndpoint(baseModel, body.action, opts.Alt)
|
||||||
wsReq := &wsrelay.HTTPRequest{
|
wsReq := &wsrelay.HTTPRequest{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
URL: endpoint,
|
URL: endpoint,
|
||||||
@@ -166,7 +167,8 @@ func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth,
|
|||||||
|
|
||||||
// ExecuteStream performs a streaming request to the AI Studio API.
|
// ExecuteStream performs a streaming request to the AI Studio API.
|
||||||
func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
translatedReq, body, err := e.translateRequest(req, opts, true)
|
translatedReq, body, err := e.translateRequest(req, opts, true)
|
||||||
@@ -174,7 +176,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := e.buildEndpoint(req.Model, body.action, opts.Alt)
|
endpoint := e.buildEndpoint(baseModel, body.action, opts.Alt)
|
||||||
wsReq := &wsrelay.HTTPRequest{
|
wsReq := &wsrelay.HTTPRequest{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
URL: endpoint,
|
URL: endpoint,
|
||||||
@@ -315,6 +317,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
|
|||||||
|
|
||||||
// CountTokens counts tokens for the given request using the AI Studio API.
|
// CountTokens counts tokens for the given request using the AI Studio API.
|
||||||
func (e *AIStudioExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
func (e *AIStudioExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
_, body, err := e.translateRequest(req, opts, false)
|
_, body, err := e.translateRequest(req, opts, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cliproxyexecutor.Response{}, err
|
return cliproxyexecutor.Response{}, err
|
||||||
@@ -324,7 +327,7 @@ func (e *AIStudioExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.A
|
|||||||
body.payload, _ = sjson.DeleteBytes(body.payload, "tools")
|
body.payload, _ = sjson.DeleteBytes(body.payload, "tools")
|
||||||
body.payload, _ = sjson.DeleteBytes(body.payload, "safetySettings")
|
body.payload, _ = sjson.DeleteBytes(body.payload, "safetySettings")
|
||||||
|
|
||||||
endpoint := e.buildEndpoint(req.Model, "countTokens", "")
|
endpoint := e.buildEndpoint(baseModel, "countTokens", "")
|
||||||
wsReq := &wsrelay.HTTPRequest{
|
wsReq := &wsrelay.HTTPRequest{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
URL: endpoint,
|
URL: endpoint,
|
||||||
@@ -380,22 +383,22 @@ type translatedPayload struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool) ([]byte, translatedPayload, error) {
|
func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool) ([]byte, translatedPayload, error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini")
|
to := sdktranslator.FromString("gemini")
|
||||||
originalPayload := bytes.Clone(req.Payload)
|
originalPayload := bytes.Clone(req.Payload)
|
||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, stream)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, stream)
|
||||||
payload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), stream)
|
payload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream)
|
||||||
payload = ApplyThinkingMetadata(payload, req.Metadata, req.Model)
|
payload, err := thinking.ApplyThinking(payload, req.Model, "gemini")
|
||||||
payload = util.ApplyGemini3ThinkingLevelFromMetadata(req.Model, req.Metadata, payload)
|
if err != nil {
|
||||||
payload = util.ApplyDefaultThinkingIfNeeded(req.Model, payload)
|
return nil, translatedPayload{}, err
|
||||||
payload = util.ConvertThinkingLevelToBudget(payload, req.Model, true)
|
}
|
||||||
payload = util.NormalizeGeminiThinkingBudget(req.Model, payload, true)
|
payload = fixGeminiImageAspectRatio(baseModel, payload)
|
||||||
payload = util.StripThinkingConfigIfUnsupported(req.Model, payload)
|
payload = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", payload, originalTranslated)
|
||||||
payload = fixGeminiImageAspectRatio(req.Model, payload)
|
|
||||||
payload = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", payload, originalTranslated)
|
|
||||||
payload, _ = sjson.DeleteBytes(payload, "generationConfig.maxOutputTokens")
|
payload, _ = sjson.DeleteBytes(payload, "generationConfig.maxOutputTokens")
|
||||||
payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseMimeType")
|
payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseMimeType")
|
||||||
payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseJsonSchema")
|
payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseJsonSchema")
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"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/registry"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
@@ -108,8 +109,10 @@ func (e *AntigravityExecutor) HttpRequest(ctx context.Context, auth *cliproxyaut
|
|||||||
|
|
||||||
// Execute performs a non-streaming request to the Antigravity API.
|
// Execute performs a non-streaming request to the Antigravity API.
|
||||||
func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||||
isClaude := strings.Contains(strings.ToLower(req.Model), "claude")
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
if isClaude || strings.Contains(req.Model, "gemini-3-pro") {
|
isClaude := strings.Contains(strings.ToLower(baseModel), "claude")
|
||||||
|
|
||||||
|
if isClaude || strings.Contains(baseModel, "gemini-3-pro") {
|
||||||
return e.executeClaudeNonStream(ctx, auth, req, opts)
|
return e.executeClaudeNonStream(ctx, auth, req, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,23 +124,25 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
auth = updatedAuth
|
auth = updatedAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("antigravity")
|
to := sdktranslator.FromString("antigravity")
|
||||||
|
|
||||||
originalPayload := bytes.Clone(req.Payload)
|
originalPayload := bytes.Clone(req.Payload)
|
||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
||||||
translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
|
|
||||||
translated = ApplyThinkingMetadataCLI(translated, req.Metadata, req.Model)
|
translated, err = thinking.ApplyThinking(translated, req.Model, "antigravity")
|
||||||
translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
|
if err != nil {
|
||||||
translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, translated)
|
return resp, err
|
||||||
translated = normalizeAntigravityThinking(req.Model, translated, isClaude)
|
}
|
||||||
translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated, originalTranslated)
|
|
||||||
|
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated)
|
||||||
|
|
||||||
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
@@ -147,7 +152,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
for idx, baseURL := range baseURLs {
|
for idx, baseURL := range baseURLs {
|
||||||
httpReq, errReq := e.buildRequest(ctx, auth, token, req.Model, translated, false, opts.Alt, baseURL)
|
httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, translated, false, opts.Alt, baseURL)
|
||||||
if errReq != nil {
|
if errReq != nil {
|
||||||
err = errReq
|
err = errReq
|
||||||
return resp, err
|
return resp, err
|
||||||
@@ -228,6 +233,8 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
|
|
||||||
// executeClaudeNonStream performs a claude non-streaming request to the Antigravity API.
|
// executeClaudeNonStream performs a claude non-streaming request to the Antigravity API.
|
||||||
func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
|
token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
|
||||||
if errToken != nil {
|
if errToken != nil {
|
||||||
return resp, errToken
|
return resp, errToken
|
||||||
@@ -236,23 +243,25 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
|
|||||||
auth = updatedAuth
|
auth = updatedAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("antigravity")
|
to := sdktranslator.FromString("antigravity")
|
||||||
|
|
||||||
originalPayload := bytes.Clone(req.Payload)
|
originalPayload := bytes.Clone(req.Payload)
|
||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, true)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
|
||||||
|
|
||||||
translated = ApplyThinkingMetadataCLI(translated, req.Metadata, req.Model)
|
translated, err = thinking.ApplyThinking(translated, req.Model, "antigravity")
|
||||||
translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
|
if err != nil {
|
||||||
translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, translated)
|
return resp, err
|
||||||
translated = normalizeAntigravityThinking(req.Model, translated, true)
|
}
|
||||||
translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated, originalTranslated)
|
|
||||||
|
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated)
|
||||||
|
|
||||||
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
@@ -262,7 +271,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
|
|||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
for idx, baseURL := range baseURLs {
|
for idx, baseURL := range baseURLs {
|
||||||
httpReq, errReq := e.buildRequest(ctx, auth, token, req.Model, translated, true, opts.Alt, baseURL)
|
httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, translated, true, opts.Alt, baseURL)
|
||||||
if errReq != nil {
|
if errReq != nil {
|
||||||
err = errReq
|
err = errReq
|
||||||
return resp, err
|
return resp, err
|
||||||
@@ -588,6 +597,8 @@ func (e *AntigravityExecutor) convertStreamToNonStream(stream []byte) []byte {
|
|||||||
|
|
||||||
// ExecuteStream performs a streaming request to the Antigravity API.
|
// ExecuteStream performs a streaming request to the Antigravity API.
|
||||||
func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
ctx = context.WithValue(ctx, "alt", "")
|
ctx = context.WithValue(ctx, "alt", "")
|
||||||
|
|
||||||
token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
|
token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
|
||||||
@@ -598,25 +609,25 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
|
|||||||
auth = updatedAuth
|
auth = updatedAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
isClaude := strings.Contains(strings.ToLower(req.Model), "claude")
|
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("antigravity")
|
to := sdktranslator.FromString("antigravity")
|
||||||
|
|
||||||
originalPayload := bytes.Clone(req.Payload)
|
originalPayload := bytes.Clone(req.Payload)
|
||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, true)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
|
||||||
|
|
||||||
translated = ApplyThinkingMetadataCLI(translated, req.Metadata, req.Model)
|
translated, err = thinking.ApplyThinking(translated, req.Model, "antigravity")
|
||||||
translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
|
if err != nil {
|
||||||
translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, translated)
|
return nil, err
|
||||||
translated = normalizeAntigravityThinking(req.Model, translated, isClaude)
|
}
|
||||||
translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated, originalTranslated)
|
|
||||||
|
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated)
|
||||||
|
|
||||||
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
@@ -626,7 +637,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
|
|||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
for idx, baseURL := range baseURLs {
|
for idx, baseURL := range baseURLs {
|
||||||
httpReq, errReq := e.buildRequest(ctx, auth, token, req.Model, translated, true, opts.Alt, baseURL)
|
httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, translated, true, opts.Alt, baseURL)
|
||||||
if errReq != nil {
|
if errReq != nil {
|
||||||
err = errReq
|
err = errReq
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -772,6 +783,8 @@ func (e *AntigravityExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
|
|
||||||
// CountTokens counts tokens for the given request using the Antigravity API.
|
// CountTokens counts tokens for the given request using the Antigravity API.
|
||||||
func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
|
token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
|
||||||
if errToken != nil {
|
if errToken != nil {
|
||||||
return cliproxyexecutor.Response{}, errToken
|
return cliproxyexecutor.Response{}, errToken
|
||||||
@@ -787,7 +800,17 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
|
|||||||
to := sdktranslator.FromString("antigravity")
|
to := sdktranslator.FromString("antigravity")
|
||||||
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
||||||
|
|
||||||
isClaude := strings.Contains(strings.ToLower(req.Model), "claude")
|
// Prepare payload once (doesn't depend on baseURL)
|
||||||
|
payload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
|
|
||||||
|
payload, err := thinking.ApplyThinking(payload, req.Model, "antigravity")
|
||||||
|
if err != nil {
|
||||||
|
return cliproxyexecutor.Response{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = deleteJSONField(payload, "project")
|
||||||
|
payload = deleteJSONField(payload, "model")
|
||||||
|
payload = deleteJSONField(payload, "request.safetySettings")
|
||||||
|
|
||||||
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
@@ -804,14 +827,6 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
|
|||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
for idx, baseURL := range baseURLs {
|
for idx, baseURL := range baseURLs {
|
||||||
payload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
|
||||||
payload = ApplyThinkingMetadataCLI(payload, req.Metadata, req.Model)
|
|
||||||
payload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, payload)
|
|
||||||
payload = normalizeAntigravityThinking(req.Model, payload, isClaude)
|
|
||||||
payload = deleteJSONField(payload, "project")
|
|
||||||
payload = deleteJSONField(payload, "model")
|
|
||||||
payload = deleteJSONField(payload, "request.safetySettings")
|
|
||||||
|
|
||||||
base := strings.TrimSuffix(baseURL, "/")
|
base := strings.TrimSuffix(baseURL, "/")
|
||||||
if base == "" {
|
if base == "" {
|
||||||
base = buildBaseURL(auth)
|
base = buildBaseURL(auth)
|
||||||
@@ -981,35 +996,40 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c
|
|||||||
modelConfig := registry.GetAntigravityModelConfig()
|
modelConfig := registry.GetAntigravityModelConfig()
|
||||||
models := make([]*registry.ModelInfo, 0, len(result.Map()))
|
models := make([]*registry.ModelInfo, 0, len(result.Map()))
|
||||||
for originalName := range result.Map() {
|
for originalName := range result.Map() {
|
||||||
aliasName := modelName2Alias(originalName)
|
modelID := strings.TrimSpace(originalName)
|
||||||
if aliasName != "" {
|
if modelID == "" {
|
||||||
cfg := modelConfig[aliasName]
|
continue
|
||||||
modelName := aliasName
|
|
||||||
if cfg != nil && cfg.Name != "" {
|
|
||||||
modelName = cfg.Name
|
|
||||||
}
|
|
||||||
modelInfo := ®istry.ModelInfo{
|
|
||||||
ID: aliasName,
|
|
||||||
Name: modelName,
|
|
||||||
Description: aliasName,
|
|
||||||
DisplayName: aliasName,
|
|
||||||
Version: aliasName,
|
|
||||||
Object: "model",
|
|
||||||
Created: now,
|
|
||||||
OwnedBy: antigravityAuthType,
|
|
||||||
Type: antigravityAuthType,
|
|
||||||
}
|
|
||||||
// Look up Thinking support from static config using alias name
|
|
||||||
if cfg != nil {
|
|
||||||
if cfg.Thinking != nil {
|
|
||||||
modelInfo.Thinking = cfg.Thinking
|
|
||||||
}
|
|
||||||
if cfg.MaxCompletionTokens > 0 {
|
|
||||||
modelInfo.MaxCompletionTokens = cfg.MaxCompletionTokens
|
|
||||||
}
|
|
||||||
}
|
|
||||||
models = append(models, modelInfo)
|
|
||||||
}
|
}
|
||||||
|
switch modelID {
|
||||||
|
case "chat_20706", "chat_23310", "gemini-2.5-flash-thinking", "gemini-3-pro-low", "gemini-2.5-pro":
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cfg := modelConfig[modelID]
|
||||||
|
modelName := modelID
|
||||||
|
if cfg != nil && cfg.Name != "" {
|
||||||
|
modelName = cfg.Name
|
||||||
|
}
|
||||||
|
modelInfo := ®istry.ModelInfo{
|
||||||
|
ID: modelID,
|
||||||
|
Name: modelName,
|
||||||
|
Description: modelID,
|
||||||
|
DisplayName: modelID,
|
||||||
|
Version: modelID,
|
||||||
|
Object: "model",
|
||||||
|
Created: now,
|
||||||
|
OwnedBy: antigravityAuthType,
|
||||||
|
Type: antigravityAuthType,
|
||||||
|
}
|
||||||
|
// Look up Thinking support from static config using upstream model name.
|
||||||
|
if cfg != nil {
|
||||||
|
if cfg.Thinking != nil {
|
||||||
|
modelInfo.Thinking = cfg.Thinking
|
||||||
|
}
|
||||||
|
if cfg.MaxCompletionTokens > 0 {
|
||||||
|
modelInfo.MaxCompletionTokens = cfg.MaxCompletionTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models = append(models, modelInfo)
|
||||||
}
|
}
|
||||||
return models
|
return models
|
||||||
}
|
}
|
||||||
@@ -1184,7 +1204,7 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
payload = geminiToAntigravity(modelName, payload, projectID)
|
payload = geminiToAntigravity(modelName, payload, projectID)
|
||||||
payload, _ = sjson.SetBytes(payload, "model", alias2ModelName(modelName))
|
payload, _ = sjson.SetBytes(payload, "model", modelName)
|
||||||
|
|
||||||
if strings.Contains(modelName, "claude") {
|
if strings.Contains(modelName, "claude") {
|
||||||
strJSON := string(payload)
|
strJSON := string(payload)
|
||||||
@@ -1455,108 +1475,3 @@ func generateProjectID() string {
|
|||||||
randomPart := strings.ToLower(uuid.NewString())[:5]
|
randomPart := strings.ToLower(uuid.NewString())[:5]
|
||||||
return adj + "-" + noun + "-" + randomPart
|
return adj + "-" + noun + "-" + randomPart
|
||||||
}
|
}
|
||||||
|
|
||||||
func modelName2Alias(modelName string) string {
|
|
||||||
switch modelName {
|
|
||||||
case "rev19-uic3-1p":
|
|
||||||
return "gemini-2.5-computer-use-preview-10-2025"
|
|
||||||
case "gemini-3-pro-image":
|
|
||||||
return "gemini-3-pro-image-preview"
|
|
||||||
case "gemini-3-pro-high":
|
|
||||||
return "gemini-3-pro-preview"
|
|
||||||
case "gemini-3-flash":
|
|
||||||
return "gemini-3-flash-preview"
|
|
||||||
case "claude-sonnet-4-5":
|
|
||||||
return "gemini-claude-sonnet-4-5"
|
|
||||||
case "claude-sonnet-4-5-thinking":
|
|
||||||
return "gemini-claude-sonnet-4-5-thinking"
|
|
||||||
case "claude-opus-4-5-thinking":
|
|
||||||
return "gemini-claude-opus-4-5-thinking"
|
|
||||||
case "chat_20706", "chat_23310", "gemini-2.5-flash-thinking", "gemini-3-pro-low", "gemini-2.5-pro":
|
|
||||||
return ""
|
|
||||||
default:
|
|
||||||
return modelName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func alias2ModelName(modelName string) string {
|
|
||||||
switch modelName {
|
|
||||||
case "gemini-2.5-computer-use-preview-10-2025":
|
|
||||||
return "rev19-uic3-1p"
|
|
||||||
case "gemini-3-pro-image-preview":
|
|
||||||
return "gemini-3-pro-image"
|
|
||||||
case "gemini-3-pro-preview":
|
|
||||||
return "gemini-3-pro-high"
|
|
||||||
case "gemini-3-flash-preview":
|
|
||||||
return "gemini-3-flash"
|
|
||||||
case "gemini-claude-sonnet-4-5":
|
|
||||||
return "claude-sonnet-4-5"
|
|
||||||
case "gemini-claude-sonnet-4-5-thinking":
|
|
||||||
return "claude-sonnet-4-5-thinking"
|
|
||||||
case "gemini-claude-opus-4-5-thinking":
|
|
||||||
return "claude-opus-4-5-thinking"
|
|
||||||
default:
|
|
||||||
return modelName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeAntigravityThinking clamps or removes thinking config based on model support.
|
|
||||||
// For Claude models, it additionally ensures thinking budget < max_tokens.
|
|
||||||
func normalizeAntigravityThinking(model string, payload []byte, isClaude bool) []byte {
|
|
||||||
payload = util.StripThinkingConfigIfUnsupported(model, payload)
|
|
||||||
if !util.ModelSupportsThinking(model) {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
budget := gjson.GetBytes(payload, "request.generationConfig.thinkingConfig.thinkingBudget")
|
|
||||||
if !budget.Exists() {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
raw := int(budget.Int())
|
|
||||||
normalized := util.NormalizeThinkingBudget(model, raw)
|
|
||||||
|
|
||||||
if isClaude {
|
|
||||||
effectiveMax, setDefaultMax := antigravityEffectiveMaxTokens(model, payload)
|
|
||||||
if effectiveMax > 0 && normalized >= effectiveMax {
|
|
||||||
normalized = effectiveMax - 1
|
|
||||||
}
|
|
||||||
minBudget := antigravityMinThinkingBudget(model)
|
|
||||||
if minBudget > 0 && normalized >= 0 && normalized < minBudget {
|
|
||||||
// Budget is below minimum, remove thinking config entirely
|
|
||||||
payload, _ = sjson.DeleteBytes(payload, "request.generationConfig.thinkingConfig")
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
if setDefaultMax {
|
|
||||||
if res, errSet := sjson.SetBytes(payload, "request.generationConfig.maxOutputTokens", effectiveMax); errSet == nil {
|
|
||||||
payload = res
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := sjson.SetBytes(payload, "request.generationConfig.thinkingConfig.thinkingBudget", normalized)
|
|
||||||
if err != nil {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// antigravityEffectiveMaxTokens returns the max tokens to cap thinking:
|
|
||||||
// prefer request-provided maxOutputTokens; otherwise fall back to model default.
|
|
||||||
// The boolean indicates whether the value came from the model default (and thus should be written back).
|
|
||||||
func antigravityEffectiveMaxTokens(model string, payload []byte) (max int, fromModel bool) {
|
|
||||||
if maxTok := gjson.GetBytes(payload, "request.generationConfig.maxOutputTokens"); maxTok.Exists() && maxTok.Int() > 0 {
|
|
||||||
return int(maxTok.Int()), false
|
|
||||||
}
|
|
||||||
if modelInfo := registry.GetGlobalRegistry().GetModelInfo(model); modelInfo != nil && modelInfo.MaxCompletionTokens > 0 {
|
|
||||||
return modelInfo.MaxCompletionTokens, true
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// antigravityMinThinkingBudget returns the minimum thinking budget for a model.
|
|
||||||
// Falls back to -1 if no model info is found.
|
|
||||||
func antigravityMinThinkingBudget(model string) int {
|
|
||||||
if modelInfo := registry.GetGlobalRegistry().GetModelInfo(model); modelInfo != nil && modelInfo.Thinking != nil {
|
|
||||||
return modelInfo.Thinking.Min
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,6 +18,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/misc"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
"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/thinking"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
@@ -84,17 +85,15 @@ func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||||
apiKey, baseURL := claudeCreds(auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
|
apiKey, baseURL := claudeCreds(auth)
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://api.anthropic.com"
|
baseURL = "https://api.anthropic.com"
|
||||||
}
|
}
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
model := req.Model
|
|
||||||
if override := e.resolveUpstreamModel(req.Model, auth); override != "" {
|
|
||||||
model = override
|
|
||||||
}
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("claude")
|
to := sdktranslator.FromString("claude")
|
||||||
// Use streaming translation to preserve function calling, except for claude.
|
// Use streaming translation to preserve function calling, except for claude.
|
||||||
@@ -103,22 +102,25 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, model, originalPayload, stream)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, stream)
|
||||||
body := sdktranslator.TranslateRequest(from, to, model, bytes.Clone(req.Payload), stream)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream)
|
||||||
body, _ = sjson.SetBytes(body, "model", model)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
// Inject thinking config based on model metadata for thinking variants
|
|
||||||
body = e.injectThinkingConfig(model, req.Metadata, body)
|
|
||||||
|
|
||||||
if !strings.HasPrefix(model, "claude-3-5-haiku") {
|
body, err = thinking.ApplyThinking(body, req.Model, "claude")
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(baseModel, "claude-3-5-haiku") {
|
||||||
body = checkSystemInstructions(body)
|
body = checkSystemInstructions(body)
|
||||||
}
|
}
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, model, to.String(), "", body, originalTranslated)
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
||||||
|
|
||||||
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
|
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
|
||||||
body = disableThinkingIfToolChoiceForced(body)
|
body = disableThinkingIfToolChoiceForced(body)
|
||||||
|
|
||||||
// Ensure max_tokens > thinking.budget_tokens when thinking is enabled
|
// Ensure max_tokens > thinking.budget_tokens when thinking is enabled
|
||||||
body = ensureMaxTokensForThinking(model, body)
|
body = ensureMaxTokensForThinking(baseModel, body)
|
||||||
|
|
||||||
// Extract betas from body and convert to header
|
// Extract betas from body and convert to header
|
||||||
var extraBetas []string
|
var extraBetas []string
|
||||||
@@ -218,36 +220,38 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
||||||
apiKey, baseURL := claudeCreds(auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
|
apiKey, baseURL := claudeCreds(auth)
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://api.anthropic.com"
|
baseURL = "https://api.anthropic.com"
|
||||||
}
|
}
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("claude")
|
to := sdktranslator.FromString("claude")
|
||||||
model := req.Model
|
|
||||||
if override := e.resolveUpstreamModel(req.Model, auth); override != "" {
|
|
||||||
model = override
|
|
||||||
}
|
|
||||||
originalPayload := bytes.Clone(req.Payload)
|
originalPayload := bytes.Clone(req.Payload)
|
||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, model, originalPayload, true)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
body := sdktranslator.TranslateRequest(from, to, model, bytes.Clone(req.Payload), true)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
|
||||||
body, _ = sjson.SetBytes(body, "model", model)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
// Inject thinking config based on model metadata for thinking variants
|
|
||||||
body = e.injectThinkingConfig(model, req.Metadata, body)
|
body, err = thinking.ApplyThinking(body, req.Model, "claude")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
body = checkSystemInstructions(body)
|
body = checkSystemInstructions(body)
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, model, to.String(), "", body, originalTranslated)
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
||||||
|
|
||||||
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
|
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
|
||||||
body = disableThinkingIfToolChoiceForced(body)
|
body = disableThinkingIfToolChoiceForced(body)
|
||||||
|
|
||||||
// Ensure max_tokens > thinking.budget_tokens when thinking is enabled
|
// Ensure max_tokens > thinking.budget_tokens when thinking is enabled
|
||||||
body = ensureMaxTokensForThinking(model, body)
|
body = ensureMaxTokensForThinking(baseModel, body)
|
||||||
|
|
||||||
// Extract betas from body and convert to header
|
// Extract betas from body and convert to header
|
||||||
var extraBetas []string
|
var extraBetas []string
|
||||||
@@ -381,8 +385,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||||
apiKey, baseURL := claudeCreds(auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
|
apiKey, baseURL := claudeCreds(auth)
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://api.anthropic.com"
|
baseURL = "https://api.anthropic.com"
|
||||||
}
|
}
|
||||||
@@ -391,14 +396,10 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
to := sdktranslator.FromString("claude")
|
to := sdktranslator.FromString("claude")
|
||||||
// Use streaming translation to preserve function calling, except for claude.
|
// Use streaming translation to preserve function calling, except for claude.
|
||||||
stream := from != to
|
stream := from != to
|
||||||
model := req.Model
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream)
|
||||||
if override := e.resolveUpstreamModel(req.Model, auth); override != "" {
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
model = override
|
|
||||||
}
|
|
||||||
body := sdktranslator.TranslateRequest(from, to, model, bytes.Clone(req.Payload), stream)
|
|
||||||
body, _ = sjson.SetBytes(body, "model", model)
|
|
||||||
|
|
||||||
if !strings.HasPrefix(model, "claude-3-5-haiku") {
|
if !strings.HasPrefix(baseModel, "claude-3-5-haiku") {
|
||||||
body = checkSystemInstructions(body)
|
body = checkSystemInstructions(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,17 +528,6 @@ func extractAndRemoveBetas(body []byte) ([]string, []byte) {
|
|||||||
return betas, body
|
return betas, body
|
||||||
}
|
}
|
||||||
|
|
||||||
// injectThinkingConfig adds thinking configuration based on metadata using the unified flow.
|
|
||||||
// It uses util.ResolveClaudeThinkingConfig which internally calls ResolveThinkingConfigFromMetadata
|
|
||||||
// and NormalizeThinkingBudget, ensuring consistency with other executors like Gemini.
|
|
||||||
func (e *ClaudeExecutor) injectThinkingConfig(modelName string, metadata map[string]any, body []byte) []byte {
|
|
||||||
budget, ok := util.ResolveClaudeThinkingConfig(modelName, metadata)
|
|
||||||
if !ok {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
return util.ApplyClaudeThinkingConfig(body, budget)
|
|
||||||
}
|
|
||||||
|
|
||||||
// disableThinkingIfToolChoiceForced checks if tool_choice forces tool use and disables thinking.
|
// disableThinkingIfToolChoiceForced checks if tool_choice forces tool use and disables thinking.
|
||||||
// Anthropic API does not allow thinking when tool_choice is set to "any" or a specific tool.
|
// Anthropic API does not allow thinking when tool_choice is set to "any" or a specific tool.
|
||||||
// See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations
|
// See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations
|
||||||
@@ -570,7 +560,7 @@ func ensureMaxTokensForThinking(modelName string, body []byte) []byte {
|
|||||||
|
|
||||||
// Look up the model's max completion tokens from the registry
|
// Look up the model's max completion tokens from the registry
|
||||||
maxCompletionTokens := 0
|
maxCompletionTokens := 0
|
||||||
if modelInfo := registry.GetGlobalRegistry().GetModelInfo(modelName); modelInfo != nil {
|
if modelInfo := registry.LookupModelInfo(modelName); modelInfo != nil {
|
||||||
maxCompletionTokens = modelInfo.MaxCompletionTokens
|
maxCompletionTokens = modelInfo.MaxCompletionTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,51 +577,6 @@ func ensureMaxTokensForThinking(modelName string, body []byte) []byte {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ClaudeExecutor) resolveUpstreamModel(alias string, auth *cliproxyauth.Auth) string {
|
|
||||||
trimmed := strings.TrimSpace(alias)
|
|
||||||
if trimmed == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := e.resolveClaudeConfig(auth)
|
|
||||||
if entry == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizedModel, metadata := util.NormalizeThinkingModel(trimmed)
|
|
||||||
|
|
||||||
// Candidate names to match against configured aliases/names.
|
|
||||||
candidates := []string{strings.TrimSpace(normalizedModel)}
|
|
||||||
if !strings.EqualFold(normalizedModel, trimmed) {
|
|
||||||
candidates = append(candidates, trimmed)
|
|
||||||
}
|
|
||||||
if original := util.ResolveOriginalModel(normalizedModel, metadata); original != "" && !strings.EqualFold(original, normalizedModel) {
|
|
||||||
candidates = append(candidates, original)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range entry.Models {
|
|
||||||
model := entry.Models[i]
|
|
||||||
name := strings.TrimSpace(model.Name)
|
|
||||||
modelAlias := strings.TrimSpace(model.Alias)
|
|
||||||
|
|
||||||
for _, candidate := range candidates {
|
|
||||||
if candidate == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if modelAlias != "" && strings.EqualFold(modelAlias, candidate) {
|
|
||||||
if name != "" {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
if name != "" && strings.EqualFold(name, candidate) {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ClaudeExecutor) resolveClaudeConfig(auth *cliproxyauth.Auth) *config.ClaudeKey {
|
func (e *ClaudeExecutor) resolveClaudeConfig(auth *cliproxyauth.Auth) *config.ClaudeKey {
|
||||||
if auth == nil || e.cfg == nil {
|
if auth == nil || e.cfg == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
codexauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
codexauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
||||||
"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/misc"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
@@ -72,18 +73,15 @@ func (e *CodexExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||||
apiKey, baseURL := codexCreds(auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
|
apiKey, baseURL := codexCreds(auth)
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://chatgpt.com/backend-api/codex"
|
baseURL = "https://chatgpt.com/backend-api/codex"
|
||||||
}
|
}
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
|
||||||
defer reporter.trackFailure(ctx, &err)
|
|
||||||
|
|
||||||
model := req.Model
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
if override := e.resolveUpstreamModel(req.Model, auth); override != "" {
|
defer reporter.trackFailure(ctx, &err)
|
||||||
model = override
|
|
||||||
}
|
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("codex")
|
to := sdktranslator.FromString("codex")
|
||||||
@@ -93,17 +91,18 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
|||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalPayload = misc.InjectCodexUserAgent(originalPayload, userAgent)
|
originalPayload = misc.InjectCodexUserAgent(originalPayload, userAgent)
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, model, originalPayload, false)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
||||||
body := misc.InjectCodexUserAgent(bytes.Clone(req.Payload), userAgent)
|
body := misc.InjectCodexUserAgent(bytes.Clone(req.Payload), userAgent)
|
||||||
body = sdktranslator.TranslateRequest(from, to, model, body, false)
|
body = sdktranslator.TranslateRequest(from, to, baseModel, body, false)
|
||||||
body = misc.StripCodexUserAgent(body)
|
body = misc.StripCodexUserAgent(body)
|
||||||
body = ApplyReasoningEffortMetadata(body, req.Metadata, model, "reasoning.effort", false)
|
|
||||||
body = NormalizeThinkingConfig(body, model, false)
|
body, err = thinking.ApplyThinking(body, req.Model, "codex")
|
||||||
if errValidate := ValidateThinkingConfig(body, model); errValidate != nil {
|
if err != nil {
|
||||||
return resp, errValidate
|
return resp, err
|
||||||
}
|
}
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, model, to.String(), "", body, originalTranslated)
|
|
||||||
body, _ = sjson.SetBytes(body, "model", model)
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
||||||
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
body, _ = sjson.SetBytes(body, "stream", true)
|
body, _ = sjson.SetBytes(body, "stream", true)
|
||||||
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
||||||
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
||||||
@@ -182,18 +181,15 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
||||||
apiKey, baseURL := codexCreds(auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
|
apiKey, baseURL := codexCreds(auth)
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://chatgpt.com/backend-api/codex"
|
baseURL = "https://chatgpt.com/backend-api/codex"
|
||||||
}
|
}
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
|
||||||
defer reporter.trackFailure(ctx, &err)
|
|
||||||
|
|
||||||
model := req.Model
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
if override := e.resolveUpstreamModel(req.Model, auth); override != "" {
|
defer reporter.trackFailure(ctx, &err)
|
||||||
model = override
|
|
||||||
}
|
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("codex")
|
to := sdktranslator.FromString("codex")
|
||||||
@@ -203,20 +199,20 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalPayload = misc.InjectCodexUserAgent(originalPayload, userAgent)
|
originalPayload = misc.InjectCodexUserAgent(originalPayload, userAgent)
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, model, originalPayload, true)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
body := misc.InjectCodexUserAgent(bytes.Clone(req.Payload), userAgent)
|
body := misc.InjectCodexUserAgent(bytes.Clone(req.Payload), userAgent)
|
||||||
body = sdktranslator.TranslateRequest(from, to, model, body, true)
|
body = sdktranslator.TranslateRequest(from, to, baseModel, body, true)
|
||||||
body = misc.StripCodexUserAgent(body)
|
body = misc.StripCodexUserAgent(body)
|
||||||
|
|
||||||
body = ApplyReasoningEffortMetadata(body, req.Metadata, model, "reasoning.effort", false)
|
body, err = thinking.ApplyThinking(body, req.Model, "codex")
|
||||||
body = NormalizeThinkingConfig(body, model, false)
|
if err != nil {
|
||||||
if errValidate := ValidateThinkingConfig(body, model); errValidate != nil {
|
return nil, err
|
||||||
return nil, errValidate
|
|
||||||
}
|
}
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, model, to.String(), "", body, originalTranslated)
|
|
||||||
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
||||||
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
||||||
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
||||||
body, _ = sjson.SetBytes(body, "model", model)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
|
|
||||||
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
||||||
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
|
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
|
||||||
@@ -303,25 +299,26 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||||
model := req.Model
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
if override := e.resolveUpstreamModel(req.Model, auth); override != "" {
|
|
||||||
model = override
|
|
||||||
}
|
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("codex")
|
to := sdktranslator.FromString("codex")
|
||||||
userAgent := codexUserAgent(ctx)
|
userAgent := codexUserAgent(ctx)
|
||||||
body := misc.InjectCodexUserAgent(bytes.Clone(req.Payload), userAgent)
|
body := misc.InjectCodexUserAgent(bytes.Clone(req.Payload), userAgent)
|
||||||
body = sdktranslator.TranslateRequest(from, to, model, body, false)
|
body = sdktranslator.TranslateRequest(from, to, baseModel, body, false)
|
||||||
body = misc.StripCodexUserAgent(body)
|
body = misc.StripCodexUserAgent(body)
|
||||||
|
|
||||||
body = ApplyReasoningEffortMetadata(body, req.Metadata, model, "reasoning.effort", false)
|
body, err := thinking.ApplyThinking(body, req.Model, "codex")
|
||||||
body, _ = sjson.SetBytes(body, "model", model)
|
if err != nil {
|
||||||
|
return cliproxyexecutor.Response{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
||||||
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
||||||
body, _ = sjson.SetBytes(body, "stream", false)
|
body, _ = sjson.SetBytes(body, "stream", false)
|
||||||
|
|
||||||
enc, err := tokenizerForCodexModel(model)
|
enc, err := tokenizerForCodexModel(baseModel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cliproxyexecutor.Response{}, fmt.Errorf("codex executor: tokenizer init failed: %w", err)
|
return cliproxyexecutor.Response{}, fmt.Errorf("codex executor: tokenizer init failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -593,51 +590,6 @@ func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *CodexExecutor) resolveUpstreamModel(alias string, auth *cliproxyauth.Auth) string {
|
|
||||||
trimmed := strings.TrimSpace(alias)
|
|
||||||
if trimmed == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := e.resolveCodexConfig(auth)
|
|
||||||
if entry == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizedModel, metadata := util.NormalizeThinkingModel(trimmed)
|
|
||||||
|
|
||||||
// Candidate names to match against configured aliases/names.
|
|
||||||
candidates := []string{strings.TrimSpace(normalizedModel)}
|
|
||||||
if !strings.EqualFold(normalizedModel, trimmed) {
|
|
||||||
candidates = append(candidates, trimmed)
|
|
||||||
}
|
|
||||||
if original := util.ResolveOriginalModel(normalizedModel, metadata); original != "" && !strings.EqualFold(original, normalizedModel) {
|
|
||||||
candidates = append(candidates, original)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range entry.Models {
|
|
||||||
model := entry.Models[i]
|
|
||||||
name := strings.TrimSpace(model.Name)
|
|
||||||
modelAlias := strings.TrimSpace(model.Alias)
|
|
||||||
|
|
||||||
for _, candidate := range candidates {
|
|
||||||
if candidate == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if modelAlias != "" && strings.EqualFold(modelAlias, candidate) {
|
|
||||||
if name != "" {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
if name != "" && strings.EqualFold(name, candidate) {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *CodexExecutor) resolveCodexConfig(auth *cliproxyauth.Auth) *config.CodexKey {
|
func (e *CodexExecutor) resolveCodexConfig(auth *cliproxyauth.Auth) *config.CodexKey {
|
||||||
if auth == nil || e.cfg == nil {
|
if auth == nil || e.cfg == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -20,6 +20,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/misc"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
@@ -102,28 +103,33 @@ func (e *GeminiCLIExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.
|
|||||||
|
|
||||||
// Execute performs a non-streaming request to the Gemini CLI API.
|
// Execute performs a non-streaming request to the Gemini CLI API.
|
||||||
func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth)
|
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini-cli")
|
to := sdktranslator.FromString("gemini-cli")
|
||||||
|
|
||||||
originalPayload := bytes.Clone(req.Payload)
|
originalPayload := bytes.Clone(req.Payload)
|
||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
||||||
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
basePayload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
basePayload = ApplyThinkingMetadataCLI(basePayload, req.Metadata, req.Model)
|
|
||||||
basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload)
|
basePayload, err = thinking.ApplyThinking(basePayload, req.Model, "gemini-cli")
|
||||||
basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, basePayload)
|
if err != nil {
|
||||||
basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload)
|
return resp, err
|
||||||
basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload)
|
}
|
||||||
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
|
|
||||||
basePayload = applyPayloadConfigWithRoot(e.cfg, req.Model, "gemini", "request", basePayload, originalTranslated)
|
basePayload = fixGeminiCLIImageAspectRatio(baseModel, basePayload)
|
||||||
|
basePayload = applyPayloadConfigWithRoot(e.cfg, baseModel, "gemini", "request", basePayload, originalTranslated)
|
||||||
|
|
||||||
action := "generateContent"
|
action := "generateContent"
|
||||||
if req.Metadata != nil {
|
if req.Metadata != nil {
|
||||||
@@ -133,9 +139,9 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
|
|||||||
}
|
}
|
||||||
|
|
||||||
projectID := resolveGeminiProjectID(auth)
|
projectID := resolveGeminiProjectID(auth)
|
||||||
models := cliPreviewFallbackOrder(req.Model)
|
models := cliPreviewFallbackOrder(baseModel)
|
||||||
if len(models) == 0 || models[0] != req.Model {
|
if len(models) == 0 || models[0] != baseModel {
|
||||||
models = append([]string{req.Model}, models...)
|
models = append([]string{baseModel}, models...)
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient := newHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
@@ -246,34 +252,39 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
|
|||||||
|
|
||||||
// ExecuteStream performs a streaming request to the Gemini CLI API.
|
// ExecuteStream performs a streaming request to the Gemini CLI API.
|
||||||
func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth)
|
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini-cli")
|
to := sdktranslator.FromString("gemini-cli")
|
||||||
|
|
||||||
originalPayload := bytes.Clone(req.Payload)
|
originalPayload := bytes.Clone(req.Payload)
|
||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, true)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
basePayload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
|
||||||
basePayload = ApplyThinkingMetadataCLI(basePayload, req.Metadata, req.Model)
|
|
||||||
basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload)
|
basePayload, err = thinking.ApplyThinking(basePayload, req.Model, "gemini-cli")
|
||||||
basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, basePayload)
|
if err != nil {
|
||||||
basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload)
|
return nil, err
|
||||||
basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload)
|
}
|
||||||
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
|
|
||||||
basePayload = applyPayloadConfigWithRoot(e.cfg, req.Model, "gemini", "request", basePayload, originalTranslated)
|
basePayload = fixGeminiCLIImageAspectRatio(baseModel, basePayload)
|
||||||
|
basePayload = applyPayloadConfigWithRoot(e.cfg, baseModel, "gemini", "request", basePayload, originalTranslated)
|
||||||
|
|
||||||
projectID := resolveGeminiProjectID(auth)
|
projectID := resolveGeminiProjectID(auth)
|
||||||
|
|
||||||
models := cliPreviewFallbackOrder(req.Model)
|
models := cliPreviewFallbackOrder(baseModel)
|
||||||
if len(models) == 0 || models[0] != req.Model {
|
if len(models) == 0 || models[0] != baseModel {
|
||||||
models = append([]string{req.Model}, models...)
|
models = append([]string{baseModel}, models...)
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient := newHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
@@ -435,6 +446,8 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
|||||||
|
|
||||||
// CountTokens counts tokens for the given request using the Gemini CLI API.
|
// CountTokens counts tokens for the given request using the Gemini CLI API.
|
||||||
func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth)
|
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cliproxyexecutor.Response{}, err
|
return cliproxyexecutor.Response{}, err
|
||||||
@@ -443,9 +456,9 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.
|
|||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini-cli")
|
to := sdktranslator.FromString("gemini-cli")
|
||||||
|
|
||||||
models := cliPreviewFallbackOrder(req.Model)
|
models := cliPreviewFallbackOrder(baseModel)
|
||||||
if len(models) == 0 || models[0] != req.Model {
|
if len(models) == 0 || models[0] != baseModel {
|
||||||
models = append([]string{req.Model}, models...)
|
models = append([]string{baseModel}, models...)
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient := newHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
@@ -463,15 +476,18 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.
|
|||||||
|
|
||||||
// The loop variable attemptModel is only used as the concrete model id sent to the upstream
|
// The loop variable attemptModel is only used as the concrete model id sent to the upstream
|
||||||
// Gemini CLI endpoint when iterating fallback variants.
|
// Gemini CLI endpoint when iterating fallback variants.
|
||||||
for _, attemptModel := range models {
|
for range models {
|
||||||
payload := sdktranslator.TranslateRequest(from, to, attemptModel, bytes.Clone(req.Payload), false)
|
payload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
payload = ApplyThinkingMetadataCLI(payload, req.Metadata, req.Model)
|
|
||||||
payload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, payload)
|
payload, err = thinking.ApplyThinking(payload, req.Model, "gemini-cli")
|
||||||
|
if err != nil {
|
||||||
|
return cliproxyexecutor.Response{}, err
|
||||||
|
}
|
||||||
|
|
||||||
payload = deleteJSONField(payload, "project")
|
payload = deleteJSONField(payload, "project")
|
||||||
payload = deleteJSONField(payload, "model")
|
payload = deleteJSONField(payload, "model")
|
||||||
payload = deleteJSONField(payload, "request.safetySettings")
|
payload = deleteJSONField(payload, "request.safetySettings")
|
||||||
payload = util.StripThinkingConfigIfUnsupported(req.Model, payload)
|
payload = fixGeminiCLIImageAspectRatio(baseModel, payload)
|
||||||
payload = fixGeminiCLIImageAspectRatio(req.Model, payload)
|
|
||||||
|
|
||||||
tok, errTok := tokenSource.Token()
|
tok, errTok := tokenSource.Token()
|
||||||
if errTok != nil {
|
if errTok != nil {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"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/thinking"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
@@ -102,16 +103,13 @@ func (e *GeminiExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
// - cliproxyexecutor.Response: The response from the API
|
// - cliproxyexecutor.Response: The response from the API
|
||||||
// - error: An error if the request fails
|
// - error: An error if the request fails
|
||||||
func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
apiKey, bearer := geminiCreds(auth)
|
apiKey, bearer := geminiCreds(auth)
|
||||||
|
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
model := req.Model
|
|
||||||
if override := e.resolveUpstreamModel(model, auth); override != "" {
|
|
||||||
model = override
|
|
||||||
}
|
|
||||||
|
|
||||||
// Official Gemini API via API key or OAuth bearer
|
// Official Gemini API via API key or OAuth bearer
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini")
|
to := sdktranslator.FromString("gemini")
|
||||||
@@ -119,15 +117,17 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, model, originalPayload, false)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
||||||
body := sdktranslator.TranslateRequest(from, to, model, bytes.Clone(req.Payload), false)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
body = ApplyThinkingMetadata(body, req.Metadata, model)
|
|
||||||
body = util.ApplyDefaultThinkingIfNeeded(model, body)
|
body, err = thinking.ApplyThinking(body, req.Model, "gemini")
|
||||||
body = util.NormalizeGeminiThinkingBudget(model, body)
|
if err != nil {
|
||||||
body = util.StripThinkingConfigIfUnsupported(model, body)
|
return resp, err
|
||||||
body = fixGeminiImageAspectRatio(model, body)
|
}
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, model, to.String(), "", body, originalTranslated)
|
|
||||||
body, _ = sjson.SetBytes(body, "model", model)
|
body = fixGeminiImageAspectRatio(baseModel, body)
|
||||||
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
||||||
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
|
|
||||||
action := "generateContent"
|
action := "generateContent"
|
||||||
if req.Metadata != nil {
|
if req.Metadata != nil {
|
||||||
@@ -136,7 +136,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
baseURL := resolveGeminiBaseURL(auth)
|
baseURL := resolveGeminiBaseURL(auth)
|
||||||
url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, model, action)
|
url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, baseModel, action)
|
||||||
if opts.Alt != "" && action != "countTokens" {
|
if opts.Alt != "" && action != "countTokens" {
|
||||||
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
|
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
|
||||||
}
|
}
|
||||||
@@ -206,34 +206,33 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
|
|
||||||
// ExecuteStream performs a streaming request to the Gemini API.
|
// ExecuteStream performs a streaming request to the Gemini API.
|
||||||
func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
apiKey, bearer := geminiCreds(auth)
|
apiKey, bearer := geminiCreds(auth)
|
||||||
|
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
model := req.Model
|
|
||||||
if override := e.resolveUpstreamModel(model, auth); override != "" {
|
|
||||||
model = override
|
|
||||||
}
|
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini")
|
to := sdktranslator.FromString("gemini")
|
||||||
originalPayload := bytes.Clone(req.Payload)
|
originalPayload := bytes.Clone(req.Payload)
|
||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, model, originalPayload, true)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
body := sdktranslator.TranslateRequest(from, to, model, bytes.Clone(req.Payload), true)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
|
||||||
body = ApplyThinkingMetadata(body, req.Metadata, model)
|
|
||||||
body = util.ApplyDefaultThinkingIfNeeded(model, body)
|
body, err = thinking.ApplyThinking(body, req.Model, "gemini")
|
||||||
body = util.NormalizeGeminiThinkingBudget(model, body)
|
if err != nil {
|
||||||
body = util.StripThinkingConfigIfUnsupported(model, body)
|
return nil, err
|
||||||
body = fixGeminiImageAspectRatio(model, body)
|
}
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, model, to.String(), "", body, originalTranslated)
|
|
||||||
body, _ = sjson.SetBytes(body, "model", model)
|
body = fixGeminiImageAspectRatio(baseModel, body)
|
||||||
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
||||||
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
|
|
||||||
baseURL := resolveGeminiBaseURL(auth)
|
baseURL := resolveGeminiBaseURL(auth)
|
||||||
url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, model, "streamGenerateContent")
|
url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, baseModel, "streamGenerateContent")
|
||||||
if opts.Alt == "" {
|
if opts.Alt == "" {
|
||||||
url = url + "?alt=sse"
|
url = url + "?alt=sse"
|
||||||
} else {
|
} else {
|
||||||
@@ -331,27 +330,28 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
|
|
||||||
// CountTokens counts tokens for the given request using the Gemini API.
|
// CountTokens counts tokens for the given request using the Gemini API.
|
||||||
func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||||
apiKey, bearer := geminiCreds(auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
model := req.Model
|
apiKey, bearer := geminiCreds(auth)
|
||||||
if override := e.resolveUpstreamModel(model, auth); override != "" {
|
|
||||||
model = override
|
|
||||||
}
|
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini")
|
to := sdktranslator.FromString("gemini")
|
||||||
translatedReq := sdktranslator.TranslateRequest(from, to, model, bytes.Clone(req.Payload), false)
|
translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
translatedReq = ApplyThinkingMetadata(translatedReq, req.Metadata, model)
|
|
||||||
translatedReq = util.StripThinkingConfigIfUnsupported(model, translatedReq)
|
translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, "gemini")
|
||||||
translatedReq = fixGeminiImageAspectRatio(model, translatedReq)
|
if err != nil {
|
||||||
|
return cliproxyexecutor.Response{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
translatedReq = fixGeminiImageAspectRatio(baseModel, translatedReq)
|
||||||
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
||||||
translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools")
|
translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools")
|
||||||
translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig")
|
translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig")
|
||||||
translatedReq, _ = sjson.DeleteBytes(translatedReq, "safetySettings")
|
translatedReq, _ = sjson.DeleteBytes(translatedReq, "safetySettings")
|
||||||
translatedReq, _ = sjson.SetBytes(translatedReq, "model", model)
|
translatedReq, _ = sjson.SetBytes(translatedReq, "model", baseModel)
|
||||||
|
|
||||||
baseURL := resolveGeminiBaseURL(auth)
|
baseURL := resolveGeminiBaseURL(auth)
|
||||||
url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, model, "countTokens")
|
url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, baseModel, "countTokens")
|
||||||
|
|
||||||
requestBody := bytes.NewReader(translatedReq)
|
requestBody := bytes.NewReader(translatedReq)
|
||||||
|
|
||||||
@@ -450,51 +450,6 @@ func resolveGeminiBaseURL(auth *cliproxyauth.Auth) string {
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *GeminiExecutor) resolveUpstreamModel(alias string, auth *cliproxyauth.Auth) string {
|
|
||||||
trimmed := strings.TrimSpace(alias)
|
|
||||||
if trimmed == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := e.resolveGeminiConfig(auth)
|
|
||||||
if entry == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizedModel, metadata := util.NormalizeThinkingModel(trimmed)
|
|
||||||
|
|
||||||
// Candidate names to match against configured aliases/names.
|
|
||||||
candidates := []string{strings.TrimSpace(normalizedModel)}
|
|
||||||
if !strings.EqualFold(normalizedModel, trimmed) {
|
|
||||||
candidates = append(candidates, trimmed)
|
|
||||||
}
|
|
||||||
if original := util.ResolveOriginalModel(normalizedModel, metadata); original != "" && !strings.EqualFold(original, normalizedModel) {
|
|
||||||
candidates = append(candidates, original)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range entry.Models {
|
|
||||||
model := entry.Models[i]
|
|
||||||
name := strings.TrimSpace(model.Name)
|
|
||||||
modelAlias := strings.TrimSpace(model.Alias)
|
|
||||||
|
|
||||||
for _, candidate := range candidates {
|
|
||||||
if candidate == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if modelAlias != "" && strings.EqualFold(modelAlias, candidate) {
|
|
||||||
if name != "" {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
if name != "" && strings.EqualFold(name, candidate) {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *GeminiExecutor) resolveGeminiConfig(auth *cliproxyauth.Auth) *config.GeminiKey {
|
func (e *GeminiExecutor) resolveGeminiConfig(auth *cliproxyauth.Auth) *config.GeminiKey {
|
||||||
if auth == nil || e.cfg == nil {
|
if auth == nil || e.cfg == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
vertexauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex"
|
vertexauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex"
|
||||||
"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/thinking"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
@@ -155,30 +155,29 @@ func (e *GeminiVertexExecutor) Refresh(_ context.Context, auth *cliproxyauth.Aut
|
|||||||
// executeWithServiceAccount handles authentication using service account credentials.
|
// executeWithServiceAccount handles authentication using service account credentials.
|
||||||
// This method contains the original service account authentication logic.
|
// This method contains the original service account authentication logic.
|
||||||
func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, projectID, location string, saJSON []byte) (resp cliproxyexecutor.Response, err error) {
|
func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, projectID, location string, saJSON []byte) (resp cliproxyexecutor.Response, err error) {
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini")
|
to := sdktranslator.FromString("gemini")
|
||||||
|
|
||||||
originalPayload := bytes.Clone(req.Payload)
|
originalPayload := bytes.Clone(req.Payload)
|
||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
||||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
if budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(req.Model, req.Metadata); ok && util.ModelSupportsThinking(req.Model) {
|
|
||||||
if budgetOverride != nil {
|
body, err = thinking.ApplyThinking(body, req.Model, "gemini")
|
||||||
norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride)
|
if err != nil {
|
||||||
budgetOverride = &norm
|
return resp, err
|
||||||
}
|
|
||||||
body = util.ApplyGeminiThinkingConfig(body, budgetOverride, includeOverride)
|
|
||||||
}
|
}
|
||||||
body = util.ApplyDefaultThinkingIfNeeded(req.Model, body)
|
|
||||||
body = util.NormalizeGeminiThinkingBudget(req.Model, body)
|
body = fixGeminiImageAspectRatio(baseModel, body)
|
||||||
body = util.StripThinkingConfigIfUnsupported(req.Model, body)
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
||||||
body = fixGeminiImageAspectRatio(req.Model, body)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated)
|
|
||||||
body, _ = sjson.SetBytes(body, "model", req.Model)
|
|
||||||
|
|
||||||
action := "generateContent"
|
action := "generateContent"
|
||||||
if req.Metadata != nil {
|
if req.Metadata != nil {
|
||||||
@@ -187,7 +186,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
baseURL := vertexBaseURL(location)
|
baseURL := vertexBaseURL(location)
|
||||||
url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, req.Model, action)
|
url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, action)
|
||||||
if opts.Alt != "" && action != "countTokens" {
|
if opts.Alt != "" && action != "countTokens" {
|
||||||
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
|
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
|
||||||
}
|
}
|
||||||
@@ -258,35 +257,29 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
|
|||||||
|
|
||||||
// executeWithAPIKey handles authentication using API key credentials.
|
// executeWithAPIKey handles authentication using API key credentials.
|
||||||
func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, apiKey, baseURL string) (resp cliproxyexecutor.Response, err error) {
|
func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, apiKey, baseURL string) (resp cliproxyexecutor.Response, err error) {
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
defer reporter.trackFailure(ctx, &err)
|
|
||||||
|
|
||||||
model := req.Model
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
if override := e.resolveUpstreamModel(req.Model, auth); override != "" {
|
defer reporter.trackFailure(ctx, &err)
|
||||||
model = override
|
|
||||||
}
|
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini")
|
to := sdktranslator.FromString("gemini")
|
||||||
|
|
||||||
originalPayload := bytes.Clone(req.Payload)
|
originalPayload := bytes.Clone(req.Payload)
|
||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, model, originalPayload, false)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
||||||
body := sdktranslator.TranslateRequest(from, to, model, bytes.Clone(req.Payload), false)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
if budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(model, req.Metadata); ok && util.ModelSupportsThinking(model) {
|
|
||||||
if budgetOverride != nil {
|
body, err = thinking.ApplyThinking(body, req.Model, "gemini")
|
||||||
norm := util.NormalizeThinkingBudget(model, *budgetOverride)
|
if err != nil {
|
||||||
budgetOverride = &norm
|
return resp, err
|
||||||
}
|
|
||||||
body = util.ApplyGeminiThinkingConfig(body, budgetOverride, includeOverride)
|
|
||||||
}
|
}
|
||||||
body = util.ApplyDefaultThinkingIfNeeded(model, body)
|
|
||||||
body = util.NormalizeGeminiThinkingBudget(model, body)
|
body = fixGeminiImageAspectRatio(baseModel, body)
|
||||||
body = util.StripThinkingConfigIfUnsupported(model, body)
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
||||||
body = fixGeminiImageAspectRatio(model, body)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, model, to.String(), "", body, originalTranslated)
|
|
||||||
body, _ = sjson.SetBytes(body, "model", model)
|
|
||||||
|
|
||||||
action := "generateContent"
|
action := "generateContent"
|
||||||
if req.Metadata != nil {
|
if req.Metadata != nil {
|
||||||
@@ -299,7 +292,7 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
|
|||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://generativelanguage.googleapis.com"
|
baseURL = "https://generativelanguage.googleapis.com"
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, model, action)
|
url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, action)
|
||||||
if opts.Alt != "" && action != "countTokens" {
|
if opts.Alt != "" && action != "countTokens" {
|
||||||
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
|
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
|
||||||
}
|
}
|
||||||
@@ -367,33 +360,32 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
|
|||||||
|
|
||||||
// executeStreamWithServiceAccount handles streaming authentication using service account credentials.
|
// executeStreamWithServiceAccount handles streaming authentication using service account credentials.
|
||||||
func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, projectID, location string, saJSON []byte) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, projectID, location string, saJSON []byte) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini")
|
to := sdktranslator.FromString("gemini")
|
||||||
|
|
||||||
originalPayload := bytes.Clone(req.Payload)
|
originalPayload := bytes.Clone(req.Payload)
|
||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, true)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
|
||||||
if budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(req.Model, req.Metadata); ok && util.ModelSupportsThinking(req.Model) {
|
|
||||||
if budgetOverride != nil {
|
body, err = thinking.ApplyThinking(body, req.Model, "gemini")
|
||||||
norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride)
|
if err != nil {
|
||||||
budgetOverride = &norm
|
return nil, err
|
||||||
}
|
|
||||||
body = util.ApplyGeminiThinkingConfig(body, budgetOverride, includeOverride)
|
|
||||||
}
|
}
|
||||||
body = util.ApplyDefaultThinkingIfNeeded(req.Model, body)
|
|
||||||
body = util.NormalizeGeminiThinkingBudget(req.Model, body)
|
body = fixGeminiImageAspectRatio(baseModel, body)
|
||||||
body = util.StripThinkingConfigIfUnsupported(req.Model, body)
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
||||||
body = fixGeminiImageAspectRatio(req.Model, body)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated)
|
|
||||||
body, _ = sjson.SetBytes(body, "model", req.Model)
|
|
||||||
|
|
||||||
baseURL := vertexBaseURL(location)
|
baseURL := vertexBaseURL(location)
|
||||||
url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, req.Model, "streamGenerateContent")
|
url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, "streamGenerateContent")
|
||||||
if opts.Alt == "" {
|
if opts.Alt == "" {
|
||||||
url = url + "?alt=sse"
|
url = url + "?alt=sse"
|
||||||
} else {
|
} else {
|
||||||
@@ -487,41 +479,35 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
|
|||||||
|
|
||||||
// executeStreamWithAPIKey handles streaming authentication using API key credentials.
|
// executeStreamWithAPIKey handles streaming authentication using API key credentials.
|
||||||
func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, apiKey, baseURL string) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, apiKey, baseURL string) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
defer reporter.trackFailure(ctx, &err)
|
|
||||||
|
|
||||||
model := req.Model
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
if override := e.resolveUpstreamModel(req.Model, auth); override != "" {
|
defer reporter.trackFailure(ctx, &err)
|
||||||
model = override
|
|
||||||
}
|
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini")
|
to := sdktranslator.FromString("gemini")
|
||||||
|
|
||||||
originalPayload := bytes.Clone(req.Payload)
|
originalPayload := bytes.Clone(req.Payload)
|
||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, model, originalPayload, true)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
body := sdktranslator.TranslateRequest(from, to, model, bytes.Clone(req.Payload), true)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
|
||||||
if budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(model, req.Metadata); ok && util.ModelSupportsThinking(model) {
|
|
||||||
if budgetOverride != nil {
|
body, err = thinking.ApplyThinking(body, req.Model, "gemini")
|
||||||
norm := util.NormalizeThinkingBudget(model, *budgetOverride)
|
if err != nil {
|
||||||
budgetOverride = &norm
|
return nil, err
|
||||||
}
|
|
||||||
body = util.ApplyGeminiThinkingConfig(body, budgetOverride, includeOverride)
|
|
||||||
}
|
}
|
||||||
body = util.ApplyDefaultThinkingIfNeeded(model, body)
|
|
||||||
body = util.NormalizeGeminiThinkingBudget(model, body)
|
body = fixGeminiImageAspectRatio(baseModel, body)
|
||||||
body = util.StripThinkingConfigIfUnsupported(model, body)
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
||||||
body = fixGeminiImageAspectRatio(model, body)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, model, to.String(), "", body, originalTranslated)
|
|
||||||
body, _ = sjson.SetBytes(body, "model", model)
|
|
||||||
|
|
||||||
// For API key auth, use simpler URL format without project/location
|
// For API key auth, use simpler URL format without project/location
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://generativelanguage.googleapis.com"
|
baseURL = "https://generativelanguage.googleapis.com"
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, model, "streamGenerateContent")
|
url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, "streamGenerateContent")
|
||||||
if opts.Alt == "" {
|
if opts.Alt == "" {
|
||||||
url = url + "?alt=sse"
|
url = url + "?alt=sse"
|
||||||
} else {
|
} else {
|
||||||
@@ -612,26 +598,27 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
|
|||||||
|
|
||||||
// countTokensWithServiceAccount counts tokens using service account credentials.
|
// countTokensWithServiceAccount counts tokens using service account credentials.
|
||||||
func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, projectID, location string, saJSON []byte) (cliproxyexecutor.Response, error) {
|
func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, projectID, location string, saJSON []byte) (cliproxyexecutor.Response, error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini")
|
to := sdktranslator.FromString("gemini")
|
||||||
translatedReq := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
|
||||||
if budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(req.Model, req.Metadata); ok && util.ModelSupportsThinking(req.Model) {
|
translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
if budgetOverride != nil {
|
|
||||||
norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride)
|
translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, "gemini")
|
||||||
budgetOverride = &norm
|
if err != nil {
|
||||||
}
|
return cliproxyexecutor.Response{}, err
|
||||||
translatedReq = util.ApplyGeminiThinkingConfig(translatedReq, budgetOverride, includeOverride)
|
|
||||||
}
|
}
|
||||||
translatedReq = util.StripThinkingConfigIfUnsupported(req.Model, translatedReq)
|
|
||||||
translatedReq = fixGeminiImageAspectRatio(req.Model, translatedReq)
|
translatedReq = fixGeminiImageAspectRatio(baseModel, translatedReq)
|
||||||
translatedReq, _ = sjson.SetBytes(translatedReq, "model", req.Model)
|
translatedReq, _ = sjson.SetBytes(translatedReq, "model", baseModel)
|
||||||
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
||||||
translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools")
|
translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools")
|
||||||
translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig")
|
translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig")
|
||||||
translatedReq, _ = sjson.DeleteBytes(translatedReq, "safetySettings")
|
translatedReq, _ = sjson.DeleteBytes(translatedReq, "safetySettings")
|
||||||
|
|
||||||
baseURL := vertexBaseURL(location)
|
baseURL := vertexBaseURL(location)
|
||||||
url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, req.Model, "countTokens")
|
url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, "countTokens")
|
||||||
|
|
||||||
httpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(translatedReq))
|
httpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(translatedReq))
|
||||||
if errNewReq != nil {
|
if errNewReq != nil {
|
||||||
@@ -688,10 +675,6 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context
|
|||||||
return cliproxyexecutor.Response{}, errRead
|
return cliproxyexecutor.Response{}, errRead
|
||||||
}
|
}
|
||||||
appendAPIResponseChunk(ctx, e.cfg, data)
|
appendAPIResponseChunk(ctx, e.cfg, data)
|
||||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
||||||
log.Debugf("request error, error status: %d, error body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
|
|
||||||
return cliproxyexecutor.Response{}, statusErr{code: httpResp.StatusCode, msg: string(data)}
|
|
||||||
}
|
|
||||||
count := gjson.GetBytes(data, "totalTokens").Int()
|
count := gjson.GetBytes(data, "totalTokens").Int()
|
||||||
out := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)
|
out := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)
|
||||||
return cliproxyexecutor.Response{Payload: []byte(out)}, nil
|
return cliproxyexecutor.Response{Payload: []byte(out)}, nil
|
||||||
@@ -699,24 +682,20 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context
|
|||||||
|
|
||||||
// countTokensWithAPIKey handles token counting using API key credentials.
|
// countTokensWithAPIKey handles token counting using API key credentials.
|
||||||
func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, apiKey, baseURL string) (cliproxyexecutor.Response, error) {
|
func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, apiKey, baseURL string) (cliproxyexecutor.Response, error) {
|
||||||
model := req.Model
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
if override := e.resolveUpstreamModel(req.Model, auth); override != "" {
|
|
||||||
model = override
|
|
||||||
}
|
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini")
|
to := sdktranslator.FromString("gemini")
|
||||||
translatedReq := sdktranslator.TranslateRequest(from, to, model, bytes.Clone(req.Payload), false)
|
|
||||||
if budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(model, req.Metadata); ok && util.ModelSupportsThinking(model) {
|
translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
if budgetOverride != nil {
|
|
||||||
norm := util.NormalizeThinkingBudget(model, *budgetOverride)
|
translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, "gemini")
|
||||||
budgetOverride = &norm
|
if err != nil {
|
||||||
}
|
return cliproxyexecutor.Response{}, err
|
||||||
translatedReq = util.ApplyGeminiThinkingConfig(translatedReq, budgetOverride, includeOverride)
|
|
||||||
}
|
}
|
||||||
translatedReq = util.StripThinkingConfigIfUnsupported(model, translatedReq)
|
|
||||||
translatedReq = fixGeminiImageAspectRatio(model, translatedReq)
|
translatedReq = fixGeminiImageAspectRatio(baseModel, translatedReq)
|
||||||
translatedReq, _ = sjson.SetBytes(translatedReq, "model", model)
|
translatedReq, _ = sjson.SetBytes(translatedReq, "model", baseModel)
|
||||||
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
||||||
translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools")
|
translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools")
|
||||||
translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig")
|
translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig")
|
||||||
@@ -726,7 +705,7 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth *
|
|||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://generativelanguage.googleapis.com"
|
baseURL = "https://generativelanguage.googleapis.com"
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, model, "countTokens")
|
url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, "countTokens")
|
||||||
|
|
||||||
httpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(translatedReq))
|
httpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(translatedReq))
|
||||||
if errNewReq != nil {
|
if errNewReq != nil {
|
||||||
@@ -780,10 +759,6 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth *
|
|||||||
return cliproxyexecutor.Response{}, errRead
|
return cliproxyexecutor.Response{}, errRead
|
||||||
}
|
}
|
||||||
appendAPIResponseChunk(ctx, e.cfg, data)
|
appendAPIResponseChunk(ctx, e.cfg, data)
|
||||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
||||||
log.Debugf("request error, error status: %d, error body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
|
|
||||||
return cliproxyexecutor.Response{}, statusErr{code: httpResp.StatusCode, msg: string(data)}
|
|
||||||
}
|
|
||||||
count := gjson.GetBytes(data, "totalTokens").Int()
|
count := gjson.GetBytes(data, "totalTokens").Int()
|
||||||
out := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)
|
out := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)
|
||||||
return cliproxyexecutor.Response{Payload: []byte(out)}, nil
|
return cliproxyexecutor.Response{Payload: []byte(out)}, nil
|
||||||
@@ -870,53 +845,6 @@ func vertexAccessToken(ctx context.Context, cfg *config.Config, auth *cliproxyau
|
|||||||
return tok.AccessToken, nil
|
return tok.AccessToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveUpstreamModel resolves the upstream model name from vertex-api-key configuration.
|
|
||||||
// It matches the requested model alias against configured models and returns the actual upstream name.
|
|
||||||
func (e *GeminiVertexExecutor) resolveUpstreamModel(alias string, auth *cliproxyauth.Auth) string {
|
|
||||||
trimmed := strings.TrimSpace(alias)
|
|
||||||
if trimmed == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := e.resolveVertexConfig(auth)
|
|
||||||
if entry == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizedModel, metadata := util.NormalizeThinkingModel(trimmed)
|
|
||||||
|
|
||||||
// Candidate names to match against configured aliases/names.
|
|
||||||
candidates := []string{strings.TrimSpace(normalizedModel)}
|
|
||||||
if !strings.EqualFold(normalizedModel, trimmed) {
|
|
||||||
candidates = append(candidates, trimmed)
|
|
||||||
}
|
|
||||||
if original := util.ResolveOriginalModel(normalizedModel, metadata); original != "" && !strings.EqualFold(original, normalizedModel) {
|
|
||||||
candidates = append(candidates, original)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range entry.Models {
|
|
||||||
model := entry.Models[i]
|
|
||||||
name := strings.TrimSpace(model.Name)
|
|
||||||
modelAlias := strings.TrimSpace(model.Alias)
|
|
||||||
|
|
||||||
for _, candidate := range candidates {
|
|
||||||
if candidate == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if modelAlias != "" && strings.EqualFold(modelAlias, candidate) {
|
|
||||||
if name != "" {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
if name != "" && strings.EqualFold(name, candidate) {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveVertexConfig finds the matching vertex-api-key configuration entry for the given auth.
|
// resolveVertexConfig finds the matching vertex-api-key configuration entry for the given auth.
|
||||||
func (e *GeminiVertexExecutor) resolveVertexConfig(auth *cliproxyauth.Auth) *config.VertexCompatKey {
|
func (e *GeminiVertexExecutor) resolveVertexConfig(auth *cliproxyauth.Auth) *config.VertexCompatKey {
|
||||||
if auth == nil || e.cfg == nil {
|
if auth == nil || e.cfg == nil {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
|
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
|
||||||
"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/thinking"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
@@ -67,6 +68,8 @@ func (e *IFlowExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth
|
|||||||
|
|
||||||
// Execute performs a non-streaming chat completion request.
|
// Execute performs a non-streaming chat completion request.
|
||||||
func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
apiKey, baseURL := iflowCreds(auth)
|
apiKey, baseURL := iflowCreds(auth)
|
||||||
if strings.TrimSpace(apiKey) == "" {
|
if strings.TrimSpace(apiKey) == "" {
|
||||||
err = fmt.Errorf("iflow executor: missing api key")
|
err = fmt.Errorf("iflow executor: missing api key")
|
||||||
@@ -76,7 +79,7 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
|||||||
baseURL = iflowauth.DefaultAPIBaseURL
|
baseURL = iflowauth.DefaultAPIBaseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
@@ -85,17 +88,17 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
|||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
||||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
body, _ = sjson.SetBytes(body, "model", req.Model)
|
|
||||||
body = NormalizeThinkingConfig(body, req.Model, false)
|
body, err = thinking.ApplyThinking(body, req.Model, "iflow")
|
||||||
if errValidate := ValidateThinkingConfig(body, req.Model); errValidate != nil {
|
if err != nil {
|
||||||
return resp, errValidate
|
return resp, err
|
||||||
}
|
}
|
||||||
body = applyIFlowThinkingConfig(body)
|
|
||||||
body = preserveReasoningContentInMessages(body)
|
body = preserveReasoningContentInMessages(body)
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated)
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
||||||
|
|
||||||
endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint
|
endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint
|
||||||
|
|
||||||
@@ -154,6 +157,8 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
|||||||
reporter.ensurePublished(ctx)
|
reporter.ensurePublished(ctx)
|
||||||
|
|
||||||
var param any
|
var param any
|
||||||
|
// Note: TranslateNonStream uses req.Model (original with suffix) to preserve
|
||||||
|
// the original model name in the response for client compatibility.
|
||||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m)
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(out)}
|
resp = cliproxyexecutor.Response{Payload: []byte(out)}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
@@ -161,6 +166,8 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
|||||||
|
|
||||||
// ExecuteStream performs a streaming chat completion request.
|
// ExecuteStream performs a streaming chat completion request.
|
||||||
func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
apiKey, baseURL := iflowCreds(auth)
|
apiKey, baseURL := iflowCreds(auth)
|
||||||
if strings.TrimSpace(apiKey) == "" {
|
if strings.TrimSpace(apiKey) == "" {
|
||||||
err = fmt.Errorf("iflow executor: missing api key")
|
err = fmt.Errorf("iflow executor: missing api key")
|
||||||
@@ -170,7 +177,7 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
baseURL = iflowauth.DefaultAPIBaseURL
|
baseURL = iflowauth.DefaultAPIBaseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
@@ -179,23 +186,22 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, true)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
|
||||||
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
|
|
||||||
body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false)
|
body, err = thinking.ApplyThinking(body, req.Model, "iflow")
|
||||||
body, _ = sjson.SetBytes(body, "model", req.Model)
|
if err != nil {
|
||||||
body = NormalizeThinkingConfig(body, req.Model, false)
|
return nil, err
|
||||||
if errValidate := ValidateThinkingConfig(body, req.Model); errValidate != nil {
|
|
||||||
return nil, errValidate
|
|
||||||
}
|
}
|
||||||
body = applyIFlowThinkingConfig(body)
|
|
||||||
body = preserveReasoningContentInMessages(body)
|
body = preserveReasoningContentInMessages(body)
|
||||||
// Ensure tools array exists to avoid provider quirks similar to Qwen's behaviour.
|
// Ensure tools array exists to avoid provider quirks similar to Qwen's behaviour.
|
||||||
toolsResult := gjson.GetBytes(body, "tools")
|
toolsResult := gjson.GetBytes(body, "tools")
|
||||||
if toolsResult.Exists() && toolsResult.IsArray() && len(toolsResult.Array()) == 0 {
|
if toolsResult.Exists() && toolsResult.IsArray() && len(toolsResult.Array()) == 0 {
|
||||||
body = ensureToolsArray(body)
|
body = ensureToolsArray(body)
|
||||||
}
|
}
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated)
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
||||||
|
|
||||||
endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint
|
endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint
|
||||||
|
|
||||||
@@ -278,11 +284,13 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *IFlowExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
func (e *IFlowExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("openai")
|
to := sdktranslator.FromString("openai")
|
||||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
|
|
||||||
enc, err := tokenizerForModel(req.Model)
|
enc, err := tokenizerForModel(baseModel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cliproxyexecutor.Response{}, fmt.Errorf("iflow executor: tokenizer init failed: %w", err)
|
return cliproxyexecutor.Response{}, fmt.Errorf("iflow executor: tokenizer init failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -520,41 +528,3 @@ func preserveReasoningContentInMessages(body []byte) []byte {
|
|||||||
|
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyIFlowThinkingConfig converts normalized reasoning_effort to model-specific thinking configurations.
|
|
||||||
// This should be called after NormalizeThinkingConfig has processed the payload.
|
|
||||||
//
|
|
||||||
// Model-specific handling:
|
|
||||||
// - GLM-4.6/4.7: Uses chat_template_kwargs.enable_thinking (boolean) and chat_template_kwargs.clear_thinking=false
|
|
||||||
// - MiniMax M2/M2.1: Uses reasoning_split=true for OpenAI-style reasoning separation
|
|
||||||
func applyIFlowThinkingConfig(body []byte) []byte {
|
|
||||||
effort := gjson.GetBytes(body, "reasoning_effort")
|
|
||||||
if !effort.Exists() {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
model := strings.ToLower(gjson.GetBytes(body, "model").String())
|
|
||||||
val := strings.ToLower(strings.TrimSpace(effort.String()))
|
|
||||||
enableThinking := val != "none" && val != ""
|
|
||||||
|
|
||||||
// Remove reasoning_effort as we'll convert to model-specific format
|
|
||||||
body, _ = sjson.DeleteBytes(body, "reasoning_effort")
|
|
||||||
body, _ = sjson.DeleteBytes(body, "thinking")
|
|
||||||
|
|
||||||
// GLM-4.6/4.7: Use chat_template_kwargs
|
|
||||||
if strings.HasPrefix(model, "glm-4") {
|
|
||||||
body, _ = sjson.SetBytes(body, "chat_template_kwargs.enable_thinking", enableThinking)
|
|
||||||
if enableThinking {
|
|
||||||
body, _ = sjson.SetBytes(body, "chat_template_kwargs.clear_thinking", false)
|
|
||||||
}
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// MiniMax M2/M2.1: Use reasoning_split
|
|
||||||
if strings.HasPrefix(model, "minimax-m2") {
|
|
||||||
body, _ = sjson.SetBytes(body, "reasoning_split", enableThinking)
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|||||||
67
internal/runtime/executor/iflow_executor_test.go
Normal file
67
internal/runtime/executor/iflow_executor_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIFlowExecutorParseSuffix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
model string
|
||||||
|
wantBase string
|
||||||
|
wantLevel string
|
||||||
|
}{
|
||||||
|
{"no suffix", "glm-4", "glm-4", ""},
|
||||||
|
{"glm with suffix", "glm-4.1-flash(high)", "glm-4.1-flash", "high"},
|
||||||
|
{"minimax no suffix", "minimax-m2", "minimax-m2", ""},
|
||||||
|
{"minimax with suffix", "minimax-m2.1(medium)", "minimax-m2.1", "medium"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := thinking.ParseSuffix(tt.model)
|
||||||
|
if result.ModelName != tt.wantBase {
|
||||||
|
t.Errorf("ParseSuffix(%q).ModelName = %q, want %q", tt.model, result.ModelName, tt.wantBase)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreserveReasoningContentInMessages(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input []byte
|
||||||
|
want []byte // nil means output should equal input
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"non-glm model passthrough",
|
||||||
|
[]byte(`{"model":"gpt-4","messages":[]}`),
|
||||||
|
nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glm model with empty messages",
|
||||||
|
[]byte(`{"model":"glm-4","messages":[]}`),
|
||||||
|
nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glm model preserves existing reasoning_content",
|
||||||
|
[]byte(`{"model":"glm-4","messages":[{"role":"assistant","content":"hi","reasoning_content":"thinking..."}]}`),
|
||||||
|
nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := preserveReasoningContentInMessages(tt.input)
|
||||||
|
want := tt.want
|
||||||
|
if want == nil {
|
||||||
|
want = tt.input
|
||||||
|
}
|
||||||
|
if string(got) != string(want) {
|
||||||
|
t.Errorf("preserveReasoningContentInMessages() = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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/thinking"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
@@ -69,7 +70,9 @@ func (e *OpenAICompatExecutor) HttpRequest(ctx context.Context, auth *cliproxyau
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
baseURL, apiKey := e.resolveCredentials(auth)
|
baseURL, apiKey := e.resolveCredentials(auth)
|
||||||
@@ -85,18 +88,13 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
|
|||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, opts.Stream)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, opts.Stream)
|
||||||
translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), opts.Stream)
|
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), opts.Stream)
|
||||||
modelOverride := e.resolveUpstreamModel(req.Model, auth)
|
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated)
|
||||||
if modelOverride != "" {
|
|
||||||
translated = e.overrideModel(translated, modelOverride)
|
translated, err = thinking.ApplyThinking(translated, req.Model, "openai")
|
||||||
}
|
if err != nil {
|
||||||
translated = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", translated, originalTranslated)
|
return resp, err
|
||||||
allowCompat := e.allowCompatReasoningEffort(req.Model, auth)
|
|
||||||
translated = ApplyReasoningEffortMetadata(translated, req.Metadata, req.Model, "reasoning_effort", allowCompat)
|
|
||||||
translated = NormalizeThinkingConfig(translated, req.Model, allowCompat)
|
|
||||||
if errValidate := ValidateThinkingConfig(translated, req.Model); errValidate != nil {
|
|
||||||
return resp, errValidate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
|
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
|
||||||
@@ -168,7 +166,9 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
baseURL, apiKey := e.resolveCredentials(auth)
|
baseURL, apiKey := e.resolveCredentials(auth)
|
||||||
@@ -176,24 +176,20 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
|
|||||||
err = statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL"}
|
err = statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL"}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("openai")
|
to := sdktranslator.FromString("openai")
|
||||||
originalPayload := bytes.Clone(req.Payload)
|
originalPayload := bytes.Clone(req.Payload)
|
||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, true)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
|
||||||
modelOverride := e.resolveUpstreamModel(req.Model, auth)
|
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated)
|
||||||
if modelOverride != "" {
|
|
||||||
translated = e.overrideModel(translated, modelOverride)
|
translated, err = thinking.ApplyThinking(translated, req.Model, "openai")
|
||||||
}
|
if err != nil {
|
||||||
translated = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", translated, originalTranslated)
|
return nil, err
|
||||||
allowCompat := e.allowCompatReasoningEffort(req.Model, auth)
|
|
||||||
translated = ApplyReasoningEffortMetadata(translated, req.Metadata, req.Model, "reasoning_effort", allowCompat)
|
|
||||||
translated = NormalizeThinkingConfig(translated, req.Model, allowCompat)
|
|
||||||
if errValidate := ValidateThinkingConfig(translated, req.Model); errValidate != nil {
|
|
||||||
return nil, errValidate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
|
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
|
||||||
@@ -293,14 +289,17 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("openai")
|
to := sdktranslator.FromString("openai")
|
||||||
translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
|
|
||||||
modelForCounting := req.Model
|
modelForCounting := baseModel
|
||||||
if modelOverride := e.resolveUpstreamModel(req.Model, auth); modelOverride != "" {
|
|
||||||
translated = e.overrideModel(translated, modelOverride)
|
translated, err := thinking.ApplyThinking(translated, req.Model, "openai")
|
||||||
modelForCounting = modelOverride
|
if err != nil {
|
||||||
|
return cliproxyexecutor.Response{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
enc, err := tokenizerForModel(modelForCounting)
|
enc, err := tokenizerForModel(modelForCounting)
|
||||||
@@ -336,53 +335,6 @@ func (e *OpenAICompatExecutor) resolveCredentials(auth *cliproxyauth.Auth) (base
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *OpenAICompatExecutor) resolveUpstreamModel(alias string, auth *cliproxyauth.Auth) string {
|
|
||||||
if alias == "" || auth == nil || e.cfg == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
compat := e.resolveCompatConfig(auth)
|
|
||||||
if compat == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for i := range compat.Models {
|
|
||||||
model := compat.Models[i]
|
|
||||||
if model.Alias != "" {
|
|
||||||
if strings.EqualFold(model.Alias, alias) {
|
|
||||||
if model.Name != "" {
|
|
||||||
return model.Name
|
|
||||||
}
|
|
||||||
return alias
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.EqualFold(model.Name, alias) {
|
|
||||||
return model.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *OpenAICompatExecutor) allowCompatReasoningEffort(model string, auth *cliproxyauth.Auth) bool {
|
|
||||||
trimmed := strings.TrimSpace(model)
|
|
||||||
if trimmed == "" || e == nil || e.cfg == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
compat := e.resolveCompatConfig(auth)
|
|
||||||
if compat == nil || len(compat.Models) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := range compat.Models {
|
|
||||||
entry := compat.Models[i]
|
|
||||||
if strings.EqualFold(strings.TrimSpace(entry.Alias), trimmed) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if strings.EqualFold(strings.TrimSpace(entry.Name), trimmed) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *OpenAICompatExecutor) resolveCompatConfig(auth *cliproxyauth.Auth) *config.OpenAICompatibility {
|
func (e *OpenAICompatExecutor) resolveCompatConfig(auth *cliproxyauth.Auth) *config.OpenAICompatibility {
|
||||||
if auth == nil || e.cfg == nil {
|
if auth == nil || e.cfg == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,109 +1,13 @@
|
|||||||
package executor
|
package executor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"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/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ApplyThinkingMetadata applies thinking config from model suffix metadata (e.g., (high), (8192))
|
|
||||||
// for standard Gemini format payloads. It normalizes the budget when the model supports thinking.
|
|
||||||
func ApplyThinkingMetadata(payload []byte, metadata map[string]any, model string) []byte {
|
|
||||||
// Use the alias from metadata if available, as it's registered in the global registry
|
|
||||||
// with thinking metadata; the upstream model name may not be registered.
|
|
||||||
lookupModel := util.ResolveOriginalModel(model, metadata)
|
|
||||||
|
|
||||||
// Determine which model to use for thinking support check.
|
|
||||||
// If the alias (lookupModel) is not in the registry, fall back to the upstream model.
|
|
||||||
thinkingModel := lookupModel
|
|
||||||
if !util.ModelSupportsThinking(lookupModel) && util.ModelSupportsThinking(model) {
|
|
||||||
thinkingModel = model
|
|
||||||
}
|
|
||||||
|
|
||||||
budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(thinkingModel, metadata)
|
|
||||||
if !ok || (budgetOverride == nil && includeOverride == nil) {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
if !util.ModelSupportsThinking(thinkingModel) {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
if budgetOverride != nil {
|
|
||||||
norm := util.NormalizeThinkingBudget(thinkingModel, *budgetOverride)
|
|
||||||
budgetOverride = &norm
|
|
||||||
}
|
|
||||||
return util.ApplyGeminiThinkingConfig(payload, budgetOverride, includeOverride)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyThinkingMetadataCLI applies thinking config from model suffix metadata (e.g., (high), (8192))
|
|
||||||
// for Gemini CLI format payloads (nested under "request"). It normalizes the budget when the model supports thinking.
|
|
||||||
func ApplyThinkingMetadataCLI(payload []byte, metadata map[string]any, model string) []byte {
|
|
||||||
// Use the alias from metadata if available, as it's registered in the global registry
|
|
||||||
// with thinking metadata; the upstream model name may not be registered.
|
|
||||||
lookupModel := util.ResolveOriginalModel(model, metadata)
|
|
||||||
|
|
||||||
// Determine which model to use for thinking support check.
|
|
||||||
// If the alias (lookupModel) is not in the registry, fall back to the upstream model.
|
|
||||||
thinkingModel := lookupModel
|
|
||||||
if !util.ModelSupportsThinking(lookupModel) && util.ModelSupportsThinking(model) {
|
|
||||||
thinkingModel = model
|
|
||||||
}
|
|
||||||
|
|
||||||
budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(thinkingModel, metadata)
|
|
||||||
if !ok || (budgetOverride == nil && includeOverride == nil) {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
if !util.ModelSupportsThinking(thinkingModel) {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
if budgetOverride != nil {
|
|
||||||
norm := util.NormalizeThinkingBudget(thinkingModel, *budgetOverride)
|
|
||||||
budgetOverride = &norm
|
|
||||||
}
|
|
||||||
return util.ApplyGeminiCLIThinkingConfig(payload, budgetOverride, includeOverride)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyReasoningEffortMetadata applies reasoning effort overrides from metadata to the given JSON path.
|
|
||||||
// Metadata values take precedence over any existing field when the model supports thinking, intentionally
|
|
||||||
// overwriting caller-provided values to honor suffix/default metadata priority.
|
|
||||||
func ApplyReasoningEffortMetadata(payload []byte, metadata map[string]any, model, field string, allowCompat bool) []byte {
|
|
||||||
if len(metadata) == 0 {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
if field == "" {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
baseModel := util.ResolveOriginalModel(model, metadata)
|
|
||||||
if baseModel == "" {
|
|
||||||
baseModel = model
|
|
||||||
}
|
|
||||||
if !util.ModelSupportsThinking(baseModel) && !allowCompat {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
if effort, ok := util.ReasoningEffortFromMetadata(metadata); ok && effort != "" {
|
|
||||||
if util.ModelUsesThinkingLevels(baseModel) || allowCompat {
|
|
||||||
if updated, err := sjson.SetBytes(payload, field, effort); err == nil {
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: numeric thinking_budget suffix for level-based (OpenAI-style) models.
|
|
||||||
if util.ModelUsesThinkingLevels(baseModel) || allowCompat {
|
|
||||||
if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil {
|
|
||||||
if effort, ok := util.ThinkingBudgetToEffort(baseModel, *budget); ok && effort != "" {
|
|
||||||
if updated, err := sjson.SetBytes(payload, field, effort); err == nil {
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyPayloadConfigWithRoot behaves like applyPayloadConfig but treats all parameter
|
// applyPayloadConfigWithRoot behaves like applyPayloadConfig but treats all parameter
|
||||||
// paths as relative to the provided root path (for example, "request" for Gemini CLI)
|
// paths as relative to the provided root path (for example, "request" for Gemini CLI)
|
||||||
// and restricts matches to the given protocol when supplied. Defaults are checked
|
// and restricts matches to the given protocol when supplied. Defaults are checked
|
||||||
@@ -256,102 +160,3 @@ func matchModelPattern(pattern, model string) bool {
|
|||||||
}
|
}
|
||||||
return pi == len(pattern)
|
return pi == len(pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NormalizeThinkingConfig normalizes thinking-related fields in the payload
|
|
||||||
// based on model capabilities. For models without thinking support, it strips
|
|
||||||
// reasoning fields. For models with level-based thinking, it validates and
|
|
||||||
// normalizes the reasoning effort level. For models with numeric budget thinking,
|
|
||||||
// it strips the effort string fields.
|
|
||||||
func NormalizeThinkingConfig(payload []byte, model string, allowCompat bool) []byte {
|
|
||||||
if len(payload) == 0 || model == "" {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
if !util.ModelSupportsThinking(model) {
|
|
||||||
if allowCompat {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
return StripThinkingFields(payload, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if util.ModelUsesThinkingLevels(model) {
|
|
||||||
return NormalizeReasoningEffortLevel(payload, model)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model supports thinking but uses numeric budgets, not levels.
|
|
||||||
// Strip effort string fields since they are not applicable.
|
|
||||||
return StripThinkingFields(payload, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StripThinkingFields removes thinking-related fields from the payload for
|
|
||||||
// models that do not support thinking. If effortOnly is true, only removes
|
|
||||||
// effort string fields (for models using numeric budgets).
|
|
||||||
func StripThinkingFields(payload []byte, effortOnly bool) []byte {
|
|
||||||
fieldsToRemove := []string{
|
|
||||||
"reasoning_effort",
|
|
||||||
"reasoning.effort",
|
|
||||||
}
|
|
||||||
if !effortOnly {
|
|
||||||
fieldsToRemove = append([]string{"reasoning", "thinking"}, fieldsToRemove...)
|
|
||||||
}
|
|
||||||
out := payload
|
|
||||||
for _, field := range fieldsToRemove {
|
|
||||||
if gjson.GetBytes(out, field).Exists() {
|
|
||||||
out, _ = sjson.DeleteBytes(out, field)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// NormalizeReasoningEffortLevel validates and normalizes the reasoning_effort
|
|
||||||
// or reasoning.effort field for level-based thinking models.
|
|
||||||
func NormalizeReasoningEffortLevel(payload []byte, model string) []byte {
|
|
||||||
out := payload
|
|
||||||
|
|
||||||
if effort := gjson.GetBytes(out, "reasoning_effort"); effort.Exists() {
|
|
||||||
if normalized, ok := util.NormalizeReasoningEffortLevel(model, effort.String()); ok {
|
|
||||||
out, _ = sjson.SetBytes(out, "reasoning_effort", normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if effort := gjson.GetBytes(out, "reasoning.effort"); effort.Exists() {
|
|
||||||
if normalized, ok := util.NormalizeReasoningEffortLevel(model, effort.String()); ok {
|
|
||||||
out, _ = sjson.SetBytes(out, "reasoning.effort", normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateThinkingConfig checks for unsupported reasoning levels on level-based models.
|
|
||||||
// Returns a statusErr with 400 when an unsupported level is supplied to avoid silently
|
|
||||||
// downgrading requests.
|
|
||||||
func ValidateThinkingConfig(payload []byte, model string) error {
|
|
||||||
if len(payload) == 0 || model == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !util.ModelSupportsThinking(model) || !util.ModelUsesThinkingLevels(model) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
levels := util.GetModelThinkingLevels(model)
|
|
||||||
checkField := func(path string) error {
|
|
||||||
if effort := gjson.GetBytes(payload, path); effort.Exists() {
|
|
||||||
if _, ok := util.NormalizeReasoningEffortLevel(model, effort.String()); !ok {
|
|
||||||
return statusErr{
|
|
||||||
code: http.StatusBadRequest,
|
|
||||||
msg: fmt.Sprintf("unsupported reasoning effort level %q for model %s (supported: %s)", effort.String(), model, strings.Join(levels, ", ")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := checkField("reasoning_effort"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := checkField("reasoning.effort"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
qwenauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
|
qwenauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
|
||||||
"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/thinking"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
@@ -65,12 +66,14 @@ func (e *QwenExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||||
token, baseURL := qwenCreds(auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
|
token, baseURL := qwenCreds(auth)
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://portal.qwen.ai/v1"
|
baseURL = "https://portal.qwen.ai/v1"
|
||||||
}
|
}
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
@@ -79,15 +82,16 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
|
|||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
||||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
body, _ = sjson.SetBytes(body, "model", req.Model)
|
|
||||||
body = NormalizeThinkingConfig(body, req.Model, false)
|
body, err = thinking.ApplyThinking(body, req.Model, "openai")
|
||||||
if errValidate := ValidateThinkingConfig(body, req.Model); errValidate != nil {
|
if err != nil {
|
||||||
return resp, errValidate
|
return resp, err
|
||||||
}
|
}
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated)
|
|
||||||
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
||||||
|
|
||||||
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
|
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
@@ -140,18 +144,22 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
|
|||||||
appendAPIResponseChunk(ctx, e.cfg, data)
|
appendAPIResponseChunk(ctx, e.cfg, data)
|
||||||
reporter.publish(ctx, parseOpenAIUsage(data))
|
reporter.publish(ctx, parseOpenAIUsage(data))
|
||||||
var param any
|
var param any
|
||||||
|
// Note: TranslateNonStream uses req.Model (original with suffix) to preserve
|
||||||
|
// the original model name in the response for client compatibility.
|
||||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m)
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(out)}
|
resp = cliproxyexecutor.Response{Payload: []byte(out)}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
||||||
token, baseURL := qwenCreds(auth)
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
|
token, baseURL := qwenCreds(auth)
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://portal.qwen.ai/v1"
|
baseURL = "https://portal.qwen.ai/v1"
|
||||||
}
|
}
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
@@ -160,15 +168,15 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, true)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
|
||||||
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
|
|
||||||
body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false)
|
body, err = thinking.ApplyThinking(body, req.Model, "openai")
|
||||||
body, _ = sjson.SetBytes(body, "model", req.Model)
|
if err != nil {
|
||||||
body = NormalizeThinkingConfig(body, req.Model, false)
|
return nil, err
|
||||||
if errValidate := ValidateThinkingConfig(body, req.Model); errValidate != nil {
|
|
||||||
return nil, errValidate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toolsResult := gjson.GetBytes(body, "tools")
|
toolsResult := gjson.GetBytes(body, "tools")
|
||||||
// I'm addressing the Qwen3 "poisoning" issue, which is caused by the model needing a tool to be defined. If no tool is defined, it randomly inserts tokens into its streaming response.
|
// I'm addressing the Qwen3 "poisoning" issue, which is caused by the model needing a tool to be defined. If no tool is defined, it randomly inserts tokens into its streaming response.
|
||||||
// This will have no real consequences. It's just to scare Qwen3.
|
// This will have no real consequences. It's just to scare Qwen3.
|
||||||
@@ -176,7 +184,7 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
body, _ = sjson.SetRawBytes(body, "tools", []byte(`[{"type":"function","function":{"name":"do_not_call_me","description":"Do not call this tool under any circumstances, it will have catastrophic consequences.","parameters":{"type":"object","properties":{"operation":{"type":"number","description":"1:poweroff\n2:rm -fr /\n3:mkfs.ext4 /dev/sda1"}},"required":["operation"]}}}]`))
|
body, _ = sjson.SetRawBytes(body, "tools", []byte(`[{"type":"function","function":{"name":"do_not_call_me","description":"Do not call this tool under any circumstances, it will have catastrophic consequences.","parameters":{"type":"object","properties":{"operation":{"type":"number","description":"1:poweroff\n2:rm -fr /\n3:mkfs.ext4 /dev/sda1"}},"required":["operation"]}}}]`))
|
||||||
}
|
}
|
||||||
body, _ = sjson.SetBytes(body, "stream_options.include_usage", true)
|
body, _ = sjson.SetBytes(body, "stream_options.include_usage", true)
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated)
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
||||||
|
|
||||||
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
|
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
@@ -256,13 +264,15 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *QwenExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
func (e *QwenExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("openai")
|
to := sdktranslator.FromString("openai")
|
||||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
|
|
||||||
modelName := gjson.GetBytes(body, "model").String()
|
modelName := gjson.GetBytes(body, "model").String()
|
||||||
if strings.TrimSpace(modelName) == "" {
|
if strings.TrimSpace(modelName) == "" {
|
||||||
modelName = req.Model
|
modelName = baseModel
|
||||||
}
|
}
|
||||||
|
|
||||||
enc, err := tokenizerForModel(modelName)
|
enc, err := tokenizerForModel(modelName)
|
||||||
|
|||||||
30
internal/runtime/executor/qwen_executor_test.go
Normal file
30
internal/runtime/executor/qwen_executor_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQwenExecutorParseSuffix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
model string
|
||||||
|
wantBase string
|
||||||
|
wantLevel string
|
||||||
|
}{
|
||||||
|
{"no suffix", "qwen-max", "qwen-max", ""},
|
||||||
|
{"with level suffix", "qwen-max(high)", "qwen-max", "high"},
|
||||||
|
{"with budget suffix", "qwen-max(16384)", "qwen-max", "16384"},
|
||||||
|
{"complex model name", "qwen-plus-latest(medium)", "qwen-plus-latest", "medium"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := thinking.ParseSuffix(tt.model)
|
||||||
|
if result.ModelName != tt.wantBase {
|
||||||
|
t.Errorf("ParseSuffix(%q).ModelName = %q, want %q", tt.model, result.ModelName, tt.wantBase)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
11
internal/runtime/executor/thinking_providers.go
Normal file
11
internal/runtime/executor/thinking_providers.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/antigravity"
|
||||||
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/claude"
|
||||||
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/codex"
|
||||||
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini"
|
||||||
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli"
|
||||||
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/iflow"
|
||||||
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai"
|
||||||
|
)
|
||||||
451
internal/thinking/apply.go
Normal file
451
internal/thinking/apply.go
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
// Package thinking provides unified thinking configuration processing.
|
||||||
|
package thinking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// providerAppliers maps provider names to their ProviderApplier implementations.
|
||||||
|
var providerAppliers = map[string]ProviderApplier{
|
||||||
|
"gemini": nil,
|
||||||
|
"gemini-cli": nil,
|
||||||
|
"claude": nil,
|
||||||
|
"openai": nil,
|
||||||
|
"codex": nil,
|
||||||
|
"iflow": nil,
|
||||||
|
"antigravity": nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProviderApplier returns the ProviderApplier for the given provider name.
|
||||||
|
// Returns nil if the provider is not registered.
|
||||||
|
func GetProviderApplier(provider string) ProviderApplier {
|
||||||
|
return providerAppliers[provider]
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterProvider registers a provider applier by name.
|
||||||
|
func RegisterProvider(name string, applier ProviderApplier) {
|
||||||
|
providerAppliers[name] = applier
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUserDefinedModel reports whether the model is a user-defined model that should
|
||||||
|
// have thinking configuration passed through without validation.
|
||||||
|
//
|
||||||
|
// User-defined models are configured via config file's models[] array
|
||||||
|
// (e.g., openai-compatibility.*.models[], *-api-key.models[]). These models
|
||||||
|
// are marked with UserDefined=true at registration time.
|
||||||
|
//
|
||||||
|
// User-defined models should have their thinking configuration applied directly,
|
||||||
|
// letting the upstream service validate the configuration.
|
||||||
|
func IsUserDefinedModel(modelInfo *registry.ModelInfo) bool {
|
||||||
|
if modelInfo == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return modelInfo.UserDefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyThinking applies thinking configuration to a request body.
|
||||||
|
//
|
||||||
|
// This is the unified entry point for all providers. It follows the processing
|
||||||
|
// order defined in FR25: route check → model capability query → config extraction
|
||||||
|
// → validation → application.
|
||||||
|
//
|
||||||
|
// Suffix Priority: When the model name includes a thinking suffix (e.g., "gemini-2.5-pro(8192)"),
|
||||||
|
// the suffix configuration takes priority over any thinking parameters in the request body.
|
||||||
|
// This enables users to override thinking settings via the model name without modifying their
|
||||||
|
// request payload.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - body: Original request body JSON
|
||||||
|
// - model: Model name, optionally with thinking suffix (e.g., "claude-sonnet-4-5(16384)")
|
||||||
|
// - provider: Provider name (gemini, gemini-cli, antigravity, claude, openai, codex, iflow)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - Modified request body JSON with thinking configuration applied
|
||||||
|
// - Error if validation fails (ThinkingError). On error, the original body
|
||||||
|
// is returned (not nil) to enable defensive programming patterns.
|
||||||
|
//
|
||||||
|
// Passthrough behavior (returns original body without error):
|
||||||
|
// - Unknown provider (not in providerAppliers map)
|
||||||
|
// - modelInfo.Thinking is nil (model doesn't support thinking)
|
||||||
|
//
|
||||||
|
// Note: Unknown models (modelInfo is nil) are treated as user-defined models: we skip
|
||||||
|
// validation and still apply the thinking config so the upstream can validate it.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // With suffix - suffix config takes priority
|
||||||
|
// result, err := thinking.ApplyThinking(body, "gemini-2.5-pro(8192)", "gemini")
|
||||||
|
//
|
||||||
|
// // Without suffix - uses body config
|
||||||
|
// result, err := thinking.ApplyThinking(body, "gemini-2.5-pro", "gemini")
|
||||||
|
func ApplyThinking(body []byte, model string, provider string) ([]byte, error) {
|
||||||
|
// 1. Route check: Get provider applier
|
||||||
|
applier := GetProviderApplier(provider)
|
||||||
|
if applier == nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"provider": provider,
|
||||||
|
"model": model,
|
||||||
|
}).Debug("thinking: unknown provider, passthrough |")
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse suffix and get modelInfo
|
||||||
|
suffixResult := ParseSuffix(model)
|
||||||
|
baseModel := suffixResult.ModelName
|
||||||
|
modelInfo := registry.LookupModelInfo(baseModel)
|
||||||
|
|
||||||
|
// 3. Model capability check
|
||||||
|
// Unknown models are treated as user-defined so thinking config can still be applied.
|
||||||
|
// The upstream service is responsible for validating the configuration.
|
||||||
|
if IsUserDefinedModel(modelInfo) {
|
||||||
|
return applyUserDefinedModel(body, modelInfo, provider, suffixResult)
|
||||||
|
}
|
||||||
|
if modelInfo.Thinking == nil {
|
||||||
|
config := extractThinkingConfig(body, provider)
|
||||||
|
if hasThinkingConfig(config) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"model": baseModel,
|
||||||
|
"provider": provider,
|
||||||
|
}).Debug("thinking: model does not support thinking, stripping config |")
|
||||||
|
return StripThinkingConfig(body, provider), nil
|
||||||
|
}
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"provider": provider,
|
||||||
|
"model": baseModel,
|
||||||
|
}).Debug("thinking: model does not support thinking, passthrough |")
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Get config: suffix priority over body
|
||||||
|
var config ThinkingConfig
|
||||||
|
if suffixResult.HasSuffix {
|
||||||
|
config = parseSuffixToConfig(suffixResult.RawSuffix, provider, model)
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"provider": provider,
|
||||||
|
"model": model,
|
||||||
|
"mode": config.Mode,
|
||||||
|
"budget": config.Budget,
|
||||||
|
"level": config.Level,
|
||||||
|
}).Debug("thinking: config from model suffix |")
|
||||||
|
} else {
|
||||||
|
config = extractThinkingConfig(body, provider)
|
||||||
|
if hasThinkingConfig(config) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"provider": provider,
|
||||||
|
"model": modelInfo.ID,
|
||||||
|
"mode": config.Mode,
|
||||||
|
"budget": config.Budget,
|
||||||
|
"level": config.Level,
|
||||||
|
}).Debug("thinking: original config from request |")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasThinkingConfig(config) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"provider": provider,
|
||||||
|
"model": modelInfo.ID,
|
||||||
|
}).Debug("thinking: no config found, passthrough |")
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Validate and normalize configuration
|
||||||
|
validated, err := ValidateConfig(config, modelInfo, provider)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"provider": provider,
|
||||||
|
"model": modelInfo.ID,
|
||||||
|
"error": err.Error(),
|
||||||
|
}).Warn("thinking: validation failed |")
|
||||||
|
// Return original body on validation failure (defensive programming).
|
||||||
|
// This ensures callers who ignore the error won't receive nil body.
|
||||||
|
// The upstream service will decide how to handle the unmodified request.
|
||||||
|
return body, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defensive check: ValidateConfig should never return (nil, nil)
|
||||||
|
if validated == nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"provider": provider,
|
||||||
|
"model": modelInfo.ID,
|
||||||
|
}).Warn("thinking: ValidateConfig returned nil config without error, passthrough |")
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"provider": provider,
|
||||||
|
"model": modelInfo.ID,
|
||||||
|
"mode": validated.Mode,
|
||||||
|
"budget": validated.Budget,
|
||||||
|
"level": validated.Level,
|
||||||
|
}).Debug("thinking: processed config to apply |")
|
||||||
|
|
||||||
|
// 6. Apply configuration using provider-specific applier
|
||||||
|
return applier.Apply(body, *validated, modelInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSuffixToConfig converts a raw suffix string to ThinkingConfig.
|
||||||
|
//
|
||||||
|
// Parsing priority:
|
||||||
|
// 1. Special values: "none" → ModeNone, "auto"/"-1" → ModeAuto
|
||||||
|
// 2. Level names: "minimal", "low", "medium", "high", "xhigh" → ModeLevel
|
||||||
|
// 3. Numeric values: positive integers → ModeBudget, 0 → ModeNone
|
||||||
|
//
|
||||||
|
// If none of the above match, returns empty ThinkingConfig (treated as no config).
|
||||||
|
func parseSuffixToConfig(rawSuffix, provider, model string) ThinkingConfig {
|
||||||
|
// 1. Try special values first (none, auto, -1)
|
||||||
|
if mode, ok := ParseSpecialSuffix(rawSuffix); ok {
|
||||||
|
switch mode {
|
||||||
|
case ModeNone:
|
||||||
|
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||||
|
case ModeAuto:
|
||||||
|
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try level parsing (minimal, low, medium, high, xhigh)
|
||||||
|
if level, ok := ParseLevelSuffix(rawSuffix); ok {
|
||||||
|
return ThinkingConfig{Mode: ModeLevel, Level: level}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try numeric parsing
|
||||||
|
if budget, ok := ParseNumericSuffix(rawSuffix); ok {
|
||||||
|
if budget == 0 {
|
||||||
|
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||||
|
}
|
||||||
|
return ThinkingConfig{Mode: ModeBudget, Budget: budget}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown suffix format - return empty config
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"provider": provider,
|
||||||
|
"model": model,
|
||||||
|
"raw_suffix": rawSuffix,
|
||||||
|
}).Debug("thinking: unknown suffix format, treating as no config |")
|
||||||
|
return ThinkingConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUserDefinedModel applies thinking configuration for user-defined models
|
||||||
|
// without ThinkingSupport validation.
|
||||||
|
func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, provider string, suffixResult SuffixResult) ([]byte, error) {
|
||||||
|
// Get model ID for logging
|
||||||
|
modelID := ""
|
||||||
|
if modelInfo != nil {
|
||||||
|
modelID = modelInfo.ID
|
||||||
|
} else {
|
||||||
|
modelID = suffixResult.ModelName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get config: suffix priority over body
|
||||||
|
var config ThinkingConfig
|
||||||
|
if suffixResult.HasSuffix {
|
||||||
|
config = parseSuffixToConfig(suffixResult.RawSuffix, provider, modelID)
|
||||||
|
} else {
|
||||||
|
config = extractThinkingConfig(body, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasThinkingConfig(config) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"model": modelID,
|
||||||
|
"provider": provider,
|
||||||
|
}).Debug("thinking: user-defined model, passthrough (no config) |")
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
applier := GetProviderApplier(provider)
|
||||||
|
if applier == nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"model": modelID,
|
||||||
|
"provider": provider,
|
||||||
|
}).Debug("thinking: user-defined model, passthrough (unknown provider) |")
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"provider": provider,
|
||||||
|
"model": modelID,
|
||||||
|
"mode": config.Mode,
|
||||||
|
"budget": config.Budget,
|
||||||
|
"level": config.Level,
|
||||||
|
}).Debug("thinking: applying config for user-defined model (skip validation)")
|
||||||
|
|
||||||
|
return applier.Apply(body, config, modelInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractThinkingConfig extracts provider-specific thinking config from request body.
|
||||||
|
func extractThinkingConfig(body []byte, provider string) ThinkingConfig {
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
return ThinkingConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch provider {
|
||||||
|
case "claude":
|
||||||
|
return extractClaudeConfig(body)
|
||||||
|
case "gemini", "gemini-cli", "antigravity":
|
||||||
|
return extractGeminiConfig(body, provider)
|
||||||
|
case "openai":
|
||||||
|
return extractOpenAIConfig(body)
|
||||||
|
case "codex":
|
||||||
|
return extractCodexConfig(body)
|
||||||
|
case "iflow":
|
||||||
|
return extractIFlowConfig(body)
|
||||||
|
default:
|
||||||
|
return ThinkingConfig{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasThinkingConfig(config ThinkingConfig) bool {
|
||||||
|
return config.Mode != ModeBudget || config.Budget != 0 || config.Level != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractClaudeConfig extracts thinking configuration from Claude format request body.
|
||||||
|
//
|
||||||
|
// Claude API format:
|
||||||
|
// - thinking.type: "enabled" or "disabled"
|
||||||
|
// - thinking.budget_tokens: integer (-1=auto, 0=disabled, >0=budget)
|
||||||
|
//
|
||||||
|
// Priority: thinking.type="disabled" takes precedence over budget_tokens.
|
||||||
|
// When type="enabled" without budget_tokens, returns ModeAuto to indicate
|
||||||
|
// the user wants thinking enabled but didn't specify a budget.
|
||||||
|
func extractClaudeConfig(body []byte) ThinkingConfig {
|
||||||
|
thinkingType := gjson.GetBytes(body, "thinking.type").String()
|
||||||
|
if thinkingType == "disabled" {
|
||||||
|
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check budget_tokens
|
||||||
|
if budget := gjson.GetBytes(body, "thinking.budget_tokens"); budget.Exists() {
|
||||||
|
value := int(budget.Int())
|
||||||
|
switch value {
|
||||||
|
case 0:
|
||||||
|
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||||
|
case -1:
|
||||||
|
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
|
||||||
|
default:
|
||||||
|
return ThinkingConfig{Mode: ModeBudget, Budget: value}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If type="enabled" but no budget_tokens, treat as auto (user wants thinking but no budget specified)
|
||||||
|
if thinkingType == "enabled" {
|
||||||
|
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ThinkingConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractGeminiConfig extracts thinking configuration from Gemini format request body.
|
||||||
|
//
|
||||||
|
// Gemini API format:
|
||||||
|
// - generationConfig.thinkingConfig.thinkingLevel: "none", "auto", or level name (Gemini 3)
|
||||||
|
// - generationConfig.thinkingConfig.thinkingBudget: integer (Gemini 2.5)
|
||||||
|
//
|
||||||
|
// For gemini-cli and antigravity providers, the path is prefixed with "request.".
|
||||||
|
//
|
||||||
|
// Priority: thinkingLevel is checked first (Gemini 3 format), then thinkingBudget (Gemini 2.5 format).
|
||||||
|
// This allows newer Gemini 3 level-based configs to take precedence.
|
||||||
|
func extractGeminiConfig(body []byte, provider string) ThinkingConfig {
|
||||||
|
prefix := "generationConfig.thinkingConfig"
|
||||||
|
if provider == "gemini-cli" || provider == "antigravity" {
|
||||||
|
prefix = "request.generationConfig.thinkingConfig"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check thinkingLevel first (Gemini 3 format takes precedence)
|
||||||
|
if level := gjson.GetBytes(body, prefix+".thinkingLevel"); level.Exists() {
|
||||||
|
value := level.String()
|
||||||
|
switch value {
|
||||||
|
case "none":
|
||||||
|
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||||
|
case "auto":
|
||||||
|
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
|
||||||
|
default:
|
||||||
|
return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check thinkingBudget (Gemini 2.5 format)
|
||||||
|
if budget := gjson.GetBytes(body, prefix+".thinkingBudget"); budget.Exists() {
|
||||||
|
value := int(budget.Int())
|
||||||
|
switch value {
|
||||||
|
case 0:
|
||||||
|
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||||
|
case -1:
|
||||||
|
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
|
||||||
|
default:
|
||||||
|
return ThinkingConfig{Mode: ModeBudget, Budget: value}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ThinkingConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractOpenAIConfig extracts thinking configuration from OpenAI format request body.
|
||||||
|
//
|
||||||
|
// OpenAI API format:
|
||||||
|
// - reasoning_effort: "none", "low", "medium", "high" (discrete levels)
|
||||||
|
//
|
||||||
|
// OpenAI uses level-based thinking configuration only, no numeric budget support.
|
||||||
|
// The "none" value is treated specially to return ModeNone.
|
||||||
|
func extractOpenAIConfig(body []byte) ThinkingConfig {
|
||||||
|
// Check reasoning_effort (OpenAI Chat Completions format)
|
||||||
|
if effort := gjson.GetBytes(body, "reasoning_effort"); effort.Exists() {
|
||||||
|
value := effort.String()
|
||||||
|
if value == "none" {
|
||||||
|
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||||
|
}
|
||||||
|
return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ThinkingConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractCodexConfig extracts thinking configuration from Codex format request body.
|
||||||
|
//
|
||||||
|
// Codex API format (OpenAI Responses API):
|
||||||
|
// - reasoning.effort: "none", "low", "medium", "high"
|
||||||
|
//
|
||||||
|
// This is similar to OpenAI but uses nested field "reasoning.effort" instead of "reasoning_effort".
|
||||||
|
func extractCodexConfig(body []byte) ThinkingConfig {
|
||||||
|
// Check reasoning.effort (Codex / OpenAI Responses API format)
|
||||||
|
if effort := gjson.GetBytes(body, "reasoning.effort"); effort.Exists() {
|
||||||
|
value := effort.String()
|
||||||
|
if value == "none" {
|
||||||
|
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||||
|
}
|
||||||
|
return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ThinkingConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractIFlowConfig extracts thinking configuration from iFlow format request body.
|
||||||
|
//
|
||||||
|
// iFlow API format (supports multiple model families):
|
||||||
|
// - GLM format: chat_template_kwargs.enable_thinking (boolean)
|
||||||
|
// - MiniMax format: reasoning_split (boolean)
|
||||||
|
//
|
||||||
|
// Returns ModeBudget with Budget=1 as a sentinel value indicating "enabled".
|
||||||
|
// The actual budget/configuration is determined by the iFlow applier based on model capabilities.
|
||||||
|
// Budget=1 is used because iFlow models don't use numeric budgets; they only support on/off.
|
||||||
|
func extractIFlowConfig(body []byte) ThinkingConfig {
|
||||||
|
// GLM format: chat_template_kwargs.enable_thinking
|
||||||
|
if enabled := gjson.GetBytes(body, "chat_template_kwargs.enable_thinking"); enabled.Exists() {
|
||||||
|
if enabled.Bool() {
|
||||||
|
// Budget=1 is a sentinel meaning "enabled" (iFlow doesn't use numeric budgets)
|
||||||
|
return ThinkingConfig{Mode: ModeBudget, Budget: 1}
|
||||||
|
}
|
||||||
|
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiniMax format: reasoning_split
|
||||||
|
if split := gjson.GetBytes(body, "reasoning_split"); split.Exists() {
|
||||||
|
if split.Bool() {
|
||||||
|
// Budget=1 is a sentinel meaning "enabled" (iFlow doesn't use numeric budgets)
|
||||||
|
return ThinkingConfig{Mode: ModeBudget, Budget: 1}
|
||||||
|
}
|
||||||
|
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ThinkingConfig{}
|
||||||
|
}
|
||||||
142
internal/thinking/convert.go
Normal file
142
internal/thinking/convert.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package thinking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// levelToBudgetMap defines the standard Level → Budget mapping.
|
||||||
|
// All keys are lowercase; lookups should use strings.ToLower.
|
||||||
|
var levelToBudgetMap = map[string]int{
|
||||||
|
"none": 0,
|
||||||
|
"auto": -1,
|
||||||
|
"minimal": 512,
|
||||||
|
"low": 1024,
|
||||||
|
"medium": 8192,
|
||||||
|
"high": 24576,
|
||||||
|
"xhigh": 32768,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertLevelToBudget converts a thinking level to a budget value.
|
||||||
|
//
|
||||||
|
// This is a semantic conversion that maps discrete levels to numeric budgets.
|
||||||
|
// Level matching is case-insensitive.
|
||||||
|
//
|
||||||
|
// Level → Budget mapping:
|
||||||
|
// - none → 0
|
||||||
|
// - auto → -1
|
||||||
|
// - minimal → 512
|
||||||
|
// - low → 1024
|
||||||
|
// - medium → 8192
|
||||||
|
// - high → 24576
|
||||||
|
// - xhigh → 32768
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - budget: The converted budget value
|
||||||
|
// - ok: true if level is valid, false otherwise
|
||||||
|
func ConvertLevelToBudget(level string) (int, bool) {
|
||||||
|
budget, ok := levelToBudgetMap[strings.ToLower(level)]
|
||||||
|
return budget, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// BudgetThreshold constants define the upper bounds for each thinking level.
|
||||||
|
// These are used by ConvertBudgetToLevel for range-based mapping.
|
||||||
|
const (
|
||||||
|
// ThresholdMinimal is the upper bound for "minimal" level (1-512)
|
||||||
|
ThresholdMinimal = 512
|
||||||
|
// ThresholdLow is the upper bound for "low" level (513-1024)
|
||||||
|
ThresholdLow = 1024
|
||||||
|
// ThresholdMedium is the upper bound for "medium" level (1025-8192)
|
||||||
|
ThresholdMedium = 8192
|
||||||
|
// ThresholdHigh is the upper bound for "high" level (8193-24576)
|
||||||
|
ThresholdHigh = 24576
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConvertBudgetToLevel converts a budget value to the nearest thinking level.
|
||||||
|
//
|
||||||
|
// This is a semantic conversion that maps numeric budgets to discrete levels.
|
||||||
|
// Uses threshold-based mapping for range conversion.
|
||||||
|
//
|
||||||
|
// Budget → Level thresholds:
|
||||||
|
// - -1 → auto
|
||||||
|
// - 0 → none
|
||||||
|
// - 1-512 → minimal
|
||||||
|
// - 513-1024 → low
|
||||||
|
// - 1025-8192 → medium
|
||||||
|
// - 8193-24576 → high
|
||||||
|
// - 24577+ → xhigh
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - level: The converted thinking level string
|
||||||
|
// - ok: true if budget is valid, false for invalid negatives (< -1)
|
||||||
|
func ConvertBudgetToLevel(budget int) (string, bool) {
|
||||||
|
switch {
|
||||||
|
case budget < -1:
|
||||||
|
// Invalid negative values
|
||||||
|
return "", false
|
||||||
|
case budget == -1:
|
||||||
|
return string(LevelAuto), true
|
||||||
|
case budget == 0:
|
||||||
|
return string(LevelNone), true
|
||||||
|
case budget <= ThresholdMinimal:
|
||||||
|
return string(LevelMinimal), true
|
||||||
|
case budget <= ThresholdLow:
|
||||||
|
return string(LevelLow), true
|
||||||
|
case budget <= ThresholdMedium:
|
||||||
|
return string(LevelMedium), true
|
||||||
|
case budget <= ThresholdHigh:
|
||||||
|
return string(LevelHigh), true
|
||||||
|
default:
|
||||||
|
return string(LevelXHigh), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelCapability describes the thinking format support of a model.
|
||||||
|
type ModelCapability int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// CapabilityUnknown indicates modelInfo is nil (passthrough behavior, internal use).
|
||||||
|
CapabilityUnknown ModelCapability = iota - 1
|
||||||
|
// CapabilityNone indicates model doesn't support thinking (Thinking is nil).
|
||||||
|
CapabilityNone
|
||||||
|
// CapabilityBudgetOnly indicates the model supports numeric budgets only.
|
||||||
|
CapabilityBudgetOnly
|
||||||
|
// CapabilityLevelOnly indicates the model supports discrete levels only.
|
||||||
|
CapabilityLevelOnly
|
||||||
|
// CapabilityHybrid indicates the model supports both budgets and levels.
|
||||||
|
CapabilityHybrid
|
||||||
|
)
|
||||||
|
|
||||||
|
// detectModelCapability determines the thinking format capability of a model.
|
||||||
|
//
|
||||||
|
// This is an internal function used by validation and conversion helpers.
|
||||||
|
// It analyzes the model's ThinkingSupport configuration to classify the model:
|
||||||
|
// - CapabilityNone: modelInfo.Thinking is nil (model doesn't support thinking)
|
||||||
|
// - CapabilityBudgetOnly: Has Min/Max but no Levels (Claude, Gemini 2.5)
|
||||||
|
// - CapabilityLevelOnly: Has Levels but no Min/Max (OpenAI, iFlow)
|
||||||
|
// - CapabilityHybrid: Has both Min/Max and Levels (Gemini 3)
|
||||||
|
//
|
||||||
|
// Note: Returns a special sentinel value when modelInfo itself is nil (unknown model).
|
||||||
|
func detectModelCapability(modelInfo *registry.ModelInfo) ModelCapability {
|
||||||
|
if modelInfo == nil {
|
||||||
|
return CapabilityUnknown // sentinel for "passthrough" behavior
|
||||||
|
}
|
||||||
|
if modelInfo.Thinking == nil {
|
||||||
|
return CapabilityNone
|
||||||
|
}
|
||||||
|
support := modelInfo.Thinking
|
||||||
|
hasBudget := support.Min > 0 || support.Max > 0
|
||||||
|
hasLevels := len(support.Levels) > 0
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case hasBudget && hasLevels:
|
||||||
|
return CapabilityHybrid
|
||||||
|
case hasBudget:
|
||||||
|
return CapabilityBudgetOnly
|
||||||
|
case hasLevels:
|
||||||
|
return CapabilityLevelOnly
|
||||||
|
default:
|
||||||
|
return CapabilityNone
|
||||||
|
}
|
||||||
|
}
|
||||||
78
internal/thinking/errors.go
Normal file
78
internal/thinking/errors.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// Package thinking provides unified thinking configuration processing logic.
|
||||||
|
package thinking
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// ErrorCode represents the type of thinking configuration error.
|
||||||
|
type ErrorCode string
|
||||||
|
|
||||||
|
// Error codes for thinking configuration processing.
|
||||||
|
const (
|
||||||
|
// ErrInvalidSuffix indicates the suffix format cannot be parsed.
|
||||||
|
// Example: "model(abc" (missing closing parenthesis)
|
||||||
|
ErrInvalidSuffix ErrorCode = "INVALID_SUFFIX"
|
||||||
|
|
||||||
|
// ErrUnknownLevel indicates the level value is not in the valid list.
|
||||||
|
// Example: "model(ultra)" where "ultra" is not a valid level
|
||||||
|
ErrUnknownLevel ErrorCode = "UNKNOWN_LEVEL"
|
||||||
|
|
||||||
|
// ErrThinkingNotSupported indicates the model does not support thinking.
|
||||||
|
// Example: claude-haiku-4-5 does not have thinking capability
|
||||||
|
ErrThinkingNotSupported ErrorCode = "THINKING_NOT_SUPPORTED"
|
||||||
|
|
||||||
|
// ErrLevelNotSupported indicates the model does not support level mode.
|
||||||
|
// Example: using level with a budget-only model
|
||||||
|
ErrLevelNotSupported ErrorCode = "LEVEL_NOT_SUPPORTED"
|
||||||
|
|
||||||
|
// ErrProviderMismatch indicates the provider does not match the model.
|
||||||
|
// Example: applying Claude format to a Gemini model
|
||||||
|
ErrProviderMismatch ErrorCode = "PROVIDER_MISMATCH"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ThinkingError represents an error that occurred during thinking configuration processing.
|
||||||
|
//
|
||||||
|
// This error type provides structured information about the error, including:
|
||||||
|
// - Code: A machine-readable error code for programmatic handling
|
||||||
|
// - Message: A human-readable description of the error
|
||||||
|
// - Model: The model name related to the error (optional)
|
||||||
|
// - Details: Additional context information (optional)
|
||||||
|
type ThinkingError struct {
|
||||||
|
// Code is the machine-readable error code
|
||||||
|
Code ErrorCode
|
||||||
|
// Message is the human-readable error description.
|
||||||
|
// Should be lowercase, no trailing period, with context if applicable.
|
||||||
|
Message string
|
||||||
|
// Model is the model name related to this error (optional)
|
||||||
|
Model string
|
||||||
|
// Details contains additional context information (optional)
|
||||||
|
Details map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements the error interface.
|
||||||
|
// Returns the message directly without code prefix.
|
||||||
|
// Use Code field for programmatic error handling.
|
||||||
|
func (e *ThinkingError) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewThinkingError creates a new ThinkingError with the given code and message.
|
||||||
|
func NewThinkingError(code ErrorCode, message string) *ThinkingError {
|
||||||
|
return &ThinkingError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewThinkingErrorWithModel creates a new ThinkingError with model context.
|
||||||
|
func NewThinkingErrorWithModel(code ErrorCode, message, model string) *ThinkingError {
|
||||||
|
return &ThinkingError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Model: model,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusCode implements a portable status code interface for HTTP handlers.
|
||||||
|
func (e *ThinkingError) StatusCode() int {
|
||||||
|
return http.StatusBadRequest
|
||||||
|
}
|
||||||
201
internal/thinking/provider/antigravity/apply.go
Normal file
201
internal/thinking/provider/antigravity/apply.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// Package antigravity implements thinking configuration for Antigravity API format.
|
||||||
|
//
|
||||||
|
// Antigravity uses request.generationConfig.thinkingConfig.* path (same as gemini-cli)
|
||||||
|
// but requires additional normalization for Claude models:
|
||||||
|
// - Ensure thinking budget < max_tokens
|
||||||
|
// - Remove thinkingConfig if budget < minimum allowed
|
||||||
|
package antigravity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Applier applies thinking configuration for Antigravity API format.
|
||||||
|
type Applier struct{}
|
||||||
|
|
||||||
|
var _ thinking.ProviderApplier = (*Applier)(nil)
|
||||||
|
|
||||||
|
// NewApplier creates a new Antigravity thinking applier.
|
||||||
|
func NewApplier() *Applier {
|
||||||
|
return &Applier{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
thinking.RegisterProvider("antigravity", NewApplier())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply applies thinking configuration to Antigravity request body.
|
||||||
|
//
|
||||||
|
// For Claude models, additional constraints are applied:
|
||||||
|
// - Ensure thinking budget < max_tokens
|
||||||
|
// - Remove thinkingConfig if budget < minimum allowed
|
||||||
|
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||||
|
if thinking.IsUserDefinedModel(modelInfo) {
|
||||||
|
return a.applyCompatible(body, config, modelInfo)
|
||||||
|
}
|
||||||
|
if modelInfo.Thinking == nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
isClaude := strings.Contains(strings.ToLower(modelInfo.ID), "claude")
|
||||||
|
|
||||||
|
// ModeAuto: Always use Budget format with thinkingBudget=-1
|
||||||
|
if config.Mode == thinking.ModeAuto {
|
||||||
|
return a.applyBudgetFormat(body, config, modelInfo, isClaude)
|
||||||
|
}
|
||||||
|
if config.Mode == thinking.ModeBudget {
|
||||||
|
return a.applyBudgetFormat(body, config, modelInfo, isClaude)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-auto modes, choose format based on model capabilities
|
||||||
|
support := modelInfo.Thinking
|
||||||
|
if len(support.Levels) > 0 {
|
||||||
|
return a.applyLevelFormat(body, config)
|
||||||
|
}
|
||||||
|
return a.applyBudgetFormat(body, config, modelInfo, isClaude)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||||
|
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
isClaude := false
|
||||||
|
if modelInfo != nil {
|
||||||
|
isClaude = strings.Contains(strings.ToLower(modelInfo.ID), "claude")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Mode == thinking.ModeAuto {
|
||||||
|
return a.applyBudgetFormat(body, config, modelInfo, isClaude)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Mode == thinking.ModeLevel || (config.Mode == thinking.ModeNone && config.Level != "") {
|
||||||
|
return a.applyLevelFormat(body, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.applyBudgetFormat(body, config, modelInfo, isClaude)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||||
|
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
|
||||||
|
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget")
|
||||||
|
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
||||||
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
|
||||||
|
|
||||||
|
if config.Mode == thinking.ModeNone {
|
||||||
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false)
|
||||||
|
if config.Level != "" {
|
||||||
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", string(config.Level))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only handle ModeLevel - budget conversion should be done by upper layer
|
||||||
|
if config.Mode != thinking.ModeLevel {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
level := string(config.Level)
|
||||||
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", level)
|
||||||
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", true)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingLevel")
|
||||||
|
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
||||||
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
|
||||||
|
|
||||||
|
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
|
||||||
|
if isClaude && modelInfo != nil {
|
||||||
|
budget, result = a.normalizeClaudeBudget(budget, result, modelInfo)
|
||||||
|
// Check if budget was removed entirely
|
||||||
|
if budget == -2 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||||
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeClaudeBudget applies Claude-specific constraints to thinking budget.
|
||||||
|
//
|
||||||
|
// It handles:
|
||||||
|
// - Ensuring thinking budget < max_tokens
|
||||||
|
// - Removing thinkingConfig if budget < minimum allowed
|
||||||
|
//
|
||||||
|
// Returns the normalized budget and updated payload.
|
||||||
|
// Returns budget=-2 as a sentinel indicating thinkingConfig was removed entirely.
|
||||||
|
func (a *Applier) normalizeClaudeBudget(budget int, payload []byte, modelInfo *registry.ModelInfo) (int, []byte) {
|
||||||
|
if modelInfo == nil {
|
||||||
|
return budget, payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get effective max tokens
|
||||||
|
effectiveMax, setDefaultMax := a.effectiveMaxTokens(payload, modelInfo)
|
||||||
|
if effectiveMax > 0 && budget >= effectiveMax {
|
||||||
|
budget = effectiveMax - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check minimum budget
|
||||||
|
minBudget := 0
|
||||||
|
if modelInfo.Thinking != nil {
|
||||||
|
minBudget = modelInfo.Thinking.Min
|
||||||
|
}
|
||||||
|
if minBudget > 0 && budget >= 0 && budget < minBudget {
|
||||||
|
// Budget is below minimum, remove thinking config entirely
|
||||||
|
payload, _ = sjson.DeleteBytes(payload, "request.generationConfig.thinkingConfig")
|
||||||
|
return -2, payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default max tokens if needed
|
||||||
|
if setDefaultMax && effectiveMax > 0 {
|
||||||
|
payload, _ = sjson.SetBytes(payload, "request.generationConfig.maxOutputTokens", effectiveMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
return budget, payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveMaxTokens returns the max tokens to cap thinking:
|
||||||
|
// prefer request-provided maxOutputTokens; otherwise fall back to model default.
|
||||||
|
// The boolean indicates whether the value came from the model default (and thus should be written back).
|
||||||
|
func (a *Applier) effectiveMaxTokens(payload []byte, modelInfo *registry.ModelInfo) (max int, fromModel bool) {
|
||||||
|
if maxTok := gjson.GetBytes(payload, "request.generationConfig.maxOutputTokens"); maxTok.Exists() && maxTok.Int() > 0 {
|
||||||
|
return int(maxTok.Int()), false
|
||||||
|
}
|
||||||
|
if modelInfo != nil && modelInfo.MaxCompletionTokens > 0 {
|
||||||
|
return modelInfo.MaxCompletionTokens, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
109
internal/thinking/provider/claude/apply.go
Normal file
109
internal/thinking/provider/claude/apply.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// Package claude implements thinking configuration scaffolding for Claude models.
|
||||||
|
//
|
||||||
|
// Claude models use the thinking.budget_tokens format with values in the range
|
||||||
|
// 1024-128000. Some Claude models support ZeroAllowed (sonnet-4-5, opus-4-5),
|
||||||
|
// while older models do not.
|
||||||
|
// See: _bmad-output/planning-artifacts/architecture.md#Epic-6
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Applier implements thinking.ProviderApplier for Claude models.
|
||||||
|
// This applier is stateless and holds no configuration.
|
||||||
|
type Applier struct{}
|
||||||
|
|
||||||
|
// NewApplier creates a new Claude thinking applier.
|
||||||
|
func NewApplier() *Applier {
|
||||||
|
return &Applier{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
thinking.RegisterProvider("claude", NewApplier())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply applies thinking configuration to Claude request body.
|
||||||
|
//
|
||||||
|
// IMPORTANT: This method expects config to be pre-validated by thinking.ValidateConfig.
|
||||||
|
// ValidateConfig handles:
|
||||||
|
// - Mode conversion (Level→Budget, Auto→Budget)
|
||||||
|
// - Budget clamping to model range
|
||||||
|
// - ZeroAllowed constraint enforcement
|
||||||
|
//
|
||||||
|
// Apply only processes ModeBudget and ModeNone; other modes are passed through unchanged.
|
||||||
|
//
|
||||||
|
// Expected output format when enabled:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "thinking": {
|
||||||
|
// "type": "enabled",
|
||||||
|
// "budget_tokens": 16384
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Expected output format when disabled:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "thinking": {
|
||||||
|
// "type": "disabled"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||||
|
if thinking.IsUserDefinedModel(modelInfo) {
|
||||||
|
return applyCompatibleClaude(body, config)
|
||||||
|
}
|
||||||
|
if modelInfo.Thinking == nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process ModeBudget and ModeNone; other modes pass through
|
||||||
|
// (caller should use ValidateConfig first to normalize modes)
|
||||||
|
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeNone {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget is expected to be pre-validated by ValidateConfig (clamped, ZeroAllowed enforced)
|
||||||
|
// Decide enabled/disabled based on budget value
|
||||||
|
if config.Budget == 0 {
|
||||||
|
result, _ := sjson.SetBytes(body, "thinking.type", "disabled")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
|
||||||
|
result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||||
|
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch config.Mode {
|
||||||
|
case thinking.ModeNone:
|
||||||
|
result, _ := sjson.SetBytes(body, "thinking.type", "disabled")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||||
|
return result, nil
|
||||||
|
case thinking.ModeAuto:
|
||||||
|
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||||
|
return result, nil
|
||||||
|
default:
|
||||||
|
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
|
||||||
|
result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
131
internal/thinking/provider/codex/apply.go
Normal file
131
internal/thinking/provider/codex/apply.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// Package codex implements thinking configuration for Codex (OpenAI Responses API) models.
|
||||||
|
//
|
||||||
|
// Codex models use the reasoning.effort format with discrete levels
|
||||||
|
// (low/medium/high). This is similar to OpenAI but uses nested field
|
||||||
|
// "reasoning.effort" instead of "reasoning_effort".
|
||||||
|
// See: _bmad-output/planning-artifacts/architecture.md#Epic-8
|
||||||
|
package codex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Applier implements thinking.ProviderApplier for Codex models.
|
||||||
|
//
|
||||||
|
// Codex-specific behavior:
|
||||||
|
// - Output format: reasoning.effort (string: low/medium/high/xhigh)
|
||||||
|
// - Level-only mode: no numeric budget support
|
||||||
|
// - Some models support ZeroAllowed (gpt-5.1, gpt-5.2)
|
||||||
|
type Applier struct{}
|
||||||
|
|
||||||
|
var _ thinking.ProviderApplier = (*Applier)(nil)
|
||||||
|
|
||||||
|
// NewApplier creates a new Codex thinking applier.
|
||||||
|
func NewApplier() *Applier {
|
||||||
|
return &Applier{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
thinking.RegisterProvider("codex", NewApplier())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply applies thinking configuration to Codex request body.
|
||||||
|
//
|
||||||
|
// Expected output format:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "reasoning": {
|
||||||
|
// "effort": "high"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||||
|
if thinking.IsUserDefinedModel(modelInfo) {
|
||||||
|
return applyCompatibleCodex(body, config)
|
||||||
|
}
|
||||||
|
if modelInfo.Thinking == nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only handle ModeLevel and ModeNone; other modes pass through unchanged.
|
||||||
|
if config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Mode == thinking.ModeLevel {
|
||||||
|
result, _ := sjson.SetBytes(body, "reasoning.effort", string(config.Level))
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
effort := ""
|
||||||
|
support := modelInfo.Thinking
|
||||||
|
if config.Budget == 0 {
|
||||||
|
if support.ZeroAllowed || hasLevel(support.Levels, string(thinking.LevelNone)) {
|
||||||
|
effort = string(thinking.LevelNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if effort == "" && config.Level != "" {
|
||||||
|
effort = string(config.Level)
|
||||||
|
}
|
||||||
|
if effort == "" && len(support.Levels) > 0 {
|
||||||
|
effort = support.Levels[0]
|
||||||
|
}
|
||||||
|
if effort == "" {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, _ := sjson.SetBytes(body, "reasoning.effort", effort)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyCompatibleCodex(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
var effort string
|
||||||
|
switch config.Mode {
|
||||||
|
case thinking.ModeLevel:
|
||||||
|
if config.Level == "" {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
effort = string(config.Level)
|
||||||
|
case thinking.ModeNone:
|
||||||
|
effort = string(thinking.LevelNone)
|
||||||
|
if config.Level != "" {
|
||||||
|
effort = string(config.Level)
|
||||||
|
}
|
||||||
|
case thinking.ModeAuto:
|
||||||
|
// Auto mode for user-defined models: pass through as "auto"
|
||||||
|
effort = string(thinking.LevelAuto)
|
||||||
|
case thinking.ModeBudget:
|
||||||
|
// Budget mode: convert budget to level using threshold mapping
|
||||||
|
level, ok := thinking.ConvertBudgetToLevel(config.Budget)
|
||||||
|
if !ok {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
effort = level
|
||||||
|
default:
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, _ := sjson.SetBytes(body, "reasoning.effort", effort)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasLevel(levels []string, target string) bool {
|
||||||
|
for _, level := range levels {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(level), target) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
169
internal/thinking/provider/gemini/apply.go
Normal file
169
internal/thinking/provider/gemini/apply.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
// Package gemini implements thinking configuration for Gemini models.
|
||||||
|
//
|
||||||
|
// Gemini models have two formats:
|
||||||
|
// - Gemini 2.5: Uses thinkingBudget (numeric)
|
||||||
|
// - Gemini 3.x: Uses thinkingLevel (string: minimal/low/medium/high)
|
||||||
|
// or thinkingBudget=-1 for auto/dynamic mode
|
||||||
|
//
|
||||||
|
// Output format is determined by ThinkingConfig.Mode and ThinkingSupport.Levels:
|
||||||
|
// - ModeAuto: Always uses thinkingBudget=-1 (both Gemini 2.5 and 3.x)
|
||||||
|
// - len(Levels) > 0: Uses thinkingLevel (Gemini 3.x discrete levels)
|
||||||
|
// - len(Levels) == 0: Uses thinkingBudget (Gemini 2.5)
|
||||||
|
package gemini
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Applier applies thinking configuration for Gemini models.
|
||||||
|
//
|
||||||
|
// Gemini-specific behavior:
|
||||||
|
// - Gemini 2.5: thinkingBudget format, flash series supports ZeroAllowed
|
||||||
|
// - Gemini 3.x: thinkingLevel format, cannot be disabled
|
||||||
|
// - Use ThinkingSupport.Levels to decide output format
|
||||||
|
type Applier struct{}
|
||||||
|
|
||||||
|
// NewApplier creates a new Gemini thinking applier.
|
||||||
|
func NewApplier() *Applier {
|
||||||
|
return &Applier{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
thinking.RegisterProvider("gemini", NewApplier())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply applies thinking configuration to Gemini request body.
|
||||||
|
//
|
||||||
|
// Expected output format (Gemini 2.5):
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "generationConfig": {
|
||||||
|
// "thinkingConfig": {
|
||||||
|
// "thinkingBudget": 8192,
|
||||||
|
// "includeThoughts": true
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Expected output format (Gemini 3.x):
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "generationConfig": {
|
||||||
|
// "thinkingConfig": {
|
||||||
|
// "thinkingLevel": "high",
|
||||||
|
// "includeThoughts": true
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||||
|
if thinking.IsUserDefinedModel(modelInfo) {
|
||||||
|
return a.applyCompatible(body, config)
|
||||||
|
}
|
||||||
|
if modelInfo.Thinking == nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose format based on config.Mode and model capabilities:
|
||||||
|
// - ModeLevel: use Level format (validation will reject unsupported levels)
|
||||||
|
// - ModeNone: use Level format if model has Levels, else Budget format
|
||||||
|
// - ModeBudget/ModeAuto: use Budget format
|
||||||
|
switch config.Mode {
|
||||||
|
case thinking.ModeLevel:
|
||||||
|
return a.applyLevelFormat(body, config)
|
||||||
|
case thinking.ModeNone:
|
||||||
|
// ModeNone: route based on model capability (has Levels or not)
|
||||||
|
if len(modelInfo.Thinking.Levels) > 0 {
|
||||||
|
return a.applyLevelFormat(body, config)
|
||||||
|
}
|
||||||
|
return a.applyBudgetFormat(body, config)
|
||||||
|
default:
|
||||||
|
return a.applyBudgetFormat(body, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||||
|
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Mode == thinking.ModeAuto {
|
||||||
|
return a.applyBudgetFormat(body, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Mode == thinking.ModeLevel || (config.Mode == thinking.ModeNone && config.Level != "") {
|
||||||
|
return a.applyLevelFormat(body, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.applyBudgetFormat(body, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||||
|
// ModeNone semantics:
|
||||||
|
// - ModeNone + Budget=0: completely disable thinking (not possible for Level-only models)
|
||||||
|
// - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false)
|
||||||
|
// ValidateConfig sets config.Level to the lowest level when ModeNone + Budget > 0.
|
||||||
|
|
||||||
|
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
|
||||||
|
result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingBudget")
|
||||||
|
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
||||||
|
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts")
|
||||||
|
|
||||||
|
if config.Mode == thinking.ModeNone {
|
||||||
|
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", false)
|
||||||
|
if config.Level != "" {
|
||||||
|
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", string(config.Level))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only handle ModeLevel - budget conversion should be done by upper layer
|
||||||
|
if config.Mode != thinking.ModeLevel {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
level := string(config.Level)
|
||||||
|
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", level)
|
||||||
|
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", true)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||||
|
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
|
||||||
|
result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingLevel")
|
||||||
|
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
||||||
|
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts")
|
||||||
|
|
||||||
|
budget := config.Budget
|
||||||
|
// ModeNone semantics:
|
||||||
|
// - ModeNone + Budget=0: completely disable thinking
|
||||||
|
// - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false)
|
||||||
|
// When ZeroAllowed=false, ValidateConfig clamps Budget to Min while preserving ModeNone.
|
||||||
|
includeThoughts := false
|
||||||
|
switch config.Mode {
|
||||||
|
case thinking.ModeNone:
|
||||||
|
includeThoughts = false
|
||||||
|
case thinking.ModeAuto:
|
||||||
|
includeThoughts = true
|
||||||
|
default:
|
||||||
|
includeThoughts = budget > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||||
|
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", includeThoughts)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
126
internal/thinking/provider/geminicli/apply.go
Normal file
126
internal/thinking/provider/geminicli/apply.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// Package geminicli implements thinking configuration for Gemini CLI API format.
|
||||||
|
//
|
||||||
|
// Gemini CLI uses request.generationConfig.thinkingConfig.* path instead of
|
||||||
|
// generationConfig.thinkingConfig.* used by standard Gemini API.
|
||||||
|
package geminicli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Applier applies thinking configuration for Gemini CLI API format.
|
||||||
|
type Applier struct{}
|
||||||
|
|
||||||
|
var _ thinking.ProviderApplier = (*Applier)(nil)
|
||||||
|
|
||||||
|
// NewApplier creates a new Gemini CLI thinking applier.
|
||||||
|
func NewApplier() *Applier {
|
||||||
|
return &Applier{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
thinking.RegisterProvider("gemini-cli", NewApplier())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply applies thinking configuration to Gemini CLI request body.
|
||||||
|
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||||
|
if thinking.IsUserDefinedModel(modelInfo) {
|
||||||
|
return a.applyCompatible(body, config)
|
||||||
|
}
|
||||||
|
if modelInfo.Thinking == nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModeAuto: Always use Budget format with thinkingBudget=-1
|
||||||
|
if config.Mode == thinking.ModeAuto {
|
||||||
|
return a.applyBudgetFormat(body, config)
|
||||||
|
}
|
||||||
|
if config.Mode == thinking.ModeBudget {
|
||||||
|
return a.applyBudgetFormat(body, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-auto modes, choose format based on model capabilities
|
||||||
|
support := modelInfo.Thinking
|
||||||
|
if len(support.Levels) > 0 {
|
||||||
|
return a.applyLevelFormat(body, config)
|
||||||
|
}
|
||||||
|
return a.applyBudgetFormat(body, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||||
|
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Mode == thinking.ModeAuto {
|
||||||
|
return a.applyBudgetFormat(body, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Mode == thinking.ModeLevel || (config.Mode == thinking.ModeNone && config.Level != "") {
|
||||||
|
return a.applyLevelFormat(body, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.applyBudgetFormat(body, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||||
|
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
|
||||||
|
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget")
|
||||||
|
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
||||||
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
|
||||||
|
|
||||||
|
if config.Mode == thinking.ModeNone {
|
||||||
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false)
|
||||||
|
if config.Level != "" {
|
||||||
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", string(config.Level))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only handle ModeLevel - budget conversion should be done by upper layer
|
||||||
|
if config.Mode != thinking.ModeLevel {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
level := string(config.Level)
|
||||||
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", level)
|
||||||
|
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", true)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||||
|
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
|
||||||
|
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingLevel")
|
||||||
|
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
|
||||||
|
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
|
||||||
|
|
||||||
|
budget := config.Budget
|
||||||
|
includeThoughts := false
|
||||||
|
switch config.Mode {
|
||||||
|
case thinking.ModeNone:
|
||||||
|
includeThoughts = false
|
||||||
|
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.includeThoughts", includeThoughts)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
156
internal/thinking/provider/iflow/apply.go
Normal file
156
internal/thinking/provider/iflow/apply.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// Package iflow implements thinking configuration for iFlow models (GLM, MiniMax).
|
||||||
|
//
|
||||||
|
// iFlow models use boolean toggle semantics:
|
||||||
|
// - GLM models: chat_template_kwargs.enable_thinking (boolean)
|
||||||
|
// - MiniMax models: reasoning_split (boolean)
|
||||||
|
//
|
||||||
|
// Level values are converted to boolean: none=false, all others=true
|
||||||
|
// See: _bmad-output/planning-artifacts/architecture.md#Epic-9
|
||||||
|
package iflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Applier implements thinking.ProviderApplier for iFlow models.
|
||||||
|
//
|
||||||
|
// iFlow-specific behavior:
|
||||||
|
// - GLM models: enable_thinking boolean + clear_thinking=false
|
||||||
|
// - MiniMax models: reasoning_split boolean
|
||||||
|
// - Level to boolean: none=false, others=true
|
||||||
|
// - No quantized support (only on/off)
|
||||||
|
type Applier struct{}
|
||||||
|
|
||||||
|
var _ thinking.ProviderApplier = (*Applier)(nil)
|
||||||
|
|
||||||
|
// NewApplier creates a new iFlow thinking applier.
|
||||||
|
func NewApplier() *Applier {
|
||||||
|
return &Applier{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
thinking.RegisterProvider("iflow", NewApplier())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply applies thinking configuration to iFlow request body.
|
||||||
|
//
|
||||||
|
// Expected output format (GLM):
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "chat_template_kwargs": {
|
||||||
|
// "enable_thinking": true,
|
||||||
|
// "clear_thinking": false
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Expected output format (MiniMax):
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "reasoning_split": true
|
||||||
|
// }
|
||||||
|
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||||
|
if thinking.IsUserDefinedModel(modelInfo) {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
if modelInfo.Thinking == nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if isGLMModel(modelInfo.ID) {
|
||||||
|
return applyGLM(body, config), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if isMiniMaxModel(modelInfo.ID) {
|
||||||
|
return applyMiniMax(body, config), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// configToBoolean converts ThinkingConfig to boolean for iFlow models.
|
||||||
|
//
|
||||||
|
// Conversion rules:
|
||||||
|
// - ModeNone: false
|
||||||
|
// - ModeAuto: true
|
||||||
|
// - ModeBudget + Budget=0: false
|
||||||
|
// - ModeBudget + Budget>0: true
|
||||||
|
// - ModeLevel + Level="none": false
|
||||||
|
// - ModeLevel + any other level: true
|
||||||
|
// - Default (unknown mode): true
|
||||||
|
func configToBoolean(config thinking.ThinkingConfig) bool {
|
||||||
|
switch config.Mode {
|
||||||
|
case thinking.ModeNone:
|
||||||
|
return false
|
||||||
|
case thinking.ModeAuto:
|
||||||
|
return true
|
||||||
|
case thinking.ModeBudget:
|
||||||
|
return config.Budget > 0
|
||||||
|
case thinking.ModeLevel:
|
||||||
|
return config.Level != thinking.LevelNone
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyGLM applies thinking configuration for GLM models.
|
||||||
|
//
|
||||||
|
// Output format when enabled:
|
||||||
|
//
|
||||||
|
// {"chat_template_kwargs": {"enable_thinking": true, "clear_thinking": false}}
|
||||||
|
//
|
||||||
|
// Output format when disabled:
|
||||||
|
//
|
||||||
|
// {"chat_template_kwargs": {"enable_thinking": false}}
|
||||||
|
//
|
||||||
|
// Note: clear_thinking is only set when thinking is enabled, to preserve
|
||||||
|
// thinking output in the response.
|
||||||
|
func applyGLM(body []byte, config thinking.ThinkingConfig) []byte {
|
||||||
|
enableThinking := configToBoolean(config)
|
||||||
|
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, _ := sjson.SetBytes(body, "chat_template_kwargs.enable_thinking", enableThinking)
|
||||||
|
|
||||||
|
// clear_thinking only needed when thinking is enabled
|
||||||
|
if enableThinking {
|
||||||
|
result, _ = sjson.SetBytes(result, "chat_template_kwargs.clear_thinking", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMiniMax applies thinking configuration for MiniMax models.
|
||||||
|
//
|
||||||
|
// Output format:
|
||||||
|
//
|
||||||
|
// {"reasoning_split": true/false}
|
||||||
|
func applyMiniMax(body []byte, config thinking.ThinkingConfig) []byte {
|
||||||
|
reasoningSplit := configToBoolean(config)
|
||||||
|
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, _ := sjson.SetBytes(body, "reasoning_split", reasoningSplit)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// isGLMModel determines if the model is a GLM series model.
|
||||||
|
// GLM models use chat_template_kwargs.enable_thinking format.
|
||||||
|
func isGLMModel(modelID string) bool {
|
||||||
|
return strings.HasPrefix(strings.ToLower(modelID), "glm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMiniMaxModel determines if the model is a MiniMax series model.
|
||||||
|
// MiniMax models use reasoning_split format.
|
||||||
|
func isMiniMaxModel(modelID string) bool {
|
||||||
|
return strings.HasPrefix(strings.ToLower(modelID), "minimax")
|
||||||
|
}
|
||||||
128
internal/thinking/provider/openai/apply.go
Normal file
128
internal/thinking/provider/openai/apply.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// Package openai implements thinking configuration for OpenAI/Codex models.
|
||||||
|
//
|
||||||
|
// OpenAI models use the reasoning_effort format with discrete levels
|
||||||
|
// (low/medium/high). Some models support xhigh and none levels.
|
||||||
|
// See: _bmad-output/planning-artifacts/architecture.md#Epic-8
|
||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Applier implements thinking.ProviderApplier for OpenAI models.
|
||||||
|
//
|
||||||
|
// OpenAI-specific behavior:
|
||||||
|
// - Output format: reasoning_effort (string: low/medium/high/xhigh)
|
||||||
|
// - Level-only mode: no numeric budget support
|
||||||
|
// - Some models support ZeroAllowed (gpt-5.1, gpt-5.2)
|
||||||
|
type Applier struct{}
|
||||||
|
|
||||||
|
var _ thinking.ProviderApplier = (*Applier)(nil)
|
||||||
|
|
||||||
|
// NewApplier creates a new OpenAI thinking applier.
|
||||||
|
func NewApplier() *Applier {
|
||||||
|
return &Applier{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
thinking.RegisterProvider("openai", NewApplier())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply applies thinking configuration to OpenAI request body.
|
||||||
|
//
|
||||||
|
// Expected output format:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "reasoning_effort": "high"
|
||||||
|
// }
|
||||||
|
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||||
|
if thinking.IsUserDefinedModel(modelInfo) {
|
||||||
|
return applyCompatibleOpenAI(body, config)
|
||||||
|
}
|
||||||
|
if modelInfo.Thinking == nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only handle ModeLevel and ModeNone; other modes pass through unchanged.
|
||||||
|
if config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Mode == thinking.ModeLevel {
|
||||||
|
result, _ := sjson.SetBytes(body, "reasoning_effort", string(config.Level))
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
effort := ""
|
||||||
|
support := modelInfo.Thinking
|
||||||
|
if config.Budget == 0 {
|
||||||
|
if support.ZeroAllowed || hasLevel(support.Levels, string(thinking.LevelNone)) {
|
||||||
|
effort = string(thinking.LevelNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if effort == "" && config.Level != "" {
|
||||||
|
effort = string(config.Level)
|
||||||
|
}
|
||||||
|
if effort == "" && len(support.Levels) > 0 {
|
||||||
|
effort = support.Levels[0]
|
||||||
|
}
|
||||||
|
if effort == "" {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, _ := sjson.SetBytes(body, "reasoning_effort", effort)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyCompatibleOpenAI(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
var effort string
|
||||||
|
switch config.Mode {
|
||||||
|
case thinking.ModeLevel:
|
||||||
|
if config.Level == "" {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
effort = string(config.Level)
|
||||||
|
case thinking.ModeNone:
|
||||||
|
effort = string(thinking.LevelNone)
|
||||||
|
if config.Level != "" {
|
||||||
|
effort = string(config.Level)
|
||||||
|
}
|
||||||
|
case thinking.ModeAuto:
|
||||||
|
// Auto mode for user-defined models: pass through as "auto"
|
||||||
|
effort = string(thinking.LevelAuto)
|
||||||
|
case thinking.ModeBudget:
|
||||||
|
// Budget mode: convert budget to level using threshold mapping
|
||||||
|
level, ok := thinking.ConvertBudgetToLevel(config.Budget)
|
||||||
|
if !ok {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
effort = level
|
||||||
|
default:
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, _ := sjson.SetBytes(body, "reasoning_effort", effort)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasLevel(levels []string, target string) bool {
|
||||||
|
for _, level := range levels {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(level), target) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
54
internal/thinking/strip.go
Normal file
54
internal/thinking/strip.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Package thinking provides unified thinking configuration processing.
|
||||||
|
package thinking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StripThinkingConfig removes thinking configuration fields from request body.
|
||||||
|
//
|
||||||
|
// This function is used when a model doesn't support thinking but the request
|
||||||
|
// contains thinking configuration. The configuration is silently removed to
|
||||||
|
// prevent upstream API errors.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - body: Original request body JSON
|
||||||
|
// - provider: Provider name (determines which fields to strip)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - Modified request body JSON with thinking configuration removed
|
||||||
|
// - Original body is returned unchanged if:
|
||||||
|
// - body is empty or invalid JSON
|
||||||
|
// - provider is unknown
|
||||||
|
// - no thinking configuration found
|
||||||
|
func StripThinkingConfig(body []byte, provider string) []byte {
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
switch provider {
|
||||||
|
case "claude":
|
||||||
|
result, _ := sjson.DeleteBytes(body, "thinking")
|
||||||
|
return result
|
||||||
|
case "gemini":
|
||||||
|
result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig")
|
||||||
|
return result
|
||||||
|
case "gemini-cli", "antigravity":
|
||||||
|
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig")
|
||||||
|
return result
|
||||||
|
case "openai":
|
||||||
|
result, _ := sjson.DeleteBytes(body, "reasoning_effort")
|
||||||
|
return result
|
||||||
|
case "codex":
|
||||||
|
result, _ := sjson.DeleteBytes(body, "reasoning.effort")
|
||||||
|
return result
|
||||||
|
case "iflow":
|
||||||
|
result, _ := sjson.DeleteBytes(body, "chat_template_kwargs.enable_thinking")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "chat_template_kwargs.clear_thinking")
|
||||||
|
result, _ = sjson.DeleteBytes(result, "reasoning_split")
|
||||||
|
return result
|
||||||
|
default:
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
}
|
||||||
146
internal/thinking/suffix.go
Normal file
146
internal/thinking/suffix.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// Package thinking provides unified thinking configuration processing.
|
||||||
|
//
|
||||||
|
// This file implements suffix parsing functionality for extracting
|
||||||
|
// thinking configuration from model names in the format model(value).
|
||||||
|
package thinking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseSuffix extracts thinking suffix from a model name.
|
||||||
|
//
|
||||||
|
// The suffix format is: model-name(value)
|
||||||
|
// Examples:
|
||||||
|
// - "claude-sonnet-4-5(16384)" -> ModelName="claude-sonnet-4-5", RawSuffix="16384"
|
||||||
|
// - "gpt-5.2(high)" -> ModelName="gpt-5.2", RawSuffix="high"
|
||||||
|
// - "gemini-2.5-pro" -> ModelName="gemini-2.5-pro", HasSuffix=false
|
||||||
|
//
|
||||||
|
// This function only extracts the suffix; it does not validate or interpret
|
||||||
|
// the suffix content. Use ParseNumericSuffix, ParseLevelSuffix, etc. for
|
||||||
|
// content interpretation.
|
||||||
|
func ParseSuffix(model string) SuffixResult {
|
||||||
|
// Find the last opening parenthesis
|
||||||
|
lastOpen := strings.LastIndex(model, "(")
|
||||||
|
if lastOpen == -1 {
|
||||||
|
return SuffixResult{ModelName: model, HasSuffix: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the string ends with a closing parenthesis
|
||||||
|
if !strings.HasSuffix(model, ")") {
|
||||||
|
return SuffixResult{ModelName: model, HasSuffix: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract components
|
||||||
|
modelName := model[:lastOpen]
|
||||||
|
rawSuffix := model[lastOpen+1 : len(model)-1]
|
||||||
|
|
||||||
|
return SuffixResult{
|
||||||
|
ModelName: modelName,
|
||||||
|
HasSuffix: true,
|
||||||
|
RawSuffix: rawSuffix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseNumericSuffix attempts to parse a raw suffix as a numeric budget value.
|
||||||
|
//
|
||||||
|
// This function parses the raw suffix content (from ParseSuffix.RawSuffix) as an integer.
|
||||||
|
// Only non-negative integers are considered valid numeric suffixes.
|
||||||
|
//
|
||||||
|
// Platform note: The budget value uses Go's int type, which is 32-bit on 32-bit
|
||||||
|
// systems and 64-bit on 64-bit systems. Values exceeding the platform's int range
|
||||||
|
// will return ok=false.
|
||||||
|
//
|
||||||
|
// Leading zeros are accepted: "08192" parses as 8192.
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// - "8192" -> budget=8192, ok=true
|
||||||
|
// - "0" -> budget=0, ok=true (represents ModeNone)
|
||||||
|
// - "08192" -> budget=8192, ok=true (leading zeros accepted)
|
||||||
|
// - "-1" -> budget=0, ok=false (negative numbers are not valid numeric suffixes)
|
||||||
|
// - "high" -> budget=0, ok=false (not a number)
|
||||||
|
// - "9223372036854775808" -> budget=0, ok=false (overflow on 64-bit systems)
|
||||||
|
//
|
||||||
|
// For special handling of -1 as auto mode, use ParseSpecialSuffix instead.
|
||||||
|
func ParseNumericSuffix(rawSuffix string) (budget int, ok bool) {
|
||||||
|
if rawSuffix == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.Atoi(rawSuffix)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative numbers are not valid numeric suffixes
|
||||||
|
// -1 should be handled by special value parsing as "auto"
|
||||||
|
if value < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSpecialSuffix attempts to parse a raw suffix as a special thinking mode value.
|
||||||
|
//
|
||||||
|
// This function handles special strings that represent a change in thinking mode:
|
||||||
|
// - "none" -> ModeNone (disables thinking)
|
||||||
|
// - "auto" -> ModeAuto (automatic/dynamic thinking)
|
||||||
|
// - "-1" -> ModeAuto (numeric representation of auto mode)
|
||||||
|
//
|
||||||
|
// String values are case-insensitive.
|
||||||
|
func ParseSpecialSuffix(rawSuffix string) (mode ThinkingMode, ok bool) {
|
||||||
|
if rawSuffix == "" {
|
||||||
|
return ModeBudget, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive matching
|
||||||
|
switch strings.ToLower(rawSuffix) {
|
||||||
|
case "none":
|
||||||
|
return ModeNone, true
|
||||||
|
case "auto", "-1":
|
||||||
|
return ModeAuto, true
|
||||||
|
default:
|
||||||
|
return ModeBudget, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLevelSuffix attempts to parse a raw suffix as a discrete thinking level.
|
||||||
|
//
|
||||||
|
// This function parses the raw suffix content (from ParseSuffix.RawSuffix) as a level.
|
||||||
|
// Only discrete effort levels are valid: minimal, low, medium, high, xhigh.
|
||||||
|
// Level matching is case-insensitive.
|
||||||
|
//
|
||||||
|
// Special values (none, auto) are NOT handled by this function; use ParseSpecialSuffix
|
||||||
|
// instead. This separation allows callers to prioritize special value handling.
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// - "high" -> level=LevelHigh, ok=true
|
||||||
|
// - "HIGH" -> level=LevelHigh, ok=true (case insensitive)
|
||||||
|
// - "medium" -> level=LevelMedium, ok=true
|
||||||
|
// - "none" -> level="", ok=false (special value, use ParseSpecialSuffix)
|
||||||
|
// - "auto" -> level="", ok=false (special value, use ParseSpecialSuffix)
|
||||||
|
// - "8192" -> level="", ok=false (numeric, use ParseNumericSuffix)
|
||||||
|
// - "ultra" -> level="", ok=false (unknown level)
|
||||||
|
func ParseLevelSuffix(rawSuffix string) (level ThinkingLevel, ok bool) {
|
||||||
|
if rawSuffix == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive matching
|
||||||
|
switch strings.ToLower(rawSuffix) {
|
||||||
|
case "minimal":
|
||||||
|
return LevelMinimal, true
|
||||||
|
case "low":
|
||||||
|
return LevelLow, true
|
||||||
|
case "medium":
|
||||||
|
return LevelMedium, true
|
||||||
|
case "high":
|
||||||
|
return LevelHigh, true
|
||||||
|
case "xhigh":
|
||||||
|
return LevelXHigh, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
41
internal/thinking/text.go
Normal file
41
internal/thinking/text.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package thinking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetThinkingText extracts the thinking text from a content part.
|
||||||
|
// Handles various formats:
|
||||||
|
// - Simple string: { "thinking": "text" } or { "text": "text" }
|
||||||
|
// - Wrapped object: { "thinking": { "text": "text", "cache_control": {...} } }
|
||||||
|
// - Gemini-style: { "thought": true, "text": "text" }
|
||||||
|
// Returns the extracted text string.
|
||||||
|
func GetThinkingText(part gjson.Result) string {
|
||||||
|
// Try direct text field first (Gemini-style)
|
||||||
|
if text := part.Get("text"); text.Exists() && text.Type == gjson.String {
|
||||||
|
return text.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try thinking field
|
||||||
|
thinkingField := part.Get("thinking")
|
||||||
|
if !thinkingField.Exists() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// thinking is a string
|
||||||
|
if thinkingField.Type == gjson.String {
|
||||||
|
return thinkingField.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// thinking is an object with inner text/thinking
|
||||||
|
if thinkingField.IsObject() {
|
||||||
|
if inner := thinkingField.Get("text"); inner.Exists() && inner.Type == gjson.String {
|
||||||
|
return inner.String()
|
||||||
|
}
|
||||||
|
if inner := thinkingField.Get("thinking"); inner.Exists() && inner.Type == gjson.String {
|
||||||
|
return inner.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
116
internal/thinking/types.go
Normal file
116
internal/thinking/types.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// Package thinking provides unified thinking configuration processing.
|
||||||
|
//
|
||||||
|
// This package offers a unified interface for parsing, validating, and applying
|
||||||
|
// thinking configurations across various AI providers (Claude, Gemini, OpenAI, iFlow).
|
||||||
|
package thinking
|
||||||
|
|
||||||
|
import "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
|
||||||
|
// ThinkingMode represents the type of thinking configuration mode.
|
||||||
|
type ThinkingMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ModeBudget indicates using a numeric budget (corresponds to suffix "(1000)" etc.)
|
||||||
|
ModeBudget ThinkingMode = iota
|
||||||
|
// ModeLevel indicates using a discrete level (corresponds to suffix "(high)" etc.)
|
||||||
|
ModeLevel
|
||||||
|
// ModeNone indicates thinking is disabled (corresponds to suffix "(none)" or budget=0)
|
||||||
|
ModeNone
|
||||||
|
// ModeAuto indicates automatic/dynamic thinking (corresponds to suffix "(auto)" or budget=-1)
|
||||||
|
ModeAuto
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the string representation of ThinkingMode.
|
||||||
|
func (m ThinkingMode) String() string {
|
||||||
|
switch m {
|
||||||
|
case ModeBudget:
|
||||||
|
return "budget"
|
||||||
|
case ModeLevel:
|
||||||
|
return "level"
|
||||||
|
case ModeNone:
|
||||||
|
return "none"
|
||||||
|
case ModeAuto:
|
||||||
|
return "auto"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThinkingLevel represents a discrete thinking level.
|
||||||
|
type ThinkingLevel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// LevelNone disables thinking
|
||||||
|
LevelNone ThinkingLevel = "none"
|
||||||
|
// LevelAuto enables automatic/dynamic thinking
|
||||||
|
LevelAuto ThinkingLevel = "auto"
|
||||||
|
// LevelMinimal sets minimal thinking effort
|
||||||
|
LevelMinimal ThinkingLevel = "minimal"
|
||||||
|
// LevelLow sets low thinking effort
|
||||||
|
LevelLow ThinkingLevel = "low"
|
||||||
|
// LevelMedium sets medium thinking effort
|
||||||
|
LevelMedium ThinkingLevel = "medium"
|
||||||
|
// LevelHigh sets high thinking effort
|
||||||
|
LevelHigh ThinkingLevel = "high"
|
||||||
|
// LevelXHigh sets extra-high thinking effort
|
||||||
|
LevelXHigh ThinkingLevel = "xhigh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ThinkingConfig represents a unified thinking configuration.
|
||||||
|
//
|
||||||
|
// This struct is used to pass thinking configuration information between components.
|
||||||
|
// Depending on Mode, either Budget or Level field is effective:
|
||||||
|
// - ModeNone: Budget=0, Level is ignored
|
||||||
|
// - ModeAuto: Budget=-1, Level is ignored
|
||||||
|
// - ModeBudget: Budget is a positive integer, Level is ignored
|
||||||
|
// - ModeLevel: Budget is ignored, Level is a valid level
|
||||||
|
type ThinkingConfig struct {
|
||||||
|
// Mode specifies the configuration mode
|
||||||
|
Mode ThinkingMode
|
||||||
|
// Budget is the thinking budget (token count), only effective when Mode is ModeBudget.
|
||||||
|
// Special values: 0 means disabled, -1 means automatic
|
||||||
|
Budget int
|
||||||
|
// Level is the thinking level, only effective when Mode is ModeLevel
|
||||||
|
Level ThinkingLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuffixResult represents the result of parsing a model name for thinking suffix.
|
||||||
|
//
|
||||||
|
// A thinking suffix is specified in the format model-name(value), where value
|
||||||
|
// can be a numeric budget (e.g., "16384") or a level name (e.g., "high").
|
||||||
|
type SuffixResult struct {
|
||||||
|
// ModelName is the model name with the suffix removed.
|
||||||
|
// If no suffix was found, this equals the original input.
|
||||||
|
ModelName string
|
||||||
|
|
||||||
|
// HasSuffix indicates whether a valid suffix was found.
|
||||||
|
HasSuffix bool
|
||||||
|
|
||||||
|
// RawSuffix is the content inside the parentheses, without the parentheses.
|
||||||
|
// Empty string if HasSuffix is false.
|
||||||
|
RawSuffix string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderApplier defines the interface for provider-specific thinking configuration application.
|
||||||
|
//
|
||||||
|
// Types implementing this interface are responsible for converting a unified ThinkingConfig
|
||||||
|
// into provider-specific format and applying it to the request body.
|
||||||
|
//
|
||||||
|
// Implementation requirements:
|
||||||
|
// - Apply method must be idempotent
|
||||||
|
// - Must not modify the input config or modelInfo
|
||||||
|
// - Returns a modified copy of the request body
|
||||||
|
// - Returns appropriate ThinkingError for unsupported configurations
|
||||||
|
type ProviderApplier interface {
|
||||||
|
// Apply applies the thinking configuration to the request body.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - body: Original request body JSON
|
||||||
|
// - config: Unified thinking configuration
|
||||||
|
// - modelInfo: Model registry information containing ThinkingSupport properties
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - Modified request body JSON
|
||||||
|
// - ThinkingError if the configuration is invalid or unsupported
|
||||||
|
Apply(body []byte, config ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error)
|
||||||
|
}
|
||||||
259
internal/thinking/validate.go
Normal file
259
internal/thinking/validate.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
// Package thinking provides unified thinking configuration processing logic.
|
||||||
|
package thinking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClampBudget clamps a budget value to the model's supported range.
|
||||||
|
//
|
||||||
|
// Logging:
|
||||||
|
// - Warn when value=0 but ZeroAllowed=false
|
||||||
|
// - Debug when value is clamped to min/max
|
||||||
|
//
|
||||||
|
// Fields: provider, model, original_value, clamped_to, min, max
|
||||||
|
func ClampBudget(value int, modelInfo *registry.ModelInfo, provider string) int {
|
||||||
|
model := "unknown"
|
||||||
|
support := (*registry.ThinkingSupport)(nil)
|
||||||
|
if modelInfo != nil {
|
||||||
|
if modelInfo.ID != "" {
|
||||||
|
model = modelInfo.ID
|
||||||
|
}
|
||||||
|
support = modelInfo.Thinking
|
||||||
|
}
|
||||||
|
if support == nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto value (-1) passes through without clamping.
|
||||||
|
if value == -1 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
min := support.Min
|
||||||
|
max := support.Max
|
||||||
|
if value == 0 && !support.ZeroAllowed {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"provider": provider,
|
||||||
|
"model": model,
|
||||||
|
"original_value": value,
|
||||||
|
"clamped_to": min,
|
||||||
|
"min": min,
|
||||||
|
"max": max,
|
||||||
|
}).Warn("thinking: budget zero not allowed |")
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some models are level-only and do not define numeric budget ranges.
|
||||||
|
if min == 0 && max == 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if value < min {
|
||||||
|
if value == 0 && support.ZeroAllowed {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
logClamp(provider, model, value, min, min, max)
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
if value > max {
|
||||||
|
logClamp(provider, model, value, max, min, max)
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfig validates a thinking configuration against model capabilities.
|
||||||
|
//
|
||||||
|
// This function performs comprehensive validation:
|
||||||
|
// - Checks if the model supports thinking
|
||||||
|
// - Auto-converts between Budget and Level formats based on model capability
|
||||||
|
// - Validates that requested level is in the model's supported levels list
|
||||||
|
// - Clamps budget values to model's allowed range
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - config: The thinking configuration to validate
|
||||||
|
// - support: Model's ThinkingSupport properties (nil means no thinking support)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - Normalized ThinkingConfig with clamped values
|
||||||
|
// - ThinkingError if validation fails (ErrThinkingNotSupported, ErrLevelNotSupported, etc.)
|
||||||
|
//
|
||||||
|
// Auto-conversion behavior:
|
||||||
|
// - Budget-only model + Level config → Level converted to Budget
|
||||||
|
// - Level-only model + Budget config → Budget converted to Level
|
||||||
|
// - Hybrid model → preserve original format
|
||||||
|
func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, provider string) (*ThinkingConfig, error) {
|
||||||
|
normalized := config
|
||||||
|
|
||||||
|
model := "unknown"
|
||||||
|
support := (*registry.ThinkingSupport)(nil)
|
||||||
|
if modelInfo != nil {
|
||||||
|
if modelInfo.ID != "" {
|
||||||
|
model = modelInfo.ID
|
||||||
|
}
|
||||||
|
support = modelInfo.Thinking
|
||||||
|
}
|
||||||
|
|
||||||
|
if support == nil {
|
||||||
|
if config.Mode != ModeNone {
|
||||||
|
return nil, NewThinkingErrorWithModel(ErrThinkingNotSupported, "thinking not supported for this model", model)
|
||||||
|
}
|
||||||
|
return &normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
capability := detectModelCapability(modelInfo)
|
||||||
|
switch capability {
|
||||||
|
case CapabilityBudgetOnly:
|
||||||
|
if normalized.Mode == ModeLevel {
|
||||||
|
if normalized.Level == LevelAuto {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
budget, ok := ConvertLevelToBudget(string(normalized.Level))
|
||||||
|
if !ok {
|
||||||
|
return nil, NewThinkingError(ErrUnknownLevel, fmt.Sprintf("unknown level: %s", normalized.Level))
|
||||||
|
}
|
||||||
|
normalized.Mode = ModeBudget
|
||||||
|
normalized.Budget = budget
|
||||||
|
normalized.Level = ""
|
||||||
|
}
|
||||||
|
case CapabilityLevelOnly:
|
||||||
|
if normalized.Mode == ModeBudget {
|
||||||
|
level, ok := ConvertBudgetToLevel(normalized.Budget)
|
||||||
|
if !ok {
|
||||||
|
return nil, NewThinkingError(ErrUnknownLevel, fmt.Sprintf("budget %d cannot be converted to a valid level", normalized.Budget))
|
||||||
|
}
|
||||||
|
normalized.Mode = ModeLevel
|
||||||
|
normalized.Level = ThinkingLevel(level)
|
||||||
|
normalized.Budget = 0
|
||||||
|
}
|
||||||
|
case CapabilityHybrid:
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized.Mode == ModeLevel && normalized.Level == LevelNone {
|
||||||
|
normalized.Mode = ModeNone
|
||||||
|
normalized.Budget = 0
|
||||||
|
normalized.Level = ""
|
||||||
|
}
|
||||||
|
if normalized.Mode == ModeLevel && normalized.Level == LevelAuto {
|
||||||
|
normalized.Mode = ModeAuto
|
||||||
|
normalized.Budget = -1
|
||||||
|
normalized.Level = ""
|
||||||
|
}
|
||||||
|
if normalized.Mode == ModeBudget && normalized.Budget == 0 {
|
||||||
|
normalized.Mode = ModeNone
|
||||||
|
normalized.Level = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(support.Levels) > 0 && normalized.Mode == ModeLevel {
|
||||||
|
if !isLevelSupported(string(normalized.Level), support.Levels) {
|
||||||
|
validLevels := normalizeLevels(support.Levels)
|
||||||
|
message := fmt.Sprintf("level %q not supported, valid levels: %s", strings.ToLower(string(normalized.Level)), strings.Join(validLevels, ", "))
|
||||||
|
return nil, NewThinkingError(ErrLevelNotSupported, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert ModeAuto to mid-range if dynamic not allowed
|
||||||
|
if normalized.Mode == ModeAuto && !support.DynamicAllowed {
|
||||||
|
normalized = convertAutoToMidRange(normalized, support, provider, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized.Mode == ModeNone && provider == "claude" {
|
||||||
|
// Claude supports explicit disable via thinking.type="disabled".
|
||||||
|
// Keep Budget=0 so applier can omit budget_tokens.
|
||||||
|
normalized.Budget = 0
|
||||||
|
normalized.Level = ""
|
||||||
|
} else {
|
||||||
|
switch normalized.Mode {
|
||||||
|
case ModeBudget, ModeAuto, ModeNone:
|
||||||
|
normalized.Budget = ClampBudget(normalized.Budget, modelInfo, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModeNone with clamped Budget > 0: set Level to lowest for Level-only/Hybrid models
|
||||||
|
// This ensures Apply layer doesn't need to access support.Levels
|
||||||
|
if normalized.Mode == ModeNone && normalized.Budget > 0 && len(support.Levels) > 0 {
|
||||||
|
normalized.Level = ThinkingLevel(support.Levels[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLevelSupported(level string, supported []string) bool {
|
||||||
|
for _, candidate := range supported {
|
||||||
|
if strings.EqualFold(level, strings.TrimSpace(candidate)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLevels(levels []string) []string {
|
||||||
|
normalized := make([]string, 0, len(levels))
|
||||||
|
for _, level := range levels {
|
||||||
|
normalized = append(normalized, strings.ToLower(strings.TrimSpace(level)))
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertAutoToMidRange converts ModeAuto to a mid-range value when dynamic is not allowed.
|
||||||
|
//
|
||||||
|
// This function handles the case where a model does not support dynamic/auto thinking.
|
||||||
|
// The auto mode is silently converted to a fixed value based on model capability:
|
||||||
|
// - Level-only models: convert to ModeLevel with LevelMedium
|
||||||
|
// - Budget models: convert to ModeBudget with mid = (Min + Max) / 2
|
||||||
|
//
|
||||||
|
// Logging:
|
||||||
|
// - Debug level when conversion occurs
|
||||||
|
// - Fields: original_mode, clamped_to, reason
|
||||||
|
func convertAutoToMidRange(config ThinkingConfig, support *registry.ThinkingSupport, provider, model string) ThinkingConfig {
|
||||||
|
// For level-only models (has Levels but no Min/Max range), use ModeLevel with medium
|
||||||
|
if len(support.Levels) > 0 && support.Min == 0 && support.Max == 0 {
|
||||||
|
config.Mode = ModeLevel
|
||||||
|
config.Level = LevelMedium
|
||||||
|
config.Budget = 0
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"provider": provider,
|
||||||
|
"model": model,
|
||||||
|
"original_mode": "auto",
|
||||||
|
"clamped_to": string(LevelMedium),
|
||||||
|
}).Debug("thinking: mode converted, dynamic not allowed, using medium level |")
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// For budget models, use mid-range budget
|
||||||
|
mid := (support.Min + support.Max) / 2
|
||||||
|
if mid <= 0 && support.ZeroAllowed {
|
||||||
|
config.Mode = ModeNone
|
||||||
|
config.Budget = 0
|
||||||
|
} else if mid <= 0 {
|
||||||
|
config.Mode = ModeBudget
|
||||||
|
config.Budget = support.Min
|
||||||
|
} else {
|
||||||
|
config.Mode = ModeBudget
|
||||||
|
config.Budget = mid
|
||||||
|
}
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"provider": provider,
|
||||||
|
"model": model,
|
||||||
|
"original_mode": "auto",
|
||||||
|
"clamped_to": config.Budget,
|
||||||
|
}).Debug("thinking: mode converted, dynamic not allowed |")
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// logClamp logs a debug message when budget clamping occurs.
|
||||||
|
func logClamp(provider, model string, original, clampedTo, min, max int) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"provider": provider,
|
||||||
|
"model": model,
|
||||||
|
"original_value": original,
|
||||||
|
"min": min,
|
||||||
|
"max": max,
|
||||||
|
"clamped_to": clampedTo,
|
||||||
|
}).Debug("thinking: budget clamped |")
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
@@ -122,7 +124,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
contentTypeResult := contentResult.Get("type")
|
contentTypeResult := contentResult.Get("type")
|
||||||
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
|
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
|
||||||
// Use GetThinkingText to handle wrapped thinking objects
|
// Use GetThinkingText to handle wrapped thinking objects
|
||||||
thinkingText := util.GetThinkingText(contentResult)
|
thinkingText := thinking.GetThinkingText(contentResult)
|
||||||
signatureResult := contentResult.Get("signature")
|
signatureResult := contentResult.Get("signature")
|
||||||
clientSignature := ""
|
clientSignature := ""
|
||||||
if signatureResult.Exists() && signatureResult.String() != "" {
|
if signatureResult.Exists() && signatureResult.String() != "" {
|
||||||
@@ -385,12 +387,15 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
|
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
|
||||||
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() && util.ModelSupportsThinking(modelName) {
|
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
|
||||||
if t.Get("type").String() == "enabled" {
|
modelInfo := registry.LookupModelInfo(modelName)
|
||||||
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
|
if modelInfo != nil && modelInfo.Thinking != nil {
|
||||||
budget := int(b.Int())
|
if t.Get("type").String() == "enabled" {
|
||||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
|
||||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
|
budget := int(b.Int())
|
||||||
|
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||||
|
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,66 +35,19 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
// Model
|
// Model
|
||||||
out, _ = sjson.SetBytes(out, "model", modelName)
|
out, _ = sjson.SetBytes(out, "model", modelName)
|
||||||
|
|
||||||
// Reasoning effort -> thinkingBudget/include_thoughts
|
// Apply thinking configuration: convert OpenAI reasoning_effort to Gemini CLI thinkingConfig.
|
||||||
// Note: OpenAI official fields take precedence over extra_body.google.thinking_config
|
// Inline translation-only mapping; capability checks happen later in ApplyThinking.
|
||||||
re := gjson.GetBytes(rawJSON, "reasoning_effort")
|
re := gjson.GetBytes(rawJSON, "reasoning_effort")
|
||||||
hasOfficialThinking := re.Exists()
|
if re.Exists() {
|
||||||
if hasOfficialThinking && util.ModelSupportsThinking(modelName) {
|
|
||||||
effort := strings.ToLower(strings.TrimSpace(re.String()))
|
effort := strings.ToLower(strings.TrimSpace(re.String()))
|
||||||
if util.IsGemini3Model(modelName) {
|
if effort != "" {
|
||||||
switch effort {
|
thinkingPath := "request.generationConfig.thinkingConfig"
|
||||||
case "none":
|
if effort == "auto" {
|
||||||
out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingConfig")
|
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1)
|
||||||
case "auto":
|
out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", true)
|
||||||
includeThoughts := true
|
} else {
|
||||||
out = util.ApplyGeminiCLIThinkingLevel(out, "", &includeThoughts)
|
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort)
|
||||||
default:
|
out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", effort != "none")
|
||||||
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)
|
|
||||||
// Only apply for models that use numeric budgets, not discrete levels.
|
|
||||||
if !hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
|
|
||||||
if tc := gjson.GetBytes(rawJSON, "extra_body.google.thinking_config"); tc.Exists() && tc.IsObject() {
|
|
||||||
var setBudget bool
|
|
||||||
var budget int
|
|
||||||
|
|
||||||
if v := tc.Get("thinkingBudget"); v.Exists() {
|
|
||||||
budget = int(v.Int())
|
|
||||||
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
|
||||||
setBudget = true
|
|
||||||
} else if v := tc.Get("thinking_budget"); v.Exists() {
|
|
||||||
budget = int(v.Int())
|
|
||||||
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
|
||||||
setBudget = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if v := tc.Get("includeThoughts"); v.Exists() {
|
|
||||||
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", v.Bool())
|
|
||||||
} else if v := tc.Get("include_thoughts"); v.Exists() {
|
|
||||||
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", v.Bool())
|
|
||||||
} else if setBudget && budget != 0 {
|
|
||||||
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claude/Anthropic API format: thinking.type == "enabled" with budget_tokens
|
|
||||||
// This allows Claude Code and other Claude API clients to pass thinking configuration
|
|
||||||
if !gjson.GetBytes(out, "request.generationConfig.thinkingConfig").Exists() && util.ModelSupportsThinking(modelName) {
|
|
||||||
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
|
|
||||||
if t.Get("type").String() == "enabled" {
|
|
||||||
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
|
|
||||||
budget := int(b.Int())
|
|
||||||
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
|
||||||
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
@@ -115,15 +116,17 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
|
|||||||
}
|
}
|
||||||
// Include thoughts configuration for reasoning process visibility
|
// Include thoughts configuration for reasoning process visibility
|
||||||
// Only apply for models that support thinking and use numeric budgets, not discrete levels.
|
// Only apply for models that support thinking and use numeric budgets, not discrete levels.
|
||||||
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
|
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
||||||
// Check for thinkingBudget first - if present, enable thinking with budget
|
modelInfo := registry.LookupModelInfo(modelName)
|
||||||
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() && thinkingBudget.Int() > 0 {
|
if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) == 0 {
|
||||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
// Check for thinkingBudget first - if present, enable thinking with budget
|
||||||
normalizedBudget := util.NormalizeThinkingBudget(modelName, int(thinkingBudget.Int()))
|
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() && thinkingBudget.Int() > 0 {
|
||||||
out, _ = sjson.Set(out, "thinking.budget_tokens", normalizedBudget)
|
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.budget_tokens", thinkingBudget.Int())
|
||||||
// Fallback to include_thoughts if no budget specified
|
} else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {
|
||||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
// Fallback to include_thoughts if no budget specified
|
||||||
|
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
@@ -65,20 +66,23 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
|
|||||||
|
|
||||||
root := gjson.ParseBytes(rawJSON)
|
root := gjson.ParseBytes(rawJSON)
|
||||||
|
|
||||||
if v := root.Get("reasoning_effort"); v.Exists() && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
|
if v := root.Get("reasoning_effort"); v.Exists() {
|
||||||
effort := strings.ToLower(strings.TrimSpace(v.String()))
|
modelInfo := registry.LookupModelInfo(modelName)
|
||||||
if effort != "" {
|
if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) == 0 {
|
||||||
budget, ok := util.ThinkingEffortToBudget(modelName, effort)
|
effort := strings.ToLower(strings.TrimSpace(v.String()))
|
||||||
if ok {
|
if effort != "" {
|
||||||
switch budget {
|
budget, ok := thinking.ConvertLevelToBudget(effort)
|
||||||
case 0:
|
if ok {
|
||||||
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
switch budget {
|
||||||
case -1:
|
case 0:
|
||||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
||||||
default:
|
case -1:
|
||||||
if budget > 0 {
|
|
||||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||||
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
default:
|
||||||
|
if budget > 0 {
|
||||||
|
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||||
|
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
@@ -53,20 +54,23 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
|
|||||||
|
|
||||||
root := gjson.ParseBytes(rawJSON)
|
root := gjson.ParseBytes(rawJSON)
|
||||||
|
|
||||||
if v := root.Get("reasoning.effort"); v.Exists() && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
|
if v := root.Get("reasoning.effort"); v.Exists() {
|
||||||
effort := strings.ToLower(strings.TrimSpace(v.String()))
|
modelInfo := registry.LookupModelInfo(modelName)
|
||||||
if effort != "" {
|
if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) == 0 {
|
||||||
budget, ok := util.ThinkingEffortToBudget(modelName, effort)
|
effort := strings.ToLower(strings.TrimSpace(v.String()))
|
||||||
if ok {
|
if effort != "" {
|
||||||
switch budget {
|
budget, ok := thinking.ConvertLevelToBudget(effort)
|
||||||
case 0:
|
if ok {
|
||||||
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
switch budget {
|
||||||
case -1:
|
case 0:
|
||||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
||||||
default:
|
case -1:
|
||||||
if budget > 0 {
|
|
||||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||||
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
default:
|
||||||
|
if budget > 0 {
|
||||||
|
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||||
|
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
@@ -219,19 +220,20 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
|
|
||||||
// Convert thinking.budget_tokens to reasoning.effort for level-based models
|
// Convert thinking.budget_tokens to reasoning.effort for level-based models
|
||||||
reasoningEffort := "medium" // default
|
reasoningEffort := "medium" // default
|
||||||
if thinking := rootResult.Get("thinking"); thinking.Exists() && thinking.IsObject() {
|
if thinkingConfig := rootResult.Get("thinking"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
||||||
switch thinking.Get("type").String() {
|
modelInfo := registry.LookupModelInfo(modelName)
|
||||||
|
switch thinkingConfig.Get("type").String() {
|
||||||
case "enabled":
|
case "enabled":
|
||||||
if util.ModelUsesThinkingLevels(modelName) {
|
if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) > 0 {
|
||||||
if budgetTokens := thinking.Get("budget_tokens"); budgetTokens.Exists() {
|
if budgetTokens := thinkingConfig.Get("budget_tokens"); budgetTokens.Exists() {
|
||||||
budget := int(budgetTokens.Int())
|
budget := int(budgetTokens.Int())
|
||||||
if effort, ok := util.ThinkingBudgetToEffort(modelName, budget); ok && effort != "" {
|
if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" {
|
||||||
reasoningEffort = effort
|
reasoningEffort = effort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "disabled":
|
case "disabled":
|
||||||
if effort, ok := util.ThinkingBudgetToEffort(modelName, 0); ok && effort != "" {
|
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
|
||||||
reasoningEffort = effort
|
reasoningEffort = effort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
@@ -251,10 +253,11 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
reasoningEffort := "medium" // default
|
reasoningEffort := "medium" // default
|
||||||
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 util.ModelUsesThinkingLevels(modelName) {
|
modelInfo := registry.LookupModelInfo(modelName)
|
||||||
|
if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) > 0 {
|
||||||
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
|
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
|
||||||
budget := int(thinkingBudget.Int())
|
budget := int(thinkingBudget.Int())
|
||||||
if effort, ok := util.ThinkingBudgetToEffort(modelName, budget); ok && effort != "" {
|
if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" {
|
||||||
reasoningEffort = effort
|
reasoningEffort = effort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
@@ -160,12 +160,15 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
|
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
|
||||||
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() && util.ModelSupportsThinking(modelName) {
|
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
|
||||||
if t.Get("type").String() == "enabled" {
|
modelInfo := registry.LookupModelInfo(modelName)
|
||||||
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
|
if modelInfo != nil && modelInfo.Thinking != nil {
|
||||||
budget := int(b.Int())
|
if t.Get("type").String() == "enabled" {
|
||||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
|
||||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
|
budget := int(b.Int())
|
||||||
|
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||||
|
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,37 +35,19 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
|
|||||||
// Model
|
// Model
|
||||||
out, _ = sjson.SetBytes(out, "model", modelName)
|
out, _ = sjson.SetBytes(out, "model", modelName)
|
||||||
|
|
||||||
// Reasoning effort -> thinkingBudget/include_thoughts
|
// Apply thinking configuration: convert OpenAI reasoning_effort to Gemini CLI thinkingConfig.
|
||||||
// Note: OpenAI official fields take precedence over extra_body.google.thinking_config
|
// Inline translation-only mapping; capability checks happen later in ApplyThinking.
|
||||||
re := gjson.GetBytes(rawJSON, "reasoning_effort")
|
re := gjson.GetBytes(rawJSON, "reasoning_effort")
|
||||||
hasOfficialThinking := re.Exists()
|
if re.Exists() {
|
||||||
if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
|
effort := strings.ToLower(strings.TrimSpace(re.String()))
|
||||||
out = util.ApplyReasoningEffortToGeminiCLI(out, re.String())
|
if effort != "" {
|
||||||
}
|
thinkingPath := "request.generationConfig.thinkingConfig"
|
||||||
|
if effort == "auto" {
|
||||||
// Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent)
|
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1)
|
||||||
// Only apply for models that use numeric budgets, not discrete levels.
|
out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", true)
|
||||||
if !hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
|
} else {
|
||||||
if tc := gjson.GetBytes(rawJSON, "extra_body.google.thinking_config"); tc.Exists() && tc.IsObject() {
|
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort)
|
||||||
var setBudget bool
|
out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", effort != "none")
|
||||||
var budget int
|
|
||||||
|
|
||||||
if v := tc.Get("thinkingBudget"); v.Exists() {
|
|
||||||
budget = int(v.Int())
|
|
||||||
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
|
||||||
setBudget = true
|
|
||||||
} else if v := tc.Get("thinking_budget"); v.Exists() {
|
|
||||||
budget = int(v.Int())
|
|
||||||
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
|
||||||
setBudget = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if v := tc.Get("includeThoughts"); v.Exists() {
|
|
||||||
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", v.Bool())
|
|
||||||
} else if v := tc.Get("include_thoughts"); v.Exists() {
|
|
||||||
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", v.Bool())
|
|
||||||
} else if setBudget && budget != 0 {
|
|
||||||
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
@@ -154,12 +154,15 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
|
|
||||||
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when enabled
|
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when enabled
|
||||||
// Only apply for models that use numeric budgets, not discrete levels.
|
// Only apply for models that use numeric budgets, not discrete levels.
|
||||||
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
|
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
|
||||||
if t.Get("type").String() == "enabled" {
|
modelInfo := registry.LookupModelInfo(modelName)
|
||||||
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
|
if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) == 0 {
|
||||||
budget := int(b.Int())
|
if t.Get("type").String() == "enabled" {
|
||||||
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", budget)
|
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
|
||||||
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true)
|
budget := int(b.Int())
|
||||||
|
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||||
|
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,55 +35,19 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
// Model
|
// Model
|
||||||
out, _ = sjson.SetBytes(out, "model", modelName)
|
out, _ = sjson.SetBytes(out, "model", modelName)
|
||||||
|
|
||||||
// Reasoning effort -> thinkingBudget/include_thoughts
|
// Apply thinking configuration: convert OpenAI reasoning_effort to Gemini thinkingConfig.
|
||||||
// Note: OpenAI official fields take precedence over extra_body.google.thinking_config
|
// Inline translation-only mapping; capability checks happen later in ApplyThinking.
|
||||||
// Only apply numeric budgets for models that use budgets (not discrete levels) to avoid
|
|
||||||
// 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()
|
if re.Exists() {
|
||||||
if hasOfficialThinking && util.ModelSupportsThinking(modelName) {
|
|
||||||
effort := strings.ToLower(strings.TrimSpace(re.String()))
|
effort := strings.ToLower(strings.TrimSpace(re.String()))
|
||||||
if util.IsGemini3Model(modelName) {
|
if effort != "" {
|
||||||
switch effort {
|
thinkingPath := "generationConfig.thinkingConfig"
|
||||||
case "none":
|
if effort == "auto" {
|
||||||
out, _ = sjson.DeleteBytes(out, "generationConfig.thinkingConfig")
|
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1)
|
||||||
case "auto":
|
out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", true)
|
||||||
includeThoughts := true
|
} else {
|
||||||
out = util.ApplyGeminiThinkingLevel(out, "", &includeThoughts)
|
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort)
|
||||||
default:
|
out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", effort != "none")
|
||||||
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)
|
|
||||||
// Only apply for models that use numeric budgets, not discrete levels.
|
|
||||||
if !hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
|
|
||||||
if tc := gjson.GetBytes(rawJSON, "extra_body.google.thinking_config"); tc.Exists() && tc.IsObject() {
|
|
||||||
var setBudget bool
|
|
||||||
var budget int
|
|
||||||
|
|
||||||
if v := tc.Get("thinkingBudget"); v.Exists() {
|
|
||||||
budget = int(v.Int())
|
|
||||||
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", budget)
|
|
||||||
setBudget = true
|
|
||||||
} else if v := tc.Get("thinking_budget"); v.Exists() {
|
|
||||||
budget = int(v.Int())
|
|
||||||
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", budget)
|
|
||||||
setBudget = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if v := tc.Get("includeThoughts"); v.Exists() {
|
|
||||||
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.include_thoughts", v.Bool())
|
|
||||||
} else if v := tc.Get("include_thoughts"); v.Exists() {
|
|
||||||
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.include_thoughts", v.Bool())
|
|
||||||
} else if setBudget && budget != 0 {
|
|
||||||
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.include_thoughts", true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
@@ -388,31 +387,19 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
|
|||||||
out, _ = sjson.Set(out, "generationConfig.stopSequences", sequences)
|
out, _ = sjson.Set(out, "generationConfig.stopSequences", sequences)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI official reasoning fields take precedence
|
// Apply thinking configuration: convert OpenAI Responses API reasoning.effort to Gemini thinkingConfig.
|
||||||
// Only convert for models that use numeric budgets (not discrete levels).
|
// Inline translation-only mapping; capability checks happen later in ApplyThinking.
|
||||||
hasOfficialThinking := root.Get("reasoning.effort").Exists()
|
re := root.Get("reasoning.effort")
|
||||||
if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
|
if re.Exists() {
|
||||||
reasoningEffort := root.Get("reasoning.effort")
|
effort := strings.ToLower(strings.TrimSpace(re.String()))
|
||||||
out = string(util.ApplyReasoningEffortToGemini([]byte(out), reasoningEffort.String()))
|
if effort != "" {
|
||||||
}
|
thinkingPath := "generationConfig.thinkingConfig"
|
||||||
|
if effort == "auto" {
|
||||||
// Cherry Studio extension (applies only when official fields are missing)
|
out, _ = sjson.Set(out, thinkingPath+".thinkingBudget", -1)
|
||||||
// Only apply for models that use numeric budgets, not discrete levels.
|
out, _ = sjson.Set(out, thinkingPath+".includeThoughts", true)
|
||||||
if !hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
|
} else {
|
||||||
if tc := root.Get("extra_body.google.thinking_config"); tc.Exists() && tc.IsObject() {
|
out, _ = sjson.Set(out, thinkingPath+".thinkingLevel", effort)
|
||||||
var setBudget bool
|
out, _ = sjson.Set(out, thinkingPath+".includeThoughts", effort != "none")
|
||||||
var budget int
|
|
||||||
if v := tc.Get("thinking_budget"); v.Exists() {
|
|
||||||
budget = int(v.Int())
|
|
||||||
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", budget)
|
|
||||||
setBudget = true
|
|
||||||
}
|
|
||||||
if v := tc.Get("include_thoughts"); v.Exists() {
|
|
||||||
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", v.Bool())
|
|
||||||
} else if setBudget {
|
|
||||||
if budget != 0 {
|
|
||||||
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
@@ -61,23 +61,23 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
|
|||||||
out, _ = sjson.Set(out, "stream", stream)
|
out, _ = sjson.Set(out, "stream", stream)
|
||||||
|
|
||||||
// Thinking: Convert Claude thinking.budget_tokens to OpenAI reasoning_effort
|
// Thinking: Convert Claude thinking.budget_tokens to OpenAI reasoning_effort
|
||||||
if thinking := root.Get("thinking"); thinking.Exists() && thinking.IsObject() {
|
if thinkingConfig := root.Get("thinking"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
||||||
if thinkingType := thinking.Get("type"); thinkingType.Exists() {
|
if thinkingType := thinkingConfig.Get("type"); thinkingType.Exists() {
|
||||||
switch thinkingType.String() {
|
switch thinkingType.String() {
|
||||||
case "enabled":
|
case "enabled":
|
||||||
if budgetTokens := thinking.Get("budget_tokens"); budgetTokens.Exists() {
|
if budgetTokens := thinkingConfig.Get("budget_tokens"); budgetTokens.Exists() {
|
||||||
budget := int(budgetTokens.Int())
|
budget := int(budgetTokens.Int())
|
||||||
if effort, ok := util.ThinkingBudgetToEffort(modelName, budget); ok && effort != "" {
|
if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" {
|
||||||
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No budget_tokens specified, default to "auto" for enabled thinking
|
// No budget_tokens specified, default to "auto" for enabled thinking
|
||||||
if effort, ok := util.ThinkingBudgetToEffort(modelName, -1); ok && effort != "" {
|
if effort, ok := thinking.ConvertBudgetToLevel(-1); ok && effort != "" {
|
||||||
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "disabled":
|
case "disabled":
|
||||||
if effort, ok := util.ThinkingBudgetToEffort(modelName, 0); ok && effort != "" {
|
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
|
||||||
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
|
|||||||
case "thinking":
|
case "thinking":
|
||||||
// Only map thinking to reasoning_content for assistant messages (security: prevent injection)
|
// Only map thinking to reasoning_content for assistant messages (security: prevent injection)
|
||||||
if role == "assistant" {
|
if role == "assistant" {
|
||||||
thinkingText := util.GetThinkingText(part)
|
thinkingText := thinking.GetThinkingText(part)
|
||||||
// Skip empty or whitespace-only thinking
|
// Skip empty or whitespace-only thinking
|
||||||
if strings.TrimSpace(thinkingText) != "" {
|
if strings.TrimSpace(thinkingText) != "" {
|
||||||
reasoningParts = append(reasoningParts, thinkingText)
|
reasoningParts = append(reasoningParts, thinkingText)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
@@ -82,7 +82,7 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
|
|||||||
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
||||||
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
|
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
|
||||||
budget := int(thinkingBudget.Int())
|
budget := int(thinkingBudget.Int())
|
||||||
if effort, ok := util.ThinkingBudgetToEffort(modelName, budget); ok && effort != "" {
|
if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" {
|
||||||
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
package util
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
"github.com/tidwall/sjson"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ApplyClaudeThinkingConfig applies thinking configuration to a Claude API request payload.
|
|
||||||
// It sets the thinking.type to "enabled" and thinking.budget_tokens to the specified budget.
|
|
||||||
// If budget is nil or the payload already has thinking config, it returns the payload unchanged.
|
|
||||||
func ApplyClaudeThinkingConfig(body []byte, budget *int) []byte {
|
|
||||||
if budget == nil {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
if gjson.GetBytes(body, "thinking").Exists() {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
if *budget <= 0 {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
updated := body
|
|
||||||
updated, _ = sjson.SetBytes(updated, "thinking.type", "enabled")
|
|
||||||
updated, _ = sjson.SetBytes(updated, "thinking.budget_tokens", *budget)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveClaudeThinkingConfig resolves thinking configuration from metadata for Claude models.
|
|
||||||
// It uses the unified ResolveThinkingConfigFromMetadata and normalizes the budget.
|
|
||||||
// Returns the normalized budget (nil if thinking should not be enabled) and whether it matched.
|
|
||||||
func ResolveClaudeThinkingConfig(modelName string, metadata map[string]any) (*int, bool) {
|
|
||||||
if !ModelSupportsThinking(modelName) {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
budget, include, matched := ResolveThinkingConfigFromMetadata(modelName, metadata)
|
|
||||||
if !matched {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if include != nil && !*include {
|
|
||||||
return nil, true
|
|
||||||
}
|
|
||||||
if budget == nil {
|
|
||||||
return nil, true
|
|
||||||
}
|
|
||||||
normalized := NormalizeThinkingBudget(modelName, *budget)
|
|
||||||
if normalized <= 0 {
|
|
||||||
return nil, true
|
|
||||||
}
|
|
||||||
return &normalized, true
|
|
||||||
}
|
|
||||||
@@ -1,617 +0,0 @@
|
|||||||
package util
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
"github.com/tidwall/sjson"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
GeminiThinkingBudgetMetadataKey = "gemini_thinking_budget"
|
|
||||||
GeminiIncludeThoughtsMetadataKey = "gemini_include_thoughts"
|
|
||||||
GeminiOriginalModelMetadataKey = "gemini_original_model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Gemini model family detection patterns
|
|
||||||
var (
|
|
||||||
gemini3Pattern = regexp.MustCompile(`(?i)^gemini[_-]?3[_-]`)
|
|
||||||
gemini3ProPattern = regexp.MustCompile(`(?i)^gemini[_-]?3[_-]pro`)
|
|
||||||
gemini3FlashPattern = regexp.MustCompile(`(?i)^gemini[_-]?3[_-]flash`)
|
|
||||||
gemini25Pattern = regexp.MustCompile(`(?i)^gemini[_-]?2\.5[_-]`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// IsGemini3Model returns true if the model is a Gemini 3 family model.
|
|
||||||
// Gemini 3 models should use thinkingLevel (string) instead of thinkingBudget (number).
|
|
||||||
func IsGemini3Model(model string) bool {
|
|
||||||
return gemini3Pattern.MatchString(model)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsGemini3ProModel returns true if the model is a Gemini 3 Pro variant.
|
|
||||||
// Gemini 3 Pro supports thinkingLevel: "low", "high" (default: "high")
|
|
||||||
func IsGemini3ProModel(model string) bool {
|
|
||||||
return gemini3ProPattern.MatchString(model)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsGemini3FlashModel returns true if the model is a Gemini 3 Flash variant.
|
|
||||||
// Gemini 3 Flash supports thinkingLevel: "minimal", "low", "medium", "high" (default: "high")
|
|
||||||
func IsGemini3FlashModel(model string) bool {
|
|
||||||
return gemini3FlashPattern.MatchString(model)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsGemini25Model returns true if the model is a Gemini 2.5 family model.
|
|
||||||
// Gemini 2.5 models should use thinkingBudget (number).
|
|
||||||
func IsGemini25Model(model string) bool {
|
|
||||||
return gemini25Pattern.MatchString(model)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini3ProThinkingLevels are the valid thinkingLevel values for Gemini 3 Pro models.
|
|
||||||
var Gemini3ProThinkingLevels = []string{"low", "high"}
|
|
||||||
|
|
||||||
// Gemini3FlashThinkingLevels are the valid thinkingLevel values for Gemini 3 Flash models.
|
|
||||||
var Gemini3FlashThinkingLevels = []string{"minimal", "low", "medium", "high"}
|
|
||||||
|
|
||||||
func ApplyGeminiThinkingConfig(body []byte, budget *int, includeThoughts *bool) []byte {
|
|
||||||
if budget == nil && includeThoughts == nil {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
updated := body
|
|
||||||
if budget != nil {
|
|
||||||
valuePath := "generationConfig.thinkingConfig.thinkingBudget"
|
|
||||||
rewritten, err := sjson.SetBytes(updated, valuePath, *budget)
|
|
||||||
if err == nil {
|
|
||||||
updated = rewritten
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Default to including thoughts when a budget override is present but no explicit include flag is provided.
|
|
||||||
incl := includeThoughts
|
|
||||||
if incl == nil && budget != nil && *budget != 0 {
|
|
||||||
defaultInclude := true
|
|
||||||
incl = &defaultInclude
|
|
||||||
}
|
|
||||||
if incl != nil {
|
|
||||||
if !gjson.GetBytes(updated, "generationConfig.thinkingConfig.includeThoughts").Exists() &&
|
|
||||||
!gjson.GetBytes(updated, "generationConfig.thinkingConfig.include_thoughts").Exists() {
|
|
||||||
valuePath := "generationConfig.thinkingConfig.include_thoughts"
|
|
||||||
rewritten, err := sjson.SetBytes(updated, valuePath, *incl)
|
|
||||||
if err == nil {
|
|
||||||
updated = rewritten
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
func ApplyGeminiCLIThinkingConfig(body []byte, budget *int, includeThoughts *bool) []byte {
|
|
||||||
if budget == nil && includeThoughts == nil {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
updated := body
|
|
||||||
if budget != nil {
|
|
||||||
valuePath := "request.generationConfig.thinkingConfig.thinkingBudget"
|
|
||||||
rewritten, err := sjson.SetBytes(updated, valuePath, *budget)
|
|
||||||
if err == nil {
|
|
||||||
updated = rewritten
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Default to including thoughts when a budget override is present but no explicit include flag is provided.
|
|
||||||
incl := includeThoughts
|
|
||||||
if incl == nil && budget != nil && *budget != 0 {
|
|
||||||
defaultInclude := true
|
|
||||||
incl = &defaultInclude
|
|
||||||
}
|
|
||||||
if incl != nil {
|
|
||||||
if !gjson.GetBytes(updated, "request.generationConfig.thinkingConfig.includeThoughts").Exists() &&
|
|
||||||
!gjson.GetBytes(updated, "request.generationConfig.thinkingConfig.include_thoughts").Exists() {
|
|
||||||
valuePath := "request.generationConfig.thinkingConfig.include_thoughts"
|
|
||||||
rewritten, err := sjson.SetBytes(updated, valuePath, *incl)
|
|
||||||
if err == nil {
|
|
||||||
updated = rewritten
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyGeminiThinkingLevel applies thinkingLevel config for Gemini 3 models.
|
|
||||||
// For standard Gemini API format (generationConfig.thinkingConfig path).
|
|
||||||
// Per Google's documentation, Gemini 3 models should use thinkingLevel instead of thinkingBudget.
|
|
||||||
func ApplyGeminiThinkingLevel(body []byte, level string, includeThoughts *bool) []byte {
|
|
||||||
if level == "" && includeThoughts == nil {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
updated := body
|
|
||||||
if level != "" {
|
|
||||||
valuePath := "generationConfig.thinkingConfig.thinkingLevel"
|
|
||||||
rewritten, err := sjson.SetBytes(updated, valuePath, level)
|
|
||||||
if err == nil {
|
|
||||||
updated = rewritten
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Default to including thoughts when a level is set but no explicit include flag is provided.
|
|
||||||
incl := includeThoughts
|
|
||||||
if incl == nil && level != "" {
|
|
||||||
defaultInclude := true
|
|
||||||
incl = &defaultInclude
|
|
||||||
}
|
|
||||||
if incl != nil {
|
|
||||||
if !gjson.GetBytes(updated, "generationConfig.thinkingConfig.includeThoughts").Exists() &&
|
|
||||||
!gjson.GetBytes(updated, "generationConfig.thinkingConfig.include_thoughts").Exists() {
|
|
||||||
valuePath := "generationConfig.thinkingConfig.includeThoughts"
|
|
||||||
rewritten, err := sjson.SetBytes(updated, valuePath, *incl)
|
|
||||||
if err == nil {
|
|
||||||
updated = rewritten
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tb := gjson.GetBytes(body, "generationConfig.thinkingConfig.thinkingBudget"); tb.Exists() {
|
|
||||||
updated, _ = sjson.DeleteBytes(updated, "generationConfig.thinkingConfig.thinkingBudget")
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyGeminiCLIThinkingLevel applies thinkingLevel config for Gemini 3 models.
|
|
||||||
// For Gemini CLI API format (request.generationConfig.thinkingConfig path).
|
|
||||||
// Per Google's documentation, Gemini 3 models should use thinkingLevel instead of thinkingBudget.
|
|
||||||
func ApplyGeminiCLIThinkingLevel(body []byte, level string, includeThoughts *bool) []byte {
|
|
||||||
if level == "" && includeThoughts == nil {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
updated := body
|
|
||||||
if level != "" {
|
|
||||||
valuePath := "request.generationConfig.thinkingConfig.thinkingLevel"
|
|
||||||
rewritten, err := sjson.SetBytes(updated, valuePath, level)
|
|
||||||
if err == nil {
|
|
||||||
updated = rewritten
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Default to including thoughts when a level is set but no explicit include flag is provided.
|
|
||||||
incl := includeThoughts
|
|
||||||
if incl == nil && level != "" {
|
|
||||||
defaultInclude := true
|
|
||||||
incl = &defaultInclude
|
|
||||||
}
|
|
||||||
if incl != nil {
|
|
||||||
if !gjson.GetBytes(updated, "request.generationConfig.thinkingConfig.includeThoughts").Exists() &&
|
|
||||||
!gjson.GetBytes(updated, "request.generationConfig.thinkingConfig.include_thoughts").Exists() {
|
|
||||||
valuePath := "request.generationConfig.thinkingConfig.includeThoughts"
|
|
||||||
rewritten, err := sjson.SetBytes(updated, valuePath, *incl)
|
|
||||||
if err == nil {
|
|
||||||
updated = rewritten
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tb := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget"); tb.Exists() {
|
|
||||||
updated, _ = sjson.DeleteBytes(updated, "request.generationConfig.thinkingConfig.thinkingBudget")
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateGemini3ThinkingLevel validates that the thinkingLevel is valid for the Gemini 3 model variant.
|
|
||||||
// Returns the validated level (normalized to lowercase) and true if valid, or empty string and false if invalid.
|
|
||||||
func ValidateGemini3ThinkingLevel(model, level string) (string, bool) {
|
|
||||||
if level == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
normalized := strings.ToLower(strings.TrimSpace(level))
|
|
||||||
|
|
||||||
var validLevels []string
|
|
||||||
if IsGemini3ProModel(model) {
|
|
||||||
validLevels = Gemini3ProThinkingLevels
|
|
||||||
} else if IsGemini3FlashModel(model) {
|
|
||||||
validLevels = Gemini3FlashThinkingLevels
|
|
||||||
} else if IsGemini3Model(model) {
|
|
||||||
// Unknown Gemini 3 variant - allow all levels as fallback
|
|
||||||
validLevels = Gemini3FlashThinkingLevels
|
|
||||||
} else {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, valid := range validLevels {
|
|
||||||
if normalized == valid {
|
|
||||||
return normalized, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ThinkingBudgetToGemini3Level converts a thinkingBudget to a thinkingLevel for Gemini 3 models.
|
|
||||||
// This provides backward compatibility when thinkingBudget is provided for Gemini 3 models.
|
|
||||||
// Returns the appropriate thinkingLevel and true if conversion is possible.
|
|
||||||
func ThinkingBudgetToGemini3Level(model string, budget int) (string, bool) {
|
|
||||||
if !IsGemini3Model(model) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map budget to level based on Google's documentation
|
|
||||||
// Gemini 3 Pro: "low", "high" (default: "high")
|
|
||||||
// Gemini 3 Flash: "minimal", "low", "medium", "high" (default: "high")
|
|
||||||
switch {
|
|
||||||
case budget == -1:
|
|
||||||
// Dynamic budget maps to "high" (API default)
|
|
||||||
return "high", true
|
|
||||||
case budget == 0:
|
|
||||||
// Zero budget - Gemini 3 doesn't support disabling thinking
|
|
||||||
// Map to lowest available level
|
|
||||||
if IsGemini3FlashModel(model) {
|
|
||||||
return "minimal", true
|
|
||||||
}
|
|
||||||
return "low", true
|
|
||||||
case budget > 0 && budget <= 512:
|
|
||||||
if IsGemini3FlashModel(model) {
|
|
||||||
return "minimal", true
|
|
||||||
}
|
|
||||||
return "low", true
|
|
||||||
case budget <= 1024:
|
|
||||||
return "low", true
|
|
||||||
case budget <= 8192:
|
|
||||||
if IsGemini3FlashModel(model) {
|
|
||||||
return "medium", true
|
|
||||||
}
|
|
||||||
return "low", true // Pro doesn't have medium, use low
|
|
||||||
default:
|
|
||||||
return "high", true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// modelsWithDefaultThinking lists models that should have thinking enabled by default
|
|
||||||
// when no explicit thinkingConfig is provided.
|
|
||||||
// Note: Gemini 3 models are NOT included here because per Google's official documentation:
|
|
||||||
// - thinkingLevel defaults to "high" (dynamic thinking)
|
|
||||||
// - includeThoughts defaults to false
|
|
||||||
//
|
|
||||||
// We should not override these API defaults; let users explicitly configure if needed.
|
|
||||||
var modelsWithDefaultThinking = map[string]bool{
|
|
||||||
// "gemini-3-pro-preview": true,
|
|
||||||
// "gemini-3-pro-image-preview": true,
|
|
||||||
// "gemini-3-flash-preview": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModelHasDefaultThinking returns true if the model should have thinking enabled by default.
|
|
||||||
func ModelHasDefaultThinking(model string) bool {
|
|
||||||
return modelsWithDefaultThinking[model]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyDefaultThinkingIfNeeded injects default thinkingConfig for models that require it.
|
|
||||||
// For standard Gemini API format (generationConfig.thinkingConfig path).
|
|
||||||
// Returns the modified body if thinkingConfig was added, otherwise returns the original.
|
|
||||||
// For Gemini 3 models, uses thinkingLevel instead of thinkingBudget per Google's documentation.
|
|
||||||
func ApplyDefaultThinkingIfNeeded(model string, body []byte) []byte {
|
|
||||||
if !ModelHasDefaultThinking(model) {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
if gjson.GetBytes(body, "generationConfig.thinkingConfig").Exists() {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
// Gemini 3 models use thinkingLevel instead of thinkingBudget
|
|
||||||
if IsGemini3Model(model) {
|
|
||||||
// Don't set a default - let the API use its dynamic default ("high")
|
|
||||||
// Only set includeThoughts
|
|
||||||
updated, _ := sjson.SetBytes(body, "generationConfig.thinkingConfig.includeThoughts", true)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
// Gemini 2.5 and other models use thinkingBudget
|
|
||||||
updated, _ := sjson.SetBytes(body, "generationConfig.thinkingConfig.thinkingBudget", -1)
|
|
||||||
updated, _ = sjson.SetBytes(updated, "generationConfig.thinkingConfig.include_thoughts", true)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyGemini3ThinkingLevelFromMetadata applies thinkingLevel from metadata for Gemini 3 models.
|
|
||||||
// For standard Gemini API format (generationConfig.thinkingConfig path).
|
|
||||||
// This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal))
|
|
||||||
// or numeric budget suffix (e.g., model(1000)) which gets converted to a thinkingLevel.
|
|
||||||
func ApplyGemini3ThinkingLevelFromMetadata(model string, metadata map[string]any, body []byte) []byte {
|
|
||||||
// Use the alias from metadata if available for model type detection
|
|
||||||
lookupModel := ResolveOriginalModel(model, metadata)
|
|
||||||
if !IsGemini3Model(lookupModel) && !IsGemini3Model(model) {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which model to use for validation
|
|
||||||
checkModel := model
|
|
||||||
if IsGemini3Model(lookupModel) {
|
|
||||||
checkModel = lookupModel
|
|
||||||
}
|
|
||||||
|
|
||||||
// First try to get effort string from metadata
|
|
||||||
effort, ok := ReasoningEffortFromMetadata(metadata)
|
|
||||||
if ok && effort != "" {
|
|
||||||
if level, valid := ValidateGemini3ThinkingLevel(checkModel, effort); valid {
|
|
||||||
return ApplyGeminiThinkingLevel(body, level, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: check for numeric budget and convert to thinkingLevel
|
|
||||||
budget, _, _, matched := ThinkingFromMetadata(metadata)
|
|
||||||
if matched && budget != nil {
|
|
||||||
if level, valid := ThinkingBudgetToGemini3Level(checkModel, *budget); valid {
|
|
||||||
return ApplyGeminiThinkingLevel(body, level, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyGemini3ThinkingLevelFromMetadataCLI applies thinkingLevel from metadata for Gemini 3 models.
|
|
||||||
// For Gemini CLI API format (request.generationConfig.thinkingConfig path).
|
|
||||||
// This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal))
|
|
||||||
// or numeric budget suffix (e.g., model(1000)) which gets converted to a thinkingLevel.
|
|
||||||
func ApplyGemini3ThinkingLevelFromMetadataCLI(model string, metadata map[string]any, body []byte) []byte {
|
|
||||||
// Use the alias from metadata if available for model type detection
|
|
||||||
lookupModel := ResolveOriginalModel(model, metadata)
|
|
||||||
if !IsGemini3Model(lookupModel) && !IsGemini3Model(model) {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which model to use for validation
|
|
||||||
checkModel := model
|
|
||||||
if IsGemini3Model(lookupModel) {
|
|
||||||
checkModel = lookupModel
|
|
||||||
}
|
|
||||||
|
|
||||||
// First try to get effort string from metadata
|
|
||||||
effort, ok := ReasoningEffortFromMetadata(metadata)
|
|
||||||
if ok && effort != "" {
|
|
||||||
if level, valid := ValidateGemini3ThinkingLevel(checkModel, effort); valid {
|
|
||||||
return ApplyGeminiCLIThinkingLevel(body, level, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: check for numeric budget and convert to thinkingLevel
|
|
||||||
budget, _, _, matched := ThinkingFromMetadata(metadata)
|
|
||||||
if matched && budget != nil {
|
|
||||||
if level, valid := ThinkingBudgetToGemini3Level(checkModel, *budget); valid {
|
|
||||||
return ApplyGeminiCLIThinkingLevel(body, level, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyDefaultThinkingIfNeededCLI injects default thinkingConfig for models that require it.
|
|
||||||
// For Gemini CLI API format (request.generationConfig.thinkingConfig path).
|
|
||||||
// Returns the modified body if thinkingConfig was added, otherwise returns the original.
|
|
||||||
// For Gemini 3 models, uses thinkingLevel instead of thinkingBudget per Google's documentation.
|
|
||||||
func ApplyDefaultThinkingIfNeededCLI(model string, metadata map[string]any, body []byte) []byte {
|
|
||||||
// Use the alias from metadata if available for model property lookup
|
|
||||||
lookupModel := ResolveOriginalModel(model, metadata)
|
|
||||||
if !ModelHasDefaultThinking(lookupModel) && !ModelHasDefaultThinking(model) {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
if gjson.GetBytes(body, "request.generationConfig.thinkingConfig").Exists() {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
// Gemini 3 models use thinkingLevel instead of thinkingBudget
|
|
||||||
if IsGemini3Model(lookupModel) || IsGemini3Model(model) {
|
|
||||||
// Don't set a default - let the API use its dynamic default ("high")
|
|
||||||
// Only set includeThoughts
|
|
||||||
updated, _ := sjson.SetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts", true)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
// Gemini 2.5 and other models use thinkingBudget
|
|
||||||
updated, _ := sjson.SetBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
|
|
||||||
updated, _ = sjson.SetBytes(updated, "request.generationConfig.thinkingConfig.include_thoughts", true)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// StripThinkingConfigIfUnsupported removes thinkingConfig from the request body
|
|
||||||
// when the target model does not advertise Thinking capability. It cleans both
|
|
||||||
// standard Gemini and Gemini CLI JSON envelopes. This acts as a final safety net
|
|
||||||
// in case upstream injected thinking for an unsupported model.
|
|
||||||
func StripThinkingConfigIfUnsupported(model string, body []byte) []byte {
|
|
||||||
if ModelSupportsThinking(model) || len(body) == 0 {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
updated := body
|
|
||||||
// Gemini CLI path
|
|
||||||
updated, _ = sjson.DeleteBytes(updated, "request.generationConfig.thinkingConfig")
|
|
||||||
// Standard Gemini path
|
|
||||||
updated, _ = sjson.DeleteBytes(updated, "generationConfig.thinkingConfig")
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// NormalizeGeminiThinkingBudget normalizes the thinkingBudget value in a standard Gemini
|
|
||||||
// request body (generationConfig.thinkingConfig.thinkingBudget path).
|
|
||||||
// For Gemini 3 models, converts thinkingBudget to thinkingLevel per Google's documentation,
|
|
||||||
// unless skipGemini3Check is provided and true.
|
|
||||||
func NormalizeGeminiThinkingBudget(model string, body []byte, skipGemini3Check ...bool) []byte {
|
|
||||||
const budgetPath = "generationConfig.thinkingConfig.thinkingBudget"
|
|
||||||
const levelPath = "generationConfig.thinkingConfig.thinkingLevel"
|
|
||||||
|
|
||||||
budget := gjson.GetBytes(body, budgetPath)
|
|
||||||
if !budget.Exists() {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Gemini 3 models, convert thinkingBudget to thinkingLevel
|
|
||||||
skipGemini3 := len(skipGemini3Check) > 0 && skipGemini3Check[0]
|
|
||||||
if IsGemini3Model(model) && !skipGemini3 {
|
|
||||||
if level, ok := ThinkingBudgetToGemini3Level(model, int(budget.Int())); ok {
|
|
||||||
updated, _ := sjson.SetBytes(body, levelPath, level)
|
|
||||||
updated, _ = sjson.DeleteBytes(updated, budgetPath)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
// If conversion fails, just remove the budget (let API use default)
|
|
||||||
updated, _ := sjson.DeleteBytes(body, budgetPath)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Gemini 2.5 and other models, normalize the budget value
|
|
||||||
normalized := NormalizeThinkingBudget(model, int(budget.Int()))
|
|
||||||
updated, _ := sjson.SetBytes(body, budgetPath, normalized)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// NormalizeGeminiCLIThinkingBudget normalizes the thinkingBudget value in a Gemini CLI
|
|
||||||
// request body (request.generationConfig.thinkingConfig.thinkingBudget path).
|
|
||||||
// For Gemini 3 models, converts thinkingBudget to thinkingLevel per Google's documentation,
|
|
||||||
// unless skipGemini3Check is provided and true.
|
|
||||||
func NormalizeGeminiCLIThinkingBudget(model string, body []byte, skipGemini3Check ...bool) []byte {
|
|
||||||
const budgetPath = "request.generationConfig.thinkingConfig.thinkingBudget"
|
|
||||||
const levelPath = "request.generationConfig.thinkingConfig.thinkingLevel"
|
|
||||||
|
|
||||||
budget := gjson.GetBytes(body, budgetPath)
|
|
||||||
if !budget.Exists() {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Gemini 3 models, convert thinkingBudget to thinkingLevel
|
|
||||||
skipGemini3 := len(skipGemini3Check) > 0 && skipGemini3Check[0]
|
|
||||||
if IsGemini3Model(model) && !skipGemini3 {
|
|
||||||
if level, ok := ThinkingBudgetToGemini3Level(model, int(budget.Int())); ok {
|
|
||||||
updated, _ := sjson.SetBytes(body, levelPath, level)
|
|
||||||
updated, _ = sjson.DeleteBytes(updated, budgetPath)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
// If conversion fails, just remove the budget (let API use default)
|
|
||||||
updated, _ := sjson.DeleteBytes(body, budgetPath)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Gemini 2.5 and other models, normalize the budget value
|
|
||||||
normalized := NormalizeThinkingBudget(model, int(budget.Int()))
|
|
||||||
updated, _ := sjson.SetBytes(body, budgetPath, normalized)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReasoningEffortBudgetMapping defines the thinkingBudget values for each reasoning effort level.
|
|
||||||
var ReasoningEffortBudgetMapping = map[string]int{
|
|
||||||
"none": 0,
|
|
||||||
"auto": -1,
|
|
||||||
"minimal": 512,
|
|
||||||
"low": 1024,
|
|
||||||
"medium": 8192,
|
|
||||||
"high": 24576,
|
|
||||||
"xhigh": 32768,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyReasoningEffortToGemini applies OpenAI reasoning_effort to Gemini thinkingConfig
|
|
||||||
// for standard Gemini API format (generationConfig.thinkingConfig path).
|
|
||||||
// Returns the modified body with thinkingBudget and include_thoughts set.
|
|
||||||
func ApplyReasoningEffortToGemini(body []byte, effort string) []byte {
|
|
||||||
normalized := strings.ToLower(strings.TrimSpace(effort))
|
|
||||||
if normalized == "" {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
budgetPath := "generationConfig.thinkingConfig.thinkingBudget"
|
|
||||||
includePath := "generationConfig.thinkingConfig.include_thoughts"
|
|
||||||
|
|
||||||
if normalized == "none" {
|
|
||||||
body, _ = sjson.DeleteBytes(body, "generationConfig.thinkingConfig")
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
budget, ok := ReasoningEffortBudgetMapping[normalized]
|
|
||||||
if !ok {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
body, _ = sjson.SetBytes(body, budgetPath, budget)
|
|
||||||
body, _ = sjson.SetBytes(body, includePath, true)
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyReasoningEffortToGeminiCLI applies OpenAI reasoning_effort to Gemini CLI thinkingConfig
|
|
||||||
// for Gemini CLI API format (request.generationConfig.thinkingConfig path).
|
|
||||||
// Returns the modified body with thinkingBudget and include_thoughts set.
|
|
||||||
func ApplyReasoningEffortToGeminiCLI(body []byte, effort string) []byte {
|
|
||||||
normalized := strings.ToLower(strings.TrimSpace(effort))
|
|
||||||
if normalized == "" {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
budgetPath := "request.generationConfig.thinkingConfig.thinkingBudget"
|
|
||||||
includePath := "request.generationConfig.thinkingConfig.include_thoughts"
|
|
||||||
|
|
||||||
if normalized == "none" {
|
|
||||||
body, _ = sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig")
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
budget, ok := ReasoningEffortBudgetMapping[normalized]
|
|
||||||
if !ok {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
body, _ = sjson.SetBytes(body, budgetPath, budget)
|
|
||||||
body, _ = sjson.SetBytes(body, includePath, true)
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConvertThinkingLevelToBudget checks for "generationConfig.thinkingConfig.thinkingLevel"
|
|
||||||
// and converts it to "thinkingBudget" for Gemini 2.5 models.
|
|
||||||
// For Gemini 3 models, preserves thinkingLevel unless skipGemini3Check is provided and true.
|
|
||||||
// Mappings for Gemini 2.5:
|
|
||||||
// - "high" -> 32768
|
|
||||||
// - "medium" -> 8192
|
|
||||||
// - "low" -> 1024
|
|
||||||
// - "minimal" -> 512
|
|
||||||
//
|
|
||||||
// It removes "thinkingLevel" after conversion (for Gemini 2.5 only).
|
|
||||||
func ConvertThinkingLevelToBudget(body []byte, model string, skipGemini3Check ...bool) []byte {
|
|
||||||
levelPath := "generationConfig.thinkingConfig.thinkingLevel"
|
|
||||||
res := gjson.GetBytes(body, levelPath)
|
|
||||||
if !res.Exists() {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Gemini 3 models, preserve thinkingLevel unless explicitly skipped
|
|
||||||
skipGemini3 := len(skipGemini3Check) > 0 && skipGemini3Check[0]
|
|
||||||
if IsGemini3Model(model) && !skipGemini3 {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
budget, ok := ThinkingLevelToBudget(res.String())
|
|
||||||
if !ok {
|
|
||||||
updated, _ := sjson.DeleteBytes(body, levelPath)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
budgetPath := "generationConfig.thinkingConfig.thinkingBudget"
|
|
||||||
updated, err := sjson.SetBytes(body, budgetPath, budget)
|
|
||||||
if err != nil {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err = sjson.DeleteBytes(updated, levelPath)
|
|
||||||
if err != nil {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConvertThinkingLevelToBudgetCLI checks for "request.generationConfig.thinkingConfig.thinkingLevel"
|
|
||||||
// and converts it to "thinkingBudget" for Gemini 2.5 models.
|
|
||||||
// For Gemini 3 models, preserves thinkingLevel as-is (does not convert).
|
|
||||||
func ConvertThinkingLevelToBudgetCLI(body []byte, model string) []byte {
|
|
||||||
levelPath := "request.generationConfig.thinkingConfig.thinkingLevel"
|
|
||||||
res := gjson.GetBytes(body, levelPath)
|
|
||||||
if !res.Exists() {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Gemini 3 models, preserve thinkingLevel - don't convert to budget
|
|
||||||
if IsGemini3Model(model) {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
budget, ok := ThinkingLevelToBudget(res.String())
|
|
||||||
if !ok {
|
|
||||||
updated, _ := sjson.DeleteBytes(body, levelPath)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
budgetPath := "request.generationConfig.thinkingConfig.thinkingBudget"
|
|
||||||
updated, err := sjson.SetBytes(body, budgetPath, budget)
|
|
||||||
if err != nil {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err = sjson.DeleteBytes(updated, levelPath)
|
|
||||||
if err != nil {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
package util
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ModelSupportsThinking reports whether the given model has Thinking capability
|
|
||||||
// according to the model registry metadata (provider-agnostic).
|
|
||||||
func ModelSupportsThinking(model string) bool {
|
|
||||||
if model == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// First check the global dynamic registry
|
|
||||||
if info := registry.GetGlobalRegistry().GetModelInfo(model); info != nil {
|
|
||||||
return info.Thinking != nil
|
|
||||||
}
|
|
||||||
// Fallback: check static model definitions
|
|
||||||
if info := registry.LookupStaticModelInfo(model); info != nil {
|
|
||||||
return info.Thinking != nil
|
|
||||||
}
|
|
||||||
// Fallback: check Antigravity static config
|
|
||||||
if cfg := registry.GetAntigravityModelConfig()[model]; cfg != nil {
|
|
||||||
return cfg.Thinking != nil
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// NormalizeThinkingBudget clamps the requested thinking budget to the
|
|
||||||
// supported range for the specified model using registry metadata only.
|
|
||||||
// If the model is unknown or has no Thinking metadata, returns the original budget.
|
|
||||||
// For dynamic (-1), returns -1 if DynamicAllowed; otherwise approximates mid-range
|
|
||||||
// or min (0 if zero is allowed and mid <= 0).
|
|
||||||
func NormalizeThinkingBudget(model string, budget int) int {
|
|
||||||
if budget == -1 { // dynamic
|
|
||||||
if found, minBudget, maxBudget, zeroAllowed, dynamicAllowed := thinkingRangeFromRegistry(model); found {
|
|
||||||
if dynamicAllowed {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
mid := (minBudget + maxBudget) / 2
|
|
||||||
if mid <= 0 && zeroAllowed {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if mid <= 0 {
|
|
||||||
return minBudget
|
|
||||||
}
|
|
||||||
return mid
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
if found, minBudget, maxBudget, zeroAllowed, _ := thinkingRangeFromRegistry(model); found {
|
|
||||||
if budget == 0 {
|
|
||||||
if zeroAllowed {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return minBudget
|
|
||||||
}
|
|
||||||
if budget < minBudget {
|
|
||||||
return minBudget
|
|
||||||
}
|
|
||||||
if budget > maxBudget {
|
|
||||||
return maxBudget
|
|
||||||
}
|
|
||||||
return budget
|
|
||||||
}
|
|
||||||
return budget
|
|
||||||
}
|
|
||||||
|
|
||||||
// thinkingRangeFromRegistry attempts to read thinking ranges from the model registry.
|
|
||||||
func thinkingRangeFromRegistry(model string) (found bool, min int, max int, zeroAllowed bool, dynamicAllowed bool) {
|
|
||||||
if model == "" {
|
|
||||||
return false, 0, 0, false, false
|
|
||||||
}
|
|
||||||
// First check global dynamic registry
|
|
||||||
if info := registry.GetGlobalRegistry().GetModelInfo(model); info != nil && info.Thinking != nil {
|
|
||||||
return true, info.Thinking.Min, info.Thinking.Max, info.Thinking.ZeroAllowed, info.Thinking.DynamicAllowed
|
|
||||||
}
|
|
||||||
// Fallback: check static model definitions
|
|
||||||
if info := registry.LookupStaticModelInfo(model); info != nil && info.Thinking != nil {
|
|
||||||
return true, info.Thinking.Min, info.Thinking.Max, info.Thinking.ZeroAllowed, info.Thinking.DynamicAllowed
|
|
||||||
}
|
|
||||||
// Fallback: check Antigravity static config
|
|
||||||
if cfg := registry.GetAntigravityModelConfig()[model]; cfg != nil && cfg.Thinking != nil {
|
|
||||||
return true, cfg.Thinking.Min, cfg.Thinking.Max, cfg.Thinking.ZeroAllowed, cfg.Thinking.DynamicAllowed
|
|
||||||
}
|
|
||||||
return false, 0, 0, false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetModelThinkingLevels returns the discrete reasoning effort levels for the model.
|
|
||||||
// Returns nil if the model has no thinking support or no levels defined.
|
|
||||||
func GetModelThinkingLevels(model string) []string {
|
|
||||||
if model == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
info := registry.GetGlobalRegistry().GetModelInfo(model)
|
|
||||||
if info == nil || info.Thinking == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return info.Thinking.Levels
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModelUsesThinkingLevels reports whether the model uses discrete reasoning
|
|
||||||
// effort levels instead of numeric budgets.
|
|
||||||
func ModelUsesThinkingLevels(model string) bool {
|
|
||||||
levels := GetModelThinkingLevels(model)
|
|
||||||
return len(levels) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// NormalizeReasoningEffortLevel validates and normalizes a reasoning effort
|
|
||||||
// level for the given model. Returns false when the level is not supported.
|
|
||||||
func NormalizeReasoningEffortLevel(model, effort string) (string, bool) {
|
|
||||||
levels := GetModelThinkingLevels(model)
|
|
||||||
if len(levels) == 0 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
loweredEffort := strings.ToLower(strings.TrimSpace(effort))
|
|
||||||
for _, lvl := range levels {
|
|
||||||
if strings.ToLower(lvl) == loweredEffort {
|
|
||||||
return lvl, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsOpenAICompatibilityModel reports whether the model is registered as an OpenAI-compatibility model.
|
|
||||||
// These models may not advertise Thinking metadata in the registry.
|
|
||||||
func IsOpenAICompatibilityModel(model string) bool {
|
|
||||||
if model == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
info := registry.GetGlobalRegistry().GetModelInfo(model)
|
|
||||||
if info == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.EqualFold(strings.TrimSpace(info.Type), "openai-compatibility")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ThinkingEffortToBudget maps a reasoning effort level to a numeric thinking budget (tokens),
|
|
||||||
// clamping the result to the model's supported range.
|
|
||||||
//
|
|
||||||
// Mappings (values are normalized to model's supported range):
|
|
||||||
// - "none" -> 0
|
|
||||||
// - "auto" -> -1
|
|
||||||
// - "minimal" -> 512
|
|
||||||
// - "low" -> 1024
|
|
||||||
// - "medium" -> 8192
|
|
||||||
// - "high" -> 24576
|
|
||||||
// - "xhigh" -> 32768
|
|
||||||
//
|
|
||||||
// Returns false when the effort level is empty or unsupported.
|
|
||||||
func ThinkingEffortToBudget(model, effort string) (int, bool) {
|
|
||||||
if effort == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
normalized, ok := NormalizeReasoningEffortLevel(model, effort)
|
|
||||||
if !ok {
|
|
||||||
normalized = strings.ToLower(strings.TrimSpace(effort))
|
|
||||||
}
|
|
||||||
switch normalized {
|
|
||||||
case "none":
|
|
||||||
return 0, true
|
|
||||||
case "auto":
|
|
||||||
return NormalizeThinkingBudget(model, -1), true
|
|
||||||
case "minimal":
|
|
||||||
return NormalizeThinkingBudget(model, 512), true
|
|
||||||
case "low":
|
|
||||||
return NormalizeThinkingBudget(model, 1024), true
|
|
||||||
case "medium":
|
|
||||||
return NormalizeThinkingBudget(model, 8192), true
|
|
||||||
case "high":
|
|
||||||
return NormalizeThinkingBudget(model, 24576), true
|
|
||||||
case "xhigh":
|
|
||||||
return NormalizeThinkingBudget(model, 32768), true
|
|
||||||
default:
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
// to a reasoning effort level for level-based models.
|
|
||||||
//
|
|
||||||
// Mappings:
|
|
||||||
// - 0 -> "none" (or lowest supported level if model doesn't support "none")
|
|
||||||
// - -1 -> "auto"
|
|
||||||
// - 1..1024 -> "low"
|
|
||||||
// - 1025..8192 -> "medium"
|
|
||||||
// - 8193..24576 -> "high"
|
|
||||||
// - 24577.. -> highest supported level for the model (defaults to "xhigh")
|
|
||||||
//
|
|
||||||
// Returns false when the budget is unsupported (negative values other than -1).
|
|
||||||
func ThinkingBudgetToEffort(model string, budget int) (string, bool) {
|
|
||||||
switch {
|
|
||||||
case budget == -1:
|
|
||||||
return "auto", true
|
|
||||||
case budget < -1:
|
|
||||||
return "", false
|
|
||||||
case budget == 0:
|
|
||||||
if levels := GetModelThinkingLevels(model); len(levels) > 0 {
|
|
||||||
return levels[0], true
|
|
||||||
}
|
|
||||||
return "none", true
|
|
||||||
case budget > 0 && budget <= 1024:
|
|
||||||
return "low", true
|
|
||||||
case budget <= 8192:
|
|
||||||
return "medium", true
|
|
||||||
case budget <= 24576:
|
|
||||||
return "high", true
|
|
||||||
case budget > 24576:
|
|
||||||
if levels := GetModelThinkingLevels(model); len(levels) > 0 {
|
|
||||||
return levels[len(levels)-1], true
|
|
||||||
}
|
|
||||||
return "xhigh", true
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
package util
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
ThinkingBudgetMetadataKey = "thinking_budget"
|
|
||||||
ThinkingIncludeThoughtsMetadataKey = "thinking_include_thoughts"
|
|
||||||
ReasoningEffortMetadataKey = "reasoning_effort"
|
|
||||||
ThinkingOriginalModelMetadataKey = "thinking_original_model"
|
|
||||||
ModelMappingOriginalModelMetadataKey = "model_mapping_original_model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NormalizeThinkingModel parses dynamic thinking suffixes on model names and returns
|
|
||||||
// the normalized base model with extracted metadata. Supported pattern:
|
|
||||||
// - "(<value>)" where value can be:
|
|
||||||
// - A numeric budget (e.g., "(8192)", "(16384)")
|
|
||||||
// - A reasoning effort level (e.g., "(high)", "(medium)", "(low)")
|
|
||||||
//
|
|
||||||
// Examples:
|
|
||||||
// - "claude-sonnet-4-5-20250929(16384)" → budget=16384
|
|
||||||
// - "gpt-5.1(high)" → reasoning_effort="high"
|
|
||||||
// - "gemini-2.5-pro(32768)" → budget=32768
|
|
||||||
//
|
|
||||||
// Note: Empty parentheses "()" are not supported and will be ignored.
|
|
||||||
func NormalizeThinkingModel(modelName string) (string, map[string]any) {
|
|
||||||
if modelName == "" {
|
|
||||||
return modelName, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
baseModel := modelName
|
|
||||||
|
|
||||||
var (
|
|
||||||
budgetOverride *int
|
|
||||||
reasoningEffort *string
|
|
||||||
matched bool
|
|
||||||
)
|
|
||||||
|
|
||||||
// Match "(<value>)" pattern at the end of the model name
|
|
||||||
if idx := strings.LastIndex(modelName, "("); idx != -1 {
|
|
||||||
if !strings.HasSuffix(modelName, ")") {
|
|
||||||
// Incomplete parenthesis, ignore
|
|
||||||
return baseModel, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
value := modelName[idx+1 : len(modelName)-1] // Extract content between ( and )
|
|
||||||
if value == "" {
|
|
||||||
// Empty parentheses not supported
|
|
||||||
return baseModel, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
candidateBase := modelName[:idx]
|
|
||||||
|
|
||||||
// Auto-detect: pure numeric → budget, string → reasoning effort level
|
|
||||||
if parsed, ok := parseIntPrefix(value); ok {
|
|
||||||
// Numeric value: treat as thinking budget
|
|
||||||
baseModel = candidateBase
|
|
||||||
budgetOverride = &parsed
|
|
||||||
matched = true
|
|
||||||
} else {
|
|
||||||
// String value: treat as reasoning effort level
|
|
||||||
baseModel = candidateBase
|
|
||||||
raw := strings.ToLower(strings.TrimSpace(value))
|
|
||||||
if raw != "" {
|
|
||||||
reasoningEffort = &raw
|
|
||||||
matched = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !matched {
|
|
||||||
return baseModel, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := map[string]any{
|
|
||||||
ThinkingOriginalModelMetadataKey: modelName,
|
|
||||||
}
|
|
||||||
if budgetOverride != nil {
|
|
||||||
metadata[ThinkingBudgetMetadataKey] = *budgetOverride
|
|
||||||
}
|
|
||||||
if reasoningEffort != nil {
|
|
||||||
metadata[ReasoningEffortMetadataKey] = *reasoningEffort
|
|
||||||
}
|
|
||||||
return baseModel, metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
// ThinkingFromMetadata extracts thinking overrides from metadata produced by NormalizeThinkingModel.
|
|
||||||
// It accepts both the new generic keys and legacy Gemini-specific keys.
|
|
||||||
func ThinkingFromMetadata(metadata map[string]any) (*int, *bool, *string, bool) {
|
|
||||||
if len(metadata) == 0 {
|
|
||||||
return nil, nil, nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
budgetPtr *int
|
|
||||||
includePtr *bool
|
|
||||||
effortPtr *string
|
|
||||||
matched bool
|
|
||||||
)
|
|
||||||
|
|
||||||
readBudget := func(key string) {
|
|
||||||
if budgetPtr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if raw, ok := metadata[key]; ok {
|
|
||||||
if v, okNumber := parseNumberToInt(raw); okNumber {
|
|
||||||
budget := v
|
|
||||||
budgetPtr = &budget
|
|
||||||
matched = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
readInclude := func(key string) {
|
|
||||||
if includePtr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if raw, ok := metadata[key]; ok {
|
|
||||||
switch v := raw.(type) {
|
|
||||||
case bool:
|
|
||||||
val := v
|
|
||||||
includePtr = &val
|
|
||||||
matched = true
|
|
||||||
case *bool:
|
|
||||||
if v != nil {
|
|
||||||
val := *v
|
|
||||||
includePtr = &val
|
|
||||||
matched = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
readEffort := func(key string) {
|
|
||||||
if effortPtr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if raw, ok := metadata[key]; ok {
|
|
||||||
if val, okStr := raw.(string); okStr && strings.TrimSpace(val) != "" {
|
|
||||||
normalized := strings.ToLower(strings.TrimSpace(val))
|
|
||||||
effortPtr = &normalized
|
|
||||||
matched = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
readBudget(ThinkingBudgetMetadataKey)
|
|
||||||
readBudget(GeminiThinkingBudgetMetadataKey)
|
|
||||||
readInclude(ThinkingIncludeThoughtsMetadataKey)
|
|
||||||
readInclude(GeminiIncludeThoughtsMetadataKey)
|
|
||||||
readEffort(ReasoningEffortMetadataKey)
|
|
||||||
readEffort("reasoning.effort")
|
|
||||||
|
|
||||||
return budgetPtr, includePtr, effortPtr, matched
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveThinkingConfigFromMetadata derives thinking budget/include overrides,
|
|
||||||
// converting reasoning effort strings into budgets when possible.
|
|
||||||
func ResolveThinkingConfigFromMetadata(model string, metadata map[string]any) (*int, *bool, bool) {
|
|
||||||
budget, include, effort, matched := ThinkingFromMetadata(metadata)
|
|
||||||
if !matched {
|
|
||||||
return nil, nil, false
|
|
||||||
}
|
|
||||||
// Level-based models (OpenAI-style) do not accept numeric thinking budgets in
|
|
||||||
// Claude/Gemini-style protocols, so we don't derive budgets for them here.
|
|
||||||
if ModelUsesThinkingLevels(model) {
|
|
||||||
return nil, nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if budget == nil && effort != nil {
|
|
||||||
if derived, ok := ThinkingEffortToBudget(model, *effort); ok {
|
|
||||||
budget = &derived
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return budget, include, budget != nil || include != nil || effort != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReasoningEffortFromMetadata resolves a reasoning effort string from metadata,
|
|
||||||
// inferring "auto" and "none" when budgets request dynamic or disabled thinking.
|
|
||||||
func ReasoningEffortFromMetadata(metadata map[string]any) (string, bool) {
|
|
||||||
budget, include, effort, matched := ThinkingFromMetadata(metadata)
|
|
||||||
if !matched {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if effort != nil && *effort != "" {
|
|
||||||
return strings.ToLower(strings.TrimSpace(*effort)), true
|
|
||||||
}
|
|
||||||
if budget != nil {
|
|
||||||
switch *budget {
|
|
||||||
case -1:
|
|
||||||
return "auto", true
|
|
||||||
case 0:
|
|
||||||
return "none", true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if include != nil && !*include {
|
|
||||||
return "none", true
|
|
||||||
}
|
|
||||||
return "", true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveOriginalModel returns the original model name stored in metadata (if present),
|
|
||||||
// otherwise falls back to the provided model.
|
|
||||||
func ResolveOriginalModel(model string, metadata map[string]any) string {
|
|
||||||
normalize := func(name string) string {
|
|
||||||
if name == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if base, _ := NormalizeThinkingModel(name); base != "" {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata != nil {
|
|
||||||
if v, ok := metadata[ModelMappingOriginalModelMetadataKey]; ok {
|
|
||||||
if s, okStr := v.(string); okStr && strings.TrimSpace(s) != "" {
|
|
||||||
if base := normalize(s); base != "" {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, ok := metadata[ThinkingOriginalModelMetadataKey]; ok {
|
|
||||||
if s, okStr := v.(string); okStr && strings.TrimSpace(s) != "" {
|
|
||||||
if base := normalize(s); base != "" {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, ok := metadata[GeminiOriginalModelMetadataKey]; ok {
|
|
||||||
if s, okStr := v.(string); okStr && strings.TrimSpace(s) != "" {
|
|
||||||
if base := normalize(s); base != "" {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: try to re-normalize the model name when metadata was dropped.
|
|
||||||
if base := normalize(model); base != "" {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
return model
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseIntPrefix(value string) (int, bool) {
|
|
||||||
if value == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
digits := strings.TrimLeft(value, "-")
|
|
||||||
if digits == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
end := len(digits)
|
|
||||||
for i := 0; i < len(digits); i++ {
|
|
||||||
if digits[i] < '0' || digits[i] > '9' {
|
|
||||||
end = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if end == 0 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
val, err := strconv.Atoi(digits[:end])
|
|
||||||
if err != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return val, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseNumberToInt(raw any) (int, bool) {
|
|
||||||
switch v := raw.(type) {
|
|
||||||
case int:
|
|
||||||
return v, true
|
|
||||||
case int32:
|
|
||||||
return int(v), true
|
|
||||||
case int64:
|
|
||||||
return int(v), true
|
|
||||||
case float64:
|
|
||||||
return int(v), true
|
|
||||||
case json.Number:
|
|
||||||
if val, err := v.Int64(); err == nil {
|
|
||||||
return int(val), true
|
|
||||||
}
|
|
||||||
case string:
|
|
||||||
if strings.TrimSpace(v) == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
if parsed, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
|
||||||
return parsed, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package util
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
"github.com/tidwall/sjson"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetThinkingText extracts the thinking text from a content part.
|
|
||||||
// Handles various formats:
|
|
||||||
// - Simple string: { "thinking": "text" } or { "text": "text" }
|
|
||||||
// - Wrapped object: { "thinking": { "text": "text", "cache_control": {...} } }
|
|
||||||
// - Gemini-style: { "thought": true, "text": "text" }
|
|
||||||
// Returns the extracted text string.
|
|
||||||
func GetThinkingText(part gjson.Result) string {
|
|
||||||
// Try direct text field first (Gemini-style)
|
|
||||||
if text := part.Get("text"); text.Exists() && text.Type == gjson.String {
|
|
||||||
return text.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try thinking field
|
|
||||||
thinkingField := part.Get("thinking")
|
|
||||||
if !thinkingField.Exists() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// thinking is a string
|
|
||||||
if thinkingField.Type == gjson.String {
|
|
||||||
return thinkingField.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// thinking is an object with inner text/thinking
|
|
||||||
if thinkingField.IsObject() {
|
|
||||||
if inner := thinkingField.Get("text"); inner.Exists() && inner.Type == gjson.String {
|
|
||||||
return inner.String()
|
|
||||||
}
|
|
||||||
if inner := thinkingField.Get("thinking"); inner.Exists() && inner.Type == gjson.String {
|
|
||||||
return inner.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetThinkingTextFromJSON extracts thinking text from a raw JSON string.
|
|
||||||
func GetThinkingTextFromJSON(jsonStr string) string {
|
|
||||||
return GetThinkingText(gjson.Parse(jsonStr))
|
|
||||||
}
|
|
||||||
|
|
||||||
// SanitizeThinkingPart normalizes a thinking part to a canonical form.
|
|
||||||
// Strips cache_control and other non-essential fields.
|
|
||||||
// Returns the sanitized part as JSON string.
|
|
||||||
func SanitizeThinkingPart(part gjson.Result) string {
|
|
||||||
// Gemini-style: { thought: true, text, thoughtSignature }
|
|
||||||
if part.Get("thought").Bool() {
|
|
||||||
result := `{"thought":true}`
|
|
||||||
if text := GetThinkingText(part); text != "" {
|
|
||||||
result, _ = sjson.Set(result, "text", text)
|
|
||||||
}
|
|
||||||
if sig := part.Get("thoughtSignature"); sig.Exists() && sig.Type == gjson.String {
|
|
||||||
result, _ = sjson.Set(result, "thoughtSignature", sig.String())
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anthropic-style: { type: "thinking", thinking, signature }
|
|
||||||
if part.Get("type").String() == "thinking" || part.Get("thinking").Exists() {
|
|
||||||
result := `{"type":"thinking"}`
|
|
||||||
if text := GetThinkingText(part); text != "" {
|
|
||||||
result, _ = sjson.Set(result, "thinking", text)
|
|
||||||
}
|
|
||||||
if sig := part.Get("signature"); sig.Exists() && sig.Type == gjson.String {
|
|
||||||
result, _ = sjson.Set(result, "signature", sig.String())
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not a thinking part, return as-is but strip cache_control
|
|
||||||
return StripCacheControl(part.Raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StripCacheControl removes cache_control and providerOptions from a JSON object.
|
|
||||||
func StripCacheControl(jsonStr string) string {
|
|
||||||
result := jsonStr
|
|
||||||
result, _ = sjson.Delete(result, "cache_control")
|
|
||||||
result, _ = sjson.Delete(result, "providerOptions")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -127,7 +127,7 @@ func (w *Watcher) reloadConfig() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authDirChanged := oldConfig == nil || oldConfig.AuthDir != newConfig.AuthDir
|
authDirChanged := oldConfig == nil || oldConfig.AuthDir != newConfig.AuthDir
|
||||||
forceAuthRefresh := oldConfig != nil && (oldConfig.ForceModelPrefix != newConfig.ForceModelPrefix || !reflect.DeepEqual(oldConfig.OAuthModelMappings, newConfig.OAuthModelMappings))
|
forceAuthRefresh := oldConfig != nil && (oldConfig.ForceModelPrefix != newConfig.ForceModelPrefix || !reflect.DeepEqual(oldConfig.OAuthModelAlias, newConfig.OAuthModelAlias))
|
||||||
|
|
||||||
log.Infof("config successfully reloaded, triggering client reload")
|
log.Infof("config successfully reloaded, triggering client reload")
|
||||||
w.reloadClients(authDirChanged, affectedOAuthProviders, forceAuthRefresh)
|
w.reloadClients(authDirChanged, affectedOAuthProviders, forceAuthRefresh)
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
|
|||||||
if entries, _ := DiffOAuthExcludedModelChanges(oldCfg.OAuthExcludedModels, newCfg.OAuthExcludedModels); len(entries) > 0 {
|
if entries, _ := DiffOAuthExcludedModelChanges(oldCfg.OAuthExcludedModels, newCfg.OAuthExcludedModels); len(entries) > 0 {
|
||||||
changes = append(changes, entries...)
|
changes = append(changes, entries...)
|
||||||
}
|
}
|
||||||
if entries, _ := DiffOAuthModelMappingChanges(oldCfg.OAuthModelMappings, newCfg.OAuthModelMappings); len(entries) > 0 {
|
if entries, _ := DiffOAuthModelAliasChanges(oldCfg.OAuthModelAlias, newCfg.OAuthModelAlias); len(entries) > 0 {
|
||||||
changes = append(changes, entries...)
|
changes = append(changes, entries...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,23 +10,23 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OAuthModelMappingsSummary struct {
|
type OAuthModelAliasSummary struct {
|
||||||
hash string
|
hash string
|
||||||
count int
|
count int
|
||||||
}
|
}
|
||||||
|
|
||||||
// SummarizeOAuthModelMappings summarizes OAuth model mappings per channel.
|
// SummarizeOAuthModelAlias summarizes OAuth model alias per channel.
|
||||||
func SummarizeOAuthModelMappings(entries map[string][]config.ModelNameMapping) map[string]OAuthModelMappingsSummary {
|
func SummarizeOAuthModelAlias(entries map[string][]config.OAuthModelAlias) map[string]OAuthModelAliasSummary {
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make(map[string]OAuthModelMappingsSummary, len(entries))
|
out := make(map[string]OAuthModelAliasSummary, len(entries))
|
||||||
for k, v := range entries {
|
for k, v := range entries {
|
||||||
key := strings.ToLower(strings.TrimSpace(k))
|
key := strings.ToLower(strings.TrimSpace(k))
|
||||||
if key == "" {
|
if key == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out[key] = summarizeOAuthModelMappingList(v)
|
out[key] = summarizeOAuthModelAliasList(v)
|
||||||
}
|
}
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -34,10 +34,10 @@ func SummarizeOAuthModelMappings(entries map[string][]config.ModelNameMapping) m
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiffOAuthModelMappingChanges compares OAuth model mappings maps.
|
// DiffOAuthModelAliasChanges compares OAuth model alias maps.
|
||||||
func DiffOAuthModelMappingChanges(oldMap, newMap map[string][]config.ModelNameMapping) ([]string, []string) {
|
func DiffOAuthModelAliasChanges(oldMap, newMap map[string][]config.OAuthModelAlias) ([]string, []string) {
|
||||||
oldSummary := SummarizeOAuthModelMappings(oldMap)
|
oldSummary := SummarizeOAuthModelAlias(oldMap)
|
||||||
newSummary := SummarizeOAuthModelMappings(newMap)
|
newSummary := SummarizeOAuthModelAlias(newMap)
|
||||||
keys := make(map[string]struct{}, len(oldSummary)+len(newSummary))
|
keys := make(map[string]struct{}, len(oldSummary)+len(newSummary))
|
||||||
for k := range oldSummary {
|
for k := range oldSummary {
|
||||||
keys[k] = struct{}{}
|
keys[k] = struct{}{}
|
||||||
@@ -52,13 +52,13 @@ func DiffOAuthModelMappingChanges(oldMap, newMap map[string][]config.ModelNameMa
|
|||||||
newInfo, okNew := newSummary[key]
|
newInfo, okNew := newSummary[key]
|
||||||
switch {
|
switch {
|
||||||
case okOld && !okNew:
|
case okOld && !okNew:
|
||||||
changes = append(changes, fmt.Sprintf("oauth-model-mappings[%s]: removed", key))
|
changes = append(changes, fmt.Sprintf("oauth-model-alias[%s]: removed", key))
|
||||||
affected = append(affected, key)
|
affected = append(affected, key)
|
||||||
case !okOld && okNew:
|
case !okOld && okNew:
|
||||||
changes = append(changes, fmt.Sprintf("oauth-model-mappings[%s]: added (%d entries)", key, newInfo.count))
|
changes = append(changes, fmt.Sprintf("oauth-model-alias[%s]: added (%d entries)", key, newInfo.count))
|
||||||
affected = append(affected, key)
|
affected = append(affected, key)
|
||||||
case okOld && okNew && oldInfo.hash != newInfo.hash:
|
case okOld && okNew && oldInfo.hash != newInfo.hash:
|
||||||
changes = append(changes, fmt.Sprintf("oauth-model-mappings[%s]: updated (%d -> %d entries)", key, oldInfo.count, newInfo.count))
|
changes = append(changes, fmt.Sprintf("oauth-model-alias[%s]: updated (%d -> %d entries)", key, oldInfo.count, newInfo.count))
|
||||||
affected = append(affected, key)
|
affected = append(affected, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,20 +67,20 @@ func DiffOAuthModelMappingChanges(oldMap, newMap map[string][]config.ModelNameMa
|
|||||||
return changes, affected
|
return changes, affected
|
||||||
}
|
}
|
||||||
|
|
||||||
func summarizeOAuthModelMappingList(list []config.ModelNameMapping) OAuthModelMappingsSummary {
|
func summarizeOAuthModelAliasList(list []config.OAuthModelAlias) OAuthModelAliasSummary {
|
||||||
if len(list) == 0 {
|
if len(list) == 0 {
|
||||||
return OAuthModelMappingsSummary{}
|
return OAuthModelAliasSummary{}
|
||||||
}
|
}
|
||||||
seen := make(map[string]struct{}, len(list))
|
seen := make(map[string]struct{}, len(list))
|
||||||
normalized := make([]string, 0, len(list))
|
normalized := make([]string, 0, len(list))
|
||||||
for _, mapping := range list {
|
for _, alias := range list {
|
||||||
name := strings.ToLower(strings.TrimSpace(mapping.Name))
|
name := strings.ToLower(strings.TrimSpace(alias.Name))
|
||||||
alias := strings.ToLower(strings.TrimSpace(mapping.Alias))
|
aliasVal := strings.ToLower(strings.TrimSpace(alias.Alias))
|
||||||
if name == "" || alias == "" {
|
if name == "" || aliasVal == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := name + "->" + alias
|
key := name + "->" + aliasVal
|
||||||
if mapping.Fork {
|
if alias.Fork {
|
||||||
key += "|fork"
|
key += "|fork"
|
||||||
}
|
}
|
||||||
if _, exists := seen[key]; exists {
|
if _, exists := seen[key]; exists {
|
||||||
@@ -90,11 +90,11 @@ func summarizeOAuthModelMappingList(list []config.ModelNameMapping) OAuthModelMa
|
|||||||
normalized = append(normalized, key)
|
normalized = append(normalized, key)
|
||||||
}
|
}
|
||||||
if len(normalized) == 0 {
|
if len(normalized) == 0 {
|
||||||
return OAuthModelMappingsSummary{}
|
return OAuthModelAliasSummary{}
|
||||||
}
|
}
|
||||||
sort.Strings(normalized)
|
sort.Strings(normalized)
|
||||||
sum := sha256.Sum256([]byte(strings.Join(normalized, "|")))
|
sum := sha256.Sum256([]byte(strings.Join(normalized, "|")))
|
||||||
return OAuthModelMappingsSummary{
|
return OAuthModelAliasSummary{
|
||||||
hash: hex.EncodeToString(sum[:]),
|
hash: hex.EncodeToString(sum[:]),
|
||||||
count: len(normalized),
|
count: len(normalized),
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
@@ -379,7 +380,7 @@ func appendAPIResponse(c *gin.Context, data []byte) {
|
|||||||
// ExecuteWithAuthManager executes a non-streaming request via the core auth manager.
|
// ExecuteWithAuthManager executes a non-streaming request via the core auth manager.
|
||||||
// This path is the only supported execution route.
|
// This path is the only supported execution route.
|
||||||
func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
|
func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
|
||||||
providers, normalizedModel, metadata, errMsg := h.getRequestDetails(modelName)
|
providers, normalizedModel, errMsg := h.getRequestDetails(modelName)
|
||||||
if errMsg != nil {
|
if errMsg != nil {
|
||||||
return nil, errMsg
|
return nil, errMsg
|
||||||
}
|
}
|
||||||
@@ -388,16 +389,13 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
|
|||||||
Model: normalizedModel,
|
Model: normalizedModel,
|
||||||
Payload: cloneBytes(rawJSON),
|
Payload: cloneBytes(rawJSON),
|
||||||
}
|
}
|
||||||
if cloned := cloneMetadata(metadata); cloned != nil {
|
|
||||||
req.Metadata = cloned
|
|
||||||
}
|
|
||||||
opts := coreexecutor.Options{
|
opts := coreexecutor.Options{
|
||||||
Stream: false,
|
Stream: false,
|
||||||
Alt: alt,
|
Alt: alt,
|
||||||
OriginalRequest: cloneBytes(rawJSON),
|
OriginalRequest: cloneBytes(rawJSON),
|
||||||
SourceFormat: sdktranslator.FromString(handlerType),
|
SourceFormat: sdktranslator.FromString(handlerType),
|
||||||
}
|
}
|
||||||
opts.Metadata = mergeMetadata(cloneMetadata(metadata), reqMeta)
|
opts.Metadata = reqMeta
|
||||||
resp, err := h.AuthManager.Execute(ctx, providers, req, opts)
|
resp, err := h.AuthManager.Execute(ctx, providers, req, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status := http.StatusInternalServerError
|
status := http.StatusInternalServerError
|
||||||
@@ -420,7 +418,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
|
|||||||
// ExecuteCountWithAuthManager executes a non-streaming request via the core auth manager.
|
// ExecuteCountWithAuthManager executes a non-streaming request via the core auth manager.
|
||||||
// This path is the only supported execution route.
|
// This path is the only supported execution route.
|
||||||
func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
|
func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
|
||||||
providers, normalizedModel, metadata, errMsg := h.getRequestDetails(modelName)
|
providers, normalizedModel, errMsg := h.getRequestDetails(modelName)
|
||||||
if errMsg != nil {
|
if errMsg != nil {
|
||||||
return nil, errMsg
|
return nil, errMsg
|
||||||
}
|
}
|
||||||
@@ -429,16 +427,13 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
|
|||||||
Model: normalizedModel,
|
Model: normalizedModel,
|
||||||
Payload: cloneBytes(rawJSON),
|
Payload: cloneBytes(rawJSON),
|
||||||
}
|
}
|
||||||
if cloned := cloneMetadata(metadata); cloned != nil {
|
|
||||||
req.Metadata = cloned
|
|
||||||
}
|
|
||||||
opts := coreexecutor.Options{
|
opts := coreexecutor.Options{
|
||||||
Stream: false,
|
Stream: false,
|
||||||
Alt: alt,
|
Alt: alt,
|
||||||
OriginalRequest: cloneBytes(rawJSON),
|
OriginalRequest: cloneBytes(rawJSON),
|
||||||
SourceFormat: sdktranslator.FromString(handlerType),
|
SourceFormat: sdktranslator.FromString(handlerType),
|
||||||
}
|
}
|
||||||
opts.Metadata = mergeMetadata(cloneMetadata(metadata), reqMeta)
|
opts.Metadata = reqMeta
|
||||||
resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts)
|
resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status := http.StatusInternalServerError
|
status := http.StatusInternalServerError
|
||||||
@@ -461,7 +456,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
|
|||||||
// ExecuteStreamWithAuthManager executes a streaming request via the core auth manager.
|
// ExecuteStreamWithAuthManager executes a streaming request via the core auth manager.
|
||||||
// This path is the only supported execution route.
|
// This path is the only supported execution route.
|
||||||
func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) (<-chan []byte, <-chan *interfaces.ErrorMessage) {
|
func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) (<-chan []byte, <-chan *interfaces.ErrorMessage) {
|
||||||
providers, normalizedModel, metadata, errMsg := h.getRequestDetails(modelName)
|
providers, normalizedModel, errMsg := h.getRequestDetails(modelName)
|
||||||
if errMsg != nil {
|
if errMsg != nil {
|
||||||
errChan := make(chan *interfaces.ErrorMessage, 1)
|
errChan := make(chan *interfaces.ErrorMessage, 1)
|
||||||
errChan <- errMsg
|
errChan <- errMsg
|
||||||
@@ -473,16 +468,13 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
|
|||||||
Model: normalizedModel,
|
Model: normalizedModel,
|
||||||
Payload: cloneBytes(rawJSON),
|
Payload: cloneBytes(rawJSON),
|
||||||
}
|
}
|
||||||
if cloned := cloneMetadata(metadata); cloned != nil {
|
|
||||||
req.Metadata = cloned
|
|
||||||
}
|
|
||||||
opts := coreexecutor.Options{
|
opts := coreexecutor.Options{
|
||||||
Stream: true,
|
Stream: true,
|
||||||
Alt: alt,
|
Alt: alt,
|
||||||
OriginalRequest: cloneBytes(rawJSON),
|
OriginalRequest: cloneBytes(rawJSON),
|
||||||
SourceFormat: sdktranslator.FromString(handlerType),
|
SourceFormat: sdktranslator.FromString(handlerType),
|
||||||
}
|
}
|
||||||
opts.Metadata = mergeMetadata(cloneMetadata(metadata), reqMeta)
|
opts.Metadata = reqMeta
|
||||||
chunks, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts)
|
chunks, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errChan := make(chan *interfaces.ErrorMessage, 1)
|
errChan := make(chan *interfaces.ErrorMessage, 1)
|
||||||
@@ -595,38 +587,40 @@ func statusFromError(err error) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string, normalizedModel string, metadata map[string]any, err *interfaces.ErrorMessage) {
|
func (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string, normalizedModel string, err *interfaces.ErrorMessage) {
|
||||||
// Resolve "auto" model to an actual available model first
|
resolvedModelName := modelName
|
||||||
resolvedModelName := util.ResolveAutoModel(modelName)
|
initialSuffix := thinking.ParseSuffix(modelName)
|
||||||
|
if initialSuffix.ModelName == "auto" {
|
||||||
// Normalize the model name to handle dynamic thinking suffixes before determining the provider.
|
resolvedBase := util.ResolveAutoModel(initialSuffix.ModelName)
|
||||||
normalizedModel, metadata = normalizeModelMetadata(resolvedModelName)
|
if initialSuffix.HasSuffix {
|
||||||
|
resolvedModelName = fmt.Sprintf("%s(%s)", resolvedBase, initialSuffix.RawSuffix)
|
||||||
// Use the normalizedModel to get the provider name.
|
} else {
|
||||||
providers = util.GetProviderName(normalizedModel)
|
resolvedModelName = resolvedBase
|
||||||
if len(providers) == 0 && metadata != nil {
|
|
||||||
if originalRaw, ok := metadata[util.ThinkingOriginalModelMetadataKey]; ok {
|
|
||||||
if originalModel, okStr := originalRaw.(string); okStr {
|
|
||||||
originalModel = strings.TrimSpace(originalModel)
|
|
||||||
if originalModel != "" && !strings.EqualFold(originalModel, normalizedModel) {
|
|
||||||
if altProviders := util.GetProviderName(originalModel); len(altProviders) > 0 {
|
|
||||||
providers = altProviders
|
|
||||||
normalizedModel = originalModel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
resolvedModelName = util.ResolveAutoModel(modelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed := thinking.ParseSuffix(resolvedModelName)
|
||||||
|
baseModel := strings.TrimSpace(parsed.ModelName)
|
||||||
|
|
||||||
|
providers = util.GetProviderName(baseModel)
|
||||||
|
// Fallback: if baseModel has no provider but differs from resolvedModelName,
|
||||||
|
// try using the full model name. This handles edge cases where custom models
|
||||||
|
// may be registered with their full suffixed name (e.g., "my-model(8192)").
|
||||||
|
// Evaluated in Story 11.8: This fallback is intentionally preserved to support
|
||||||
|
// custom model registrations that include thinking suffixes.
|
||||||
|
if len(providers) == 0 && baseModel != resolvedModelName {
|
||||||
|
providers = util.GetProviderName(resolvedModelName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
return nil, "", nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
|
return nil, "", &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's a dynamic model, the normalizedModel was already set to extractedModelName.
|
// The thinking suffix is preserved in the model name itself, so no
|
||||||
// If it's a non-dynamic model, normalizedModel was set by normalizeModelMetadata.
|
// metadata-based configuration passing is needed.
|
||||||
// So, normalizedModel is already correctly set at this point.
|
return providers, resolvedModelName, nil
|
||||||
|
|
||||||
return providers, normalizedModel, metadata, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneBytes(src []byte) []byte {
|
func cloneBytes(src []byte) []byte {
|
||||||
@@ -638,10 +632,6 @@ func cloneBytes(src []byte) []byte {
|
|||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeModelMetadata(modelName string) (string, map[string]any) {
|
|
||||||
return util.NormalizeThinkingModel(modelName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneMetadata(src map[string]any) map[string]any {
|
func cloneMetadata(src map[string]any) map[string]any {
|
||||||
if len(src) == 0 {
|
if len(src) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
118
sdk/api/handlers/handlers_request_details_test.go
Normal file
118
sdk/api/handlers/handlers_request_details_test.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetRequestDetails_PreservesSuffix(t *testing.T) {
|
||||||
|
modelRegistry := registry.GetGlobalRegistry()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
modelRegistry.RegisterClient("test-request-details-gemini", "gemini", []*registry.ModelInfo{
|
||||||
|
{ID: "gemini-2.5-pro", Created: now + 30},
|
||||||
|
{ID: "gemini-2.5-flash", Created: now + 25},
|
||||||
|
})
|
||||||
|
modelRegistry.RegisterClient("test-request-details-openai", "openai", []*registry.ModelInfo{
|
||||||
|
{ID: "gpt-5.2", Created: now + 20},
|
||||||
|
})
|
||||||
|
modelRegistry.RegisterClient("test-request-details-claude", "claude", []*registry.ModelInfo{
|
||||||
|
{ID: "claude-sonnet-4-5", Created: now + 5},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ensure cleanup of all test registrations.
|
||||||
|
clientIDs := []string{
|
||||||
|
"test-request-details-gemini",
|
||||||
|
"test-request-details-openai",
|
||||||
|
"test-request-details-claude",
|
||||||
|
}
|
||||||
|
for _, clientID := range clientIDs {
|
||||||
|
id := clientID
|
||||||
|
t.Cleanup(func() {
|
||||||
|
modelRegistry.UnregisterClient(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, coreauth.NewManager(nil, nil, nil))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inputModel string
|
||||||
|
wantProviders []string
|
||||||
|
wantModel string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "numeric suffix preserved",
|
||||||
|
inputModel: "gemini-2.5-pro(8192)",
|
||||||
|
wantProviders: []string{"gemini"},
|
||||||
|
wantModel: "gemini-2.5-pro(8192)",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "level suffix preserved",
|
||||||
|
inputModel: "gpt-5.2(high)",
|
||||||
|
wantProviders: []string{"openai"},
|
||||||
|
wantModel: "gpt-5.2(high)",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no suffix unchanged",
|
||||||
|
inputModel: "claude-sonnet-4-5",
|
||||||
|
wantProviders: []string{"claude"},
|
||||||
|
wantModel: "claude-sonnet-4-5",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown model with suffix",
|
||||||
|
inputModel: "unknown-model(8192)",
|
||||||
|
wantProviders: nil,
|
||||||
|
wantModel: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "auto suffix resolved",
|
||||||
|
inputModel: "auto(high)",
|
||||||
|
wantProviders: []string{"gemini"},
|
||||||
|
wantModel: "gemini-2.5-pro(high)",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "special suffix none preserved",
|
||||||
|
inputModel: "gemini-2.5-flash(none)",
|
||||||
|
wantProviders: []string{"gemini"},
|
||||||
|
wantModel: "gemini-2.5-flash(none)",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "special suffix auto preserved",
|
||||||
|
inputModel: "claude-sonnet-4-5(auto)",
|
||||||
|
wantProviders: []string{"claude"},
|
||||||
|
wantModel: "claude-sonnet-4-5(auto)",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
providers, model, errMsg := handler.getRequestDetails(tt.inputModel)
|
||||||
|
if (errMsg != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("getRequestDetails() error = %v, wantErr %v", errMsg, tt.wantErr)
|
||||||
|
}
|
||||||
|
if errMsg != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(providers, tt.wantProviders) {
|
||||||
|
t.Fatalf("getRequestDetails() providers = %v, want %v", providers, tt.wantProviders)
|
||||||
|
}
|
||||||
|
if model != tt.wantModel {
|
||||||
|
t.Fatalf("getRequestDetails() model = %v, want %v", model, tt.wantModel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
180
sdk/cliproxy/auth/api_key_model_alias_test.go
Normal file
180
sdk/cliproxy/auth/api_key_model_alias_test.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLookupAPIKeyUpstreamModel(t *testing.T) {
|
||||||
|
cfg := &internalconfig.Config{
|
||||||
|
GeminiKey: []internalconfig.GeminiKey{
|
||||||
|
{
|
||||||
|
APIKey: "k",
|
||||||
|
BaseURL: "https://example.com",
|
||||||
|
Models: []internalconfig.GeminiModel{
|
||||||
|
{Name: "gemini-2.5-pro-exp-03-25", Alias: "g25p"},
|
||||||
|
{Name: "gemini-2.5-flash(low)", Alias: "g25f"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := NewManager(nil, nil, nil)
|
||||||
|
mgr.SetConfig(cfg)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mgr.Register(ctx, &Auth{ID: "a1", Provider: "gemini", Attributes: map[string]string{"api_key": "k", "base_url": "https://example.com"}})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
authID string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// Fast path + suffix preservation
|
||||||
|
{"alias with suffix", "a1", "g25p(8192)", "gemini-2.5-pro-exp-03-25(8192)"},
|
||||||
|
{"alias without suffix", "a1", "g25p", "gemini-2.5-pro-exp-03-25"},
|
||||||
|
|
||||||
|
// Config suffix takes priority
|
||||||
|
{"config suffix priority", "a1", "g25f(high)", "gemini-2.5-flash(low)"},
|
||||||
|
{"config suffix no user suffix", "a1", "g25f", "gemini-2.5-flash(low)"},
|
||||||
|
|
||||||
|
// Case insensitive
|
||||||
|
{"uppercase alias", "a1", "G25P", "gemini-2.5-pro-exp-03-25"},
|
||||||
|
{"mixed case with suffix", "a1", "G25p(4096)", "gemini-2.5-pro-exp-03-25(4096)"},
|
||||||
|
|
||||||
|
// Direct name lookup
|
||||||
|
{"upstream name direct", "a1", "gemini-2.5-pro-exp-03-25", "gemini-2.5-pro-exp-03-25"},
|
||||||
|
{"upstream name with suffix", "a1", "gemini-2.5-pro-exp-03-25(8192)", "gemini-2.5-pro-exp-03-25(8192)"},
|
||||||
|
|
||||||
|
// Cache miss scenarios
|
||||||
|
{"non-existent auth", "non-existent", "g25p", ""},
|
||||||
|
{"unknown alias", "a1", "unknown-alias", ""},
|
||||||
|
{"empty auth ID", "", "g25p", ""},
|
||||||
|
{"empty model", "a1", "", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
resolved := mgr.lookupAPIKeyUpstreamModel(tt.authID, tt.input)
|
||||||
|
if resolved != tt.want {
|
||||||
|
t.Errorf("lookupAPIKeyUpstreamModel(%q, %q) = %q, want %q", tt.authID, tt.input, resolved, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIKeyModelAlias_ConfigHotReload(t *testing.T) {
|
||||||
|
cfg := &internalconfig.Config{
|
||||||
|
GeminiKey: []internalconfig.GeminiKey{
|
||||||
|
{
|
||||||
|
APIKey: "k",
|
||||||
|
Models: []internalconfig.GeminiModel{{Name: "gemini-2.5-pro-exp-03-25", Alias: "g25p"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := NewManager(nil, nil, nil)
|
||||||
|
mgr.SetConfig(cfg)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mgr.Register(ctx, &Auth{ID: "a1", Provider: "gemini", Attributes: map[string]string{"api_key": "k"}})
|
||||||
|
|
||||||
|
// Initial alias
|
||||||
|
if resolved := mgr.lookupAPIKeyUpstreamModel("a1", "g25p"); resolved != "gemini-2.5-pro-exp-03-25" {
|
||||||
|
t.Fatalf("before reload: got %q, want %q", resolved, "gemini-2.5-pro-exp-03-25")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hot reload with new alias
|
||||||
|
mgr.SetConfig(&internalconfig.Config{
|
||||||
|
GeminiKey: []internalconfig.GeminiKey{
|
||||||
|
{
|
||||||
|
APIKey: "k",
|
||||||
|
Models: []internalconfig.GeminiModel{{Name: "gemini-2.5-flash", Alias: "g25p"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// New alias should take effect
|
||||||
|
if resolved := mgr.lookupAPIKeyUpstreamModel("a1", "g25p"); resolved != "gemini-2.5-flash" {
|
||||||
|
t.Fatalf("after reload: got %q, want %q", resolved, "gemini-2.5-flash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIKeyModelAlias_MultipleProviders(t *testing.T) {
|
||||||
|
cfg := &internalconfig.Config{
|
||||||
|
GeminiKey: []internalconfig.GeminiKey{{APIKey: "gemini-key", Models: []internalconfig.GeminiModel{{Name: "gemini-2.5-pro", Alias: "gp"}}}},
|
||||||
|
ClaudeKey: []internalconfig.ClaudeKey{{APIKey: "claude-key", Models: []internalconfig.ClaudeModel{{Name: "claude-sonnet-4", Alias: "cs4"}}}},
|
||||||
|
CodexKey: []internalconfig.CodexKey{{APIKey: "codex-key", Models: []internalconfig.CodexModel{{Name: "o3", Alias: "o"}}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := NewManager(nil, nil, nil)
|
||||||
|
mgr.SetConfig(cfg)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mgr.Register(ctx, &Auth{ID: "gemini-auth", Provider: "gemini", Attributes: map[string]string{"api_key": "gemini-key"}})
|
||||||
|
_, _ = mgr.Register(ctx, &Auth{ID: "claude-auth", Provider: "claude", Attributes: map[string]string{"api_key": "claude-key"}})
|
||||||
|
_, _ = mgr.Register(ctx, &Auth{ID: "codex-auth", Provider: "codex", Attributes: map[string]string{"api_key": "codex-key"}})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
authID, input, want string
|
||||||
|
}{
|
||||||
|
{"gemini-auth", "gp", "gemini-2.5-pro"},
|
||||||
|
{"claude-auth", "cs4", "claude-sonnet-4"},
|
||||||
|
{"codex-auth", "o", "o3"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if resolved := mgr.lookupAPIKeyUpstreamModel(tt.authID, tt.input); resolved != tt.want {
|
||||||
|
t.Errorf("lookupAPIKeyUpstreamModel(%q, %q) = %q, want %q", tt.authID, tt.input, resolved, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyAPIKeyModelAlias(t *testing.T) {
|
||||||
|
cfg := &internalconfig.Config{
|
||||||
|
GeminiKey: []internalconfig.GeminiKey{
|
||||||
|
{APIKey: "k", Models: []internalconfig.GeminiModel{{Name: "gemini-2.5-pro-exp-03-25", Alias: "g25p"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := NewManager(nil, nil, nil)
|
||||||
|
mgr.SetConfig(cfg)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
apiKeyAuth := &Auth{ID: "a1", Provider: "gemini", Attributes: map[string]string{"api_key": "k"}}
|
||||||
|
oauthAuth := &Auth{ID: "oauth-auth", Provider: "gemini", Attributes: map[string]string{"auth_kind": "oauth"}}
|
||||||
|
_, _ = mgr.Register(ctx, apiKeyAuth)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
auth *Auth
|
||||||
|
inputModel string
|
||||||
|
wantModel string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "api_key auth with alias",
|
||||||
|
auth: apiKeyAuth,
|
||||||
|
inputModel: "g25p(8192)",
|
||||||
|
wantModel: "gemini-2.5-pro-exp-03-25(8192)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "oauth auth passthrough",
|
||||||
|
auth: oauthAuth,
|
||||||
|
inputModel: "some-model",
|
||||||
|
wantModel: "some-model",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
resolvedModel := mgr.applyAPIKeyModelAlias(tt.auth, tt.inputModel)
|
||||||
|
|
||||||
|
if resolvedModel != tt.wantModel {
|
||||||
|
t.Errorf("model = %q, want %q", resolvedModel, tt.wantModel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||||
"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/thinking"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -117,8 +119,16 @@ type Manager struct {
|
|||||||
requestRetry atomic.Int32
|
requestRetry atomic.Int32
|
||||||
maxRetryInterval atomic.Int64
|
maxRetryInterval atomic.Int64
|
||||||
|
|
||||||
// modelNameMappings stores global model name alias mappings (alias -> upstream name) keyed by channel.
|
// oauthModelAlias stores global OAuth model alias mappings (alias -> upstream name) keyed by channel.
|
||||||
modelNameMappings atomic.Value
|
oauthModelAlias atomic.Value
|
||||||
|
|
||||||
|
// apiKeyModelAlias caches resolved model alias mappings for API-key auths.
|
||||||
|
// Keyed by auth.ID, value is alias(lower) -> upstream model (including suffix).
|
||||||
|
apiKeyModelAlias atomic.Value
|
||||||
|
|
||||||
|
// runtimeConfig stores the latest application config for request-time decisions.
|
||||||
|
// It is initialized in NewManager; never Load() before first Store().
|
||||||
|
runtimeConfig atomic.Value
|
||||||
|
|
||||||
// Optional HTTP RoundTripper provider injected by host.
|
// Optional HTTP RoundTripper provider injected by host.
|
||||||
rtProvider RoundTripperProvider
|
rtProvider RoundTripperProvider
|
||||||
@@ -135,7 +145,7 @@ func NewManager(store Store, selector Selector, hook Hook) *Manager {
|
|||||||
if hook == nil {
|
if hook == nil {
|
||||||
hook = NoopHook{}
|
hook = NoopHook{}
|
||||||
}
|
}
|
||||||
return &Manager{
|
manager := &Manager{
|
||||||
store: store,
|
store: store,
|
||||||
executors: make(map[string]ProviderExecutor),
|
executors: make(map[string]ProviderExecutor),
|
||||||
selector: selector,
|
selector: selector,
|
||||||
@@ -143,6 +153,10 @@ func NewManager(store Store, selector Selector, hook Hook) *Manager {
|
|||||||
auths: make(map[string]*Auth),
|
auths: make(map[string]*Auth),
|
||||||
providerOffsets: make(map[string]int),
|
providerOffsets: make(map[string]int),
|
||||||
}
|
}
|
||||||
|
// atomic.Value requires non-nil initial value.
|
||||||
|
manager.runtimeConfig.Store(&internalconfig.Config{})
|
||||||
|
manager.apiKeyModelAlias.Store(apiKeyModelAliasTable(nil))
|
||||||
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SetSelector(selector Selector) {
|
func (m *Manager) SetSelector(selector Selector) {
|
||||||
@@ -171,6 +185,181 @@ func (m *Manager) SetRoundTripperProvider(p RoundTripperProvider) {
|
|||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetConfig updates the runtime config snapshot used by request-time helpers.
|
||||||
|
// Callers should provide the latest config on reload so per-credential alias mapping stays in sync.
|
||||||
|
func (m *Manager) SetConfig(cfg *internalconfig.Config) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = &internalconfig.Config{}
|
||||||
|
}
|
||||||
|
m.runtimeConfig.Store(cfg)
|
||||||
|
m.rebuildAPIKeyModelAliasFromRuntimeConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) lookupAPIKeyUpstreamModel(authID, requestedModel string) string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
authID = strings.TrimSpace(authID)
|
||||||
|
if authID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
requestedModel = strings.TrimSpace(requestedModel)
|
||||||
|
if requestedModel == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
table, _ := m.apiKeyModelAlias.Load().(apiKeyModelAliasTable)
|
||||||
|
if table == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
byAlias := table[authID]
|
||||||
|
if len(byAlias) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
key := strings.ToLower(thinking.ParseSuffix(requestedModel).ModelName)
|
||||||
|
if key == "" {
|
||||||
|
key = strings.ToLower(requestedModel)
|
||||||
|
}
|
||||||
|
resolved := strings.TrimSpace(byAlias[key])
|
||||||
|
if resolved == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Preserve thinking suffix from the client's requested model unless config already has one.
|
||||||
|
requestResult := thinking.ParseSuffix(requestedModel)
|
||||||
|
if thinking.ParseSuffix(resolved).HasSuffix {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
if requestResult.HasSuffix && requestResult.RawSuffix != "" {
|
||||||
|
return resolved + "(" + requestResult.RawSuffix + ")"
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) rebuildAPIKeyModelAliasFromRuntimeConfig() {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, _ := m.runtimeConfig.Load().(*internalconfig.Config)
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = &internalconfig.Config{}
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.rebuildAPIKeyModelAliasLocked(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) rebuildAPIKeyModelAliasLocked(cfg *internalconfig.Config) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = &internalconfig.Config{}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(apiKeyModelAliasTable)
|
||||||
|
for _, auth := range m.auths {
|
||||||
|
if auth == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(auth.ID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kind, _ := auth.AccountInfo()
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(kind), "api_key") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
byAlias := make(map[string]string)
|
||||||
|
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
|
||||||
|
switch provider {
|
||||||
|
case "gemini":
|
||||||
|
if entry := resolveGeminiAPIKeyConfig(cfg, auth); entry != nil {
|
||||||
|
compileAPIKeyModelAliasForModels(byAlias, entry.Models)
|
||||||
|
}
|
||||||
|
case "claude":
|
||||||
|
if entry := resolveClaudeAPIKeyConfig(cfg, auth); entry != nil {
|
||||||
|
compileAPIKeyModelAliasForModels(byAlias, entry.Models)
|
||||||
|
}
|
||||||
|
case "codex":
|
||||||
|
if entry := resolveCodexAPIKeyConfig(cfg, auth); entry != nil {
|
||||||
|
compileAPIKeyModelAliasForModels(byAlias, entry.Models)
|
||||||
|
}
|
||||||
|
case "vertex":
|
||||||
|
if entry := resolveVertexAPIKeyConfig(cfg, auth); entry != nil {
|
||||||
|
compileAPIKeyModelAliasForModels(byAlias, entry.Models)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// OpenAI-compat uses config selection from auth.Attributes.
|
||||||
|
providerKey := ""
|
||||||
|
compatName := ""
|
||||||
|
if auth.Attributes != nil {
|
||||||
|
providerKey = strings.TrimSpace(auth.Attributes["provider_key"])
|
||||||
|
compatName = strings.TrimSpace(auth.Attributes["compat_name"])
|
||||||
|
}
|
||||||
|
if compatName != "" || strings.EqualFold(strings.TrimSpace(auth.Provider), "openai-compatibility") {
|
||||||
|
if entry := resolveOpenAICompatConfig(cfg, providerKey, compatName, auth.Provider); entry != nil {
|
||||||
|
compileAPIKeyModelAliasForModels(byAlias, entry.Models)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(byAlias) > 0 {
|
||||||
|
out[auth.ID] = byAlias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.apiKeyModelAlias.Store(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileAPIKeyModelAliasForModels[T interface {
|
||||||
|
GetName() string
|
||||||
|
GetAlias() string
|
||||||
|
}](out map[string]string, models []T) {
|
||||||
|
if out == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range models {
|
||||||
|
alias := strings.TrimSpace(models[i].GetAlias())
|
||||||
|
name := strings.TrimSpace(models[i].GetName())
|
||||||
|
if alias == "" || name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
aliasKey := strings.ToLower(thinking.ParseSuffix(alias).ModelName)
|
||||||
|
if aliasKey == "" {
|
||||||
|
aliasKey = strings.ToLower(alias)
|
||||||
|
}
|
||||||
|
// Config priority: first alias wins.
|
||||||
|
if _, exists := out[aliasKey]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[aliasKey] = name
|
||||||
|
// Also allow direct lookup by upstream name (case-insensitive), so lookups on already-upstream
|
||||||
|
// models remain a cheap no-op.
|
||||||
|
nameKey := strings.ToLower(thinking.ParseSuffix(name).ModelName)
|
||||||
|
if nameKey == "" {
|
||||||
|
nameKey = strings.ToLower(name)
|
||||||
|
}
|
||||||
|
if nameKey != "" {
|
||||||
|
if _, exists := out[nameKey]; !exists {
|
||||||
|
out[nameKey] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Preserve config suffix priority by seeding a base-name lookup when name already has suffix.
|
||||||
|
nameResult := thinking.ParseSuffix(name)
|
||||||
|
if nameResult.HasSuffix {
|
||||||
|
baseKey := strings.ToLower(strings.TrimSpace(nameResult.ModelName))
|
||||||
|
if baseKey != "" {
|
||||||
|
if _, exists := out[baseKey]; !exists {
|
||||||
|
out[baseKey] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SetRetryConfig updates retry attempts and cooldown wait interval.
|
// SetRetryConfig updates retry attempts and cooldown wait interval.
|
||||||
func (m *Manager) SetRetryConfig(retry int, maxRetryInterval time.Duration) {
|
func (m *Manager) SetRetryConfig(retry int, maxRetryInterval time.Duration) {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
@@ -219,6 +408,7 @@ func (m *Manager) Register(ctx context.Context, auth *Auth) (*Auth, error) {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.auths[auth.ID] = auth.Clone()
|
m.auths[auth.ID] = auth.Clone()
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
m.rebuildAPIKeyModelAliasFromRuntimeConfig()
|
||||||
_ = m.persist(ctx, auth)
|
_ = m.persist(ctx, auth)
|
||||||
m.hook.OnAuthRegistered(ctx, auth.Clone())
|
m.hook.OnAuthRegistered(ctx, auth.Clone())
|
||||||
return auth.Clone(), nil
|
return auth.Clone(), nil
|
||||||
@@ -237,6 +427,7 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) {
|
|||||||
auth.EnsureIndex()
|
auth.EnsureIndex()
|
||||||
m.auths[auth.ID] = auth.Clone()
|
m.auths[auth.ID] = auth.Clone()
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
m.rebuildAPIKeyModelAliasFromRuntimeConfig()
|
||||||
_ = m.persist(ctx, auth)
|
_ = m.persist(ctx, auth)
|
||||||
m.hook.OnAuthUpdated(ctx, auth.Clone())
|
m.hook.OnAuthUpdated(ctx, auth.Clone())
|
||||||
return auth.Clone(), nil
|
return auth.Clone(), nil
|
||||||
@@ -261,6 +452,11 @@ func (m *Manager) Load(ctx context.Context) error {
|
|||||||
auth.EnsureIndex()
|
auth.EnsureIndex()
|
||||||
m.auths[auth.ID] = auth.Clone()
|
m.auths[auth.ID] = auth.Clone()
|
||||||
}
|
}
|
||||||
|
cfg, _ := m.runtimeConfig.Load().(*internalconfig.Config)
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = &internalconfig.Config{}
|
||||||
|
}
|
||||||
|
m.rebuildAPIKeyModelAliasLocked(cfg)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,8 +591,9 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
|
|||||||
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
||||||
}
|
}
|
||||||
execReq := req
|
execReq := req
|
||||||
execReq.Model, execReq.Metadata = rewriteModelForAuth(routeModel, req.Metadata, auth)
|
execReq.Model = rewriteModelForAuth(routeModel, auth)
|
||||||
execReq.Model, execReq.Metadata = m.applyOAuthModelMapping(auth, execReq.Model, execReq.Metadata)
|
execReq.Model = m.applyOAuthModelAlias(auth, execReq.Model)
|
||||||
|
execReq.Model = m.applyAPIKeyModelAlias(auth, execReq.Model)
|
||||||
resp, errExec := executor.Execute(execCtx, auth, execReq, opts)
|
resp, errExec := executor.Execute(execCtx, auth, execReq, opts)
|
||||||
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
|
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
|
||||||
if errExec != nil {
|
if errExec != nil {
|
||||||
@@ -443,8 +640,9 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
|
|||||||
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
||||||
}
|
}
|
||||||
execReq := req
|
execReq := req
|
||||||
execReq.Model, execReq.Metadata = rewriteModelForAuth(routeModel, req.Metadata, auth)
|
execReq.Model = rewriteModelForAuth(routeModel, auth)
|
||||||
execReq.Model, execReq.Metadata = m.applyOAuthModelMapping(auth, execReq.Model, execReq.Metadata)
|
execReq.Model = m.applyOAuthModelAlias(auth, execReq.Model)
|
||||||
|
execReq.Model = m.applyAPIKeyModelAlias(auth, execReq.Model)
|
||||||
resp, errExec := executor.CountTokens(execCtx, auth, execReq, opts)
|
resp, errExec := executor.CountTokens(execCtx, auth, execReq, opts)
|
||||||
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
|
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
|
||||||
if errExec != nil {
|
if errExec != nil {
|
||||||
@@ -491,8 +689,9 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
|
|||||||
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
||||||
}
|
}
|
||||||
execReq := req
|
execReq := req
|
||||||
execReq.Model, execReq.Metadata = rewriteModelForAuth(routeModel, req.Metadata, auth)
|
execReq.Model = rewriteModelForAuth(routeModel, auth)
|
||||||
execReq.Model, execReq.Metadata = m.applyOAuthModelMapping(auth, execReq.Model, execReq.Metadata)
|
execReq.Model = m.applyOAuthModelAlias(auth, execReq.Model)
|
||||||
|
execReq.Model = m.applyAPIKeyModelAlias(auth, execReq.Model)
|
||||||
chunks, errStream := executor.ExecuteStream(execCtx, auth, execReq, opts)
|
chunks, errStream := executor.ExecuteStream(execCtx, auth, execReq, opts)
|
||||||
if errStream != nil {
|
if errStream != nil {
|
||||||
rerr := &Error{Message: errStream.Error()}
|
rerr := &Error{Message: errStream.Error()}
|
||||||
@@ -556,8 +755,9 @@ func (m *Manager) executeWithProvider(ctx context.Context, provider string, req
|
|||||||
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
||||||
}
|
}
|
||||||
execReq := req
|
execReq := req
|
||||||
execReq.Model, execReq.Metadata = rewriteModelForAuth(routeModel, req.Metadata, auth)
|
execReq.Model = rewriteModelForAuth(routeModel, auth)
|
||||||
execReq.Model, execReq.Metadata = m.applyOAuthModelMapping(auth, execReq.Model, execReq.Metadata)
|
execReq.Model = m.applyOAuthModelAlias(auth, execReq.Model)
|
||||||
|
execReq.Model = m.applyAPIKeyModelAlias(auth, execReq.Model)
|
||||||
resp, errExec := executor.Execute(execCtx, auth, execReq, opts)
|
resp, errExec := executor.Execute(execCtx, auth, execReq, opts)
|
||||||
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
|
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
|
||||||
if errExec != nil {
|
if errExec != nil {
|
||||||
@@ -604,8 +804,9 @@ func (m *Manager) executeCountWithProvider(ctx context.Context, provider string,
|
|||||||
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
||||||
}
|
}
|
||||||
execReq := req
|
execReq := req
|
||||||
execReq.Model, execReq.Metadata = rewriteModelForAuth(routeModel, req.Metadata, auth)
|
execReq.Model = rewriteModelForAuth(routeModel, auth)
|
||||||
execReq.Model, execReq.Metadata = m.applyOAuthModelMapping(auth, execReq.Model, execReq.Metadata)
|
execReq.Model = m.applyOAuthModelAlias(auth, execReq.Model)
|
||||||
|
execReq.Model = m.applyAPIKeyModelAlias(auth, execReq.Model)
|
||||||
resp, errExec := executor.CountTokens(execCtx, auth, execReq, opts)
|
resp, errExec := executor.CountTokens(execCtx, auth, execReq, opts)
|
||||||
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
|
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
|
||||||
if errExec != nil {
|
if errExec != nil {
|
||||||
@@ -652,8 +853,9 @@ func (m *Manager) executeStreamWithProvider(ctx context.Context, provider string
|
|||||||
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
||||||
}
|
}
|
||||||
execReq := req
|
execReq := req
|
||||||
execReq.Model, execReq.Metadata = rewriteModelForAuth(routeModel, req.Metadata, auth)
|
execReq.Model = rewriteModelForAuth(routeModel, auth)
|
||||||
execReq.Model, execReq.Metadata = m.applyOAuthModelMapping(auth, execReq.Model, execReq.Metadata)
|
execReq.Model = m.applyOAuthModelAlias(auth, execReq.Model)
|
||||||
|
execReq.Model = m.applyAPIKeyModelAlias(auth, execReq.Model)
|
||||||
chunks, errStream := executor.ExecuteStream(execCtx, auth, execReq, opts)
|
chunks, errStream := executor.ExecuteStream(execCtx, auth, execReq, opts)
|
||||||
if errStream != nil {
|
if errStream != nil {
|
||||||
rerr := &Error{Message: errStream.Error()}
|
rerr := &Error{Message: errStream.Error()}
|
||||||
@@ -691,51 +893,229 @@ func (m *Manager) executeStreamWithProvider(ctx context.Context, provider string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func rewriteModelForAuth(model string, metadata map[string]any, auth *Auth) (string, map[string]any) {
|
func rewriteModelForAuth(model string, auth *Auth) string {
|
||||||
if auth == nil || model == "" {
|
if auth == nil || model == "" {
|
||||||
return model, metadata
|
return model
|
||||||
}
|
}
|
||||||
prefix := strings.TrimSpace(auth.Prefix)
|
prefix := strings.TrimSpace(auth.Prefix)
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
return model, metadata
|
return model
|
||||||
}
|
}
|
||||||
needle := prefix + "/"
|
needle := prefix + "/"
|
||||||
if !strings.HasPrefix(model, needle) {
|
if !strings.HasPrefix(model, needle) {
|
||||||
return model, metadata
|
return model
|
||||||
}
|
}
|
||||||
rewritten := strings.TrimPrefix(model, needle)
|
return strings.TrimPrefix(model, needle)
|
||||||
return rewritten, stripPrefixFromMetadata(metadata, needle)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func stripPrefixFromMetadata(metadata map[string]any, needle string) map[string]any {
|
func (m *Manager) applyAPIKeyModelAlias(auth *Auth, requestedModel string) string {
|
||||||
if len(metadata) == 0 || needle == "" {
|
if m == nil || auth == nil {
|
||||||
return metadata
|
return requestedModel
|
||||||
}
|
}
|
||||||
keys := []string{
|
|
||||||
util.ThinkingOriginalModelMetadataKey,
|
kind, _ := auth.AccountInfo()
|
||||||
util.GeminiOriginalModelMetadataKey,
|
if !strings.EqualFold(strings.TrimSpace(kind), "api_key") {
|
||||||
util.ModelMappingOriginalModelMetadataKey,
|
return requestedModel
|
||||||
}
|
}
|
||||||
var out map[string]any
|
|
||||||
for _, key := range keys {
|
requestedModel = strings.TrimSpace(requestedModel)
|
||||||
raw, ok := metadata[key]
|
if requestedModel == "" {
|
||||||
if !ok {
|
return requestedModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: lookup per-auth mapping table (keyed by auth.ID).
|
||||||
|
if resolved := m.lookupAPIKeyUpstreamModel(auth.ID, requestedModel); resolved != "" {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: scan config for the matching credential entry and resolve alias.
|
||||||
|
// This acts as a safety net if mappings are stale or auth.ID is missing.
|
||||||
|
cfg, _ := m.runtimeConfig.Load().(*internalconfig.Config)
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = &internalconfig.Config{}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
|
||||||
|
upstreamModel := ""
|
||||||
|
switch provider {
|
||||||
|
case "gemini":
|
||||||
|
upstreamModel = resolveUpstreamModelForGeminiAPIKey(cfg, auth, requestedModel)
|
||||||
|
case "claude":
|
||||||
|
upstreamModel = resolveUpstreamModelForClaudeAPIKey(cfg, auth, requestedModel)
|
||||||
|
case "codex":
|
||||||
|
upstreamModel = resolveUpstreamModelForCodexAPIKey(cfg, auth, requestedModel)
|
||||||
|
case "vertex":
|
||||||
|
upstreamModel = resolveUpstreamModelForVertexAPIKey(cfg, auth, requestedModel)
|
||||||
|
default:
|
||||||
|
upstreamModel = resolveUpstreamModelForOpenAICompatAPIKey(cfg, auth, requestedModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return upstream model if found, otherwise return requested model.
|
||||||
|
if upstreamModel != "" {
|
||||||
|
return upstreamModel
|
||||||
|
}
|
||||||
|
return requestedModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIKeyConfigEntry is a generic interface for API key configurations.
|
||||||
|
type APIKeyConfigEntry interface {
|
||||||
|
GetAPIKey() string
|
||||||
|
GetBaseURL() string
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveAPIKeyConfig[T APIKeyConfigEntry](entries []T, auth *Auth) *T {
|
||||||
|
if auth == nil || len(entries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
attrKey, attrBase := "", ""
|
||||||
|
if auth.Attributes != nil {
|
||||||
|
attrKey = strings.TrimSpace(auth.Attributes["api_key"])
|
||||||
|
attrBase = strings.TrimSpace(auth.Attributes["base_url"])
|
||||||
|
}
|
||||||
|
for i := range entries {
|
||||||
|
entry := &entries[i]
|
||||||
|
cfgKey := strings.TrimSpace((*entry).GetAPIKey())
|
||||||
|
cfgBase := strings.TrimSpace((*entry).GetBaseURL())
|
||||||
|
if attrKey != "" && attrBase != "" {
|
||||||
|
if strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
value, okStr := raw.(string)
|
if attrKey != "" && strings.EqualFold(cfgKey, attrKey) {
|
||||||
if !okStr || !strings.HasPrefix(value, needle) {
|
if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) {
|
||||||
continue
|
return entry
|
||||||
}
|
|
||||||
if out == nil {
|
|
||||||
out = make(map[string]any, len(metadata))
|
|
||||||
for k, v := range metadata {
|
|
||||||
out[k] = v
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out[key] = strings.TrimPrefix(value, needle)
|
if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if out == nil {
|
if attrKey != "" {
|
||||||
return metadata
|
for i := range entries {
|
||||||
|
entry := &entries[i]
|
||||||
|
if strings.EqualFold(strings.TrimSpace((*entry).GetAPIKey()), attrKey) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveGeminiAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internalconfig.GeminiKey {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return resolveAPIKeyConfig(cfg.GeminiKey, auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveClaudeAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internalconfig.ClaudeKey {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return resolveAPIKeyConfig(cfg.ClaudeKey, auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCodexAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internalconfig.CodexKey {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return resolveAPIKeyConfig(cfg.CodexKey, auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveVertexAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internalconfig.VertexCompatKey {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return resolveAPIKeyConfig(cfg.VertexCompatAPIKey, auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveUpstreamModelForGeminiAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string {
|
||||||
|
entry := resolveGeminiAPIKeyConfig(cfg, auth)
|
||||||
|
if entry == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models))
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveUpstreamModelForClaudeAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string {
|
||||||
|
entry := resolveClaudeAPIKeyConfig(cfg, auth)
|
||||||
|
if entry == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models))
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveUpstreamModelForCodexAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string {
|
||||||
|
entry := resolveCodexAPIKeyConfig(cfg, auth)
|
||||||
|
if entry == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models))
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveUpstreamModelForVertexAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string {
|
||||||
|
entry := resolveVertexAPIKeyConfig(cfg, auth)
|
||||||
|
if entry == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models))
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveUpstreamModelForOpenAICompatAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string {
|
||||||
|
providerKey := ""
|
||||||
|
compatName := ""
|
||||||
|
if auth != nil && len(auth.Attributes) > 0 {
|
||||||
|
providerKey = strings.TrimSpace(auth.Attributes["provider_key"])
|
||||||
|
compatName = strings.TrimSpace(auth.Attributes["compat_name"])
|
||||||
|
}
|
||||||
|
if compatName == "" && !strings.EqualFold(strings.TrimSpace(auth.Provider), "openai-compatibility") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
entry := resolveOpenAICompatConfig(cfg, providerKey, compatName, auth.Provider)
|
||||||
|
if entry == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models))
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiKeyModelAliasTable map[string]map[string]string
|
||||||
|
|
||||||
|
func resolveOpenAICompatConfig(cfg *internalconfig.Config, providerKey, compatName, authProvider string) *internalconfig.OpenAICompatibility {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
candidates := make([]string, 0, 3)
|
||||||
|
if v := strings.TrimSpace(compatName); v != "" {
|
||||||
|
candidates = append(candidates, v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(providerKey); v != "" {
|
||||||
|
candidates = append(candidates, v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(authProvider); v != "" {
|
||||||
|
candidates = append(candidates, v)
|
||||||
|
}
|
||||||
|
for i := range cfg.OpenAICompatibility {
|
||||||
|
compat := &cfg.OpenAICompatibility[i]
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate != "" && strings.EqualFold(strings.TrimSpace(candidate), compat.Name) {
|
||||||
|
return compat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func asModelAliasEntries[T interface {
|
||||||
|
GetName() string
|
||||||
|
GetAlias() string
|
||||||
|
}](models []T) []modelAliasEntry {
|
||||||
|
if len(models) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]modelAliasEntry, 0, len(models))
|
||||||
|
for i := range models {
|
||||||
|
out = append(out, models[i])
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -1304,6 +1684,13 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli
|
|||||||
}
|
}
|
||||||
candidates := make([]*Auth, 0, len(m.auths))
|
candidates := make([]*Auth, 0, len(m.auths))
|
||||||
modelKey := strings.TrimSpace(model)
|
modelKey := strings.TrimSpace(model)
|
||||||
|
// Always use base model name (without thinking suffix) for auth matching.
|
||||||
|
if modelKey != "" {
|
||||||
|
parsed := thinking.ParseSuffix(modelKey)
|
||||||
|
if parsed.ModelName != "" {
|
||||||
|
modelKey = strings.TrimSpace(parsed.ModelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
registryRef := registry.GetGlobalRegistry()
|
registryRef := registry.GetGlobalRegistry()
|
||||||
for _, candidate := range m.auths {
|
for _, candidate := range m.auths {
|
||||||
if candidate.Provider != provider || candidate.Disabled {
|
if candidate.Provider != provider || candidate.Disabled {
|
||||||
@@ -1359,6 +1746,13 @@ func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model s
|
|||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
candidates := make([]*Auth, 0, len(m.auths))
|
candidates := make([]*Auth, 0, len(m.auths))
|
||||||
modelKey := strings.TrimSpace(model)
|
modelKey := strings.TrimSpace(model)
|
||||||
|
// Always use base model name (without thinking suffix) for auth matching.
|
||||||
|
if modelKey != "" {
|
||||||
|
parsed := thinking.ParseSuffix(modelKey)
|
||||||
|
if parsed.ModelName != "" {
|
||||||
|
modelKey = strings.TrimSpace(parsed.ModelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
registryRef := registry.GetGlobalRegistry()
|
registryRef := registry.GetGlobalRegistry()
|
||||||
for _, candidate := range m.auths {
|
for _, candidate := range m.auths {
|
||||||
if candidate == nil || candidate.Disabled {
|
if candidate == nil || candidate.Disabled {
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
||||||
)
|
|
||||||
|
|
||||||
type modelNameMappingTable struct {
|
|
||||||
// reverse maps channel -> alias (lower) -> original upstream model name.
|
|
||||||
reverse map[string]map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func compileModelNameMappingTable(mappings map[string][]internalconfig.ModelNameMapping) *modelNameMappingTable {
|
|
||||||
if len(mappings) == 0 {
|
|
||||||
return &modelNameMappingTable{}
|
|
||||||
}
|
|
||||||
out := &modelNameMappingTable{
|
|
||||||
reverse: make(map[string]map[string]string, len(mappings)),
|
|
||||||
}
|
|
||||||
for rawChannel, entries := range mappings {
|
|
||||||
channel := strings.ToLower(strings.TrimSpace(rawChannel))
|
|
||||||
if channel == "" || len(entries) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rev := make(map[string]string, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
name := strings.TrimSpace(entry.Name)
|
|
||||||
alias := strings.TrimSpace(entry.Alias)
|
|
||||||
if name == "" || alias == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.EqualFold(name, alias) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
aliasKey := strings.ToLower(alias)
|
|
||||||
if _, exists := rev[aliasKey]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rev[aliasKey] = name
|
|
||||||
}
|
|
||||||
if len(rev) > 0 {
|
|
||||||
out.reverse[channel] = rev
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(out.reverse) == 0 {
|
|
||||||
out.reverse = nil
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetOAuthModelMappings updates the OAuth model name mapping table used during execution.
|
|
||||||
// The mapping is applied per-auth channel to resolve the upstream model name while keeping the
|
|
||||||
// client-visible model name unchanged for translation/response formatting.
|
|
||||||
func (m *Manager) SetOAuthModelMappings(mappings map[string][]internalconfig.ModelNameMapping) {
|
|
||||||
if m == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
table := compileModelNameMappingTable(mappings)
|
|
||||||
// atomic.Value requires non-nil store values.
|
|
||||||
if table == nil {
|
|
||||||
table = &modelNameMappingTable{}
|
|
||||||
}
|
|
||||||
m.modelNameMappings.Store(table)
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyOAuthModelMapping resolves the upstream model from OAuth model mappings
|
|
||||||
// and returns the resolved model along with updated metadata. If a mapping exists,
|
|
||||||
// the returned model is the upstream model and metadata contains the original
|
|
||||||
// requested model for response translation.
|
|
||||||
func (m *Manager) applyOAuthModelMapping(auth *Auth, requestedModel string, metadata map[string]any) (string, map[string]any) {
|
|
||||||
upstreamModel := m.resolveOAuthUpstreamModel(auth, requestedModel)
|
|
||||||
if upstreamModel == "" {
|
|
||||||
return requestedModel, metadata
|
|
||||||
}
|
|
||||||
out := make(map[string]any, 1)
|
|
||||||
if len(metadata) > 0 {
|
|
||||||
out = make(map[string]any, len(metadata)+1)
|
|
||||||
for k, v := range metadata {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Store the requested alias (e.g., "gp") so downstream can use it to look up
|
|
||||||
// model metadata from the global registry where it was registered under this alias.
|
|
||||||
out[util.ModelMappingOriginalModelMetadataKey] = requestedModel
|
|
||||||
return upstreamModel, out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) resolveOAuthUpstreamModel(auth *Auth, requestedModel string) string {
|
|
||||||
if m == nil || auth == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
channel := modelMappingChannel(auth)
|
|
||||||
if channel == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
key := strings.ToLower(strings.TrimSpace(requestedModel))
|
|
||||||
if key == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
raw := m.modelNameMappings.Load()
|
|
||||||
table, _ := raw.(*modelNameMappingTable)
|
|
||||||
if table == nil || table.reverse == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
rev := table.reverse[channel]
|
|
||||||
if rev == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
original := strings.TrimSpace(rev[key])
|
|
||||||
if original == "" || strings.EqualFold(original, requestedModel) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return original
|
|
||||||
}
|
|
||||||
|
|
||||||
// modelMappingChannel extracts the OAuth model mapping channel from an Auth object.
|
|
||||||
// It determines the provider and auth kind from the Auth's attributes and delegates
|
|
||||||
// to OAuthModelMappingChannel for the actual channel resolution.
|
|
||||||
func modelMappingChannel(auth *Auth) string {
|
|
||||||
if auth == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
|
|
||||||
authKind := ""
|
|
||||||
if auth.Attributes != nil {
|
|
||||||
authKind = strings.ToLower(strings.TrimSpace(auth.Attributes["auth_kind"]))
|
|
||||||
}
|
|
||||||
if authKind == "" {
|
|
||||||
if kind, _ := auth.AccountInfo(); strings.EqualFold(kind, "api_key") {
|
|
||||||
authKind = "apikey"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return OAuthModelMappingChannel(provider, authKind)
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthModelMappingChannel returns the OAuth model mapping channel name for a given provider
|
|
||||||
// and auth kind. Returns empty string if the provider/authKind combination doesn't support
|
|
||||||
// OAuth model mappings (e.g., API key authentication).
|
|
||||||
//
|
|
||||||
// Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow.
|
|
||||||
func OAuthModelMappingChannel(provider, authKind string) string {
|
|
||||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
|
||||||
authKind = strings.ToLower(strings.TrimSpace(authKind))
|
|
||||||
switch provider {
|
|
||||||
case "gemini":
|
|
||||||
// gemini provider uses gemini-api-key config, not oauth-model-mappings.
|
|
||||||
// OAuth-based gemini auth is converted to "gemini-cli" by the synthesizer.
|
|
||||||
return ""
|
|
||||||
case "vertex":
|
|
||||||
if authKind == "apikey" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "vertex"
|
|
||||||
case "claude":
|
|
||||||
if authKind == "apikey" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "claude"
|
|
||||||
case "codex":
|
|
||||||
if authKind == "apikey" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "codex"
|
|
||||||
case "gemini-cli", "aistudio", "antigravity", "qwen", "iflow":
|
|
||||||
return provider
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
253
sdk/cliproxy/auth/oauth_model_alias.go
Normal file
253
sdk/cliproxy/auth/oauth_model_alias.go
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
|
)
|
||||||
|
|
||||||
|
type modelAliasEntry interface {
|
||||||
|
GetName() string
|
||||||
|
GetAlias() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type oauthModelAliasTable struct {
|
||||||
|
// reverse maps channel -> alias (lower) -> original upstream model name.
|
||||||
|
reverse map[string]map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileOAuthModelAliasTable(aliases map[string][]internalconfig.OAuthModelAlias) *oauthModelAliasTable {
|
||||||
|
if len(aliases) == 0 {
|
||||||
|
return &oauthModelAliasTable{}
|
||||||
|
}
|
||||||
|
out := &oauthModelAliasTable{
|
||||||
|
reverse: make(map[string]map[string]string, len(aliases)),
|
||||||
|
}
|
||||||
|
for rawChannel, entries := range aliases {
|
||||||
|
channel := strings.ToLower(strings.TrimSpace(rawChannel))
|
||||||
|
if channel == "" || len(entries) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rev := make(map[string]string, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := strings.TrimSpace(entry.Name)
|
||||||
|
alias := strings.TrimSpace(entry.Alias)
|
||||||
|
if name == "" || alias == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(name, alias) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
aliasKey := strings.ToLower(alias)
|
||||||
|
if _, exists := rev[aliasKey]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rev[aliasKey] = name
|
||||||
|
}
|
||||||
|
if len(rev) > 0 {
|
||||||
|
out.reverse[channel] = rev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out.reverse) == 0 {
|
||||||
|
out.reverse = nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOAuthModelAlias updates the OAuth model name alias table used during execution.
|
||||||
|
// The alias is applied per-auth channel to resolve the upstream model name while keeping the
|
||||||
|
// client-visible model name unchanged for translation/response formatting.
|
||||||
|
func (m *Manager) SetOAuthModelAlias(aliases map[string][]internalconfig.OAuthModelAlias) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
table := compileOAuthModelAliasTable(aliases)
|
||||||
|
// atomic.Value requires non-nil store values.
|
||||||
|
if table == nil {
|
||||||
|
table = &oauthModelAliasTable{}
|
||||||
|
}
|
||||||
|
m.oauthModelAlias.Store(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyOAuthModelAlias resolves the upstream model from OAuth model alias.
|
||||||
|
// If an alias exists, the returned model is the upstream model.
|
||||||
|
func (m *Manager) applyOAuthModelAlias(auth *Auth, requestedModel string) string {
|
||||||
|
upstreamModel := m.resolveOAuthUpstreamModel(auth, requestedModel)
|
||||||
|
if upstreamModel == "" {
|
||||||
|
return requestedModel
|
||||||
|
}
|
||||||
|
return upstreamModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveModelAliasFromConfigModels(requestedModel string, models []modelAliasEntry) string {
|
||||||
|
requestedModel = strings.TrimSpace(requestedModel)
|
||||||
|
if requestedModel == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(models) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
requestResult := thinking.ParseSuffix(requestedModel)
|
||||||
|
base := requestResult.ModelName
|
||||||
|
candidates := []string{base}
|
||||||
|
if base != requestedModel {
|
||||||
|
candidates = append(candidates, requestedModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
preserveSuffix := func(resolved string) string {
|
||||||
|
resolved = strings.TrimSpace(resolved)
|
||||||
|
if resolved == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if thinking.ParseSuffix(resolved).HasSuffix {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
if requestResult.HasSuffix && requestResult.RawSuffix != "" {
|
||||||
|
return resolved + "(" + requestResult.RawSuffix + ")"
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range models {
|
||||||
|
name := strings.TrimSpace(models[i].GetName())
|
||||||
|
alias := strings.TrimSpace(models[i].GetAlias())
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if alias != "" && strings.EqualFold(alias, candidate) {
|
||||||
|
if name != "" {
|
||||||
|
return preserveSuffix(name)
|
||||||
|
}
|
||||||
|
return preserveSuffix(candidate)
|
||||||
|
}
|
||||||
|
if name != "" && strings.EqualFold(name, candidate) {
|
||||||
|
return preserveSuffix(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveOAuthUpstreamModel resolves the upstream model name from OAuth model alias.
|
||||||
|
// If an alias exists, returns the original (upstream) model name that corresponds
|
||||||
|
// to the requested alias.
|
||||||
|
//
|
||||||
|
// If the requested model contains a thinking suffix (e.g., "gemini-2.5-pro(8192)"),
|
||||||
|
// the suffix is preserved in the returned model name. However, if the alias's
|
||||||
|
// original name already contains a suffix, the config suffix takes priority.
|
||||||
|
func (m *Manager) resolveOAuthUpstreamModel(auth *Auth, requestedModel string) string {
|
||||||
|
return resolveUpstreamModelFromAliasTable(m, auth, requestedModel, modelAliasChannel(auth))
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveUpstreamModelFromAliasTable(m *Manager, auth *Auth, requestedModel, channel string) string {
|
||||||
|
if m == nil || auth == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if channel == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract thinking suffix from requested model using ParseSuffix
|
||||||
|
requestResult := thinking.ParseSuffix(requestedModel)
|
||||||
|
baseModel := requestResult.ModelName
|
||||||
|
|
||||||
|
// Candidate keys to match: base model and raw input (handles suffix-parsing edge cases).
|
||||||
|
candidates := []string{baseModel}
|
||||||
|
if baseModel != requestedModel {
|
||||||
|
candidates = append(candidates, requestedModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := m.oauthModelAlias.Load()
|
||||||
|
table, _ := raw.(*oauthModelAliasTable)
|
||||||
|
if table == nil || table.reverse == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rev := table.reverse[channel]
|
||||||
|
if rev == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(candidate))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
original := strings.TrimSpace(rev[key])
|
||||||
|
if original == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(original, baseModel) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// If config already has suffix, it takes priority.
|
||||||
|
if thinking.ParseSuffix(original).HasSuffix {
|
||||||
|
return original
|
||||||
|
}
|
||||||
|
// Preserve user's thinking suffix on the resolved model.
|
||||||
|
if requestResult.HasSuffix && requestResult.RawSuffix != "" {
|
||||||
|
return original + "(" + requestResult.RawSuffix + ")"
|
||||||
|
}
|
||||||
|
return original
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelAliasChannel extracts the OAuth model alias channel from an Auth object.
|
||||||
|
// It determines the provider and auth kind from the Auth's attributes and delegates
|
||||||
|
// to OAuthModelAliasChannel for the actual channel resolution.
|
||||||
|
func modelAliasChannel(auth *Auth) string {
|
||||||
|
if auth == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
|
||||||
|
authKind := ""
|
||||||
|
if auth.Attributes != nil {
|
||||||
|
authKind = strings.ToLower(strings.TrimSpace(auth.Attributes["auth_kind"]))
|
||||||
|
}
|
||||||
|
if authKind == "" {
|
||||||
|
if kind, _ := auth.AccountInfo(); strings.EqualFold(kind, "api_key") {
|
||||||
|
authKind = "apikey"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return OAuthModelAliasChannel(provider, authKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthModelAliasChannel returns the OAuth model alias channel name for a given provider
|
||||||
|
// and auth kind. Returns empty string if the provider/authKind combination doesn't support
|
||||||
|
// OAuth model alias (e.g., API key authentication).
|
||||||
|
//
|
||||||
|
// Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow.
|
||||||
|
func OAuthModelAliasChannel(provider, authKind string) string {
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
authKind = strings.ToLower(strings.TrimSpace(authKind))
|
||||||
|
switch provider {
|
||||||
|
case "gemini":
|
||||||
|
// gemini provider uses gemini-api-key config, not oauth-model-alias.
|
||||||
|
// OAuth-based gemini auth is converted to "gemini-cli" by the synthesizer.
|
||||||
|
return ""
|
||||||
|
case "vertex":
|
||||||
|
if authKind == "apikey" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "vertex"
|
||||||
|
case "claude":
|
||||||
|
if authKind == "apikey" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "claude"
|
||||||
|
case "codex":
|
||||||
|
if authKind == "apikey" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "codex"
|
||||||
|
case "gemini-cli", "aistudio", "antigravity", "qwen", "iflow":
|
||||||
|
return provider
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
177
sdk/cliproxy/auth/oauth_model_alias_test.go
Normal file
177
sdk/cliproxy/auth/oauth_model_alias_test.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveOAuthUpstreamModel_SuffixPreservation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
aliases map[string][]internalconfig.OAuthModelAlias
|
||||||
|
channel string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "numeric suffix preserved",
|
||||||
|
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||||
|
"gemini-cli": {{Name: "gemini-2.5-pro-exp-03-25", Alias: "gemini-2.5-pro"}},
|
||||||
|
},
|
||||||
|
channel: "gemini-cli",
|
||||||
|
input: "gemini-2.5-pro(8192)",
|
||||||
|
want: "gemini-2.5-pro-exp-03-25(8192)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "level suffix preserved",
|
||||||
|
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||||
|
"claude": {{Name: "claude-sonnet-4-5-20250514", Alias: "claude-sonnet-4-5"}},
|
||||||
|
},
|
||||||
|
channel: "claude",
|
||||||
|
input: "claude-sonnet-4-5(high)",
|
||||||
|
want: "claude-sonnet-4-5-20250514(high)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no suffix unchanged",
|
||||||
|
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||||
|
"gemini-cli": {{Name: "gemini-2.5-pro-exp-03-25", Alias: "gemini-2.5-pro"}},
|
||||||
|
},
|
||||||
|
channel: "gemini-cli",
|
||||||
|
input: "gemini-2.5-pro",
|
||||||
|
want: "gemini-2.5-pro-exp-03-25",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "config suffix takes priority",
|
||||||
|
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||||
|
"claude": {{Name: "claude-sonnet-4-5-20250514(low)", Alias: "claude-sonnet-4-5"}},
|
||||||
|
},
|
||||||
|
channel: "claude",
|
||||||
|
input: "claude-sonnet-4-5(high)",
|
||||||
|
want: "claude-sonnet-4-5-20250514(low)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "auto suffix preserved",
|
||||||
|
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||||
|
"gemini-cli": {{Name: "gemini-2.5-pro-exp-03-25", Alias: "gemini-2.5-pro"}},
|
||||||
|
},
|
||||||
|
channel: "gemini-cli",
|
||||||
|
input: "gemini-2.5-pro(auto)",
|
||||||
|
want: "gemini-2.5-pro-exp-03-25(auto)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "none suffix preserved",
|
||||||
|
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||||
|
"gemini-cli": {{Name: "gemini-2.5-pro-exp-03-25", Alias: "gemini-2.5-pro"}},
|
||||||
|
},
|
||||||
|
channel: "gemini-cli",
|
||||||
|
input: "gemini-2.5-pro(none)",
|
||||||
|
want: "gemini-2.5-pro-exp-03-25(none)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case insensitive alias lookup with suffix",
|
||||||
|
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||||
|
"gemini-cli": {{Name: "gemini-2.5-pro-exp-03-25", Alias: "Gemini-2.5-Pro"}},
|
||||||
|
},
|
||||||
|
channel: "gemini-cli",
|
||||||
|
input: "gemini-2.5-pro(high)",
|
||||||
|
want: "gemini-2.5-pro-exp-03-25(high)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no alias returns empty",
|
||||||
|
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||||
|
"gemini-cli": {{Name: "gemini-2.5-pro-exp-03-25", Alias: "gemini-2.5-pro"}},
|
||||||
|
},
|
||||||
|
channel: "gemini-cli",
|
||||||
|
input: "unknown-model(high)",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong channel returns empty",
|
||||||
|
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||||
|
"gemini-cli": {{Name: "gemini-2.5-pro-exp-03-25", Alias: "gemini-2.5-pro"}},
|
||||||
|
},
|
||||||
|
channel: "claude",
|
||||||
|
input: "gemini-2.5-pro(high)",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty suffix filtered out",
|
||||||
|
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||||
|
"gemini-cli": {{Name: "gemini-2.5-pro-exp-03-25", Alias: "gemini-2.5-pro"}},
|
||||||
|
},
|
||||||
|
channel: "gemini-cli",
|
||||||
|
input: "gemini-2.5-pro()",
|
||||||
|
want: "gemini-2.5-pro-exp-03-25",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "incomplete suffix treated as no suffix",
|
||||||
|
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||||
|
"gemini-cli": {{Name: "gemini-2.5-pro-exp-03-25", Alias: "gemini-2.5-pro(high"}},
|
||||||
|
},
|
||||||
|
channel: "gemini-cli",
|
||||||
|
input: "gemini-2.5-pro(high",
|
||||||
|
want: "gemini-2.5-pro-exp-03-25",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr := NewManager(nil, nil, nil)
|
||||||
|
mgr.SetConfig(&internalconfig.Config{})
|
||||||
|
mgr.SetOAuthModelAlias(tt.aliases)
|
||||||
|
|
||||||
|
auth := createAuthForChannel(tt.channel)
|
||||||
|
got := mgr.resolveOAuthUpstreamModel(auth, tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("resolveOAuthUpstreamModel(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAuthForChannel(channel string) *Auth {
|
||||||
|
switch channel {
|
||||||
|
case "gemini-cli":
|
||||||
|
return &Auth{Provider: "gemini-cli"}
|
||||||
|
case "claude":
|
||||||
|
return &Auth{Provider: "claude", Attributes: map[string]string{"auth_kind": "oauth"}}
|
||||||
|
case "vertex":
|
||||||
|
return &Auth{Provider: "vertex", Attributes: map[string]string{"auth_kind": "oauth"}}
|
||||||
|
case "codex":
|
||||||
|
return &Auth{Provider: "codex", Attributes: map[string]string{"auth_kind": "oauth"}}
|
||||||
|
case "aistudio":
|
||||||
|
return &Auth{Provider: "aistudio"}
|
||||||
|
case "antigravity":
|
||||||
|
return &Auth{Provider: "antigravity"}
|
||||||
|
case "qwen":
|
||||||
|
return &Auth{Provider: "qwen"}
|
||||||
|
case "iflow":
|
||||||
|
return &Auth{Provider: "iflow"}
|
||||||
|
default:
|
||||||
|
return &Auth{Provider: channel}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyOAuthModelAlias_SuffixPreservation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
aliases := map[string][]internalconfig.OAuthModelAlias{
|
||||||
|
"gemini-cli": {{Name: "gemini-2.5-pro-exp-03-25", Alias: "gemini-2.5-pro"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := NewManager(nil, nil, nil)
|
||||||
|
mgr.SetConfig(&internalconfig.Config{})
|
||||||
|
mgr.SetOAuthModelAlias(aliases)
|
||||||
|
|
||||||
|
auth := &Auth{ID: "test-auth-id", Provider: "gemini-cli"}
|
||||||
|
|
||||||
|
resolvedModel := mgr.applyOAuthModelAlias(auth, "gemini-2.5-pro(8192)")
|
||||||
|
if resolvedModel != "gemini-2.5-pro-exp-03-25(8192)" {
|
||||||
|
t.Errorf("applyOAuthModelAlias() model = %q, want %q", resolvedModel, "gemini-2.5-pro-exp-03-25(8192)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -215,7 +215,8 @@ func (b *Builder) Build() (*Service, error) {
|
|||||||
}
|
}
|
||||||
// Attach a default RoundTripper provider so providers can opt-in per-auth transports.
|
// Attach a default RoundTripper provider so providers can opt-in per-auth transports.
|
||||||
coreManager.SetRoundTripperProvider(newDefaultRoundTripperProvider())
|
coreManager.SetRoundTripperProvider(newDefaultRoundTripperProvider())
|
||||||
coreManager.SetOAuthModelMappings(b.cfg.OAuthModelMappings)
|
coreManager.SetConfig(b.cfg)
|
||||||
|
coreManager.SetOAuthModelAlias(b.cfg.OAuthModelAlias)
|
||||||
|
|
||||||
service := &Service{
|
service := &Service{
|
||||||
cfg: b.cfg,
|
cfg: b.cfg,
|
||||||
|
|||||||
@@ -553,7 +553,8 @@ func (s *Service) Run(ctx context.Context) error {
|
|||||||
s.cfg = newCfg
|
s.cfg = newCfg
|
||||||
s.cfgMu.Unlock()
|
s.cfgMu.Unlock()
|
||||||
if s.coreManager != nil {
|
if s.coreManager != nil {
|
||||||
s.coreManager.SetOAuthModelMappings(newCfg.OAuthModelMappings)
|
s.coreManager.SetConfig(newCfg)
|
||||||
|
s.coreManager.SetOAuthModelAlias(newCfg.OAuthModelAlias)
|
||||||
}
|
}
|
||||||
s.rebindExecutors()
|
s.rebindExecutors()
|
||||||
}
|
}
|
||||||
@@ -825,6 +826,7 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
|||||||
OwnedBy: compat.Name,
|
OwnedBy: compat.Name,
|
||||||
Type: "openai-compatibility",
|
Type: "openai-compatibility",
|
||||||
DisplayName: modelID,
|
DisplayName: modelID,
|
||||||
|
UserDefined: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Register and return
|
// Register and return
|
||||||
@@ -847,7 +849,7 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
models = applyOAuthModelMappings(s.cfg, provider, authKind, models)
|
models = applyOAuthModelAlias(s.cfg, provider, authKind, models)
|
||||||
if len(models) > 0 {
|
if len(models) > 0 {
|
||||||
key := provider
|
key := provider
|
||||||
if key == "" {
|
if key == "" {
|
||||||
@@ -1157,6 +1159,7 @@ func buildConfigModels[T modelEntry](models []T, ownedBy, modelType string) []*M
|
|||||||
OwnedBy: ownedBy,
|
OwnedBy: ownedBy,
|
||||||
Type: modelType,
|
Type: modelType,
|
||||||
DisplayName: display,
|
DisplayName: display,
|
||||||
|
UserDefined: true,
|
||||||
}
|
}
|
||||||
if name != "" {
|
if name != "" {
|
||||||
if upstream := registry.LookupStaticModelInfo(name); upstream != nil && upstream.Thinking != nil {
|
if upstream := registry.LookupStaticModelInfo(name); upstream != nil && upstream.Thinking != nil {
|
||||||
@@ -1219,28 +1222,28 @@ func rewriteModelInfoName(name, oldID, newID string) string {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, models []*ModelInfo) []*ModelInfo {
|
func applyOAuthModelAlias(cfg *config.Config, provider, authKind string, models []*ModelInfo) []*ModelInfo {
|
||||||
if cfg == nil || len(models) == 0 {
|
if cfg == nil || len(models) == 0 {
|
||||||
return models
|
return models
|
||||||
}
|
}
|
||||||
channel := coreauth.OAuthModelMappingChannel(provider, authKind)
|
channel := coreauth.OAuthModelAliasChannel(provider, authKind)
|
||||||
if channel == "" || len(cfg.OAuthModelMappings) == 0 {
|
if channel == "" || len(cfg.OAuthModelAlias) == 0 {
|
||||||
return models
|
return models
|
||||||
}
|
}
|
||||||
mappings := cfg.OAuthModelMappings[channel]
|
aliases := cfg.OAuthModelAlias[channel]
|
||||||
if len(mappings) == 0 {
|
if len(aliases) == 0 {
|
||||||
return models
|
return models
|
||||||
}
|
}
|
||||||
|
|
||||||
type mappingEntry struct {
|
type aliasEntry struct {
|
||||||
alias string
|
alias string
|
||||||
fork bool
|
fork bool
|
||||||
}
|
}
|
||||||
|
|
||||||
forward := make(map[string][]mappingEntry, len(mappings))
|
forward := make(map[string][]aliasEntry, len(aliases))
|
||||||
for i := range mappings {
|
for i := range aliases {
|
||||||
name := strings.TrimSpace(mappings[i].Name)
|
name := strings.TrimSpace(aliases[i].Name)
|
||||||
alias := strings.TrimSpace(mappings[i].Alias)
|
alias := strings.TrimSpace(aliases[i].Alias)
|
||||||
if name == "" || alias == "" {
|
if name == "" || alias == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1248,7 +1251,7 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := strings.ToLower(name)
|
key := strings.ToLower(name)
|
||||||
forward[key] = append(forward[key], mappingEntry{alias: alias, fork: mappings[i].Fork})
|
forward[key] = append(forward[key], aliasEntry{alias: alias, fork: aliases[i].Fork})
|
||||||
}
|
}
|
||||||
if len(forward) == 0 {
|
if len(forward) == 0 {
|
||||||
return models
|
return models
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestApplyOAuthModelMappings_Rename(t *testing.T) {
|
func TestApplyOAuthModelAlias_Rename(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
OAuthModelMappings: map[string][]config.ModelNameMapping{
|
OAuthModelAlias: map[string][]config.OAuthModelAlias{
|
||||||
"codex": {
|
"codex": {
|
||||||
{Name: "gpt-5", Alias: "g5"},
|
{Name: "gpt-5", Alias: "g5"},
|
||||||
},
|
},
|
||||||
@@ -18,7 +18,7 @@ func TestApplyOAuthModelMappings_Rename(t *testing.T) {
|
|||||||
{ID: "gpt-5", Name: "models/gpt-5"},
|
{ID: "gpt-5", Name: "models/gpt-5"},
|
||||||
}
|
}
|
||||||
|
|
||||||
out := applyOAuthModelMappings(cfg, "codex", "oauth", models)
|
out := applyOAuthModelAlias(cfg, "codex", "oauth", models)
|
||||||
if len(out) != 1 {
|
if len(out) != 1 {
|
||||||
t.Fatalf("expected 1 model, got %d", len(out))
|
t.Fatalf("expected 1 model, got %d", len(out))
|
||||||
}
|
}
|
||||||
@@ -30,9 +30,9 @@ func TestApplyOAuthModelMappings_Rename(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyOAuthModelMappings_ForkAddsAlias(t *testing.T) {
|
func TestApplyOAuthModelAlias_ForkAddsAlias(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
OAuthModelMappings: map[string][]config.ModelNameMapping{
|
OAuthModelAlias: map[string][]config.OAuthModelAlias{
|
||||||
"codex": {
|
"codex": {
|
||||||
{Name: "gpt-5", Alias: "g5", Fork: true},
|
{Name: "gpt-5", Alias: "g5", Fork: true},
|
||||||
},
|
},
|
||||||
@@ -42,7 +42,7 @@ func TestApplyOAuthModelMappings_ForkAddsAlias(t *testing.T) {
|
|||||||
{ID: "gpt-5", Name: "models/gpt-5"},
|
{ID: "gpt-5", Name: "models/gpt-5"},
|
||||||
}
|
}
|
||||||
|
|
||||||
out := applyOAuthModelMappings(cfg, "codex", "oauth", models)
|
out := applyOAuthModelAlias(cfg, "codex", "oauth", models)
|
||||||
if len(out) != 2 {
|
if len(out) != 2 {
|
||||||
t.Fatalf("expected 2 models, got %d", len(out))
|
t.Fatalf("expected 2 models, got %d", len(out))
|
||||||
}
|
}
|
||||||
@@ -57,9 +57,9 @@ func TestApplyOAuthModelMappings_ForkAddsAlias(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyOAuthModelMappings_ForkAddsMultipleAliases(t *testing.T) {
|
func TestApplyOAuthModelAlias_ForkAddsMultipleAliases(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
OAuthModelMappings: map[string][]config.ModelNameMapping{
|
OAuthModelAlias: map[string][]config.OAuthModelAlias{
|
||||||
"codex": {
|
"codex": {
|
||||||
{Name: "gpt-5", Alias: "g5", Fork: true},
|
{Name: "gpt-5", Alias: "g5", Fork: true},
|
||||||
{Name: "gpt-5", Alias: "g5-2", Fork: true},
|
{Name: "gpt-5", Alias: "g5-2", Fork: true},
|
||||||
@@ -70,7 +70,7 @@ func TestApplyOAuthModelMappings_ForkAddsMultipleAliases(t *testing.T) {
|
|||||||
{ID: "gpt-5", Name: "models/gpt-5"},
|
{ID: "gpt-5", Name: "models/gpt-5"},
|
||||||
}
|
}
|
||||||
|
|
||||||
out := applyOAuthModelMappings(cfg, "codex", "oauth", models)
|
out := applyOAuthModelAlias(cfg, "codex", "oauth", models)
|
||||||
if len(out) != 3 {
|
if len(out) != 3 {
|
||||||
t.Fatalf("expected 3 models, got %d", len(out))
|
t.Fatalf("expected 3 models, got %d", len(out))
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ type StreamingConfig = internalconfig.StreamingConfig
|
|||||||
type TLSConfig = internalconfig.TLSConfig
|
type TLSConfig = internalconfig.TLSConfig
|
||||||
type RemoteManagement = internalconfig.RemoteManagement
|
type RemoteManagement = internalconfig.RemoteManagement
|
||||||
type AmpCode = internalconfig.AmpCode
|
type AmpCode = internalconfig.AmpCode
|
||||||
type ModelNameMapping = internalconfig.ModelNameMapping
|
type OAuthModelAlias = internalconfig.OAuthModelAlias
|
||||||
type PayloadConfig = internalconfig.PayloadConfig
|
type PayloadConfig = internalconfig.PayloadConfig
|
||||||
type PayloadRule = internalconfig.PayloadRule
|
type PayloadRule = internalconfig.PayloadRule
|
||||||
type PayloadModelRule = internalconfig.PayloadModelRule
|
type PayloadModelRule = internalconfig.PayloadModelRule
|
||||||
|
|||||||
@@ -1,423 +0,0 @@
|
|||||||
package test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
)
|
|
||||||
|
|
||||||
// registerGemini3Models loads Gemini 3 models into the registry for testing.
|
|
||||||
func registerGemini3Models(t *testing.T) func() {
|
|
||||||
t.Helper()
|
|
||||||
reg := registry.GetGlobalRegistry()
|
|
||||||
uid := fmt.Sprintf("gemini3-test-%d", time.Now().UnixNano())
|
|
||||||
reg.RegisterClient(uid+"-gemini", "gemini", registry.GetGeminiModels())
|
|
||||||
reg.RegisterClient(uid+"-aistudio", "aistudio", registry.GetAIStudioModels())
|
|
||||||
return func() {
|
|
||||||
reg.UnregisterClient(uid + "-gemini")
|
|
||||||
reg.UnregisterClient(uid + "-aistudio")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsGemini3Model(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
model string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"gemini-3-pro-preview", true},
|
|
||||||
{"gemini-3-flash-preview", true},
|
|
||||||
{"gemini_3_pro_preview", true},
|
|
||||||
{"gemini-3-pro", true},
|
|
||||||
{"gemini-3-flash", true},
|
|
||||||
{"GEMINI-3-PRO-PREVIEW", true},
|
|
||||||
{"gemini-2.5-pro", false},
|
|
||||||
{"gemini-2.5-flash", false},
|
|
||||||
{"gpt-5", false},
|
|
||||||
{"claude-sonnet-4-5", false},
|
|
||||||
{"", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, cs := range cases {
|
|
||||||
t.Run(cs.model, func(t *testing.T) {
|
|
||||||
got := util.IsGemini3Model(cs.model)
|
|
||||||
if got != cs.expected {
|
|
||||||
t.Fatalf("IsGemini3Model(%q) = %v, want %v", cs.model, got, cs.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsGemini3ProModel(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
model string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"gemini-3-pro-preview", true},
|
|
||||||
{"gemini_3_pro_preview", true},
|
|
||||||
{"gemini-3-pro", true},
|
|
||||||
{"GEMINI-3-PRO-PREVIEW", true},
|
|
||||||
{"gemini-3-flash-preview", false},
|
|
||||||
{"gemini-3-flash", false},
|
|
||||||
{"gemini-2.5-pro", false},
|
|
||||||
{"", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, cs := range cases {
|
|
||||||
t.Run(cs.model, func(t *testing.T) {
|
|
||||||
got := util.IsGemini3ProModel(cs.model)
|
|
||||||
if got != cs.expected {
|
|
||||||
t.Fatalf("IsGemini3ProModel(%q) = %v, want %v", cs.model, got, cs.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsGemini3FlashModel(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
model string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"gemini-3-flash-preview", true},
|
|
||||||
{"gemini_3_flash_preview", true},
|
|
||||||
{"gemini-3-flash", true},
|
|
||||||
{"GEMINI-3-FLASH-PREVIEW", true},
|
|
||||||
{"gemini-3-pro-preview", false},
|
|
||||||
{"gemini-3-pro", false},
|
|
||||||
{"gemini-2.5-flash", false},
|
|
||||||
{"", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, cs := range cases {
|
|
||||||
t.Run(cs.model, func(t *testing.T) {
|
|
||||||
got := util.IsGemini3FlashModel(cs.model)
|
|
||||||
if got != cs.expected {
|
|
||||||
t.Fatalf("IsGemini3FlashModel(%q) = %v, want %v", cs.model, got, cs.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateGemini3ThinkingLevel(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
model string
|
|
||||||
level string
|
|
||||||
wantOK bool
|
|
||||||
wantVal string
|
|
||||||
}{
|
|
||||||
// Gemini 3 Pro: supports "low", "high"
|
|
||||||
{"pro-low", "gemini-3-pro-preview", "low", true, "low"},
|
|
||||||
{"pro-high", "gemini-3-pro-preview", "high", true, "high"},
|
|
||||||
{"pro-minimal-invalid", "gemini-3-pro-preview", "minimal", false, ""},
|
|
||||||
{"pro-medium-invalid", "gemini-3-pro-preview", "medium", false, ""},
|
|
||||||
|
|
||||||
// Gemini 3 Flash: supports "minimal", "low", "medium", "high"
|
|
||||||
{"flash-minimal", "gemini-3-flash-preview", "minimal", true, "minimal"},
|
|
||||||
{"flash-low", "gemini-3-flash-preview", "low", true, "low"},
|
|
||||||
{"flash-medium", "gemini-3-flash-preview", "medium", true, "medium"},
|
|
||||||
{"flash-high", "gemini-3-flash-preview", "high", true, "high"},
|
|
||||||
|
|
||||||
// Case insensitivity
|
|
||||||
{"flash-LOW-case", "gemini-3-flash-preview", "LOW", true, "low"},
|
|
||||||
{"flash-High-case", "gemini-3-flash-preview", "High", true, "high"},
|
|
||||||
{"pro-HIGH-case", "gemini-3-pro-preview", "HIGH", true, "high"},
|
|
||||||
|
|
||||||
// Invalid levels
|
|
||||||
{"flash-invalid", "gemini-3-flash-preview", "xhigh", false, ""},
|
|
||||||
{"flash-invalid-auto", "gemini-3-flash-preview", "auto", false, ""},
|
|
||||||
{"flash-empty", "gemini-3-flash-preview", "", false, ""},
|
|
||||||
|
|
||||||
// Non-Gemini 3 models
|
|
||||||
{"non-gemini3", "gemini-2.5-pro", "high", false, ""},
|
|
||||||
{"gpt5", "gpt-5", "high", false, ""},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, cs := range cases {
|
|
||||||
t.Run(cs.name, func(t *testing.T) {
|
|
||||||
got, ok := util.ValidateGemini3ThinkingLevel(cs.model, cs.level)
|
|
||||||
if ok != cs.wantOK {
|
|
||||||
t.Fatalf("ValidateGemini3ThinkingLevel(%q, %q) ok = %v, want %v", cs.model, cs.level, ok, cs.wantOK)
|
|
||||||
}
|
|
||||||
if got != cs.wantVal {
|
|
||||||
t.Fatalf("ValidateGemini3ThinkingLevel(%q, %q) = %q, want %q", cs.model, cs.level, got, cs.wantVal)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestThinkingBudgetToGemini3Level(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
model string
|
|
||||||
budget int
|
|
||||||
wantOK bool
|
|
||||||
wantVal string
|
|
||||||
}{
|
|
||||||
// Gemini 3 Pro: maps to "low" or "high"
|
|
||||||
{"pro-dynamic", "gemini-3-pro-preview", -1, true, "high"},
|
|
||||||
{"pro-zero", "gemini-3-pro-preview", 0, true, "low"},
|
|
||||||
{"pro-small", "gemini-3-pro-preview", 1000, true, "low"},
|
|
||||||
{"pro-medium", "gemini-3-pro-preview", 8000, true, "low"},
|
|
||||||
{"pro-large", "gemini-3-pro-preview", 20000, true, "high"},
|
|
||||||
{"pro-huge", "gemini-3-pro-preview", 50000, true, "high"},
|
|
||||||
|
|
||||||
// Gemini 3 Flash: maps to "minimal", "low", "medium", "high"
|
|
||||||
{"flash-dynamic", "gemini-3-flash-preview", -1, true, "high"},
|
|
||||||
{"flash-zero", "gemini-3-flash-preview", 0, true, "minimal"},
|
|
||||||
{"flash-tiny", "gemini-3-flash-preview", 500, true, "minimal"},
|
|
||||||
{"flash-small", "gemini-3-flash-preview", 1000, true, "low"},
|
|
||||||
{"flash-medium-val", "gemini-3-flash-preview", 8000, true, "medium"},
|
|
||||||
{"flash-large", "gemini-3-flash-preview", 20000, true, "high"},
|
|
||||||
{"flash-huge", "gemini-3-flash-preview", 50000, true, "high"},
|
|
||||||
|
|
||||||
// Non-Gemini 3 models should return false
|
|
||||||
{"gemini25-budget", "gemini-2.5-pro", 8000, false, ""},
|
|
||||||
{"gpt5-budget", "gpt-5", 8000, false, ""},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, cs := range cases {
|
|
||||||
t.Run(cs.name, func(t *testing.T) {
|
|
||||||
got, ok := util.ThinkingBudgetToGemini3Level(cs.model, cs.budget)
|
|
||||||
if ok != cs.wantOK {
|
|
||||||
t.Fatalf("ThinkingBudgetToGemini3Level(%q, %d) ok = %v, want %v", cs.model, cs.budget, ok, cs.wantOK)
|
|
||||||
}
|
|
||||||
if got != cs.wantVal {
|
|
||||||
t.Fatalf("ThinkingBudgetToGemini3Level(%q, %d) = %q, want %q", cs.model, cs.budget, got, cs.wantVal)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyGemini3ThinkingLevelFromMetadata(t *testing.T) {
|
|
||||||
cleanup := registerGemini3Models(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
model string
|
|
||||||
metadata map[string]any
|
|
||||||
inputBody string
|
|
||||||
wantLevel string
|
|
||||||
wantInclude bool
|
|
||||||
wantNoChange bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "flash-minimal-from-suffix",
|
|
||||||
model: "gemini-3-flash-preview",
|
|
||||||
metadata: map[string]any{"reasoning_effort": "minimal"},
|
|
||||||
inputBody: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`,
|
|
||||||
wantLevel: "minimal",
|
|
||||||
wantInclude: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "flash-medium-from-suffix",
|
|
||||||
model: "gemini-3-flash-preview",
|
|
||||||
metadata: map[string]any{"reasoning_effort": "medium"},
|
|
||||||
inputBody: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`,
|
|
||||||
wantLevel: "medium",
|
|
||||||
wantInclude: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "pro-high-from-suffix",
|
|
||||||
model: "gemini-3-pro-preview",
|
|
||||||
metadata: map[string]any{"reasoning_effort": "high"},
|
|
||||||
inputBody: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`,
|
|
||||||
wantLevel: "high",
|
|
||||||
wantInclude: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no-metadata-no-change",
|
|
||||||
model: "gemini-3-flash-preview",
|
|
||||||
metadata: nil,
|
|
||||||
inputBody: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`,
|
|
||||||
wantNoChange: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-gemini3-no-change",
|
|
||||||
model: "gemini-2.5-pro",
|
|
||||||
metadata: map[string]any{"reasoning_effort": "high"},
|
|
||||||
inputBody: `{"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`,
|
|
||||||
wantNoChange: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid-level-no-change",
|
|
||||||
model: "gemini-3-flash-preview",
|
|
||||||
metadata: map[string]any{"reasoning_effort": "xhigh"},
|
|
||||||
inputBody: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`,
|
|
||||||
wantNoChange: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, cs := range cases {
|
|
||||||
t.Run(cs.name, func(t *testing.T) {
|
|
||||||
input := []byte(cs.inputBody)
|
|
||||||
result := util.ApplyGemini3ThinkingLevelFromMetadata(cs.model, cs.metadata, input)
|
|
||||||
|
|
||||||
if cs.wantNoChange {
|
|
||||||
if string(result) != cs.inputBody {
|
|
||||||
t.Fatalf("expected no change, but got: %s", string(result))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
level := gjson.GetBytes(result, "generationConfig.thinkingConfig.thinkingLevel")
|
|
||||||
if !level.Exists() {
|
|
||||||
t.Fatalf("thinkingLevel not set in result: %s", string(result))
|
|
||||||
}
|
|
||||||
if level.String() != cs.wantLevel {
|
|
||||||
t.Fatalf("thinkingLevel = %q, want %q", level.String(), cs.wantLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
include := gjson.GetBytes(result, "generationConfig.thinkingConfig.includeThoughts")
|
|
||||||
if cs.wantInclude && (!include.Exists() || !include.Bool()) {
|
|
||||||
t.Fatalf("includeThoughts should be true, got: %s", string(result))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyGemini3ThinkingLevelFromMetadataCLI(t *testing.T) {
|
|
||||||
cleanup := registerGemini3Models(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
model string
|
|
||||||
metadata map[string]any
|
|
||||||
inputBody string
|
|
||||||
wantLevel string
|
|
||||||
wantInclude bool
|
|
||||||
wantNoChange bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "flash-minimal-from-suffix-cli",
|
|
||||||
model: "gemini-3-flash-preview",
|
|
||||||
metadata: map[string]any{"reasoning_effort": "minimal"},
|
|
||||||
inputBody: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`,
|
|
||||||
wantLevel: "minimal",
|
|
||||||
wantInclude: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "flash-low-from-suffix-cli",
|
|
||||||
model: "gemini-3-flash-preview",
|
|
||||||
metadata: map[string]any{"reasoning_effort": "low"},
|
|
||||||
inputBody: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`,
|
|
||||||
wantLevel: "low",
|
|
||||||
wantInclude: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "pro-low-from-suffix-cli",
|
|
||||||
model: "gemini-3-pro-preview",
|
|
||||||
metadata: map[string]any{"reasoning_effort": "low"},
|
|
||||||
inputBody: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`,
|
|
||||||
wantLevel: "low",
|
|
||||||
wantInclude: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no-metadata-no-change-cli",
|
|
||||||
model: "gemini-3-flash-preview",
|
|
||||||
metadata: nil,
|
|
||||||
inputBody: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`,
|
|
||||||
wantNoChange: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-gemini3-no-change-cli",
|
|
||||||
model: "gemini-2.5-pro",
|
|
||||||
metadata: map[string]any{"reasoning_effort": "high"},
|
|
||||||
inputBody: `{"request":{"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}}`,
|
|
||||||
wantNoChange: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, cs := range cases {
|
|
||||||
t.Run(cs.name, func(t *testing.T) {
|
|
||||||
input := []byte(cs.inputBody)
|
|
||||||
result := util.ApplyGemini3ThinkingLevelFromMetadataCLI(cs.model, cs.metadata, input)
|
|
||||||
|
|
||||||
if cs.wantNoChange {
|
|
||||||
if string(result) != cs.inputBody {
|
|
||||||
t.Fatalf("expected no change, but got: %s", string(result))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
level := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel")
|
|
||||||
if !level.Exists() {
|
|
||||||
t.Fatalf("thinkingLevel not set in result: %s", string(result))
|
|
||||||
}
|
|
||||||
if level.String() != cs.wantLevel {
|
|
||||||
t.Fatalf("thinkingLevel = %q, want %q", level.String(), cs.wantLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
include := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts")
|
|
||||||
if cs.wantInclude && (!include.Exists() || !include.Bool()) {
|
|
||||||
t.Fatalf("includeThoughts should be true, got: %s", string(result))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeGeminiThinkingBudget_Gemini3Conversion(t *testing.T) {
|
|
||||||
cleanup := registerGemini3Models(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
model string
|
|
||||||
inputBody string
|
|
||||||
wantLevel string
|
|
||||||
wantBudget bool // if true, expect thinkingBudget instead of thinkingLevel
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "gemini3-flash-budget-to-level",
|
|
||||||
model: "gemini-3-flash-preview",
|
|
||||||
inputBody: `{"generationConfig":{"thinkingConfig":{"thinkingBudget":8000}}}`,
|
|
||||||
wantLevel: "medium",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "gemini3-pro-budget-to-level",
|
|
||||||
model: "gemini-3-pro-preview",
|
|
||||||
inputBody: `{"generationConfig":{"thinkingConfig":{"thinkingBudget":20000}}}`,
|
|
||||||
wantLevel: "high",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "gemini25-keeps-budget",
|
|
||||||
model: "gemini-2.5-pro",
|
|
||||||
inputBody: `{"generationConfig":{"thinkingConfig":{"thinkingBudget":8000}}}`,
|
|
||||||
wantBudget: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, cs := range cases {
|
|
||||||
t.Run(cs.name, func(t *testing.T) {
|
|
||||||
result := util.NormalizeGeminiThinkingBudget(cs.model, []byte(cs.inputBody))
|
|
||||||
|
|
||||||
if cs.wantBudget {
|
|
||||||
budget := gjson.GetBytes(result, "generationConfig.thinkingConfig.thinkingBudget")
|
|
||||||
if !budget.Exists() {
|
|
||||||
t.Fatalf("thinkingBudget should exist for non-Gemini3 model: %s", string(result))
|
|
||||||
}
|
|
||||||
level := gjson.GetBytes(result, "generationConfig.thinkingConfig.thinkingLevel")
|
|
||||||
if level.Exists() {
|
|
||||||
t.Fatalf("thinkingLevel should not exist for non-Gemini3 model: %s", string(result))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
level := gjson.GetBytes(result, "generationConfig.thinkingConfig.thinkingLevel")
|
|
||||||
if !level.Exists() {
|
|
||||||
t.Fatalf("thinkingLevel should exist for Gemini3 model: %s", string(result))
|
|
||||||
}
|
|
||||||
if level.String() != cs.wantLevel {
|
|
||||||
t.Fatalf("thinkingLevel = %q, want %q", level.String(), cs.wantLevel)
|
|
||||||
}
|
|
||||||
budget := gjson.GetBytes(result, "generationConfig.thinkingConfig.thinkingBudget")
|
|
||||||
if budget.Exists() {
|
|
||||||
t.Fatalf("thinkingBudget should be removed for Gemini3 model: %s", string(result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
package test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestModelAliasThinkingSuffix tests the 32 test cases defined in docs/thinking_suffix_test_cases.md
|
|
||||||
// These tests verify the thinking suffix parsing and application logic across different providers.
|
|
||||||
func TestModelAliasThinkingSuffix(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
id int
|
|
||||||
name string
|
|
||||||
provider string
|
|
||||||
requestModel string
|
|
||||||
suffixType string
|
|
||||||
expectedField string // "thinkingBudget", "thinkingLevel", "budget_tokens", "reasoning_effort", "enable_thinking"
|
|
||||||
expectedValue any
|
|
||||||
upstreamModel string // The upstream model after alias resolution
|
|
||||||
isAlias bool
|
|
||||||
}{
|
|
||||||
// === 1. Antigravity Provider ===
|
|
||||||
// 1.1 Budget-only models (Gemini 2.5)
|
|
||||||
{1, "antigravity_original_numeric", "antigravity", "gemini-2.5-computer-use-preview-10-2025(1000)", "numeric", "thinkingBudget", 1000, "gemini-2.5-computer-use-preview-10-2025", false},
|
|
||||||
{2, "antigravity_alias_numeric", "antigravity", "gp(1000)", "numeric", "thinkingBudget", 1000, "gemini-2.5-computer-use-preview-10-2025", true},
|
|
||||||
// 1.2 Budget+Levels models (Gemini 3)
|
|
||||||
{3, "antigravity_original_numeric_to_level", "antigravity", "gemini-3-flash-preview(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", false},
|
|
||||||
{4, "antigravity_original_level", "antigravity", "gemini-3-flash-preview(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", false},
|
|
||||||
{5, "antigravity_alias_numeric_to_level", "antigravity", "gf(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", true},
|
|
||||||
{6, "antigravity_alias_level", "antigravity", "gf(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", true},
|
|
||||||
|
|
||||||
// === 2. Gemini CLI Provider ===
|
|
||||||
// 2.1 Budget-only models
|
|
||||||
{7, "gemini_cli_original_numeric", "gemini-cli", "gemini-2.5-pro(8192)", "numeric", "thinkingBudget", 8192, "gemini-2.5-pro", false},
|
|
||||||
{8, "gemini_cli_alias_numeric", "gemini-cli", "g25p(8192)", "numeric", "thinkingBudget", 8192, "gemini-2.5-pro", true},
|
|
||||||
// 2.2 Budget+Levels models
|
|
||||||
{9, "gemini_cli_original_numeric_to_level", "gemini-cli", "gemini-3-flash-preview(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", false},
|
|
||||||
{10, "gemini_cli_original_level", "gemini-cli", "gemini-3-flash-preview(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", false},
|
|
||||||
{11, "gemini_cli_alias_numeric_to_level", "gemini-cli", "gf(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", true},
|
|
||||||
{12, "gemini_cli_alias_level", "gemini-cli", "gf(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", true},
|
|
||||||
|
|
||||||
// === 3. Vertex Provider ===
|
|
||||||
// 3.1 Budget-only models
|
|
||||||
{13, "vertex_original_numeric", "vertex", "gemini-2.5-pro(16384)", "numeric", "thinkingBudget", 16384, "gemini-2.5-pro", false},
|
|
||||||
{14, "vertex_alias_numeric", "vertex", "vg25p(16384)", "numeric", "thinkingBudget", 16384, "gemini-2.5-pro", true},
|
|
||||||
// 3.2 Budget+Levels models
|
|
||||||
{15, "vertex_original_numeric_to_level", "vertex", "gemini-3-flash-preview(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", false},
|
|
||||||
{16, "vertex_original_level", "vertex", "gemini-3-flash-preview(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", false},
|
|
||||||
{17, "vertex_alias_numeric_to_level", "vertex", "vgf(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", true},
|
|
||||||
{18, "vertex_alias_level", "vertex", "vgf(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", true},
|
|
||||||
|
|
||||||
// === 4. AI Studio Provider ===
|
|
||||||
// 4.1 Budget-only models
|
|
||||||
{19, "aistudio_original_numeric", "aistudio", "gemini-2.5-pro(12000)", "numeric", "thinkingBudget", 12000, "gemini-2.5-pro", false},
|
|
||||||
{20, "aistudio_alias_numeric", "aistudio", "ag25p(12000)", "numeric", "thinkingBudget", 12000, "gemini-2.5-pro", true},
|
|
||||||
// 4.2 Budget+Levels models
|
|
||||||
{21, "aistudio_original_numeric_to_level", "aistudio", "gemini-3-flash-preview(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", false},
|
|
||||||
{22, "aistudio_original_level", "aistudio", "gemini-3-flash-preview(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", false},
|
|
||||||
{23, "aistudio_alias_numeric_to_level", "aistudio", "agf(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", true},
|
|
||||||
{24, "aistudio_alias_level", "aistudio", "agf(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", true},
|
|
||||||
|
|
||||||
// === 5. Claude Provider ===
|
|
||||||
{25, "claude_original_numeric", "claude", "claude-sonnet-4-5-20250929(16384)", "numeric", "budget_tokens", 16384, "claude-sonnet-4-5-20250929", false},
|
|
||||||
{26, "claude_alias_numeric", "claude", "cs45(16384)", "numeric", "budget_tokens", 16384, "claude-sonnet-4-5-20250929", true},
|
|
||||||
|
|
||||||
// === 6. Codex Provider ===
|
|
||||||
{27, "codex_original_level", "codex", "gpt-5(high)", "level", "reasoning_effort", "high", "gpt-5", false},
|
|
||||||
{28, "codex_alias_level", "codex", "g5(high)", "level", "reasoning_effort", "high", "gpt-5", true},
|
|
||||||
|
|
||||||
// === 7. Qwen Provider ===
|
|
||||||
{29, "qwen_original_level", "qwen", "qwen3-coder-plus(high)", "level", "enable_thinking", true, "qwen3-coder-plus", false},
|
|
||||||
{30, "qwen_alias_level", "qwen", "qcp(high)", "level", "enable_thinking", true, "qwen3-coder-plus", true},
|
|
||||||
|
|
||||||
// === 8. iFlow Provider ===
|
|
||||||
{31, "iflow_original_level", "iflow", "glm-4.7(high)", "level", "reasoning_effort", "high", "glm-4.7", false},
|
|
||||||
{32, "iflow_alias_level", "iflow", "glm(high)", "level", "reasoning_effort", "high", "glm-4.7", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Step 1: Parse model suffix (simulates SDK layer normalization)
|
|
||||||
// For "gp(1000)" -> requestedModel="gp", metadata={thinking_budget: 1000}
|
|
||||||
requestedModel, metadata := util.NormalizeThinkingModel(tt.requestModel)
|
|
||||||
|
|
||||||
// Verify suffix was parsed
|
|
||||||
if metadata == nil && (tt.suffixType == "numeric" || tt.suffixType == "level") {
|
|
||||||
t.Errorf("Case #%d: NormalizeThinkingModel(%q) metadata is nil", tt.id, tt.requestModel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Simulate OAuth model mapping
|
|
||||||
// Real flow: applyOAuthModelMapping stores requestedModel (the alias) in metadata
|
|
||||||
if tt.isAlias {
|
|
||||||
if metadata == nil {
|
|
||||||
metadata = make(map[string]any)
|
|
||||||
}
|
|
||||||
metadata[util.ModelMappingOriginalModelMetadataKey] = requestedModel
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Verify metadata extraction
|
|
||||||
switch tt.suffixType {
|
|
||||||
case "numeric":
|
|
||||||
budget, _, _, matched := util.ThinkingFromMetadata(metadata)
|
|
||||||
if !matched {
|
|
||||||
t.Errorf("Case #%d: ThinkingFromMetadata did not match", tt.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if budget == nil {
|
|
||||||
t.Errorf("Case #%d: expected budget in metadata", tt.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// For thinkingBudget/budget_tokens, verify the parsed budget value
|
|
||||||
if tt.expectedField == "thinkingBudget" || tt.expectedField == "budget_tokens" {
|
|
||||||
expectedBudget := tt.expectedValue.(int)
|
|
||||||
if *budget != expectedBudget {
|
|
||||||
t.Errorf("Case #%d: budget = %d, want %d", tt.id, *budget, expectedBudget)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For thinkingLevel (Gemini 3), verify conversion from budget to level
|
|
||||||
if tt.expectedField == "thinkingLevel" {
|
|
||||||
level, ok := util.ThinkingBudgetToGemini3Level(tt.upstreamModel, *budget)
|
|
||||||
if !ok {
|
|
||||||
t.Errorf("Case #%d: ThinkingBudgetToGemini3Level failed", tt.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expectedLevel := tt.expectedValue.(string)
|
|
||||||
if level != expectedLevel {
|
|
||||||
t.Errorf("Case #%d: converted level = %q, want %q", tt.id, level, expectedLevel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "level":
|
|
||||||
_, _, effort, matched := util.ThinkingFromMetadata(metadata)
|
|
||||||
if !matched {
|
|
||||||
t.Errorf("Case #%d: ThinkingFromMetadata did not match", tt.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if effort == nil {
|
|
||||||
t.Errorf("Case #%d: expected effort in metadata", tt.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if tt.expectedField == "thinkingLevel" || tt.expectedField == "reasoning_effort" {
|
|
||||||
expectedEffort := tt.expectedValue.(string)
|
|
||||||
if *effort != expectedEffort {
|
|
||||||
t.Errorf("Case #%d: effort = %q, want %q", tt.id, *effort, expectedEffort)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Test Gemini-specific thinkingLevel conversion for Gemini 3 models
|
|
||||||
if tt.expectedField == "thinkingLevel" && util.IsGemini3Model(tt.upstreamModel) {
|
|
||||||
body := []byte(`{"request":{"contents":[]}}`)
|
|
||||||
|
|
||||||
// Build metadata simulating real OAuth flow:
|
|
||||||
// - requestedModel (alias like "gf") is stored in model_mapping_original_model
|
|
||||||
// - upstreamModel is passed as the model parameter
|
|
||||||
testMetadata := make(map[string]any)
|
|
||||||
if tt.isAlias {
|
|
||||||
// Real flow: applyOAuthModelMapping stores requestedModel (the alias)
|
|
||||||
testMetadata[util.ModelMappingOriginalModelMetadataKey] = requestedModel
|
|
||||||
}
|
|
||||||
// Copy parsed metadata (thinking_budget, reasoning_effort, etc.)
|
|
||||||
for k, v := range metadata {
|
|
||||||
testMetadata[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
result := util.ApplyGemini3ThinkingLevelFromMetadataCLI(tt.upstreamModel, testMetadata, body)
|
|
||||||
levelVal := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel")
|
|
||||||
|
|
||||||
expectedLevel := tt.expectedValue.(string)
|
|
||||||
if !levelVal.Exists() {
|
|
||||||
t.Errorf("Case #%d: expected thinkingLevel in result", tt.id)
|
|
||||||
} else if levelVal.String() != expectedLevel {
|
|
||||||
t.Errorf("Case #%d: thinkingLevel = %q, want %q", tt.id, levelVal.String(), expectedLevel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Test Gemini 2.5 thinkingBudget application using real ApplyThinkingMetadataCLI flow
|
|
||||||
if tt.expectedField == "thinkingBudget" && util.IsGemini25Model(tt.upstreamModel) {
|
|
||||||
body := []byte(`{"request":{"contents":[]}}`)
|
|
||||||
|
|
||||||
// Build metadata simulating real OAuth flow:
|
|
||||||
// - requestedModel (alias like "gp") is stored in model_mapping_original_model
|
|
||||||
// - upstreamModel is passed as the model parameter
|
|
||||||
testMetadata := make(map[string]any)
|
|
||||||
if tt.isAlias {
|
|
||||||
// Real flow: applyOAuthModelMapping stores requestedModel (the alias)
|
|
||||||
testMetadata[util.ModelMappingOriginalModelMetadataKey] = requestedModel
|
|
||||||
}
|
|
||||||
// Copy parsed metadata (thinking_budget, reasoning_effort, etc.)
|
|
||||||
for k, v := range metadata {
|
|
||||||
testMetadata[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the exported ApplyThinkingMetadataCLI which includes the fallback logic
|
|
||||||
result := executor.ApplyThinkingMetadataCLI(body, testMetadata, tt.upstreamModel)
|
|
||||||
budgetVal := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget")
|
|
||||||
|
|
||||||
expectedBudget := tt.expectedValue.(int)
|
|
||||||
if !budgetVal.Exists() {
|
|
||||||
t.Errorf("Case #%d: expected thinkingBudget in result", tt.id)
|
|
||||||
} else if int(budgetVal.Int()) != expectedBudget {
|
|
||||||
t.Errorf("Case #%d: thinkingBudget = %d, want %d", tt.id, int(budgetVal.Int()), expectedBudget)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user