Files
new-api-custom/controller/detection.go
T
cneicy a5f7b8e586
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
feat: sync deployed v1.0.0-rc.38-custom.42
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
2026-09-26 04:49:11 +08:00

499 lines
16 KiB
Go

package controller
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
var detectionKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{16,96}$`)
var detectionCalls sync.Map
type detectionRequest struct {
Group string `json:"group"`
Kind string `json:"kind"`
Key string `json:"key"`
PriceVersion string `json:"price_version"`
}
func detectionInput(c *gin.Context) (detectionRequest, *model.User, service.DetectionPrice, int, error) {
var input detectionRequest
if err := c.ShouldBindJSON(&input); err != nil {
return input, nil, service.DetectionPrice{}, 0, errors.New("invalid detection request")
}
if input.Kind != "pelican" && input.Kind != "fingerprint" {
return input, nil, service.DetectionPrice{}, 0, errors.New("invalid detection type")
}
user, err := model.GetUserById(c.GetInt("id"), false)
if err != nil {
return input, nil, service.DetectionPrice{}, 0, err
}
if !service.IsUserSelectableGroup(user.Group, input.Group) {
return input, nil, service.DetectionPrice{}, 0, errors.New("group unavailable")
}
price, reserve, err := service.DetectionPricing(user.Group, input.Group, input.Kind)
return input, user, price, reserve, err
}
func EstimateDetection(c *gin.Context) {
input, _, price, reserve, err := detectionInput(c)
if err != nil {
common.ApiError(c, err)
return
}
encoded, _ := common.Marshal(price)
calls := 1
if input.Kind == "fingerprint" {
calls = 3
}
c.JSON(200, gin.H{"success": true, "data": gin.H{"reserved": reserve, "ratio": price.Snapshot.GroupRatio, "calls": calls, "price_version": model.DetectionSecretHash(string(encoded))}})
}
func CreateUserDetection(c *gin.Context) {
input, user, price, reserve, err := detectionInput(c)
if err != nil {
common.ApiError(c, err)
return
}
if !detectionKeyPattern.MatchString(input.Key) {
common.ApiError(c, errors.New("invalid idempotency key"))
return
}
var previous []model.DetectionRun
key := fmt.Sprintf("user:%d:%s", user.Id, input.Key)
if err = model.DB.Where("request_key = ?", key).Find(&previous).Error; err != nil {
common.ApiError(c, err)
return
}
if len(previous) > 0 {
if previous[0].GroupName != input.Group || previous[0].Kind != input.Kind {
common.ApiError(c, errors.New("idempotency key conflict"))
return
}
c.JSON(200, gin.H{"success": true, "data": gin.H{"id": previous[0].ID}})
return
}
encoded, _ := common.Marshal(price)
if input.PriceVersion != model.DetectionSecretHash(string(encoded)) {
common.ApiError(c, errors.New("pricing changed; request a new estimate"))
return
}
now := time.Now()
price.Request.At = &now
encoded, _ = common.Marshal(price)
channel, err := model.GetRandomSatisfiedChannel(input.Group, fingerprint.Model, 0, nil)
if err != nil || channel == nil || !service.ChannelSupportsAstra(channel) {
common.ApiError(c, errors.New("no Astra channel available"))
return
}
id, err := common.GenerateRandomCharsKey(32)
if err != nil {
common.ApiError(c, err)
return
}
run := &model.DetectionRun{ID: id, UserID: user.Id, RequestKey: fmt.Sprintf("user:%d:%s", user.Id, input.Key), ChannelID: channel.Id, GroupName: input.Group, Kind: input.Kind, Status: "pending", CreatedAt: time.Now().Unix(), Reserved: reserve, Price: string(encoded)}
if err = model.CreateDetection(run); err != nil {
common.ApiError(c, err)
return
}
c.JSON(200, gin.H{"success": true, "data": gin.H{"id": run.ID}})
}
func GetUserDetection(c *gin.Context) {
var run model.DetectionRun
query := model.DB.Where("user_id = ?", c.GetInt("id"))
if c.Param("id") == "latest" {
query = query.Where("group_name = ?", c.Query("group")).Order("created_at desc")
} else {
query = query.Where("id = ?", c.Param("id"))
}
if err := query.First(&run).Error; err != nil {
if c.Param("id") == "latest" {
c.JSON(200, gin.H{"success": true, "data": nil})
} else {
c.JSON(404, gin.H{"success": false, "message": "Detection not found"})
}
return
}
var calls []model.DetectionCall
if err := model.DB.Where("run_id = ?", run.ID).Find(&calls).Error; err != nil {
common.ApiError(c, err)
return
}
usage := []any{}
for _, call := range calls {
var parsed any
_ = common.UnmarshalJsonStr(call.Usage, &parsed)
if parsed != nil {
usage = append(usage, parsed)
}
if call.Error != "" && run.Error == "" {
run.Error = "usage_or_generation_incomplete"
}
}
c.JSON(200, gin.H{"success": true, "data": gin.H{"id": run.ID, "kind": run.Kind, "status": run.Status, "quality": run.Quality, "reserved": run.Reserved, "charged": run.Charged, "settled": run.Settled, "usage": usage, "error": run.Error}})
}
func GetDetectionArtwork(c *gin.Context) {
getDetectionArtwork(c, false)
}
func GetSiteDetectionArtwork(c *gin.Context) {
getDetectionArtwork(c, true)
}
func getDetectionArtwork(c *gin.Context, allGroups bool) {
var run model.DetectionRun
if err := model.DB.Select("html", "channel_id", "created_at").Where("id = ? AND created_at > ?", c.Param("id"), time.Now().Add(-6*time.Hour).Unix()).First(&run).Error; err != nil || run.HTML == "" {
c.Status(404)
return
}
settings, err := model.GetStatusPageGroups(c.Request.Context())
if err != nil {
c.Status(http.StatusServiceUnavailable)
return
}
channels, err := model.GetStatusChannels(c.Request.Context())
if err != nil {
c.Status(http.StatusServiceUnavailable)
return
}
usable := service.GetUserUsableGroups("")
visible := false
for _, channel := range channels {
if channel.Id != run.ChannelID {
continue
}
for group := range strings.SplitSeq(channel.Group, ",") {
group = strings.TrimSpace(group)
if _, ok := usable[group]; (allGroups || (ok && !settings[group].Hidden)) && run.CreatedAt > settings[group].ResetBefore {
visible = true
}
}
}
if !visible {
c.Status(http.StatusNotFound)
return
}
c.Header("Content-Security-Policy", "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; form-action 'none'; base-uri 'none'; frame-ancestors 'self'")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Cache-Control", "no-store")
c.Data(200, "text/html; charset=utf-8", []byte(sanitizeDetectionHTML(run.HTML)))
}
// Decode only generation fields: external credentials cannot forward tools,
// previous responses, billing modifiers or arbitrary provider parameters.
func detectionProxyRequest(data []byte) (*dto.OpenAIResponsesRequest, error) {
var fields map[string]any
if len(data) > 64<<10 || common.Unmarshal(data, &fields) != nil {
return nil, errors.New("invalid request")
}
if name, ok := fields["model"].(string); !ok || name != fingerprint.Model {
return nil, errors.New("model not allowed")
}
for key := range fields {
switch key {
case "model", "input", "instructions", "stream", "max_output_tokens", "reasoning", "store", "tools", "tool_choice":
default:
return nil, errors.New("unsupported request field")
}
}
input, err := common.Marshal(fields["input"])
if err != nil || len(input) > 16384 {
return nil, errors.New("input too large")
}
var checkText func(any) bool
checkText = func(v any) bool {
switch value := v.(type) {
case string:
return true
case []any:
for _, item := range value {
if !checkText(item) {
return false
}
}
return true
case map[string]any:
for k, item := range value {
switch k {
case "role":
role, ok := item.(string)
if !ok || (role != "user" && role != "system" && role != "developer") {
return false
}
case "type":
kind, ok := item.(string)
if !ok || (kind != "input_text" && kind != "message") {
return false
}
case "content", "text":
if !checkText(item) {
return false
}
default:
return false
}
}
return true
}
return false
}
if !checkText(fields["input"]) {
return nil, errors.New("text input required")
}
instructions, _ := fields["instructions"].(string)
if len(input)+len(instructions) > 16384 {
return nil, errors.New("input too large")
}
// Service-selected bounds cannot be overridden by the bearer.
fields = map[string]any{"model": fingerprint.Model, "input": fields["input"], "instructions": instructions, "stream": true, "max_output_tokens": 16384, "reasoning": map[string]string{"effort": "high"}, "store": false, "tools": []any{}}
encoded, _ := common.Marshal(fields)
var request dto.OpenAIResponsesRequest
err = common.Unmarshal(encoded, &request)
return &request, err
}
func DetectionProxy(c *gin.Context) {
secret := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
if !strings.HasPrefix(secret, "det_") {
c.AbortWithStatus(401)
return
}
data, err := io.ReadAll(io.LimitReader(c.Request.Body, (64<<10)+1))
if err != nil {
c.AbortWithStatus(400)
return
}
request, err := detectionProxyRequest(data)
if err != nil {
c.JSON(400, gin.H{"error": gin.H{"message": err.Error()}})
return
}
call, err := model.ClaimDetectionCall(model.DetectionSecretHash(secret))
if err != nil {
c.AbortWithStatus(401)
return
}
var run model.DetectionRun
if err = model.DB.First(&run, "id = ?", call.RunID).Error; err != nil {
c.AbortWithStatus(503)
return
}
ctx, cancel := context.WithDeadline(c.Request.Context(), time.Unix(run.Deadline-10, 0))
detectionCalls.Store(call.ID, cancel)
defer func() { cancel(); detectionCalls.Delete(call.ID) }()
channel, err := model.GetChannelById(run.ChannelID, true)
var raw []byte
var contentType string
// Flush SSE comments while generation is buffered for accounting. Without
// these bytes, the public edge times out a valid long reasoning request.
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("X-Accel-Buffering", "no")
c.Writer.WriteHeader(http.StatusOK)
_, _ = c.Writer.WriteString(": detection started\n\n")
c.Writer.Flush()
if err == nil && channel.Status == common.ChannelStatusEnabled {
raw, contentType, err = waitDetectionResponse(c, ctx, channel, request)
} else {
err = errors.New("channel unavailable")
}
success := err == nil
call.Available = &success
if err != nil {
call.Error = "channel_call_failed"
if ctx.Err() != nil {
call.Available = nil
call.Error = "interrupted_usage_unknown"
}
} else {
usage, complete := detectionResponseUsage(raw, contentType)
if !complete {
call.Error = "incomplete_response"
call.Available = nil
} else {
call.Usage = usage.Raw
quota, quotaErr := service.DetectionUsageQuota(run.Price, usage.Get("input_tokens").Int(), usage.Get("output_tokens").Int(), usage.Get("input_tokens_details.cached_tokens").Int(), max(usage.Get("input_tokens_details.cached_creation_tokens").Int(), usage.Get("input_tokens_details.cache_creation_tokens").Int()))
if quotaErr != nil || quota > run.Reserved {
call.Error = "settlement_unavailable"
} else if run.UserID > 0 {
call.Quota = quota
}
}
}
if success {
if html := detectionHTML(raw, contentType); html != "" {
if saveErr := model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Update("html", html).Error; saveErr != nil {
call.Error = "artwork_save_failed"
success = false
}
}
}
if err = model.FinishDetectionCall(call); err != nil {
success = false
}
if !success {
_, _ = c.Writer.WriteString("event: error\ndata: {\"type\":\"error\",\"error\":{\"message\":\"Detection generation failed\"}}\n\n")
c.Writer.Flush()
return
}
if strings.Contains(contentType, "text/event-stream") {
_, _ = c.Writer.Write(raw)
} else {
event, _ := common.Marshal(map[string]any{"type": "response.completed", "response": gjson.ParseBytes(raw).Value()})
_, _ = fmt.Fprintf(c.Writer, "event: response.completed\ndata: %s\n\n", event)
}
c.Writer.Flush()
}
func waitDetectionResponse(c *gin.Context, ctx context.Context, channel *model.Channel, request *dto.OpenAIResponsesRequest) ([]byte, string, error) {
type response struct {
raw []byte
contentType string
err error
}
done := make(chan response, 1)
go func() {
raw, contentType, err := runDetectionResponse(ctx, channel, request)
done <- response{raw, contentType, err}
}()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case result := <-done:
return result.raw, result.contentType, result.err
case <-ticker.C:
_, _ = c.Writer.WriteString(": waiting for generation\n\n")
c.Writer.Flush()
}
}
}
func runDetectionResponse(ctx context.Context, channel *model.Channel, request *dto.OpenAIResponsesRequest) ([]byte, string, error) {
encoded, err := common.Marshal(request)
if err != nil {
return nil, "", err
}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/v1/responses", bytes.NewReader(encoded))
c.Request.Header.Set("Content-Type", "application/json")
if middleware.SetupContextForSelectedChannel(c, channel, fingerprint.Model) != nil {
return nil, "", errors.New("channel setup failed")
}
info, err := relaycommon.GenRelayInfo(c, types.RelayFormatOpenAIResponses, request, nil)
if err != nil {
return nil, "", err
}
info.IsChannelTest = true
info.DisablePing = true
info.AstraDetectionDisabled = true
info.AstraAutoSwitch = false
adaptor, body, closer, apiErr := relay.PrepareResponsesRequest(c, info, request)
if closer != nil {
defer closer.Close()
}
if apiErr != nil {
return nil, "", errors.New("preparation failed")
}
body, boundedCloser, err := boundDetectionBody(body, request)
if err != nil {
return nil, "", err
}
defer boundedCloser.Close()
response, err := adaptor.DoRequest(c, info, body)
if err != nil {
return nil, "", err
}
resp, ok := response.(*http.Response)
if !ok || resp == nil {
return nil, "", errors.New("unsupported transport")
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, "", errors.New("upstream request failed")
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1))
if len(raw) > 8<<20 {
return nil, "", errors.New("response too large")
}
return raw, resp.Header.Get("Content-Type"), err
}
func detectionResponseUsage(raw []byte, contentType string) (gjson.Result, bool) {
result := gjson.ParseBytes(raw)
if strings.Contains(contentType, "text/event-stream") {
result = gjson.Result{}
for line := range strings.SplitSeq(string(raw), "\n") {
if strings.HasPrefix(line, "data:") {
event := gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
switch event.Get("type").String() {
case "response.completed", "response.incomplete", "response.failed":
result = event.Get("response")
}
}
}
}
usage := result.Get("usage")
status := result.Get("status").String()
return usage, (status == "completed" || status == "incomplete" || status == "failed") && usage.Get("input_tokens").Type == gjson.Number && usage.Get("output_tokens").Type == gjson.Number
}
// Apply billing and safety limits AFTER channel parameter overrides. Never let
// an administrative override turn a bounded paid test into unbounded tool work.
func boundDetectionBody(body common.ReplayableBody, request *dto.OpenAIResponsesRequest) (common.ReplayableBody, io.Closer, error) {
raw, err := io.ReadAll(io.LimitReader(body, 128<<10))
if err != nil {
return nil, nil, err
}
var prepared map[string]any
if common.Unmarshal(raw, &prepared) != nil {
return nil, nil, errors.New("invalid prepared detection request")
}
var input any
if common.Unmarshal(request.Input, &input) != nil {
return nil, nil, errors.New("invalid detection input")
}
prepared["input"] = input
var instructions any
_ = common.Unmarshal(request.Instructions, &instructions)
prepared["instructions"] = instructions
prepared["max_output_tokens"] = *request.MaxOutputTokens
if *request.MaxOutputTokens == 16384 {
prepared["reasoning"] = map[string]string{"effort": "high"}
}
prepared["tools"] = []any{}
prepared["tool_choice"] = "none"
prepared["store"] = false
prepared["stream"] = true
for _, key := range []string{"previous_response_id", "conversation", "background", "max_tokens", "max_completion_tokens"} {
delete(prepared, key)
}
encoded, err := common.Marshal(prepared)
if err != nil {
return nil, nil, err
}
return relaycommon.NewOutboundJSONBody(encoded)
}