mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-05-14 00:13:07 +00:00
Merge remote-tracking branch 'upstream/main' into fix/oauth-extra-usage-detection
This commit is contained in:
@@ -214,19 +214,46 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
|
||||
|
||||
func (h *Handler) DeleteGeminiKey(c *gin.Context) {
|
||||
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
|
||||
out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey))
|
||||
for _, v := range h.cfg.GeminiKey {
|
||||
if v.APIKey != val {
|
||||
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
|
||||
base := strings.TrimSpace(baseRaw)
|
||||
out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey))
|
||||
for _, v := range h.cfg.GeminiKey {
|
||||
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
|
||||
continue
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if len(out) != len(h.cfg.GeminiKey) {
|
||||
h.cfg.GeminiKey = out
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persist(c)
|
||||
} else {
|
||||
c.JSON(404, gin.H{"error": "item not found"})
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(out) != len(h.cfg.GeminiKey) {
|
||||
h.cfg.GeminiKey = out
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persist(c)
|
||||
} else {
|
||||
|
||||
matchIndex := -1
|
||||
matchCount := 0
|
||||
for i := range h.cfg.GeminiKey {
|
||||
if strings.TrimSpace(h.cfg.GeminiKey[i].APIKey) == val {
|
||||
matchCount++
|
||||
if matchIndex == -1 {
|
||||
matchIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if matchCount == 0 {
|
||||
c.JSON(404, gin.H{"error": "item not found"})
|
||||
return
|
||||
}
|
||||
if matchCount > 1 {
|
||||
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
|
||||
return
|
||||
}
|
||||
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:matchIndex], h.cfg.GeminiKey[matchIndex+1:]...)
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if idxStr := c.Query("index"); idxStr != "" {
|
||||
@@ -335,14 +362,39 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteClaudeKey(c *gin.Context) {
|
||||
if val := c.Query("api-key"); val != "" {
|
||||
out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
|
||||
for _, v := range h.cfg.ClaudeKey {
|
||||
if v.APIKey != val {
|
||||
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
|
||||
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
|
||||
base := strings.TrimSpace(baseRaw)
|
||||
out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
|
||||
for _, v := range h.cfg.ClaudeKey {
|
||||
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
|
||||
continue
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
h.cfg.ClaudeKey = out
|
||||
h.cfg.SanitizeClaudeKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
|
||||
matchIndex := -1
|
||||
matchCount := 0
|
||||
for i := range h.cfg.ClaudeKey {
|
||||
if strings.TrimSpace(h.cfg.ClaudeKey[i].APIKey) == val {
|
||||
matchCount++
|
||||
if matchIndex == -1 {
|
||||
matchIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if matchCount > 1 {
|
||||
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
|
||||
return
|
||||
}
|
||||
if matchIndex != -1 {
|
||||
h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:matchIndex], h.cfg.ClaudeKey[matchIndex+1:]...)
|
||||
}
|
||||
h.cfg.ClaudeKey = out
|
||||
h.cfg.SanitizeClaudeKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
@@ -601,13 +653,38 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
|
||||
|
||||
func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
|
||||
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
|
||||
out := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey))
|
||||
for _, v := range h.cfg.VertexCompatAPIKey {
|
||||
if v.APIKey != val {
|
||||
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
|
||||
base := strings.TrimSpace(baseRaw)
|
||||
out := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey))
|
||||
for _, v := range h.cfg.VertexCompatAPIKey {
|
||||
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
|
||||
continue
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
h.cfg.VertexCompatAPIKey = out
|
||||
h.cfg.SanitizeVertexCompatKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
|
||||
matchIndex := -1
|
||||
matchCount := 0
|
||||
for i := range h.cfg.VertexCompatAPIKey {
|
||||
if strings.TrimSpace(h.cfg.VertexCompatAPIKey[i].APIKey) == val {
|
||||
matchCount++
|
||||
if matchIndex == -1 {
|
||||
matchIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if matchCount > 1 {
|
||||
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
|
||||
return
|
||||
}
|
||||
if matchIndex != -1 {
|
||||
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:matchIndex], h.cfg.VertexCompatAPIKey[matchIndex+1:]...)
|
||||
}
|
||||
h.cfg.VertexCompatAPIKey = out
|
||||
h.cfg.SanitizeVertexCompatKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
@@ -915,14 +992,39 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteCodexKey(c *gin.Context) {
|
||||
if val := c.Query("api-key"); val != "" {
|
||||
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
|
||||
for _, v := range h.cfg.CodexKey {
|
||||
if v.APIKey != val {
|
||||
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
|
||||
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
|
||||
base := strings.TrimSpace(baseRaw)
|
||||
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
|
||||
for _, v := range h.cfg.CodexKey {
|
||||
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
|
||||
continue
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
h.cfg.CodexKey = out
|
||||
h.cfg.SanitizeCodexKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
|
||||
matchIndex := -1
|
||||
matchCount := 0
|
||||
for i := range h.cfg.CodexKey {
|
||||
if strings.TrimSpace(h.cfg.CodexKey[i].APIKey) == val {
|
||||
matchCount++
|
||||
if matchIndex == -1 {
|
||||
matchIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if matchCount > 1 {
|
||||
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
|
||||
return
|
||||
}
|
||||
if matchIndex != -1 {
|
||||
h.cfg.CodexKey = append(h.cfg.CodexKey[:matchIndex], h.cfg.CodexKey[matchIndex+1:]...)
|
||||
}
|
||||
h.cfg.CodexKey = out
|
||||
h.cfg.SanitizeCodexKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
)
|
||||
|
||||
func writeTestConfigFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if errWrite := os.WriteFile(path, []byte("{}\n"), 0o600); errWrite != nil {
|
||||
t.Fatalf("failed to write test config: %v", errWrite)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestDeleteGeminiKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
GeminiKey: []config.GeminiKey{
|
||||
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
|
||||
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/gemini-api-key?api-key=shared-key", nil)
|
||||
|
||||
h.DeleteGeminiKey(c)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
if got := len(h.cfg.GeminiKey); got != 2 {
|
||||
t.Fatalf("gemini keys len = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteGeminiKey_DeletesOnlyMatchingBaseURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
GeminiKey: []config.GeminiKey{
|
||||
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
|
||||
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/gemini-api-key?api-key=shared-key&base-url=https://a.example.com", nil)
|
||||
|
||||
h.DeleteGeminiKey(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if got := len(h.cfg.GeminiKey); got != 1 {
|
||||
t.Fatalf("gemini keys len = %d, want 1", got)
|
||||
}
|
||||
if got := h.cfg.GeminiKey[0].BaseURL; got != "https://b.example.com" {
|
||||
t.Fatalf("remaining base-url = %q, want %q", got, "https://b.example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteClaudeKey_DeletesEmptyBaseURLWhenExplicitlyProvided(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
ClaudeKey: []config.ClaudeKey{
|
||||
{APIKey: "shared-key", BaseURL: ""},
|
||||
{APIKey: "shared-key", BaseURL: "https://claude.example.com"},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/claude-api-key?api-key=shared-key&base-url=", nil)
|
||||
|
||||
h.DeleteClaudeKey(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if got := len(h.cfg.ClaudeKey); got != 1 {
|
||||
t.Fatalf("claude keys len = %d, want 1", got)
|
||||
}
|
||||
if got := h.cfg.ClaudeKey[0].BaseURL; got != "https://claude.example.com" {
|
||||
t.Fatalf("remaining base-url = %q, want %q", got, "https://claude.example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteVertexCompatKey_DeletesOnlyMatchingBaseURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
VertexCompatAPIKey: []config.VertexCompatKey{
|
||||
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
|
||||
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/vertex-api-key?api-key=shared-key&base-url=https://b.example.com", nil)
|
||||
|
||||
h.DeleteVertexCompatKey(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if got := len(h.cfg.VertexCompatAPIKey); got != 1 {
|
||||
t.Fatalf("vertex keys len = %d, want 1", got)
|
||||
}
|
||||
if got := h.cfg.VertexCompatAPIKey[0].BaseURL; got != "https://a.example.com" {
|
||||
t.Fatalf("remaining base-url = %q, want %q", got, "https://a.example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCodexKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
CodexKey: []config.CodexKey{
|
||||
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
|
||||
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/codex-api-key?api-key=shared-key", nil)
|
||||
|
||||
h.DeleteCodexKey(c)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
if got := len(h.cfg.CodexKey); got != 2 {
|
||||
t.Fatalf("codex keys len = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
@@ -865,6 +865,7 @@ func (cfg *Config) SanitizeClaudeKeys() {
|
||||
}
|
||||
|
||||
// SanitizeGeminiKeys deduplicates and normalizes Gemini credentials.
|
||||
// It uses API key + base URL as the uniqueness key.
|
||||
func (cfg *Config) SanitizeGeminiKeys() {
|
||||
if cfg == nil {
|
||||
return
|
||||
@@ -883,10 +884,11 @@ func (cfg *Config) SanitizeGeminiKeys() {
|
||||
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
||||
entry.Headers = NormalizeHeaders(entry.Headers)
|
||||
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
|
||||
if _, exists := seen[entry.APIKey]; exists {
|
||||
uniqueKey := entry.APIKey + "|" + entry.BaseURL
|
||||
if _, exists := seen[uniqueKey]; exists {
|
||||
continue
|
||||
}
|
||||
seen[entry.APIKey] = struct{}{}
|
||||
seen[uniqueKey] = struct{}{}
|
||||
out = append(out, entry)
|
||||
}
|
||||
cfg.GeminiKey = out
|
||||
|
||||
@@ -25,23 +25,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
qwenUserAgent = "QwenCode/0.13.2 (darwin; arm64)"
|
||||
qwenUserAgent = "QwenCode/0.14.2 (darwin; arm64)"
|
||||
qwenRateLimitPerMin = 60 // 60 requests per minute per credential
|
||||
qwenRateLimitWindow = time.Minute // sliding window duration
|
||||
)
|
||||
|
||||
var qwenDefaultSystemMessage = []byte(`{"role":"system","content":[{"type":"text","text":"","cache_control":{"type":"ephemeral"}}]}`)
|
||||
|
||||
// qwenBeijingLoc caches the Beijing timezone to avoid repeated LoadLocation syscalls.
|
||||
var qwenBeijingLoc = func() *time.Location {
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil || loc == nil {
|
||||
log.Warnf("qwen: failed to load Asia/Shanghai timezone: %v, using fixed UTC+8", err)
|
||||
return time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
return loc
|
||||
}()
|
||||
|
||||
// qwenQuotaCodes is a package-level set of error codes that indicate quota exhaustion.
|
||||
var qwenQuotaCodes = map[string]struct{}{
|
||||
"insufficient_quota": {},
|
||||
@@ -156,22 +146,13 @@ func wrapQwenError(ctx context.Context, httpCode int, body []byte) (errCode int,
|
||||
// Qwen returns 403 for quota errors, 429 for rate limits
|
||||
if (httpCode == http.StatusForbidden || httpCode == http.StatusTooManyRequests) && isQwenQuotaError(body) {
|
||||
errCode = http.StatusTooManyRequests // Map to 429 to trigger quota logic
|
||||
cooldown := timeUntilNextDay()
|
||||
retryAfter = &cooldown
|
||||
helps.LogWithRequestID(ctx).Warnf("qwen quota exceeded (http %d -> %d), cooling down until tomorrow (%v)", httpCode, errCode, cooldown)
|
||||
// Do not force an excessively long retry-after (e.g. until tomorrow), otherwise
|
||||
// the global request-retry scheduler may skip retries due to max-retry-interval.
|
||||
helps.LogWithRequestID(ctx).Warnf("qwen quota exceeded (http %d -> %d)", httpCode, errCode)
|
||||
}
|
||||
return errCode, retryAfter
|
||||
}
|
||||
|
||||
// timeUntilNextDay returns duration until midnight Beijing time (UTC+8).
|
||||
// Qwen's daily quota resets at 00:00 Beijing time.
|
||||
func timeUntilNextDay() time.Duration {
|
||||
now := time.Now()
|
||||
nowLocal := now.In(qwenBeijingLoc)
|
||||
tomorrow := time.Date(nowLocal.Year(), nowLocal.Month(), nowLocal.Day()+1, 0, 0, 0, 0, qwenBeijingLoc)
|
||||
return tomorrow.Sub(now)
|
||||
}
|
||||
|
||||
// ensureQwenSystemMessage ensures the request has a single system message at the beginning.
|
||||
// It always injects the default system prompt and merges any user-provided system messages
|
||||
// into the injected system message content to satisfy Qwen's strict message ordering rules.
|
||||
@@ -626,19 +607,23 @@ func (e *QwenExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*c
|
||||
}
|
||||
|
||||
func applyQwenHeaders(r *http.Request, token string, stream bool) {
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
r.Header.Set("User-Agent", qwenUserAgent)
|
||||
r.Header["X-DashScope-UserAgent"] = []string{qwenUserAgent}
|
||||
r.Header.Set("X-Stainless-Runtime-Version", "v22.17.0")
|
||||
r.Header.Set("User-Agent", qwenUserAgent)
|
||||
r.Header.Set("X-Stainless-Lang", "js")
|
||||
r.Header.Set("X-Stainless-Arch", "arm64")
|
||||
r.Header.Set("X-Stainless-Package-Version", "5.11.0")
|
||||
r.Header["X-DashScope-CacheControl"] = []string{"enable"}
|
||||
r.Header.Set("X-Stainless-Retry-Count", "0")
|
||||
r.Header.Set("Accept-Language", "*")
|
||||
r.Header.Set("X-Dashscope-Cachecontrol", "enable")
|
||||
r.Header.Set("X-Stainless-Os", "MacOS")
|
||||
r.Header["X-DashScope-AuthType"] = []string{"qwen-oauth"}
|
||||
r.Header.Set("X-Dashscope-Authtype", "qwen-oauth")
|
||||
r.Header.Set("X-Stainless-Arch", "arm64")
|
||||
r.Header.Set("X-Stainless-Runtime", "node")
|
||||
r.Header.Set("X-Stainless-Retry-Count", "0")
|
||||
r.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
r.Header.Set("X-Stainless-Package-Version", "5.11.0")
|
||||
r.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("Connection", "keep-alive")
|
||||
r.Header.Set("X-Dashscope-Useragent", qwenUserAgent)
|
||||
|
||||
if stream {
|
||||
r.Header.Set("Accept", "text/event-stream")
|
||||
@@ -647,6 +632,26 @@ func applyQwenHeaders(r *http.Request, token string, stream bool) {
|
||||
r.Header.Set("Accept", "application/json")
|
||||
}
|
||||
|
||||
func normaliseQwenBaseURL(resourceURL string) string {
|
||||
raw := strings.TrimSpace(resourceURL)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
normalized := raw
|
||||
lower := strings.ToLower(normalized)
|
||||
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
|
||||
normalized = "https://" + normalized
|
||||
}
|
||||
|
||||
normalized = strings.TrimRight(normalized, "/")
|
||||
if !strings.HasSuffix(strings.ToLower(normalized), "/v1") {
|
||||
normalized += "/v1"
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func qwenCreds(a *cliproxyauth.Auth) (token, baseURL string) {
|
||||
if a == nil {
|
||||
return "", ""
|
||||
@@ -664,7 +669,7 @@ func qwenCreds(a *cliproxyauth.Auth) (token, baseURL string) {
|
||||
token = v
|
||||
}
|
||||
if v, ok := a.Metadata["resource_url"].(string); ok {
|
||||
baseURL = fmt.Sprintf("https://%s/v1", v)
|
||||
baseURL = normaliseQwenBaseURL(v)
|
||||
}
|
||||
}
|
||||
return
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
@@ -152,3 +155,57 @@ func TestEnsureQwenSystemMessage_MergesMultipleSystemMessages(t *testing.T) {
|
||||
t.Fatalf("messages[0].content[2].text = %q, want %q", parts[2].Get("text").String(), "B")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapQwenError_InsufficientQuotaDoesNotSetRetryAfter(t *testing.T) {
|
||||
body := []byte(`{"error":{"code":"insufficient_quota","message":"You exceeded your current quota","type":"insufficient_quota"}}`)
|
||||
code, retryAfter := wrapQwenError(context.Background(), http.StatusTooManyRequests, body)
|
||||
if code != http.StatusTooManyRequests {
|
||||
t.Fatalf("wrapQwenError status = %d, want %d", code, http.StatusTooManyRequests)
|
||||
}
|
||||
if retryAfter != nil {
|
||||
t.Fatalf("wrapQwenError retryAfter = %v, want nil", *retryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapQwenError_Maps403QuotaTo429WithoutRetryAfter(t *testing.T) {
|
||||
body := []byte(`{"error":{"code":"insufficient_quota","message":"You exceeded your current quota","type":"insufficient_quota"}}`)
|
||||
code, retryAfter := wrapQwenError(context.Background(), http.StatusForbidden, body)
|
||||
if code != http.StatusTooManyRequests {
|
||||
t.Fatalf("wrapQwenError status = %d, want %d", code, http.StatusTooManyRequests)
|
||||
}
|
||||
if retryAfter != nil {
|
||||
t.Fatalf("wrapQwenError retryAfter = %v, want nil", *retryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenCreds_NormalizesResourceURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resourceURL string
|
||||
wantBaseURL string
|
||||
}{
|
||||
{"host only", "portal.qwen.ai", "https://portal.qwen.ai/v1"},
|
||||
{"scheme no v1", "https://portal.qwen.ai", "https://portal.qwen.ai/v1"},
|
||||
{"scheme with v1", "https://portal.qwen.ai/v1", "https://portal.qwen.ai/v1"},
|
||||
{"scheme with v1 slash", "https://portal.qwen.ai/v1/", "https://portal.qwen.ai/v1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
auth := &cliproxyauth.Auth{
|
||||
Metadata: map[string]any{
|
||||
"access_token": "test-token",
|
||||
"resource_url": tt.resourceURL,
|
||||
},
|
||||
}
|
||||
|
||||
token, baseURL := qwenCreds(auth)
|
||||
if token != "test-token" {
|
||||
t.Fatalf("qwenCreds token = %q, want %q", token, "test-token")
|
||||
}
|
||||
if baseURL != tt.wantBaseURL {
|
||||
t.Fatalf("qwenCreds baseURL = %q, want %q", baseURL, tt.wantBaseURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user