Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions common/endpoint_defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
constant.EndpointTypeJinaRerank: {Path: "/v1/rerank", Method: "POST"},
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"},
constant.EndpointTypeOpenAIVideo: {Path: "/v1/videos", Method: "POST"},
}

// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在
Expand Down
26 changes: 24 additions & 2 deletions controller/channel-test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ type testResult struct {
newAPIError *types.NewAPIError
}

func isImageOrVideoGenerationModel(model string) bool {
lower := strings.ToLower(model)
return strings.Contains(model, "gpt-image") ||
strings.Contains(model, "dall-e") ||
strings.Contains(lower, "stable-diffusion") ||
strings.Contains(lower, "flux") ||
strings.HasPrefix(lower, "veo-") ||
strings.HasPrefix(lower, "sora-") ||
strings.Contains(lower, "imagen") ||
strings.Contains(lower, "seedream")
}
Comment on lines +45 to +55

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.

⚠️ Potential issue | 🟡 Minor

Inconsistent case-sensitivity in model classification.

gpt-image and dall-e are matched against the raw model argument, while every other token is matched against lower. This means "DALL-E-3", "DALL-E", or "GPT-Image-1" will not be classified as image-generation models and will be misrouted to /v1/chat/completions during channel tests. Use lower consistently:

🐛 Proposed fix
 func isImageOrVideoGenerationModel(model string) bool {
 	lower := strings.ToLower(model)
-	return strings.Contains(model, "gpt-image") ||
-		strings.Contains(model, "dall-e") ||
+	return strings.Contains(lower, "gpt-image") ||
+		strings.Contains(lower, "dall-e") ||
 		strings.Contains(lower, "stable-diffusion") ||
 		strings.Contains(lower, "flux") ||
 		strings.HasPrefix(lower, "veo-") ||
 		strings.HasPrefix(lower, "sora-") ||
 		strings.Contains(lower, "imagen") ||
 		strings.Contains(lower, "seedream")
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/channel-test.go` around lines 45 - 55, The
isImageOrVideoGenerationModel function inconsistently checks some tokens against
the original model and others against lower; update all substring and prefix
checks in isImageOrVideoGenerationModel to use the normalized lower variable
(e.g., replace strings.Contains(model, "gpt-image") and strings.Contains(model,
"dall-e") with strings.Contains(lower, "gpt-image") and strings.Contains(lower,
"dall-e")) so all comparisons are case-insensitive and models like "DALL-E-3" or
"GPT-Image-1" are classified correctly.


func normalizeChannelTestEndpoint(channel *model.Channel, modelName, endpointType string) string {
normalized := strings.TrimSpace(endpointType)
if normalized != "" {
Expand Down Expand Up @@ -116,8 +128,8 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
requestPath = "/v1/embeddings" // 修改请求路径
}

// VolcEngine 图像生成模型
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
// 通用图像/视频生成模型检测
if isImageOrVideoGenerationModel(testModel) {
requestPath = "/v1/images/generations"
}

Expand Down Expand Up @@ -685,6 +697,16 @@ func buildTestRequest(model string, endpointType string, channel *model.Channel,
}
}

// 图像/视频生成模型
if isImageOrVideoGenerationModel(model) {
return &dto.ImageRequest{
Model: model,
Prompt: "a cute cat",
N: lo.ToPtr(uint(1)),
Size: "1024x1024",
}
}

// Responses compaction models (must use /v1/responses/compact)
if strings.HasSuffix(model, ratio_setting.CompactModelSuffix) {
return &dto.OpenAIResponsesCompactionRequest{
Expand Down
103 changes: 103 additions & 0 deletions controller/playground.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ package controller
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"

"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types"

Expand Down Expand Up @@ -54,3 +59,101 @@ func Playground(c *gin.Context) {

Relay(c, types.RelayFormatOpenAI)
}

// PlaygroundVideoProxy proxies an authenticated video content request to the upstream.
// Route: GET /pg/video/:channelId/:videoId/content
// The backend fetches the video binary with the channel's Bearer token and streams it back.
func PlaygroundVideoProxy(c *gin.Context) {
channelIdStr := c.Param("channelId")
videoId := c.Param("videoId")

if channelIdStr == "" || videoId == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing channelId or videoId"})
return
}

channelId, err := strconv.Atoi(channelIdStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channelId"})
return
}

channel, err := model.GetChannelById(channelId, true)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
Comment on lines +81 to +85

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.

⚠️ Potential issue | 🟡 Minor

Distinguish "not found" from transient DB errors.

GetChannelById can return errors other than gorm.ErrRecordNotFound (e.g., transient connection issues), but here every error is mapped to 404 channel not found, which is misleading and can mask outages. Consider checking for errors.Is(err, gorm.ErrRecordNotFound) and returning 500 (or 502) for everything else.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/playground.go` around lines 81 - 85, The handler currently maps
every error from model.GetChannelById(channelId, true) to a 404; change it to
check errors.Is(err, gorm.ErrRecordNotFound) and only return
c.JSON(http.StatusNotFound, ...) in that case; for any other error (e.g.,
transient DB/connection errors) return a 5xx response
(http.StatusInternalServerError or http.StatusBadGateway) and log/propagate the
original err so outages aren’t masked; update the branch around GetChannelById,
channelId and the c.JSON calls accordingly.


baseURL := ""
if channel.BaseURL != nil {
baseURL = strings.TrimRight(*channel.BaseURL, "/")
}
if baseURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "channel has no base URL"})
return
}

