| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313 |
- package service
- import (
- "strings"
- "github.com/gin-gonic/gin"
- "github.com/tidwall/gjson"
- )
- const (
- upstreamResponseModelObserverContextKey = "upstream_response_model_observer"
- upstreamResponseModelMaxLength = 200
- )
- // upstreamResponseModelObserver tracks one forwarding attempt (or one WS turn).
- // A terminal declaration wins over an earlier declaration; otherwise the first
- // declaration is retained. Observation never affects the forwarding path.
- //
- // Billing normally ignores the observed model as well; the only exception is a
- // channel explicitly configured with billing_model_source = response_model,
- // where a conflict flag makes billing fall back to the baseline model
- // (see responseModelBillingDeclaration).
- //
- // The same observer also records the service tier the upstream reports having
- // used (OpenAI service_tier, Anthropic usage.speed). The observed tier stays
- // separate from the final outbound request tier until usage recording resolves
- // the billable tier for the selected credential protocol.
- type upstreamResponseModelObserver struct {
- first string
- terminal string
- conflict bool
- // firstTier holds the first non-terminal tier declaration; it is discarded
- // when later non-terminal declarations disagree. terminalTier comes from a
- // terminal event and always wins.
- firstTier string
- firstTierConflict bool
- terminalTier string
- }
- func (o *upstreamResponseModelObserver) Observe(model string, terminal bool) {
- model = normalizeObservedUpstreamResponseModel(model)
- if model == "" {
- return
- }
- current := o.Model()
- if current != "" && !strings.EqualFold(current, model) {
- o.conflict = true
- }
- if terminal {
- o.terminal = model
- return
- }
- if o.first == "" {
- o.first = model
- }
- }
- func normalizeObservedUpstreamResponseModel(model string) string {
- model = strings.TrimSpace(model)
- if model == "" {
- return ""
- }
- runes := []rune(model)
- if len(runes) > upstreamResponseModelMaxLength {
- model = string(runes[:upstreamResponseModelMaxLength])
- }
- return model
- }
- func (o *upstreamResponseModelObserver) ObserveOpenAI(payload []byte, eventType string) {
- model := firstValidTrimmedGJSONString(payload, "response.model", "model")
- terminal := isUpstreamResponseModelTerminalEvent(eventType)
- o.Observe(model, terminal)
- // Every payload that declares a service tier also declares a model, so
- // model-free delta frames skip the extra lookups entirely.
- if model == "" {
- return
- }
- // Non-terminal Responses API events echo the requested tier rather than the
- // tier actually used. Only terminal events and untyped payloads (chat
- // completions chunks, non-streaming bodies) report the processing tier.
- if !terminal && strings.TrimSpace(eventType) != "" {
- return
- }
- tier := normalizeObservedOpenAIServiceTier(firstValidTrimmedGJSONString(payload, "response.service_tier", "service_tier"))
- o.ObserveServiceTier(tier, terminal)
- }
- func (o *upstreamResponseModelObserver) ObserveAnthropic(payload []byte) {
- model := firstValidTrimmedGJSONString(payload, "message.model", "model")
- o.Observe(model, false)
- // usage.speed travels with the message object (message_start in streams,
- // the top-level body otherwise), i.e. only in payloads that declare a model.
- if model == "" {
- return
- }
- tier := normalizeObservedAnthropicSpeed(firstValidTrimmedGJSONString(payload, "message.usage.speed", "usage.speed"))
- o.ObserveServiceTier(tier, false)
- }
- // ObserveServiceTier records a tier declared by the upstream response. A
- // terminal declaration always wins; non-terminal declarations are only trusted
- // when they agree with each other.
- func (o *upstreamResponseModelObserver) ObserveServiceTier(tier string, terminal bool) {
- if o == nil || tier == "" {
- return
- }
- if terminal {
- o.terminalTier = tier
- return
- }
- if o.firstTier == "" {
- o.firstTier = tier
- return
- }
- if o.firstTier != tier {
- o.firstTierConflict = true
- }
- }
- // ServiceTier returns the tier the upstream reports having used, or "" when the
- // response never declared one unambiguously.
- func (o *upstreamResponseModelObserver) ServiceTier() string {
- if o == nil {
- return ""
- }
- if o.terminalTier != "" {
- return o.terminalTier
- }
- if o.firstTierConflict {
- return ""
- }
- return o.firstTier
- }
- // normalizeObservedOpenAIServiceTier maps a tier reported by an OpenAI response
- // onto the billing vocabulary. "auto" never describes a processing tier and
- // unknown values are ignored rather than guessed at.
- func normalizeObservedOpenAIServiceTier(raw string) string {
- switch value := strings.ToLower(strings.TrimSpace(raw)); value {
- case "priority", "fast":
- return OpenAIFastTierPriority
- case "default", "flex", "scale":
- return value
- default:
- return ""
- }
- }
- // normalizeObservedAnthropicSpeed maps Anthropic usage.speed onto the billing
- // vocabulary: "fast" is the billable fast-mode tier, "standard" the base rate.
- func normalizeObservedAnthropicSpeed(raw string) string {
- switch value := strings.ToLower(strings.TrimSpace(raw)); value {
- case "fast", "standard":
- return value
- default:
- return ""
- }
- }
- func (o *upstreamResponseModelObserver) ObserveGemini(payload []byte) {
- model := firstValidTrimmedGJSONString(
- payload,
- "modelVersion",
- "response.modelVersion",
- "response.response.modelVersion",
- )
- // Gemini streaming has no universal terminal event carrying modelVersion;
- // treating each declaration as terminal retains the latest chunk.
- o.Observe(model, true)
- }
- func (o *upstreamResponseModelObserver) Model() string {
- if o == nil {
- return ""
- }
- if o.terminal != "" {
- return o.terminal
- }
- return o.first
- }
- func (o *upstreamResponseModelObserver) Conflict() bool {
- return o != nil && o.conflict
- }
- func beginUpstreamResponseModelObservation(c *gin.Context) *upstreamResponseModelObserver {
- observer := &upstreamResponseModelObserver{}
- if c != nil {
- c.Set(upstreamResponseModelObserverContextKey, observer)
- }
- return observer
- }
- func upstreamResponseModelObserverFromContext(c *gin.Context) *upstreamResponseModelObserver {
- if c == nil {
- return nil
- }
- value, ok := c.Get(upstreamResponseModelObserverContextKey)
- if !ok {
- return nil
- }
- observer, _ := value.(*upstreamResponseModelObserver)
- return observer
- }
- func observedUpstreamResponseModel(c *gin.Context) string {
- return upstreamResponseModelObserverFromContext(c).Model()
- }
- func observedUpstreamResponseModelConflict(c *gin.Context) bool {
- return upstreamResponseModelObserverFromContext(c).Conflict()
- }
- func observedUpstreamResponseServiceTier(c *gin.Context) string {
- return upstreamResponseModelObserverFromContext(c).ServiceTier()
- }
- // resolvedOpenAIUpstreamServiceTierFromObserver preserves the final outbound
- // request tier. The observed response tier remains separate on
- // OpenAIForwardResult.UpstreamResponseServiceTier and is reconciled once, at
- // usage time, where the account protocol is available. In particular, the
- // private ChatGPT Codex backend commonly reports default even for effective
- // Fast turns, while public API response tiers remain authoritative.
- func resolvedOpenAIUpstreamServiceTierFromObserver(_ *upstreamResponseModelObserver, outboundBodyTier *string) *string {
- return outboundBodyTier
- }
- func resolvedOpenAIUpstreamServiceTier(c *gin.Context, outboundBodyTier *string) *string {
- return resolvedOpenAIUpstreamServiceTierFromObserver(upstreamResponseModelObserverFromContext(c), outboundBodyTier)
- }
- func observeOpenAISSEBody(observer *upstreamResponseModelObserver, body string) {
- if observer == nil || strings.TrimSpace(body) == "" {
- return
- }
- forEachOpenAISSEFrame(body, func(eventType string, payload []byte) {
- observer.ObserveOpenAI(payload, eventType)
- })
- }
- func firstValidTrimmedGJSONString(payload []byte, paths ...string) string {
- if len(payload) == 0 {
- return ""
- }
- for _, path := range paths {
- value := gjson.GetBytes(payload, path)
- if !value.Exists() || value.Type != gjson.String {
- continue
- }
- if text := strings.TrimSpace(value.String()); text != "" {
- // Validate only after finding a candidate. This avoids a full validation
- // pass on the common model-free delta path while still rejecting malformed
- // payloads that appear to declare a value.
- if !gjson.ValidBytes(payload) {
- return ""
- }
- return text
- }
- }
- return ""
- }
- func isUpstreamResponseModelTerminalEvent(eventType string) bool {
- switch strings.TrimSpace(eventType) {
- case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
- return true
- default:
- return false
- }
- }
- func upstreamModelMismatch(sentModel, responseModel string) *bool {
- responseModel = strings.TrimSpace(responseModel)
- if responseModel == "" {
- return nil
- }
- sentModel = strings.TrimSpace(sentModel)
- mismatch := sentModel == "" || !upstreamModelsMatchForAudit(sentModel, responseModel)
- return &mismatch
- }
- func upstreamModelsMatchForAudit(sentModel, responseModel string) bool {
- if strings.EqualFold(sentModel, responseModel) {
- return true
- }
- // xAI reports the runtime build ID for these supported public aliases.
- // Canonicalize only for mismatch auditing; keep the raw response model for
- // observability and for the separate response-model billing safeguards.
- sentGrokModel := canonicalGrokBuildRuntimeModel(sentModel)
- return sentGrokModel != "" && sentGrokModel == canonicalGrokBuildRuntimeModel(responseModel)
- }
- func canonicalGrokBuildRuntimeModel(model string) string {
- switch strings.ToLower(strings.TrimSpace(model)) {
- case "grok-4.5", "grok-4.5-latest", "grok-4.5-build":
- return "grok-4.5-build"
- case "grok-4.6", "grok-4.6-latest", "grok-4.6-build":
- return "grok-4.6-build"
- default:
- return ""
- }
- }
- func upstreamSentModel(requestedModel, upstreamModel string) string {
- sentModel := strings.TrimSpace(upstreamModel)
- if sentModel == "" {
- sentModel = strings.TrimSpace(requestedModel)
- }
- return sentModel
- }
|