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
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
499 lines
16 KiB
Go
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)
|
|
}
|