Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
d66fd83
chore: ignore stray local config and root artifacts
Apr 26, 2026
3b708fc
fix: aggregate SSE upstream into chat.completion JSON for non-stream …
Apr 30, 2026
99b276f
feat(cf-worker): default moderation:"low" for image generation
May 1, 2026
b78e7db
feat(cf-worker): multi-image edits + URL fallback + non-stream aggreg…
May 3, 2026
7cb678c
fix(cf-worker): yield in base64 helpers to dodge 2s isolate-CPU limit
May 6, 2026
41203cf
fix(cf-worker): SSE buffer yield + skip duplicate R2 upload in respon…
May 6, 2026
71ea9d8
refactor(cf-worker): remove non-stream SSE aggregator
May 6, 2026
2d7b64e
feat(image-edits): accept URL / data:URI text fields alongside file u…
May 6, 2026
a268285
fix(image-edits): early-flush gateway TTFB + worker buildImages helper
May 7, 2026
10fa109
fix(cf-worker): infer image ext from magic bytes, not upstream's claim
May 7, 2026
6a20928
docs(image-edits): publish xixiapi gpt-image-2 spec + Apifox tweaks
May 7, 2026
9914230
chore(gitignore): ignore scripts/ top-level research notes and probe …
May 7, 2026
907b4e1
feat(image-stream): in-process Go SSE aggregator for gpt-image-* gene…
May 10, 2026
3017254
feat(image-stream): /v1/images/edits via Go SSE aggregator (Phase 3)
May 10, 2026
b7f0828
refactor(image-stream): richer envelope (output_format/size/usage/model)
May 10, 2026
351b64f
chore(cf-worker): remove worker.js — image relay now in-process Go
May 10, 2026
00b5724
fix(image-stream): merge tool_usage.image_gen into billing usage
May 10, 2026
45a1a56
debug(image-stream): unconditional log of upstream usage/tool_usage o…
May 10, 2026
b55d5b0
debug: log unmarshal errors on response.completed event
May 10, 2026
b393360
debug: log every response.completed event seen by parser
May 10, 2026
3ed3c0e
debug: log SSE pump end
May 10, 2026
afe9fdb
fix(image-stream): drop strict typing for upstream background field
May 10, 2026
245af52
Merge upstream/main: web/default UI polish + perf metrics + DeepChat …
May 12, 2026
73b7040
Merge remote-tracking branch 'upstream/main'
May 15, 2026
faae963
chore: refresh website icons
May 15, 2026
8b9a28c
fix: bust default logo cache
May 15, 2026
8744c10
fix: migrate persisted header logo
May 15, 2026
467840e
docs: specify official model metadata alignment
May 16, 2026
305a9aa
fix: correct pricing model metadata display
May 16, 2026
2d5d8de
fix: preserve token count precision
May 16, 2026
948f953
feat: show official price savings on model square
May 16, 2026
f0d2dc1
Merge remote-tracking branch 'upstream/main'
May 22, 2026
420a166
fix(relay/responses): synthesize terminal SSE event when upstream cut…
May 25, 2026
7ec8842
debug(distributor): dump body head when JSON parse fails
May 25, 2026
6de0adf
fix(middleware): decompress zstd-encoded request bodies
May 25, 2026
8f50f01
feat(distributor): COMPACT_USE_BASE_MODEL bypasses compact suffix rew…
May 25, 2026
24a6441
fix(relay): cooldown depleted channels
May 27, 2026
29d5a78
fix(relay): respect compact base model in billing
May 28, 2026
534660e
fix(relay): cooldown unstable stream channels
May 28, 2026
18b51dc
fix(relay): cooldown bad upstream stream terminals
May 28, 2026
c874413
fix(relay): fall back to cooling channels instead of distributor 503
May 29, 2026
4f0328d
fix(relay): cooldown per-channel capability-gap 4xx
May 29, 2026
1c7af3c
feat(channel): show cooldown reason and remaining time on channel page
May 29, 2026
05ebed1
fix(relay): cool retried and slow channels for 30m
May 29, 2026
8bda8ba
fix(relay): short cooldown for transient 5xx, keep 30m for capability…
May 29, 2026
9e87b3d
fix: harden production relay failures
Jul 13, 2026
0717543
fix: fall back from unhealthy sticky channels
Jul 13, 2026
a30f2f3
fix: harden stream billing and affinity handling
Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,14 @@ data/
.test
token_estimator_test.go
skills-lock.json
.cla