keys := channel.GetKeys()
if len(keys) == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "channel has no API key"})
return
}
apiKey := keys[0]

upstreamURL := fmt.Sprintf("%s/videos/%s/content", baseURL, videoId)

req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, upstreamURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create upstream request"})
return
}
req.Header.Set("Authorization", "Bearer "+apiKey)

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed: " + err.Error()})
return
}
Comment on lines +112 to +117

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.

⚠️ Potential issue | 🟠 Major

Add timeout / safer transport to upstream HTTP client.

client := &http.Client{} has no Timeout and uses the default transport. If the upstream stalls after the headers are read, io.Copy(c.Writer, resp.Body) at line 137 can hang indefinitely, holding the request goroutine and a backend connection. Although c.Request.Context() is propagated via NewRequestWithContext, that only aborts in‑flight reads when the client disconnects — a slow/idle upstream will still block a healthy client.

Consider sharing a package-level *http.Client with at minimum a Transport configured for ResponseHeaderTimeout and IdleConnTimeout, and creating the client once instead of on every request.

🛠️ Suggested change
-	client := &http.Client{}
-	resp, err := client.Do(req)
+	// videoProxyClient is a package-level client; consider declaring at file scope.
+	client := &http.Client{
+		Transport: &http.Transport{
+			ResponseHeaderTimeout: 30 * time.Second,
+			IdleConnTimeout:       90 * time.Second,
+		},
+	}
+	resp, err := client.Do(req)
📝 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
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed: " + err.Error()})
return
}
// videoProxyClient is a package-level client; consider declaring at file scope.
client := &http.Client{
Transport: &http.Transport{
ResponseHeaderTimeout: 30 * time.Second,
IdleConnTimeout: 90 * time.Second,
},
}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed: " + err.Error()})
return
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/playground.go` around lines 112 - 117, Replace the per-request
HTTP client created as client := &http.Client{} in controller/playground.go with
a shared package-level *http.Client that has a Transport configured with
sensible timeouts (e.g., ResponseHeaderTimeout and IdleConnTimeout, and
MaxIdleConns/MaxIdleConnsPerHost) and optionally a global Timeout; ensure the
handler still uses NewRequestWithContext but uses this shared httpClient for
client.Do so that io.Copy(c.Writer, resp.Body) cannot hang indefinitely on slow
upstreams and connections are reused rather than allocating a new client per
request.

defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), body)
return
}

contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "video/mp4"
}
c.Header("Content-Type", contentType)
if cl := resp.Header.Get("Content-Length"); cl != "" {
c.Header("Content-Length", cl)
}
c.Header("Cache-Control", "no-store")

c.Status(http.StatusOK)
io.Copy(c.Writer, resp.Body) //nolint:errcheck
}
Comment on lines +66 to +138

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm router wiring and auth middleware (or absence thereof) on /pg routes.
rg -nP -C5 '"/pg"|/pg/video|/pg/audio|PlaygroundVideoProxy|PlaygroundAudioProxy' --type=go

Repository: QuantumNous/new-api

Length of output: 5729


🏁 Script executed:

#!/bin/bash
# 1. Find where videoId is generated or comes from (especially for Sora)
rg -nP 'videoID|videoId|video_id' controller/playground.go relay/channel/openai/ --type=go -A 2 -B 2 | head -60

# 2. Check for rate limiting implementation
rg -nP 'rate|limit|RateLimit|Limit' router/relay-router.go middleware/ --type=go 2>/dev/null | head -30

# 3. Verify videoId format from OpenAI Sora task/status handling
rg -nP 'type.*Task|TaskResponse|Status' relay/channel/openai/ --type=go | grep -i video | head -20

Repository: QuantumNous/new-api

Length of output: 5712


🏁 Script executed:

#!/bin/bash
# Check OpenAI Sora task structure to understand videoId format
fd -t f 'sora|task' relay/channel/openai/ --type=go | xargs rg -l 'video.*id|Video' --type=go | head -5

Repository: QuantumNous/new-api

Length of output: 365


🏁 Script executed:

#!/bin/bash
# Look for any rate limiting or abuse protection middleware
rg -nP 'Limiter|RateLimit|abuse|throttle|quota' --type=go | grep -i 'middleware\|controller\|handler' | head -20

Repository: QuantumNous/new-api

Length of output: 2126


🏁 Script executed:

#!/bin/bash
# Check main router setup and global middleware
rg -nP 'func.*[Rr]outer|engine.*Use|main.go|router.go' router/ --type=go -B 2 -A 5 | head -100

Repository: QuantumNous/new-api

Length of output: 3378


🏁 Script executed:

#!/bin/bash
# Look for how the main Gin engine is initialized and what global middleware is applied
fd -t f 'main.go' . | xargs cat | head -150

Repository: QuantumNous/new-api

Length of output: 3916


🏁 Script executed:

#!/bin/bash
# Check for any wrapper around pgVideoRouter that might apply middleware globally
rg -nP 'pgVideoRouter|playgroundRouter|Group.*"/pg"' router/relay-router.go -B 5 -A 10

Repository: QuantumNous/new-api

Length of output: 1197


🌐 Web query:

OpenAI Sora video ID format uuid entropy

💡 Result:

OpenAI Sora video IDs follow the format "video_" followed by a 64-character hexadecimal string. Example: "video_68d7512d07848190b3e45da0ecbebcde004da08e1e0678d5". This appears to be a custom opaque identifier rather than a standard UUID (which is typically 36 characters with hyphens in canonical form). The hex string provides high entropy (approximately 251 bits, since 64 hex digits = 256 bits minus any potential prefix/checksum), suitable for uniquely identifying billions of videos with negligible collision risk. No official documentation specifies the exact generation method (e.g., SHA-256 hash, truncated UUID, or random hex), but official API examples and references consistently use this prefixed hex format.

Citations:


🌐 Web query:

OpenAI Sora API video task response ID format

💡 Result:

The OpenAI Sora API (Videos API at /v1/videos) returns a video task (job) response containing a unique ID for the video generation job. This ID is used to poll status via GET /videos/{video_id} and download the video via GET /videos/{video_id}/content once completed. The format is a string prefixed with "video_", followed by a 64-character hexadecimal string (32 bytes in hex). Example from official documentation: "video_68d7512d07848190b3e45da0ecbebcde004da08e1e0678d5" Other examples: "video_123", "video_abc123". The full response object is a JSON with fields like: { "id": "video_...", "object": "video", "created_at": 1758941485, "status": "queued", "model": "sora-2-pro", "progress": 0, "seconds": "8", "size": "1280x720" }

Citations:


Add rate limiting to /pg/video/:channelId/:videoId/content and /pg/audio/:audioId routes; clarify the authentication assumption.

The route group pgVideoRouter (relay-router.go:72-76) lacks any middleware protection. While OpenAI Sora videoId is sufficiently high-entropy (~251 bits), channelId is numeric and fully enumerable. An attacker can iterate channel IDs and, once a videoId is observed (via chat logs, screenshots, or cached links), fetch the full video binary on the target's quota without authentication or rate limiting.

Current threat model assumptions are partially undermined:

  1. videoId entropy: ✓ Sufficient (~251 bits; OpenAI "video_" + 64 hex chars)
  2. Unauthenticated access: ✓ Confirmed—route has no auth middleware
  3. Rate limiting: ✗ Missing—no rate limiting applied despite DownloadRateLimit() existing in middleware

Recommended fix:

  • Apply middleware.DownloadRateLimit() to both video and audio proxy routes, or
  • Require authentication (token or session) for these endpoints, or
  • Add HMAC-signed URL tokens with short TTL to the generated proxy URLs.

The router comment (line 71) "IDs are unguessable" should be corrected—only videoId is unguessable; channelId is enumerable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/playground.go` around lines 66 - 138, PlaygroundVideoProxy
currently serves video content without any rate limiting or auth; add protection
by applying middleware.DownloadRateLimit() to the pgVideoRouter route that
mounts PlaygroundVideoProxy (and the /pg/audio/:audioId proxy route) or require
authentication for those endpoints (or switch to HMAC-signed short‑TTL tokens
for generated proxy URLs), update any router comment that says "IDs are
unguessable" to clarify only videoId is high-entropy while channelId is
enumerable, and verify the handler (PlaygroundVideoProxy) still reads
Authorization headers or token checks as needed after adding the chosen
protection.


