mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-21 16:40:22 +00:00
Merge branch 'router-for-me:main' into main
This commit is contained in:
@@ -82,6 +82,9 @@ routing:
|
|||||||
# When true, enable authentication for the WebSocket API (/v1/ws).
|
# When true, enable authentication for the WebSocket API (/v1/ws).
|
||||||
ws-auth: false
|
ws-auth: false
|
||||||
|
|
||||||
|
# When > 0, emit blank lines every N seconds for non-streaming responses to prevent idle timeouts.
|
||||||
|
nonstream-keepalive-interval: 0
|
||||||
|
|
||||||
# Streaming behavior (SSE keep-alives + safe bootstrap retries).
|
# Streaming behavior (SSE keep-alives + safe bootstrap retries).
|
||||||
# streaming:
|
# streaming:
|
||||||
# keepalive-seconds: 15 # Default: 0 (disabled). <= 0 disables keep-alives.
|
# keepalive-seconds: 15 # Default: 0 (disabled). <= 0 disables keep-alives.
|
||||||
@@ -217,6 +220,7 @@ ws-auth: false
|
|||||||
# These mappings rename model IDs for both model listing and request routing.
|
# These mappings rename model IDs for both model listing and request routing.
|
||||||
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot.
|
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot.
|
||||||
# NOTE: Mappings do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.
|
# NOTE: Mappings 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.
|
||||||
# oauth-model-mappings:
|
# oauth-model-mappings:
|
||||||
# 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
|
||||||
|
|||||||
@@ -567,7 +567,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
|||||||
|
|
||||||
// SanitizeOAuthModelMappings normalizes and deduplicates global OAuth model name mappings.
|
// SanitizeOAuthModelMappings normalizes and deduplicates global OAuth model name mappings.
|
||||||
// It trims whitespace, normalizes channel keys to lower-case, drops empty entries,
|
// It trims whitespace, normalizes channel keys to lower-case, drops empty entries,
|
||||||
// and ensures (From, To) pairs 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) SanitizeOAuthModelMappings() {
|
||||||
if cfg == nil || len(cfg.OAuthModelMappings) == 0 {
|
if cfg == nil || len(cfg.OAuthModelMappings) == 0 {
|
||||||
return
|
return
|
||||||
@@ -578,7 +578,6 @@ func (cfg *Config) SanitizeOAuthModelMappings() {
|
|||||||
if channel == "" || len(mappings) == 0 {
|
if channel == "" || len(mappings) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seenName := make(map[string]struct{}, len(mappings))
|
|
||||||
seenAlias := make(map[string]struct{}, len(mappings))
|
seenAlias := make(map[string]struct{}, len(mappings))
|
||||||
clean := make([]ModelNameMapping, 0, len(mappings))
|
clean := make([]ModelNameMapping, 0, len(mappings))
|
||||||
for _, mapping := range mappings {
|
for _, mapping := range mappings {
|
||||||
@@ -590,15 +589,10 @@ func (cfg *Config) SanitizeOAuthModelMappings() {
|
|||||||
if strings.EqualFold(name, alias) {
|
if strings.EqualFold(name, alias) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
nameKey := strings.ToLower(name)
|
|
||||||
aliasKey := strings.ToLower(alias)
|
aliasKey := strings.ToLower(alias)
|
||||||
if _, ok := seenName[nameKey]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seenAlias[aliasKey]; ok {
|
if _, ok := seenAlias[aliasKey]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seenName[nameKey] = struct{}{}
|
|
||||||
seenAlias[aliasKey] = struct{}{}
|
seenAlias[aliasKey] = struct{}{}
|
||||||
clean = append(clean, ModelNameMapping{Name: name, Alias: alias, Fork: mapping.Fork})
|
clean = append(clean, ModelNameMapping{Name: name, Alias: alias, Fork: mapping.Fork})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,3 +25,32 @@ func TestSanitizeOAuthModelMappings_PreservesForkFlag(t *testing.T) {
|
|||||||
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ type SDKConfig struct {
|
|||||||
|
|
||||||
// Streaming configures server-side streaming behavior (keep-alives and safe bootstrap retries).
|
// Streaming configures server-side streaming behavior (keep-alives and safe bootstrap retries).
|
||||||
Streaming StreamingConfig `yaml:"streaming" json:"streaming"`
|
Streaming StreamingConfig `yaml:"streaming" json:"streaming"`
|
||||||
|
|
||||||
|
// NonStreamKeepAliveInterval controls how often blank lines are emitted for non-streaming responses.
|
||||||
|
// <= 0 disables keep-alives. Value is in seconds.
|
||||||
|
NonStreamKeepAliveInterval int `yaml:"nonstream-keepalive-interval,omitempty" json:"nonstream-keepalive-interval,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamingConfig holds server streaming behavior configuration.
|
// StreamingConfig holds server streaming behavior configuration.
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
|||||||
body, _ = sjson.SetBytes(body, "model", model)
|
body, _ = sjson.SetBytes(body, "model", model)
|
||||||
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")
|
||||||
|
|
||||||
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)
|
||||||
@@ -214,6 +215,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
}
|
}
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, model, to.String(), "", body, originalTranslated)
|
body = applyPayloadConfigWithRoot(e.cfg, model, 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.SetBytes(body, "model", model)
|
body, _ = sjson.SetBytes(body, "model", model)
|
||||||
|
|
||||||
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
||||||
@@ -316,6 +318,7 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth
|
|||||||
body = ApplyReasoningEffortMetadata(body, req.Metadata, model, "reasoning.effort", false)
|
body = ApplyReasoningEffortMetadata(body, req.Metadata, model, "reasoning.effort", false)
|
||||||
body, _ = sjson.SetBytes(body, "model", model)
|
body, _ = sjson.SetBytes(body, "model", model)
|
||||||
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
||||||
|
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(model)
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
|
|||||||
if oldCfg.ForceModelPrefix != newCfg.ForceModelPrefix {
|
if oldCfg.ForceModelPrefix != newCfg.ForceModelPrefix {
|
||||||
changes = append(changes, fmt.Sprintf("force-model-prefix: %t -> %t", oldCfg.ForceModelPrefix, newCfg.ForceModelPrefix))
|
changes = append(changes, fmt.Sprintf("force-model-prefix: %t -> %t", oldCfg.ForceModelPrefix, newCfg.ForceModelPrefix))
|
||||||
}
|
}
|
||||||
|
if oldCfg.NonStreamKeepAliveInterval != newCfg.NonStreamKeepAliveInterval {
|
||||||
|
changes = append(changes, fmt.Sprintf("nonstream-keepalive-interval: %d -> %d", oldCfg.NonStreamKeepAliveInterval, newCfg.NonStreamKeepAliveInterval))
|
||||||
|
}
|
||||||
|
|
||||||
// Quota-exceeded behavior
|
// Quota-exceeded behavior
|
||||||
if oldCfg.QuotaExceeded.SwitchProject != newCfg.QuotaExceeded.SwitchProject {
|
if oldCfg.QuotaExceeded.SwitchProject != newCfg.QuotaExceeded.SwitchProject {
|
||||||
|
|||||||
@@ -231,10 +231,11 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) {
|
|||||||
AmpCode: config.AmpCode{UpstreamAPIKey: "keep", RestrictManagementToLocalhost: false},
|
AmpCode: config.AmpCode{UpstreamAPIKey: "keep", RestrictManagementToLocalhost: false},
|
||||||
RemoteManagement: config.RemoteManagement{DisableControlPanel: false, PanelGitHubRepository: "old/repo", SecretKey: "keep"},
|
RemoteManagement: config.RemoteManagement{DisableControlPanel: false, PanelGitHubRepository: "old/repo", SecretKey: "keep"},
|
||||||
SDKConfig: sdkconfig.SDKConfig{
|
SDKConfig: sdkconfig.SDKConfig{
|
||||||
RequestLog: false,
|
RequestLog: false,
|
||||||
ProxyURL: "http://old-proxy",
|
ProxyURL: "http://old-proxy",
|
||||||
APIKeys: []string{"key-1"},
|
APIKeys: []string{"key-1"},
|
||||||
ForceModelPrefix: false,
|
ForceModelPrefix: false,
|
||||||
|
NonStreamKeepAliveInterval: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
newCfg := &config.Config{
|
newCfg := &config.Config{
|
||||||
@@ -267,10 +268,11 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) {
|
|||||||
SecretKey: "",
|
SecretKey: "",
|
||||||
},
|
},
|
||||||
SDKConfig: sdkconfig.SDKConfig{
|
SDKConfig: sdkconfig.SDKConfig{
|
||||||
RequestLog: true,
|
RequestLog: true,
|
||||||
ProxyURL: "http://new-proxy",
|
ProxyURL: "http://new-proxy",
|
||||||
APIKeys: []string{" key-1 ", "key-2"},
|
APIKeys: []string{" key-1 ", "key-2"},
|
||||||
ForceModelPrefix: true,
|
ForceModelPrefix: true,
|
||||||
|
NonStreamKeepAliveInterval: 5,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +287,7 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) {
|
|||||||
expectContains(t, details, "proxy-url: http://old-proxy -> http://new-proxy")
|
expectContains(t, details, "proxy-url: http://old-proxy -> http://new-proxy")
|
||||||
expectContains(t, details, "ws-auth: false -> true")
|
expectContains(t, details, "ws-auth: false -> true")
|
||||||
expectContains(t, details, "force-model-prefix: false -> true")
|
expectContains(t, details, "force-model-prefix: false -> true")
|
||||||
|
expectContains(t, details, "nonstream-keepalive-interval: 0 -> 5")
|
||||||
expectContains(t, details, "quota-exceeded.switch-project: false -> true")
|
expectContains(t, details, "quota-exceeded.switch-project: false -> true")
|
||||||
expectContains(t, details, "quota-exceeded.switch-preview-model: false -> true")
|
expectContains(t, details, "quota-exceeded.switch-preview-model: false -> true")
|
||||||
expectContains(t, details, "api-keys count: 1 -> 2")
|
expectContains(t, details, "api-keys count: 1 -> 2")
|
||||||
|
|||||||
@@ -146,10 +146,12 @@ func (h *ClaudeCodeAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSO
|
|||||||
c.Header("Content-Type", "application/json")
|
c.Header("Content-Type", "application/json")
|
||||||
alt := h.GetAlt(c)
|
alt := h.GetAlt(c)
|
||||||
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
|
||||||
|
|
||||||
modelName := gjson.GetBytes(rawJSON, "model").String()
|
modelName := gjson.GetBytes(rawJSON, "model").String()
|
||||||
|
|
||||||
resp, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)
|
resp, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)
|
||||||
|
stopKeepAlive()
|
||||||
if errMsg != nil {
|
if errMsg != nil {
|
||||||
h.WriteErrorResponse(c, errMsg)
|
h.WriteErrorResponse(c, errMsg)
|
||||||
cliCancel(errMsg.Error)
|
cliCancel(errMsg.Error)
|
||||||
@@ -159,13 +161,18 @@ func (h *ClaudeCodeAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSO
|
|||||||
// Decompress gzipped responses - Claude API sometimes returns gzip without Content-Encoding header
|
// Decompress gzipped responses - Claude API sometimes returns gzip without Content-Encoding header
|
||||||
// This fixes title generation and other non-streaming responses that arrive compressed
|
// This fixes title generation and other non-streaming responses that arrive compressed
|
||||||
if len(resp) >= 2 && resp[0] == 0x1f && resp[1] == 0x8b {
|
if len(resp) >= 2 && resp[0] == 0x1f && resp[1] == 0x8b {
|
||||||
gzReader, err := gzip.NewReader(bytes.NewReader(resp))
|
gzReader, errGzip := gzip.NewReader(bytes.NewReader(resp))
|
||||||
if err != nil {
|
if errGzip != nil {
|
||||||
log.Warnf("failed to decompress gzipped Claude response: %v", err)
|
log.Warnf("failed to decompress gzipped Claude response: %v", errGzip)
|
||||||
} else {
|
} else {
|
||||||
defer gzReader.Close()
|
defer func() {
|
||||||
if decompressed, err := io.ReadAll(gzReader); err != nil {
|
if errClose := gzReader.Close(); errClose != nil {
|
||||||
log.Warnf("failed to read decompressed Claude response: %v", err)
|
log.Warnf("failed to close Claude gzip reader: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
decompressed, errRead := io.ReadAll(gzReader)
|
||||||
|
if errRead != nil {
|
||||||
|
log.Warnf("failed to read decompressed Claude response: %v", errRead)
|
||||||
} else {
|
} else {
|
||||||
resp = decompressed
|
resp = decompressed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,7 +336,9 @@ func (h *GeminiAPIHandler) handleGenerateContent(c *gin.Context, modelName strin
|
|||||||
c.Header("Content-Type", "application/json")
|
c.Header("Content-Type", "application/json")
|
||||||
alt := h.GetAlt(c)
|
alt := h.GetAlt(c)
|
||||||
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
|
||||||
resp, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)
|
resp, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)
|
||||||
|
stopKeepAlive()
|
||||||
if errMsg != nil {
|
if errMsg != nil {
|
||||||
h.WriteErrorResponse(c, errMsg)
|
h.WriteErrorResponse(c, errMsg)
|
||||||
cliCancel(errMsg.Error)
|
cliCancel(errMsg.Error)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -113,6 +114,19 @@ func StreamingKeepAliveInterval(cfg *config.SDKConfig) time.Duration {
|
|||||||
return time.Duration(seconds) * time.Second
|
return time.Duration(seconds) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NonStreamingKeepAliveInterval returns the keep-alive interval for non-streaming responses.
|
||||||
|
// Returning 0 disables keep-alives (default when unset).
|
||||||
|
func NonStreamingKeepAliveInterval(cfg *config.SDKConfig) time.Duration {
|
||||||
|
seconds := 0
|
||||||
|
if cfg != nil {
|
||||||
|
seconds = cfg.NonStreamKeepAliveInterval
|
||||||
|
}
|
||||||
|
if seconds <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Duration(seconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
// StreamingBootstrapRetries returns how many times a streaming request may be retried before any bytes are sent.
|
// StreamingBootstrapRetries returns how many times a streaming request may be retried before any bytes are sent.
|
||||||
func StreamingBootstrapRetries(cfg *config.SDKConfig) int {
|
func StreamingBootstrapRetries(cfg *config.SDKConfig) int {
|
||||||
retries := defaultStreamingBootstrapRetries
|
retries := defaultStreamingBootstrapRetries
|
||||||
@@ -294,6 +308,53 @@ func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartNonStreamingKeepAlive emits blank lines every 5 seconds while waiting for a non-streaming response.
|
||||||
|
// It returns a stop function that must be called before writing the final response.
|
||||||
|
func (h *BaseAPIHandler) StartNonStreamingKeepAlive(c *gin.Context, ctx context.Context) func() {
|
||||||
|
if h == nil || c == nil {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
interval := NonStreamingKeepAliveInterval(h.Cfg)
|
||||||
|
if interval <= 0 {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
flusher, ok := c.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
stopChan := make(chan struct{})
|
||||||
|
var stopOnce sync.Once
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
_, _ = c.Writer.Write([]byte("\n"))
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
stopOnce.Do(func() {
|
||||||
|
close(stopChan)
|
||||||
|
})
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// appendAPIResponse preserves any previously captured API response and appends new data.
|
// appendAPIResponse preserves any previously captured API response and appends new data.
|
||||||
func appendAPIResponse(c *gin.Context, data []byte) {
|
func appendAPIResponse(c *gin.Context, data []byte) {
|
||||||
if c == nil || len(data) == 0 {
|
if c == nil || len(data) == 0 {
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ func (e *failOnceStreamExecutor) CountTokens(context.Context, *coreauth.Auth, co
|
|||||||
return coreexecutor.Response{}, &coreauth.Error{Code: "not_implemented", Message: "CountTokens not implemented"}
|
return coreexecutor.Response{}, &coreauth.Error{Code: "not_implemented", Message: "CountTokens not implemented"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *failOnceStreamExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) {
|
||||||
|
return nil, &coreauth.Error{
|
||||||
|
Code: "not_implemented",
|
||||||
|
Message: "HttpRequest not implemented",
|
||||||
|
HTTPStatus: http.StatusNotImplemented,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (e *failOnceStreamExecutor) Calls() int {
|
func (e *failOnceStreamExecutor) Calls() int {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
|
|||||||
@@ -524,7 +524,9 @@ func (h *OpenAIAPIHandler) handleCompletionsNonStreamingResponse(c *gin.Context,
|
|||||||
|
|
||||||
modelName := gjson.GetBytes(chatCompletionsJSON, "model").String()
|
modelName := gjson.GetBytes(chatCompletionsJSON, "model").String()
|
||||||
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
|
stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
|
||||||
resp, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, chatCompletionsJSON, "")
|
resp, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, chatCompletionsJSON, "")
|
||||||
|
stopKeepAlive()
|
||||||
if errMsg != nil {
|
if errMsg != nil {
|
||||||
h.WriteErrorResponse(c, errMsg)
|
h.WriteErrorResponse(c, errMsg)
|
||||||
cliCancel(errMsg.Error)
|
cliCancel(errMsg.Error)
|
||||||
|
|||||||
@@ -103,20 +103,17 @@ func (h *OpenAIResponsesAPIHandler) handleNonStreamingResponse(c *gin.Context, r
|
|||||||
|
|
||||||
modelName := gjson.GetBytes(rawJSON, "model").String()
|
modelName := gjson.GetBytes(rawJSON, "model").String()
|
||||||
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
|
||||||
defer func() {
|
stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
|
||||||
cliCancel()
|
|
||||||
}()
|
|
||||||
|
|
||||||
resp, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, "")
|
resp, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, "")
|
||||||
|
stopKeepAlive()
|
||||||
if errMsg != nil {
|
if errMsg != nil {
|
||||||
h.WriteErrorResponse(c, errMsg)
|
h.WriteErrorResponse(c, errMsg)
|
||||||
|
cliCancel(errMsg.Error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _ = c.Writer.Write(resp)
|
_, _ = c.Writer.Write(resp)
|
||||||
return
|
cliCancel()
|
||||||
|
|
||||||
// no legacy fallback
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleStreamingResponse handles streaming responses for Gemini models.
|
// handleStreamingResponse handles streaming responses for Gemini models.
|
||||||
|
|||||||
@@ -1247,7 +1247,7 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode
|
|||||||
fork bool
|
fork bool
|
||||||
}
|
}
|
||||||
|
|
||||||
forward := make(map[string]mappingEntry, len(mappings))
|
forward := make(map[string][]mappingEntry, len(mappings))
|
||||||
for i := range mappings {
|
for i := range mappings {
|
||||||
name := strings.TrimSpace(mappings[i].Name)
|
name := strings.TrimSpace(mappings[i].Name)
|
||||||
alias := strings.TrimSpace(mappings[i].Alias)
|
alias := strings.TrimSpace(mappings[i].Alias)
|
||||||
@@ -1258,14 +1258,12 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := strings.ToLower(name)
|
key := strings.ToLower(name)
|
||||||
if _, exists := forward[key]; exists {
|
forward[key] = append(forward[key], mappingEntry{alias: alias, fork: mappings[i].Fork})
|
||||||
continue
|
|
||||||
}
|
|
||||||
forward[key] = mappingEntry{alias: alias, fork: mappings[i].Fork}
|
|
||||||
}
|
}
|
||||||
if len(forward) == 0 {
|
if len(forward) == 0 {
|
||||||
return models
|
return models
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]*ModelInfo, 0, len(models))
|
out := make([]*ModelInfo, 0, len(models))
|
||||||
seen := make(map[string]struct{}, len(models))
|
seen := make(map[string]struct{}, len(models))
|
||||||
for _, model := range models {
|
for _, model := range models {
|
||||||
@@ -1277,17 +1275,8 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := strings.ToLower(id)
|
key := strings.ToLower(id)
|
||||||
entry, ok := forward[key]
|
entries := forward[key]
|
||||||
if !ok {
|
if len(entries) == 0 {
|
||||||
if _, exists := seen[key]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
out = append(out, model)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mappedID := strings.TrimSpace(entry.alias)
|
|
||||||
if mappedID == "" {
|
|
||||||
if _, exists := seen[key]; exists {
|
if _, exists := seen[key]; exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1296,11 +1285,29 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.fork {
|
keepOriginal := false
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.fork {
|
||||||
|
keepOriginal = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if keepOriginal {
|
||||||
if _, exists := seen[key]; !exists {
|
if _, exists := seen[key]; !exists {
|
||||||
seen[key] = struct{}{}
|
seen[key] = struct{}{}
|
||||||
out = append(out, model)
|
out = append(out, model)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addedAlias := false
|
||||||
|
for _, entry := range entries {
|
||||||
|
mappedID := strings.TrimSpace(entry.alias)
|
||||||
|
if mappedID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(mappedID, id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
aliasKey := strings.ToLower(mappedID)
|
aliasKey := strings.ToLower(mappedID)
|
||||||
if _, exists := seen[aliasKey]; exists {
|
if _, exists := seen[aliasKey]; exists {
|
||||||
continue
|
continue
|
||||||
@@ -1312,24 +1319,16 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode
|
|||||||
clone.Name = rewriteModelInfoName(clone.Name, id, mappedID)
|
clone.Name = rewriteModelInfoName(clone.Name, id, mappedID)
|
||||||
}
|
}
|
||||||
out = append(out, &clone)
|
out = append(out, &clone)
|
||||||
continue
|
addedAlias = true
|
||||||
}
|
}
|
||||||
|
|
||||||
uniqueKey := strings.ToLower(mappedID)
|
if !keepOriginal && !addedAlias {
|
||||||
if _, exists := seen[uniqueKey]; exists {
|
if _, exists := seen[key]; exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[uniqueKey] = struct{}{}
|
seen[key] = struct{}{}
|
||||||
if mappedID == id {
|
|
||||||
out = append(out, model)
|
out = append(out, model)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
clone := *model
|
|
||||||
clone.ID = mappedID
|
|
||||||
if clone.Name != "" {
|
|
||||||
clone.Name = rewriteModelInfoName(clone.Name, id, mappedID)
|
|
||||||
}
|
|
||||||
out = append(out, &clone)
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,3 +56,37 @@ func TestApplyOAuthModelMappings_ForkAddsAlias(t *testing.T) {
|
|||||||
t.Fatalf("expected forked model name %q, got %q", "models/g5", out[1].Name)
|
t.Fatalf("expected forked model name %q, got %q", "models/g5", out[1].Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyOAuthModelMappings_ForkAddsMultipleAliases(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
OAuthModelMappings: map[string][]config.ModelNameMapping{
|
||||||
|
"codex": {
|
||||||
|
{Name: "gpt-5", Alias: "g5", Fork: true},
|
||||||
|
{Name: "gpt-5", Alias: "g5-2", Fork: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
models := []*ModelInfo{
|
||||||
|
{ID: "gpt-5", Name: "models/gpt-5"},
|
||||||
|
}
|
||||||
|
|
||||||
|
out := applyOAuthModelMappings(cfg, "codex", "oauth", models)
|
||||||
|
if len(out) != 3 {
|
||||||
|
t.Fatalf("expected 3 models, got %d", len(out))
|
||||||
|
}
|
||||||
|
if out[0].ID != "gpt-5" {
|
||||||
|
t.Fatalf("expected first model id %q, got %q", "gpt-5", out[0].ID)
|
||||||
|
}
|
||||||
|
if out[1].ID != "g5" {
|
||||||
|
t.Fatalf("expected second model id %q, got %q", "g5", out[1].ID)
|
||||||
|
}
|
||||||
|
if out[1].Name != "models/g5" {
|
||||||
|
t.Fatalf("expected forked model name %q, got %q", "models/g5", out[1].Name)
|
||||||
|
}
|
||||||
|
if out[2].ID != "g5-2" {
|
||||||
|
t.Fatalf("expected third model id %q, got %q", "g5-2", out[2].ID)
|
||||||
|
}
|
||||||
|
if out[2].Name != "models/g5-2" {
|
||||||
|
t.Fatalf("expected forked model name %q, got %q", "models/g5-2", out[2].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user