# repo-root screenshot artifacts and openapi dumps (not nested)
/*.png
/*.openapi.json
/tmp

# scripts/ top-level: research notes & probe scripts (cf-worker/ subdir stays tracked)
/scripts/*.md
/scripts/*.py
/scripts/*.sh
2 changes: 2 additions & 0 deletions common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ var BatchUpdateInterval int

var RelayTimeout int // unit is second

var RelayResponseHeaderTimeout int // unit is second; timeout for receiving response headers from upstream

var RelayMaxIdleConns int
var RelayMaxIdleConnsPerHost int

Expand Down
8 changes: 6 additions & 2 deletions common/custom-event.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,13 @@ func encode(writer io.Writer, event CustomEvent) error {
}

func writeData(w stringWriter, data interface{}) error {
dataReplacer.WriteString(w, fmt.Sprint(data))
if _, err := dataReplacer.WriteString(w, fmt.Sprint(data)); err != nil {
return err
}
if strings.HasPrefix(data.(string), "data") {
w.writeString("\n\n")
if _, err := w.writeString("\n\n"); err != nil {
return err
}
Comment on lines +66 to +72

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Prevent runtime panic on type assertion.

If data is not a string (e.g., nil, []byte, or numeric), the type assertion data.(string) will panic and crash the request. Since fmt.Sprint(data) gracefully converts any type into a string, you can capture its output in a variable to guarantee safety and avoid the redundant formatting step.

🐛 Proposed fix
 func writeData(w stringWriter, data interface{}) error {
-	if _, err := dataReplacer.WriteString(w, fmt.Sprint(data)); err != nil {
+	str := fmt.Sprint(data)
+	if _, err := dataReplacer.WriteString(w, str); err != nil {
 		return err
 	}
-	if strings.HasPrefix(data.(string), "data") {
+	if strings.HasPrefix(str, "data") {
 		if _, err := w.writeString("\n\n"); err != nil {
 			return err
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if _, err := dataReplacer.WriteString(w, fmt.Sprint(data)); err != nil {
return err
}
if strings.HasPrefix(data.(string), "data") {
w.writeString("\n\n")
if _, err := w.writeString("\n\n"); err != nil {
return err
}
str := fmt.Sprint(data)
if _, err := dataReplacer.WriteString(w, str); err != nil {
return err
}
if strings.HasPrefix(str, "data") {
if _, err := w.writeString("\n\n"); err != nil {
return err
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@common/custom-event.go` around lines 66 - 72, Update the data-writing logic
around dataReplacer.WriteString to capture fmt.Sprint(data) in a string
variable, reuse that variable for writing and the strings.HasPrefix check, and
remove the unsafe data.(string) assertion so nil, []byte, and numeric values
cannot panic.

}
return nil
}
Expand Down
1 change: 1 addition & 0 deletions common/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ func InitEnv() {
SyncFrequency = GetEnvOrDefault("SYNC_FREQUENCY", 60)
BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5)
RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0)
RelayResponseHeaderTimeout = GetEnvOrDefault("RELAY_RESPONSE_HEADER_TIMEOUT", 60)
RelayMaxIdleConns = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS", 500)
RelayMaxIdleConnsPerHost = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS_PER_HOST", 100)

Expand Down
95 changes: 95 additions & 0 deletions common/quota_math.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package common

import (
"fmt"
"math"
"strconv"

"github.com/shopspring/decimal"
)

const (
MaxQuota = math.MaxInt32
MinQuota = math.MinInt32
)

type QuotaClampKind string

const (
QuotaClampOverflow QuotaClampKind = "overflow"
QuotaClampUnderflow QuotaClampKind = "underflow"
QuotaClampNaN QuotaClampKind = "nan"
)

type QuotaClamp struct {
Op string `json:"op"`
Kind QuotaClampKind `json:"kind"`
Original string `json:"original"`
Clamped int `json:"clamped"`
}

func (c *QuotaClamp) Error() string {
if c == nil {
return ""
}
return fmt.Sprintf("quota conversion (%s) %s: original=%s, clamped=%d", c.Op, c.Kind, c.Original, c.Clamped)
}

func saturateQuota(value float64, op string) (int, *QuotaClamp) {
var clamp *QuotaClamp
switch {
case math.IsNaN(value):
clamp = &QuotaClamp{Op: op, Kind: QuotaClampNaN, Original: "NaN", Clamped: 0}
case value > MaxQuota:
clamp = &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: strconv.FormatFloat(value, 'g', -1, 64), Clamped: MaxQuota}
case value < MinQuota:
clamp = &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: strconv.FormatFloat(value, 'g', -1, 64), Clamped: MinQuota}
default:
return int(value), nil
}
return clamp.Clamped, clamp
}

func QuotaFromFloat(value float64) int {
quota, _ := QuotaFromFloatChecked(value)
return quota
}

func QuotaFromFloatChecked(value float64) (int, *QuotaClamp) {
return saturateQuota(value, "QuotaFromFloat")
}

func QuotaFromFloatStrict(value float64) (int, error) {
quota, clamp := QuotaFromFloatChecked(value)
if clamp != nil {
return 0, clamp
}
return quota, nil
}

func QuotaRound(value float64) int {
quota, _ := QuotaRoundChecked(value)
return quota
}

func QuotaRoundChecked(value float64) (int, *QuotaClamp) {
return saturateQuota(math.Round(value), "QuotaRound")
}

func QuotaRoundStrict(value float64) (int, error) {
quota, clamp := QuotaRoundChecked(value)
if clamp != nil {
return 0, clamp
}
return quota, nil
}

func QuotaFromDecimal(value decimal.Decimal) int {
quota, _ := QuotaFromDecimalChecked(value)
return quota
}

func QuotaFromDecimalChecked(value decimal.Decimal) (int, *QuotaClamp) {
rounded, _ := value.Round(0).Float64()
return saturateQuota(rounded, "QuotaFromDecimal")
}
64 changes: 64 additions & 0 deletions common/quota_math_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package common

import (
"math"
"testing"

"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
)

func TestQuotaFromFloatSaturatesOutOfRangeValues(t *testing.T) {
tests := []struct {
name string
in float64
want int
}{
{name: "overflow", in: math.MaxFloat64, want: MaxQuota},
{name: "underflow", in: -math.MaxFloat64, want: MinQuota},
{name: "nan", in: math.NaN(), want: 0},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := QuotaFromFloat(tt.in)
require.Equal(t, tt.want, got)
})
}
}

func TestQuotaRoundStrictRejectsSaturation(t *testing.T) {
quota, err := QuotaRoundStrict(float64(MaxQuota) + 1)

require.Error(t, err)
require.Zero(t, quota)
clamp, ok := err.(*QuotaClamp)
require.True(t, ok)
require.Equal(t, QuotaClampOverflow, clamp.Kind)
}

func TestQuotaRoundStrictAcceptsIntegerBounds(t *testing.T) {
maxQuota, maxErr := QuotaRoundStrict(float64(MaxQuota))
minQuota, minErr := QuotaRoundStrict(float64(MinQuota))

require.NoError(t, maxErr)
require.NoError(t, minErr)
require.Equal(t, MaxQuota, maxQuota)
require.Equal(t, MinQuota, minQuota)
}

func TestQuotaClampNaNIsJSONSafe(t *testing.T) {
_, clamp := QuotaFromFloatChecked(math.NaN())
require.NotNil(t, clamp)

data, err := Marshal(clamp)

require.NoError(t, err)
require.Contains(t, string(data), `"original":"NaN"`)
}

func TestQuotaFromDecimalRoundsBeforeSaturating(t *testing.T) {
quota := QuotaFromDecimal(decimal.NewFromFloat(1.5))

require.Equal(t, 2, quota)
}
Comment on lines +24 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use require for setup/fatal assertions and assert for value checks.

These test files violate the coding guideline that strictly requires using testify/require for setup or fatal conditions and testify/assert for non-fatal value checks. Please remember to import "github.com/stretchr/testify/assert" where applicable.

  • common/quota_math_test.go#L24-L64: replace non-fatal validations such as require.Equal, require.Zero, and require.Contains with assert.Equal, assert.Zero, and assert.Contains.
  • service/channel_affinity_template_test.go#L270-L328: replace require.Equal with assert.Equal for output verifications (e.g., verifying channelID, meta.RuleName, and overriding headers).
  • model/channel_selection_test.go#L30-L193: refactor the manual if err != nil { t.Fatalf(...) } statements to use require.NoError(t, err) for operations like DB seeding/retrieval, and use assert.NotNil, assert.Nil, and assert.Equal for channel assertions (instead of checking selected == nil || selected.Id != 29).
📍 Affects 3 files
  • common/quota_math_test.go#L24-L64 (this comment)
  • service/channel_affinity_template_test.go#L270-L328
  • model/channel_selection_test.go#L30-L193
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@common/quota_math_test.go` around lines 24 - 64, Update
common/quota_math_test.go lines 24-64 to import testify/assert and use
assert.Equal, assert.Zero, and assert.Contains for non-fatal checks while
retaining require for setup or fatal assertions. In
service/channel_affinity_template_test.go lines 270-328, use assert.Equal for
channelID, meta.RuleName, and overriding-header output checks. In
model/channel_selection_test.go lines 30-193, replace manual error checks with
require.NoError and express channel expectations with assert.NotNil, assert.Nil,
and assert.Equal, including the selected channel ID.

Source: Coding guidelines

2 changes: 1 addition & 1 deletion controller/channel-test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
requestPath = "/v1/responses/compact"
}
}
if strings.HasPrefix(requestPath, "/v1/responses/compact") {
if strings.HasPrefix(requestPath, "/v1/responses/compact") && !ratio_setting.CompactUseBaseModel() {
testModel = ratio_setting.WithCompactModelSuffix(testModel)
}

Expand Down
57 changes: 48 additions & 9 deletions controller/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
Expand All @@ -12,11 +13,13 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
relaychannel "github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/gemini"
"github.com/QuantumNous/new-api/relay/channel/ollama"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"

"github.com/gin-gonic/gin"
"gorm.io/gorm"
Expand Down Expand Up @@ -69,6 +72,20 @@ func clearChannelInfo(channel *model.Channel) {
}
}

// fillChannelCooldown annotates a channel with its current in-memory cooldown
// status (reason + expiry) so the admin UI can show why and for how long a
// channel was temporarily taken out of selection.
func fillChannelCooldown(channel *model.Channel) {
if channel == nil {
return
}
if reason, expires, cooling := model.GetChannelCooldown(channel.Id); cooling {
channel.CoolingDown = true
channel.CooldownReason = reason
channel.CooldownExpires = expires
}
}

func applyChannelStatusFilter(query *gorm.DB, statusFilter int) *gorm.DB {
if statusFilter == common.ChannelStatusEnabled {
return query.Where("status = ?", common.ChannelStatusEnabled)
Expand Down Expand Up @@ -159,6 +176,7 @@ func GetAllChannels(c *gin.Context) {

for _, datum := range channelData {
clearChannelInfo(datum)
fillChannelCooldown(datum)
}

countQuery := buildChannelListQuery(groupFilter, statusFilter, -1)
Expand Down Expand Up @@ -365,6 +383,7 @@ func SearchChannels(c *gin.Context) {

for _, datum := range pagedData {
clearChannelInfo(datum)
fillChannelCooldown(datum)
}

c.JSON(http.StatusOK, gin.H{
Expand Down Expand Up @@ -392,6 +411,7 @@ func GetChannel(c *gin.Context) {
}
if channel != nil {
clearChannelInfo(channel)
fillChannelCooldown(channel)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
Expand Down Expand Up @@ -1054,10 +1074,27 @@ func FetchModels(c *gin.Context) {
return
}

client := &http.Client{}
client := service.GetHttpClient()
if client == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"message": "HTTP client is not initialized",
})
return
}
url := fmt.Sprintf("%s/v1/models", baseURL)
fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(url, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": fmt.Sprintf("Invalid models URL: %s", err.Error()),
})
return
}

request, err := http.NewRequest("GET", url, nil)
requestCtx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, url, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
Expand All @@ -1070,29 +1107,31 @@ func FetchModels(c *gin.Context) {

response, err := client.Do(request)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
logger.LogError(c, "failed to fetch models: "+err.Error())
c.JSON(http.StatusBadGateway, gin.H{
"success": false,
"message": err.Error(),
"message": "Failed to fetch models",
})
return
}
//check status code
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
c.JSON(http.StatusInternalServerError, gin.H{
c.JSON(http.StatusBadGateway, gin.H{
"success": false,
"message": "Failed to fetch models",
"message": fmt.Sprintf("Failed to fetch models: upstream status %d", response.StatusCode),
})
return
}
defer response.Body.Close()

const maxModelsResponseBytes = 5 << 20
limitedBody := io.LimitReader(response.Body, maxModelsResponseBytes+1)
var result struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}

if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
if err := json.NewDecoder(limitedBody).Decode(&result); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": err.Error(),
Expand Down
Loading
Loading