| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276 |
- package service
- import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
- "github.com/gin-gonic/gin"
- "github.com/tidwall/gjson"
- "go.uber.org/zap"
- )
- // forwardAnthropicViaRawChatCompletions serves /v1/messages clients through
- // an OpenAI-compatible upstream that only supports /v1/chat/completions.
- //
- // Conversion chain (direct, no Responses intermediary):
- //
- // Request: Anthropic Messages → Chat Completions (AnthropicToChatCompletionsRequest)
- // Response: CC chunk/response → Anthropic events/response (direct bridge)
- //
- // This is the /v1/messages counterpart of forwardResponsesViaRawChatCompletions
- // (which serves /v1/responses clients). Unlike the Responses path, the direct
- // bridge skips the Responses API intermediate representation entirely — every
- // streaming token runs through a single state machine instead of two.
- func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions(
- ctx context.Context,
- c *gin.Context,
- account *Account,
- body []byte,
- defaultMappedModel string,
- ) (*OpenAIForwardResult, error) {
- startTime := time.Now()
- // 1. Parse Anthropic request
- var anthropicReq apicompat.AnthropicRequest
- if err := json.Unmarshal(body, &anthropicReq); err != nil {
- writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
- return nil, fmt.Errorf("parse anthropic request: %w", err)
- }
- originalModel := anthropicReq.Model
- if strings.TrimSpace(originalModel) == "" {
- writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", "model is required")
- return nil, fmt.Errorf("missing model in request")
- }
- applyOpenAICompatModelNormalization(&anthropicReq)
- clientStream := anthropicReq.Stream
- // 2. Anthropic → Chat Completions (direct, no Responses intermediary)
- chatReq, err := apicompat.AnthropicToChatCompletionsRequest(&anthropicReq)
- if err != nil {
- writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
- return nil, fmt.Errorf("convert anthropic to chat completions: %w", err)
- }
- billingModel := resolveOpenAIForwardModel(account, anthropicReq.Model, defaultMappedModel)
- upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel)
- chatReq.Model = upstreamModel
- chatReq.ReasoningEffort = openAICompatAnthropicReasoningEffort(&anthropicReq, upstreamModel, chatReq.ReasoningEffort)
- chatReq.Stream = clientStream
- if clientStream {
- chatReq.StreamOptions = &apicompat.ChatStreamOptions{IncludeUsage: true}
- }
- convertedEffort := chatReq.ReasoningEffort
- reasoningEffort := &convertedEffort
- reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel)
- serviceTier := extractOpenAIServiceTierFromBody(body)
- chatBody, err := json.Marshal(chatReq)
- if err != nil {
- return nil, fmt.Errorf("marshal chat completions request: %w", err)
- }
- if normalizedBody, normalized := NormalizeGLMOpenAIReasoningEffort(chatBody, upstreamModel); normalized {
- chatBody = normalizedBody
- }
- if account.Platform == PlatformOpenAI {
- policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody)
- if policyErr != nil {
- if IsReasoningEffortPolicyDenied(policyErr) {
- MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
- writeAnthropicError(c, http.StatusForbidden, "forbidden_error", policyErr.Error())
- }
- return nil, policyErr
- }
- if changed {
- chatBody = policyBody
- if effectiveEffort := strings.TrimSpace(gjson.GetBytes(chatBody, "reasoning_effort").String()); effectiveEffort != "" {
- reasoningEffort = &effectiveEffort
- }
- }
- }
- // Unlike forwardResponsesViaRawChatCompletions, applyOpenAIFastPolicyToBody
- // is intentionally skipped: Anthropic Messages bodies carry no service_tier,
- // so the converted Chat Completions body never contains one and the policy
- // would always be a no-op on this path.
- logger.L().Debug("openai messages: forwarding via raw chat completions",
- zap.Int64("account_id", account.ID),
- zap.String("original_model", originalModel),
- zap.String("billing_model", billingModel),
- zap.String("upstream_model", upstreamModel),
- zap.Bool("stream", clientStream),
- )
- // 3. Build and send upstream request via the shared CC pipeline
- apiKey, targetURL, err := s.resolveCCFallbackTarget(account)
- if err != nil {
- return nil, err
- }
- resp, err := s.sendCCUpstreamRequest(ctx, c, account, targetURL, chatBody, clientStream, apiKey, account.GetOpenAIUserAgent(), "")
- if err != nil {
- return nil, err
- }
- defer func() { _ = resp.Body.Close() }()
- // 4. Handle error responses
- if resp.StatusCode >= 400 {
- respBody, upstreamMsg := s.readOpenAIUpstreamError(resp)
- if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil {
- return nil, foErr
- }
- // Non-failover error: return Anthropic-formatted error to client via the
- // shared compat handler (passthrough rules, ops recording, cyber_policy).
- return s.handleAnthropicErrorResponse(resp, c, account, billingModel)
- }
- // 5. Convert response
- if clientStream {
- return s.streamChatCompletionsAsAnthropic(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
- }
- return s.bufferChatCompletionsAsAnthropic(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
- }
- func (s *OpenAIGatewayService) bufferChatCompletionsAsAnthropic(
- c *gin.Context,
- resp *http.Response,
- originalModel string,
- billingModel string,
- upstreamModel string,
- reasoningEffort *string,
- serviceTier *string,
- startTime time.Time,
- ) (*OpenAIForwardResult, error) {
- requestID := resp.Header.Get("x-request-id")
- ccResp, usage, err := s.readCCUpstreamJSONResponse(c, resp, writeAnthropicError)
- if err != nil {
- return nil, err
- }
- anthropicResp := apicompat.ChatCompletionsResponseToAnthropic(ccResp, originalModel)
- if s.responseHeaderFilter != nil {
- responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
- }
- c.JSON(http.StatusOK, anthropicResp)
- return &OpenAIForwardResult{
- RequestID: requestID,
- UpstreamHeaders: resp.Header,
- Usage: usage,
- Model: originalModel,
- BillingModel: billingModel,
- UpstreamModel: upstreamModel,
- ReasoningEffort: reasoningEffort,
- UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c),
- ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier),
- Stream: false,
- Duration: time.Since(startTime),
- }, nil
- }
- func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic(
- c *gin.Context,
- resp *http.Response,
- originalModel string,
- billingModel string,
- upstreamModel string,
- reasoningEffort *string,
- serviceTier *string,
- startTime time.Time,
- ) (*OpenAIForwardResult, error) {
- requestID := resp.Header.Get("x-request-id")
- writeStreamHeaders := s.newStreamHeaderWriter(c, resp.Header)
- anthropicState := apicompat.NewChatCompletionsToAnthropicStreamState(originalModel)
- clientDisconnected := false
- // 与 responses 兄弟不同:客户端断开后仍继续做事件转换(喂 anthropicState),
- // 仅跳过写出,保证 finalize 阶段的 usage 汇总不受断开影响。
- emitChunk := func(chunk *apicompat.ChatCompletionsChunk) {
- // CC chunk → Anthropic events (direct, single state machine)
- anthropicEvents := apicompat.ChatCompletionsChunkToAnthropicEvents(chunk, anthropicState)
- if clientDisconnected {
- return
- }
- for _, aEvt := range anthropicEvents {
- sse, err := apicompat.ResponsesAnthropicEventToSSE(aEvt)
- if err != nil {
- continue
- }
- writeStreamHeaders()
- if _, err := fmt.Fprint(c.Writer, sse); err != nil {
- clientDisconnected = true
- break
- }
- }
- if !clientDisconnected && len(anthropicEvents) > 0 {
- c.Writer.Flush()
- }
- }
- scan := s.scanCCStream(c, resp, "openai messages chat fallback", requestID, startTime, emitChunk)
- usage := scan.Usage
- if scan.Err != nil {
- // Broken upstream read: skip finalization so no synthetic message_stop
- // masks the truncation, and surface the error to flag usage incomplete
- // (mirrors forwardResponsesViaRawChatCompletions).
- return &OpenAIForwardResult{
- RequestID: requestID,
- UpstreamHeaders: resp.Header,
- Usage: usage,
- Model: originalModel,
- BillingModel: billingModel,
- UpstreamModel: upstreamModel,
- ReasoningEffort: reasoningEffort,
- UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c),
- ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier),
- Stream: true,
- Duration: time.Since(startTime),
- FirstTokenMs: scan.FirstTokenMs,
- ClientDisconnect: clientDisconnected,
- }, fmt.Errorf("stream usage incomplete: %w", scan.Err)
- }
- // Finalize: close open blocks + emit message_delta/message_stop.
- finalEvents := apicompat.FinalizeChatCompletionsAnthropicStream(anthropicState)
- if !clientDisconnected {
- for _, aEvt := range finalEvents {
- sse, err := apicompat.ResponsesAnthropicEventToSSE(aEvt)
- if err != nil {
- continue
- }
- writeStreamHeaders()
- if _, err := fmt.Fprint(c.Writer, sse); err != nil {
- clientDisconnected = true
- break
- }
- }
- c.Writer.Flush()
- }
- if !scan.SawDone {
- logCCStreamMissingDoneSentinel("openai messages chat fallback", requestID)
- }
- return &OpenAIForwardResult{
- RequestID: requestID,
- UpstreamHeaders: resp.Header,
- Usage: usage,
- Model: originalModel,
- BillingModel: billingModel,
- UpstreamModel: upstreamModel,
- ReasoningEffort: reasoningEffort,
- UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c),
- ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier),
- Stream: true,
- Duration: time.Since(startTime),
- FirstTokenMs: scan.FirstTokenMs,
- ClientDisconnect: clientDisconnected,
- }, nil
- }
|