// PlaygroundAudioProxy serves a cached TTS audio file by its UUID.
// Route: GET /pg/audio/:audioId
func PlaygroundAudioProxy(c *gin.Context) {
audioId := c.Param("audioId")

// Strip any file extension from the ID (e.g. "uuid.mp3" → "uuid")
if idx := strings.LastIndex(audioId, "."); idx != -1 {
audioId = audioId[:idx]
}

data, contentType, ok := relay.GetCachedAudio(audioId)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "audio not found or expired"})
return
}

c.Header("Cache-Control", "no-store")
c.Data(http.StatusOK, contentType, data)
}

89 changes: 89 additions & 0 deletions middleware/request_logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package middleware

import (
"bytes"
"fmt"
"strings"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/gin-gonic/gin"
)

// responseBodyWriter wraps gin.ResponseWriter to capture the response body.
type responseBodyWriter struct {
gin.ResponseWriter
buf *bytes.Buffer
}

func (w *responseBodyWriter) Write(b []byte) (int, error) {
w.buf.Write(b)
return w.ResponseWriter.Write(b)
}

func (w *responseBodyWriter) WriteString(s string) (int, error) {
w.buf.WriteString(s)
return w.ResponseWriter.WriteString(s)
}
Comment on lines +14 to +27

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.

