135 lines
5.5 KiB
Go
135 lines
5.5 KiB
Go
package openai
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
edgeservice "iop/apps/edge/internal/service"
|
|
)
|
|
|
|
// openAIRequestContext carries the routing, identity, and metrics values that
|
|
// every OpenAI-compatible surface resolves once at ingress, before any
|
|
// path-specific decode or dispatch. Chat and Responses share it so both
|
|
// surfaces classify and meter a request the same way.
|
|
//
|
|
// It is built at a single ingress point per surface and read-only afterwards.
|
|
type openAIRequestContext struct {
|
|
r *http.Request
|
|
route routeDispatch
|
|
// rawBody is the caller's original body, preserved verbatim for the tunnel
|
|
// passthrough path. The rewritten provider body is produced separately by
|
|
// the tunnel BuildBody hook and never replaces rawBody.
|
|
rawBody []byte
|
|
// callerMetadata is the parsed request metadata with the authenticated
|
|
// principal applied over any caller-supplied identity. Context constructors
|
|
// copy it before adding path-specific fields, so this ingress snapshot stays
|
|
// immutable while the request moves through tunnel and normalized branches.
|
|
callerMetadata map[string]string
|
|
workspace string
|
|
estimate int
|
|
contextClass string
|
|
endpoint string
|
|
}
|
|
|
|
// cloneMetadata derives a request-local metadata map. OpenAI ingress metadata
|
|
// is shared by multiple possible dispatch paths, while service requests need
|
|
// to add execution fields such as openai_stream and context_class. Copying at
|
|
// that boundary prevents one derived path from changing another's view.
|
|
func cloneMetadata(metadata map[string]string) map[string]string {
|
|
cloned := make(map[string]string, len(metadata))
|
|
for key, value := range metadata {
|
|
cloned[key] = value
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
// resolveCallerIdentity parses the caller metadata, applies the authenticated
|
|
// principal over it, and validates the workspace against the resolved route.
|
|
// Every OpenAI-compatible surface resolves identity through this one path so a
|
|
// caller-supplied iop_principal_* value can never win on any of them.
|
|
func resolveCallerIdentity(r *http.Request, route routeDispatch, rawMetadata json.RawMessage) (map[string]string, string, error) {
|
|
runMeta, workspace, err := parseOpenAIMetadata(rawMetadata)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
// Overwrite (not merge-if-absent): the authenticated caller identity must
|
|
// win over any caller-supplied metadata.iop_principal_* spoof attempt.
|
|
for k, v := range principalMetadata(r.Context()) {
|
|
runMeta[k] = v
|
|
}
|
|
if err := validateWorkspaceForRoute(route, workspace); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return runMeta, workspace, nil
|
|
}
|
|
|
|
// chatDispatchContext is the immutable per-request context of a dispatched chat
|
|
// completion. Everything downstream of ingress — provider-pool dispatch, live
|
|
// SSE, buffered SSE, and the non-stream completion — reads this context instead
|
|
// of taking the same values as a long parameter list.
|
|
//
|
|
// req is the effective request: the model-catalog generation policy is applied
|
|
// once at ingress, before the context is built, so no consumer sees a
|
|
// pre-policy request or re-applies the policy.
|
|
type chatDispatchContext struct {
|
|
openAIRequestContext
|
|
|
|
req chatCompletionRequest
|
|
basePrompt string
|
|
prompt string
|
|
input map[string]any
|
|
runMetadata map[string]string
|
|
outputPolicy strictOutputPolicy
|
|
validation toolValidationContract
|
|
|
|
// submitReq is the run request for the resolved path: provider-pool shaped
|
|
// (adapter/target resolved per candidate at admission) or direct.
|
|
submitReq edgeservice.SubmitRunRequest
|
|
// retrySubmit resubmits the run for a bounded tool-validation replay,
|
|
// preserving the path the request was dispatched on.
|
|
retrySubmit func(ctx context.Context, req edgeservice.SubmitRunRequest) (any, error)
|
|
}
|
|
|
|
// responsesRequestContext is the immutable ingress context for a
|
|
// /v1/responses request. It keeps the lenient routing envelope next to the
|
|
// common request context so raw provider tunnel paths never need to pass a
|
|
// parallel list of route, body, metadata, and estimate arguments.
|
|
type responsesRequestContext struct {
|
|
openAIRequestContext
|
|
envelope responsesEnvelope
|
|
}
|
|
|
|
// responsesDispatchContext is the normalized /v1/responses dispatch context.
|
|
// It is derived only after strict decode and policy application. The raw body
|
|
// remains on responsesRequestContext for the tunnel path; the normalized run
|
|
// request is represented separately by submitReq.
|
|
type responsesDispatchContext struct {
|
|
*responsesRequestContext
|
|
|
|
req responsesRequest
|
|
prompt string
|
|
input map[string]any
|
|
outputPolicy strictOutputPolicy
|
|
runMetadata map[string]string
|
|
submitReq edgeservice.SubmitRunRequest
|
|
}
|
|
|
|
func (dc *responsesDispatchContext) usageLabels(s *Server, responseMode string) usageLabels {
|
|
return s.usageLabelsFor(dc.r.Context(), strings.TrimSpace(dc.req.Model), dc.endpoint, responseMode)
|
|
}
|
|
|
|
// withRetrySubmit derives a context bound to a retry function. The
|
|
// provider-pool retry closure can only be built once the pool request exists,
|
|
// so the pool path derives a new context rather than mutating this one.
|
|
func (dc *chatDispatchContext) withRetrySubmit(fn func(ctx context.Context, req edgeservice.SubmitRunRequest) (any, error)) *chatDispatchContext {
|
|
derived := *dc
|
|
derived.retrySubmit = fn
|
|
return &derived
|
|
}
|
|
|
|
// usageLabels resolves the metric labels for this request's response mode.
|
|
func (dc *chatDispatchContext) usageLabels(s *Server, responseMode string) usageLabels {
|
|
return s.usageLabelsFor(dc.r.Context(), strings.TrimSpace(dc.req.Model), dc.endpoint, responseMode)
|
|
}
|