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.
189 lines
6.9 KiB
Go
189 lines
6.9 KiB
Go
package relay
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
|
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
|
"github.com/QuantumNous/new-api/relay/helper"
|
|
"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"
|
|
)
|
|
|
|
// Independent cursors retain the payload metadata used by net/http redirects
|
|
// and transport retries without closing the shared request storage.
|
|
type responsesAttemptBody struct {
|
|
common.ReplayableBody
|
|
reader io.Reader
|
|
}
|
|
|
|
func (b responsesAttemptBody) Read(p []byte) (int, error) { return b.reader.Read(p) }
|
|
|
|
func shouldEnableAstraResponseGuard(path string, info *relaycommon.RelayInfo) bool {
|
|
return path == "/v1/responses" &&
|
|
info.OriginModelName == "gpt-6-astra" &&
|
|
info.AstraAutoSwitch && !info.AstraDetectionDisabled &&
|
|
info.ResponseGuard == nil
|
|
}
|
|
|
|
func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) {
|
|
var responsesReq *dto.OpenAIResponsesRequest
|
|
switch req := info.Request.(type) {
|
|
case *dto.OpenAIResponsesRequest:
|
|
responsesReq = req
|
|
case *dto.OpenAIResponsesCompactionRequest:
|
|
// Only fields documented for POST /v1/responses/compact are forwarded:
|
|
// model, input, instructions, previous_response_id, prompt_cache_key,
|
|
// prompt_cache_options, prompt_cache_retention, service_tier.
|
|
// Undocumented Codex-parity fields (tools, reasoning, text) are parsed
|
|
// for client compatibility but intentionally not sent upstream.
|
|
responsesReq = &dto.OpenAIResponsesRequest{
|
|
Model: req.Model,
|
|
Input: req.Input,
|
|
Instructions: req.Instructions,
|
|
PreviousResponseID: req.PreviousResponseID,
|
|
ParallelToolCalls: req.ParallelToolCalls,
|
|
ServiceTier: req.ServiceTier,
|
|
PromptCacheKey: req.PromptCacheKey,
|
|
PromptCacheOptions: req.PromptCacheOptions,
|
|
PromptCacheRetention: req.PromptCacheRetention,
|
|
}
|
|
default:
|
|
return types.NewErrorWithStatusCode(
|
|
fmt.Errorf("invalid request type, expected dto.OpenAIResponsesRequest or dto.OpenAIResponsesCompactionRequest, got %T", info.Request),
|
|
types.ErrorCodeInvalidRequest,
|
|
http.StatusBadRequest,
|
|
types.ErrOptionWithSkipRetry(),
|
|
)
|
|
}
|
|
|
|
adaptor, requestBody, closer, apiErr := PrepareResponsesRequest(c, info, responsesReq)
|
|
if apiErr != nil {
|
|
return apiErr
|
|
}
|
|
defer closer.Close()
|
|
// Missing-usage estimation follows the actual prepared payload, including
|
|
// parameter overrides, rather than the client's pre-transformation request.
|
|
if info.DurableAttemptBilling {
|
|
if cursor, err := requestBody.NewReader(); err == nil {
|
|
var sent dto.OpenAIResponsesRequest
|
|
decodeErr := json.NewDecoder(cursor).Decode(&sent)
|
|
_ = cursor.Close()
|
|
if decodeErr == nil {
|
|
if tokens, err := service.CountRequestToken(c, sent.GetTokenCountMeta(), info); err == nil {
|
|
info.SetEstimatePromptTokens(tokens)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if shouldEnableAstraResponseGuard(c.Request.URL.Path, info) {
|
|
info.ResponseGuard = &relaycommon.ResponseModelGuard{Deadline: info.GuardDeadline}
|
|
}
|
|
|
|
guardEnabled := info.ResponseGuard != nil && info.AstraAutoSwitch
|
|
|
|
body, err := requestBody.NewReader()
|
|
if err != nil {
|
|
return types.NewOpenAIError(err, types.ErrorCodeReadRequestBodyFailed, http.StatusInternalServerError)
|
|
}
|
|
resp, requestErr := adaptor.DoRequest(c, info, responsesAttemptBody{ReplayableBody: requestBody, reader: body})
|
|
_ = body.Close()
|
|
if requestErr != nil {
|
|
apiErr := types.NewError(requestErr, types.ErrorCodeDoRequestFailed)
|
|
guard := info.ResponseGuard
|
|
if guard == nil || apiErr.GetErrorCode() != relaycommon.ResponseGuardErrorCode {
|
|
return apiErr
|
|
}
|
|
attempt := guard.Last()
|
|
if attempt != nil {
|
|
service.RequestPolicy(c).AddEvent(service.PolicyEvent{ChannelID: info.ChannelId, ErrorCode: string(relaycommon.ResponseGuardErrorCode), ErrorSource: "model_guard", Health: "unchanged", Decision: service.PolicyDecision{Action: "reject", Reason: attempt.Reason, Source: "model_guard"}})
|
|
}
|
|
return guard.APIError()
|
|
}
|
|
var httpResp *http.Response
|
|
if resp != nil {
|
|
httpResp = resp.(*http.Response)
|
|
}
|
|
if guardEnabled {
|
|
defer func() {
|
|
if attempt := info.ResponseGuard.Last(); attempt != nil {
|
|
attempt.Finish(c.Writer.Written())
|
|
}
|
|
}()
|
|
}
|
|
|
|
statusCodeMappingStr := c.GetString("status_code_mapping")
|
|
|
|
if httpResp != nil {
|
|
if httpResp.StatusCode != http.StatusOK {
|
|
newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
|
|
// reset status code 重置状态码
|
|
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
|
|
return newAPIError
|
|
}
|
|
}
|
|
|
|
usage, newAPIError := adaptor.DoResponse(c, httpResp, info)
|
|
if guardEnabled {
|
|
if attempt := info.ResponseGuard.Last(); attempt != nil {
|
|
attempt.Finish(c.Writer.Written())
|
|
if guardErr := attempt.APIError(); guardErr != nil {
|
|
if info.StreamStatus != nil {
|
|
info.StreamStatus.MarkFailed(string(relaycommon.ResponseGuardErrorCode), "api_error", http.StatusConflict)
|
|
}
|
|
if c.Writer.Written() && c.Request.Context().Err() == nil {
|
|
message := common.MessageWithRequestId(attempt.PublicErrorMessage(), c.GetString(common.RequestIdKey))
|
|
c.SSEvent("response.failed", gin.H{"type": "response.failed", "sequence_number": attempt.Sequence + 1, "response": gin.H{"id": attempt.ResponseID, "object": "response", "status": "failed", "error": gin.H{"code": relaycommon.ResponseGuardErrorCode, "message": message}}})
|
|
c.Writer.Flush()
|
|
c.Set("response_guard_error_sent", true)
|
|
}
|
|
return guardErr
|
|
}
|
|
}
|
|
}
|
|
if newAPIError != nil {
|
|
// reset status code 重置状态码
|
|
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
|
|
return newAPIError
|
|
}
|
|
|
|
usageDto := usage.(*dto.Usage)
|
|
if info.RelayMode == relayconstant.RelayModeResponsesCompact {
|
|
originModelName := info.OriginModelName
|
|
originPriceData := info.PriceData
|
|
|
|
_, err := helper.ModelPriceHelper(c, info, info.GetEstimatePromptTokens(), &types.TokenCountMeta{})
|
|
if err != nil {
|
|
info.OriginModelName = originModelName
|
|
info.PriceData = originPriceData
|
|
return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry(), types.ErrOptionWithStatusCode(http.StatusBadRequest))
|
|
}
|
|
service.PostTextConsumeQuota(c, info, usageDto, nil)
|
|
|
|
info.OriginModelName = originModelName
|
|
info.PriceData = originPriceData
|
|
return nil
|
|
}
|
|
|
|
ConsumeResponsesQuota(c, info, usageDto)
|
|
return nil
|
|
}
|
|
|
|
// ConsumeResponsesQuota applies the same settlement dispatch to HTTP and
|
|
// WebSocket Responses usage. Compact requests keep their separate repricing.
|
|
func ConsumeResponsesQuota(c *gin.Context, info *relaycommon.RelayInfo, usage *dto.Usage) {
|
|
if strings.HasPrefix(info.OriginModelName, "gpt-4o-audio") {
|
|
service.PostAudioConsumeQuota(c, info, usage, "")
|
|
return
|
|
}
|
|
service.PostTextConsumeQuota(c, info, usage, nil)
|
|
}
|