⚠️ Potential issue | 🔴 Critical

Don't buffer whole responses in this middleware.

responseBodyWriter keeps every byte in rbw.buf and only truncates at log time. Because router/relay-router.go installs this globally, the new /pg/video/.../content, /pg/audio/..., and SSE relay responses will all accumulate fully in memory before the request finishes.

Also applies to: 67-87

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@middleware/request_logger.go` around lines 14 - 27, The middleware currently
buffers entire responses in responseBodyWriter.buf via Write and WriteString,
which causes unbounded memory growth for large endpoints (e.g., relay-router
installs this globally); change responseBodyWriter to cap buffering: add a
maxBufSize constant and in responseBodyWriter.Write and WriteString only append
up to remaining bytes (ignore the rest) so the response still streams to the
underlying gin.ResponseWriter but only the first N bytes are kept for logging;
update the type (e.g., responseBodyWriter, buf, maxBufSize) and both
Write/WriteString implementations to enforce the limit and avoid full-memory
accumulation (also apply the same change to the other buffered region referenced
at lines 67-87).


// RequestLogger logs the inbound request (URL, headers, body) and the
// outbound response body when DEBUG=true.
func RequestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
if !common.DebugEnabled {
c.Next()
return
}

// --- request ---
var sb strings.Builder
sb.WriteString("\n========== Inbound Request ==========\n")
sb.WriteString(fmt.Sprintf("%s %s\n", c.Request.Method, c.Request.URL.String()))

sb.WriteString("--- Headers ---\n")
for key, values := range c.Request.Header {
for _, v := range values {
if strings.EqualFold(key, "Authorization") || strings.EqualFold(key, "x-api-key") {
if len(v) > 12 {
v = v[:12] + "***"
}
}
sb.WriteString(fmt.Sprintf("%s: %s\n", key, v))
}
}

sb.WriteString("--- Body ---\n")
bodyStorage, err := common.GetBodyStorage(c)
if err == nil {
bodyBytes, err := bodyStorage.Bytes()
if err == nil {
sb.Write(bodyBytes)
sb.WriteString("\n")
}
}
sb.WriteString("=====================================")
logger.LogDebug(c.Request.Context(), sb.String())

// --- wrap response writer to capture output ---
rbw := &responseBodyWriter{ResponseWriter: c.Writer, buf: &bytes.Buffer{}}
c.Writer = rbw

c.Next()

// --- response ---
var sb2 strings.Builder
sb2.WriteString("\n========== Inbound Response ==========\n")
sb2.WriteString(fmt.Sprintf("Status: %d\n", rbw.Status()))
sb2.WriteString("--- Body ---\n")
const maxRespBytes = 4 << 10 // 4 KB
respBytes := rbw.buf.Bytes()
if len(respBytes) > maxRespBytes {
sb2.Write(respBytes[:maxRespBytes])
sb2.WriteString(fmt.Sprintf("\n... (truncated, total %d bytes)", len(respBytes)))
} else {
sb2.Write(respBytes)
}
sb2.WriteString("\n======================================")
logger.LogDebug(c.Request.Context(), sb2.String())
Comment on lines +38 to +87

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.

⚠️ Potential issue | 🟠 Major

Avoid logging raw request and response payloads.

These logs will capture prompts, completions, user content, and any secrets passed in JSON bodies. Even behind DEBUG, full payload dumps are a privacy/compliance footgun; this should be content-type-aware, size-capped, and redacted.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@middleware/request_logger.go` around lines 38 - 87, Current logging dumps raw
request and response bodies; update the request and response logging to be
content-type aware, size-capped, and redact sensitive fields instead of printing
raw payloads. In the request block around bodyStorage/BodyBytes and the response
block using rbw.buf, only attempt to read and log bodies for text/* or
application/json (check c.Request.Header.Get("Content-Type") and
rbw.Header().Get("Content-Type")), enforce a small max size (e.g., 4 KB) for
both request and response, and if body exceeds the cap log a truncated message;
when content-type is application/json parse the JSON and redact known sensitive
keys (e.g., "password","token","authorization","api_key","secret","apiKey")
before logging; for non-text/binary types log a placeholder like "<binary
content omitted>" and retain existing header redaction for
Authorization/x-api-key. Apply changes near GetBodyStorage, bodyBytes usage, and
where rbw.buf.Bytes() is consumed, and factor redaction parsing into a helper
(e.g., redactJSON) used by the middleware.

}
}
4 changes: 2 additions & 2 deletions model/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ func InitDB() (err error) {
//_, _ = sqlDB.Exec("ALTER TABLE channels MODIFY model_mapping TEXT;") // TODO: delete this line when most users have upgraded
}
common.SysLog("database migration started")
err = migrateDB()
// err = migrateDB()

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.

⚠️ Potential issue | 🔴 Critical

Critical: AutoMigrate disabled — fresh installs and schema upgrades will break.

Commenting out migrateDB() here (and migrateLOGDB() on line 242) prevents DB.AutoMigrate(...) from running. Consequences:

  • On a fresh deployment, no tables are created. The very next call in main.gomodel.CheckSetup()GetSetup()/RootUserExists()DB.First(&user) — will fail because the users/setup tables don't exist. model.InitOptionMap() will fail for the same reason.
  • On existing deployments, new columns/tables added for this PR's "range price"/subscription/pricing features (e.g., SubscriptionPlan, price_amount decimal migration, model_limits TEXT migration, new pricing fields) will not be applied, leading to runtime errors on first query.
  • InitLogDB() similarly skips creating the Log table when a separate LOG_SQL_DSN is configured, so all log writes will fail.

This looks like leftover local-debug code that shouldn't be merged. Please restore the migration calls (or, if the intent was to switch to the parallel migrateDBFast(), wire that in explicitly).

🔧 Proposed fix
@@ InitDB
 		common.SysLog("database migration started")
-		// err = migrateDB()
+		err = migrateDB()
 		return err
@@ InitLogDB
 		common.SysLog("database migration started")
-		// err = migrateLOGDB()
+		err = migrateLOGDB()
 		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
// err = migrateDB()
err = migrateDB()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model/main.go` at line 205, The migration calls were commented out so
AutoMigrate isn't running; restore the migration steps by re-enabling the
migrateDB() and migrateLOGDB() calls (or explicitly replace them by calling
migrateDBFast() if that was the intended fast-path) so DB.AutoMigrate(...) runs;
ensure InitLogDB() still triggers creation of the Log table when LOG_SQL_DSN is
set and verify model.CheckSetup()/GetSetup()/RootUserExists() and
model.InitOptionMap() run after the migrations to avoid missing-table/column
errors.

return err
} else {
common.FatalLog(err)
Expand Down Expand Up @@ -239,7 +239,7 @@ func InitLogDB() (err error) {
return nil
}
common.SysLog("database migration started")
err = migrateLOGDB()
// err = migrateLOGDB()
return err
} else {
common.FatalLog(err)
Expand Down
6 changes: 6 additions & 0 deletions model/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ func InitOptionMap() {
common.OptionMap["ImageRatio"] = ratio_setting.ImageRatio2JSONString()
common.OptionMap["AudioRatio"] = ratio_setting.AudioRatio2JSONString()
common.OptionMap["AudioCompletionRatio"] = ratio_setting.AudioCompletionRatio2JSONString()
common.OptionMap["ContextTierRatio"] = ratio_setting.ContextTierRatio2JSONString()
common.OptionMap["AudioMinutePrice"] = ratio_setting.AudioMinutePrice2JSONString()
common.OptionMap["TopUpLink"] = common.TopUpLink
//common.OptionMap["ChatLink"] = common.ChatLink
//common.OptionMap["ChatLink2"] = common.ChatLink2
Expand Down Expand Up @@ -521,6 +523,10 @@ func updateOptionMap(key string, value string) (err error) {
err = ratio_setting.UpdateAudioRatioByJSONString(value)
case "AudioCompletionRatio":
err = ratio_setting.UpdateAudioCompletionRatioByJSONString(value)
case "ContextTierRatio":
err = ratio_setting.UpdateContextTierRatioByJSONString(value)
case "AudioMinutePrice":
err = ratio_setting.UpdateAudioMinutePriceByJSONString(value)
case "TopUpLink":
common.TopUpLink = value
//case "ChatLink":
Expand Down
38 changes: 36 additions & 2 deletions relay/channel/api_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,10 +292,25 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
if err != nil {
return nil, fmt.Errorf("get request url failed: %w", err)
}

var bodyBytes []byte
if requestBody != nil {
bodyBytes, err = io.ReadAll(requestBody)
if err != nil {
return nil, fmt.Errorf("read request body failed: %w", err)
}
}

if common2.DebugEnabled {
println("fullRequestURL:", fullRequestURL)
var sb strings.Builder
sb.WriteString("\n========== Upstream Request ==========\n")
sb.WriteString(fmt.Sprintf("URL: %s %s\n", c.Request.Method, fullRequestURL))
sb.WriteString(fmt.Sprintf("Body: %s\n", string(bodyBytes)))
sb.WriteString("======================================")
logger.LogDebug(c.Request.Context(), sb.String())
}
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)

req, err := http.NewRequest(c.Request.Method, fullRequestURL, strings.NewReader(string(bodyBytes)))
Comment on lines +296 to +313

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.

⚠️ Potential issue | 🟠 Major

Don't buffer every relay body just to support debug logging.

Line 296 now reads the full payload even when common2.DebugEnabled is false, and Line 313 copies it again through string(bodyBytes). That defeats the stream/disk-backed path for large relay requests and can spike memory on big image/audio/video payloads.

Possible fix
+import "bytes"
+
-	var bodyBytes []byte
-	if requestBody != nil {
-		bodyBytes, err = io.ReadAll(requestBody)
-		if err != nil {
-			return nil, fmt.Errorf("read request body failed: %w", err)
-		}
-	}
+	var bodyBytes []byte
+	requestReader := requestBody
+	if common2.DebugEnabled && requestBody != nil {
+		bodyBytes, err = io.ReadAll(requestBody)
+		if err != nil {
+			return nil, fmt.Errorf("read request body failed: %w", err)
+		}
+		requestReader = bytes.NewReader(bodyBytes)
+	}
...
-	req, err := http.NewRequest(c.Request.Method, fullRequestURL, strings.NewReader(string(bodyBytes)))
+	req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestReader)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/api_request.go` around lines 296 - 313, The code eagerly reads
requestBody into bodyBytes for every request which breaks streaming/disk-backed
handling and uses extra memory; change it to only read into memory when
common2.DebugEnabled is true: leave requestBody as the io.ReadCloser (or
io.Reader) and pass it directly to http.NewRequest for the normal path, and when
DebugEnabled is true, tee or buffer the stream (e.g., io.TeeReader into a
bytes.Buffer or copy into a temporary buffer) to capture body for
logger.LogDebug while preserving the original reader for http.NewRequest; update
the code around requestBody, bodyBytes, logger.LogDebug and http.NewRequest
(c.Request.Method, fullRequestURL) so buffering happens conditionally only for
debugging.

if err != nil {
return nil, fmt.Errorf("new request failed: %w", err)
}
Expand All @@ -311,6 +326,25 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
return nil, err
}
applyHeaderOverrideToRequest(req, headerOverride)

if common2.DebugEnabled {
var sb strings.Builder
sb.WriteString("\n========== Upstream Request Headers ==========\n")
for key, values := range req.Header {
for _, v := range values {
if strings.EqualFold(key, "Authorization") {
// mask the key, only show prefix
if len(v) > 12 {
v = v[:12] + "***"
}
}
sb.WriteString(fmt.Sprintf("%s: %s\n", key, v))
}
}
sb.WriteString("==============================================")
logger.LogDebug(c.Request.Context(), sb.String())
}

resp, err := doRequest(c, req, info)
if err != nil {
return nil, fmt.Errorf("do request failed: %w", err)
Expand Down
Loading