Files
new-api/controller/plugin_protocol_image.go
RedwindA 19d435e7fd fix(relay): mask only the custom upstream address in error messages (#7822)
* fix(relay): mask only the upstream address in error messages

MaskSensitiveInfo masked every URL, domain and IPv4 in upstream error
messages, which garbled documentation links, email domains and dotted
identifiers that clients need to read.

It now takes the upstream addresses of the request: the channel base URL,
the origin the gateway actually dialed (recorded in doRequest, DoWssRequest,
the AWS SDK client and the Xunfei and Volcengine TTS dials, which covers
absolute Advanced Custom routes), the channel proxy and the environment
proxy. URLs and bare hostnames on those hosts and all IPv4 addresses are
masked; other URLs and domains stay readable. With no upstream address no
address is masked. Credentials are masked on every URL regardless of host:
api_key values, query values and userinfo.

The URL matcher also keeps quotes, colons and semicolons that follow a URL,
keeps parentheses and apostrophes inside it, masks an IPv4 URL host whole and
keeps a country-code TLD when the host has a port.

* fix(relay): keep preset official endpoints readable in masked errors

A channel on the base URL new-api presets for its type reaches a public
official endpoint, so its address is not masked. Only addresses the admin
configured are: a custom base URL, the origin of an Advanced Custom route,
and the channel and environment proxies. Preset hosts are compared with
their port so a configured localhost upstream stays apart from the Ollama
preset.

The origins recorded for the AWS SDK, Xunfei and Volcengine TTS dials and the
plugin allowedHosts masking are dropped: they are all official endpoints.

* fix(relay): mask IPv4 addresses and plugin allowed hosts in every error

IPv4 addresses in network errors include the gateway's own address, so they
are masked even when the channel uses a preset base URL. Task plugin build
failures mask the plugin's allowedHosts again: an installed plugin may point
them at private hosts.

* fix(relay): mask bracketed IPv6 addresses in errors

Go network errors write IPv6 addresses in brackets, as in
"dial tcp [2001:db8::1]:443". Like IPv4, they are the resolved upstream or
the gateway's own address, so they are masked on every error once the
bracketed content parses as an IP.
2026-10-15 15:49:45 +08:00

261 lines
10 KiB
Go

package controller
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"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"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
// serveTaskPluginImageProtocol answers an OpenAI Images API request that a
// task plugin claimed. The API is synchronous: the durable submission runs
// first on a context that survives a client disconnect, an asynchronous
// upstream task is then polled inside the request until it is terminal or the
// protocol timeout elapses, and the plugin's render hook finally shapes the
// OpenAI image response from the terminal task.
func serveTaskPluginImageProtocol(c *gin.Context, pinned pluginruntime.PinnedEndpoint, deps pluginProtocolBridgeDeps) {
deps = deps.withDefaults()
requestValue, exists := c.Get(pluginruntime.ContextKeyProtocolRequest)
protocolRequest, ok := requestValue.(pluginruntime.ProtocolRequestContext)
if !exists || !ok || protocolRequest.Protocol != pinned.Protocol || pinned.Plugin == nil {
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
clientRequest := c.Request
var relayInfo *relaycommon.RelayInfo
var relayInfoErr error
var outcome *taskSubmissionOutcome
var taskErr *dto.TaskError
// DashScope bills the images as soon as it accepts the upstream call. A
// client that gives up while the submission is in flight must not cancel
// that call, the task row, its settlement, or its consume log, so the
// submission runs on its own bounded context exactly like the Responses
// bridge. Only the in-request wait and the response write observe the
// client connection.
func() {
submissionContext, cancelSubmission := context.WithTimeout(
context.WithoutCancel(clientRequest.Context()),
deps.submissionTimeout,
)
c.Request = clientRequest.Clone(submissionContext)
defer func() {
c.Request = clientRequest
cancelSubmission()
}()
relayInfo, relayInfoErr = relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if relayInfoErr != nil {
return
}
relayInfo.IsStream = false
relayInfo.OriginModelName = c.GetString("resolved_task_model")
if action := c.GetString("task_action"); action != "" {
relayInfo.Action = action
}
if taskErr = relay.ResolveOriginTask(c, relayInfo); taskErr != nil {
return
}
if taskErr = relay.ApplyOriginTaskAffinity(c, relayInfo); taskErr != nil {
return
}
outcome, taskErr = deps.submit(c, relayInfo)
}()
if clientRequest.Context().Err() != nil {
// The client is gone. A durable task keeps its settlement and consume
// log; an asynchronous one is finished by the background poller.
return
}
if relayInfoErr != nil {
logger.LogError(c, common.LogText("build task protocol relay info failed: %s", relayInfoErr.Error()))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
if taskErr != nil {
respondTaskPluginImageError(c, taskErr)
return
}
if outcome == nil || outcome.Task == nil || outcome.Task.Platform != constant.TaskPlatform(pinned.Plugin.Meta.Key) {
logger.LogError(c, common.LogText("task protocol submission returned an invalid durable outcome"))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
task := outcome.Task
if task.Status != model.TaskStatusSuccess && task.Status != model.TaskStatusFailure {
if taskErr = waitTaskPluginImageTask(c, task, deps); taskErr != nil {
if c.Request.Context().Err() == nil {
respondTaskPluginImageError(c, taskErr)
}
return
}
}
if task.Status == model.TaskStatusFailure {
reason := strings.TrimSpace(task.FailReason)
if reason == "" {
reason = "image generation failed"
}
// The fail reason comes from the upstream, so mask the channel address in it.
taskErr = service.TaskErrorWrapper(errors.New(reason), "image_generation_failed", http.StatusBadRequest, common.UpstreamAddresses(c)...)
taskErr.LocalError = true
respondTaskPluginImageError(c, taskErr)
return
}
view, err := service.BuildTaskPluginView(task)
if err != nil {
logger.LogError(c, common.LogText("build task protocol view failed: %s", err.Error()))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
viewValue, err := taskPluginProtocolJSONValue(view)
if err != nil {
logger.LogError(c, common.LogText("encode task protocol view failed: %s", err.Error()))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
payload, err := pinned.Plugin.Engine.CallPathWithAdmissionTimeout(c.Request.Context(), deps.admissionTimeout, "protocols", []string{pinned.Protocol, "render"}, protocolRequest.JSValue(), viewValue)
if err != nil {
logger.LogError(c, common.LogText("task protocol image render hook failed: %s", err.Error()))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
response, ok := payload.(map[string]any)
data, hasData := response["data"].([]any)
if !ok || !hasData {
logger.LogError(c, common.LogText("task protocol image render hook must return an object with a data array"))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
if _, present := response["created"]; !present {
createdAt := task.CreatedAt
if createdAt == 0 {
createdAt = task.SubmitTime
}
response["created"] = createdAt
}
// response_format is host-owned: the plugin renders upstream URLs and the
// host inlines each image when the client asked for Base64.
if taskPluginImageResponseFormat(protocolRequest) == "b64_json" {
for _, entry := range data {
item, isObject := entry.(map[string]any)
if !isObject {
continue
}
url, _ := item["url"].(string)
if existing, _ := item["b64_json"].(string); strings.TrimSpace(url) == "" || existing != "" {
continue
}
_, encoded, downloadErr := deps.downloadImage(url)
if downloadErr != nil {
logger.LogWarn(c, common.LogText("task protocol image download failed; returning the upstream URL instead of b64_json: %s", common.MaskSensitiveInfo(downloadErr.Error(), common.UpstreamAddresses(c)...)))
continue
}
item["b64_json"] = encoded
}
}
c.JSON(http.StatusOK, response)
}
// waitTaskPluginImageTask polls an asynchronous image task inside the client
// request through the ordinary task polling path, so completion settles
// billing exactly as the background poller would, until the task is terminal
// or the protocol timeout elapses. A client disconnect stops waiting; the
// background poller then finishes the durable task.
func waitTaskPluginImageTask(c *gin.Context, task *model.Task, deps pluginProtocolBridgeDeps) *dto.TaskError {
ctx, cancel := context.WithTimeout(c.Request.Context(), deps.submissionTimeout)
defer cancel()
for {
timer := time.NewTimer(deps.imagePollInterval)
select {
case <-ctx.Done():
timer.Stop()
if errors.Is(ctx.Err(), context.DeadlineExceeded) && c.Request.Context().Err() == nil {
return service.TaskErrorWrapperLocal(fmt.Errorf("image generation is still running; task %s settles when it completes", task.TaskID), "task_timeout", http.StatusGatewayTimeout)
}
return service.TaskErrorWrapperLocal(ctx.Err(), "request_cancelled", http.StatusRequestTimeout)
case <-timer.C:
}
if err := deps.pollTask(ctx, task); err != nil {
logger.LogWarn(c, common.LogText("task protocol image poll failed for task %s: %s", task.TaskID, common.MaskSensitiveInfo(err.Error(), common.UpstreamAddresses(c)...)))
continue
}
if task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure {
return nil
}
}
}
// pollTaskPluginImageTask runs one poll round for a single task through the
// same code path as the scheduled task poller, including status CAS and
// completion settlement.
func pollTaskPluginImageTask(ctx context.Context, task *model.Task) error {
upstreamID := task.GetUpstreamTaskID()
if upstreamID == "" {
return errors.New("task has no upstream id")
}
return service.UpdateVideoTasks(ctx, task.Platform, map[int][]string{task.ChannelId: {upstreamID}}, map[string]*model.Task{upstreamID: task})
}
func taskPluginImageResponseFormat(request pluginruntime.ProtocolRequestContext) string {
body, ok := request.Body.(map[string]any)
if !ok {
return ""
}
switch body["kind"] {
case string(pluginruntime.BodyJSON):
value, _ := body["value"].(map[string]any)
format, _ := value["response_format"].(string)
return strings.TrimSpace(format)
case string(pluginruntime.BodyMultipart), string(pluginruntime.BodyForm):
fields, _ := body["fields"].(map[string][]string)
if values := fields["response_format"]; len(values) > 0 {
return strings.TrimSpace(values[0])
}
}
return ""
}
// respondTaskPluginImageError writes an OpenAI error envelope for the Images
// API. DashScope failure bodies arrive as JSON {code, message}; their message
// is surfaced so a client sees the vendor reason instead of raw JSON.
func respondTaskPluginImageError(c *gin.Context, taskErr *dto.TaskError) {
service.RecordRequestPolicyTermination(c, taskSubmissionAPIError(taskErr))
status := http.StatusInternalServerError
if taskErr.StatusCode >= 400 && taskErr.StatusCode <= 599 {
status = taskErr.StatusCode
}
code := taskErr.Code
message := taskErr.Message
var upstream struct {
Code string `json:"code"`
Message string `json:"message"`
}
if common.UnmarshalJsonStr(message, &upstream) == nil && strings.TrimSpace(upstream.Message) != "" {
message = upstream.Message
if strings.TrimSpace(upstream.Code) != "" {
code = upstream.Code
}
}
errorType := "new_api_error"
if status < http.StatusInternalServerError {
errorType = "invalid_request_error"
}
c.JSON(status, gin.H{"error": gin.H{
"message": common.MessageWithRequestId(message, c.GetString(common.RequestIdKey)),
"type": errorType,
"code": code,
}})
}