Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/extproc/mainlib/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ func Main(ctx context.Context, args []string, stderr io.Writer) (err error) {
embeddingsMetricsFactory := metrics.NewMetricsFactory(meter, metricsRequestHeaderAttributes, metrics.GenAIOperationEmbedding)
imageGenerationMetricsFactory := metrics.NewMetricsFactory(meter, metricsRequestHeaderAttributes, metrics.GenAIOperationImageGeneration)
responsesMetricsFactory := metrics.NewMetricsFactory(meter, metricsRequestHeaderAttributes, metrics.GenAIOperationResponses)
decisionsMetricsFactory := metrics.NewMetricsFactory(meter, metricsRequestHeaderAttributes, metrics.GenAIOperationDecisions)
speechMetricsFactory := metrics.NewMetricsFactory(meter, metricsRequestHeaderAttributes, metrics.GenAIOperationSpeech)
transcriptionMetricsFactory := metrics.NewMetricsFactory(meter, metricsRequestHeaderAttributes, metrics.GenAIOperationTranscription)
translationMetricsFactory := metrics.NewMetricsFactory(meter, metricsRequestHeaderAttributes, metrics.GenAIOperationTranslation)
Expand All @@ -333,6 +334,8 @@ func Main(ctx context.Context, args []string, stderr io.Writer) (err error) {
embeddingsMetricsFactory, tracing.EmbeddingsTracer(), endpointspec.EmbeddingsEndpointSpec{}))
server.Register(path.Join(flags.rootPrefix, endpointPrefixes.OpenAI, "/v1/responses"), extproc.NewFactory(
responsesMetricsFactory, tracing.ResponsesTracer(), endpointspec.ResponsesEndpointSpec{}))
server.Register(path.Join(flags.rootPrefix, endpointPrefixes.OpenAI, "/v1/decisions"), extproc.NewFactory(
decisionsMetricsFactory, tracing.DecisionsTracer(), endpointspec.DecisionsEndpointSpec{}))
server.Register(path.Join(flags.rootPrefix, endpointPrefixes.OpenAI, "/v1/responses/input_tokens"), extproc.NewFactory(
responsesInputTokensMetricsFactory, tracing.ResponsesInputTokensTracer(), endpointspec.ResponsesInputTokensEndpointSpec{}))
server.Register(path.Join(flags.rootPrefix, endpointPrefixes.OpenAI, "/v1/audio/speech"), extproc.NewFactory(
Expand Down
79 changes: 79 additions & 0 deletions internal/apischema/openai/decisions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright Envoy AI Gateway Authors
// SPDX-License-Identifier: Apache-2.0
// The full text of the Apache license is available in the LICENSE file at
// the root of the repo.

package openai

import "github.com/envoyproxy/ai-gateway/internal/json"

// DecisionRequest represents the request body for POST /v1/decisions.
// Input is kept as raw JSON because the API accepts either a string or an array
// of user messages containing text and inline base64 images.
// Docs: https://developers.openai.com/api/reference/resources/decisions/methods/create
type DecisionRequest struct {
Model string `json:"model"`
Input json.RawMessage `json:"input"`
Questions []DecisionQuestion `json:"questions"`
// SafetyIdentifier is an opaque caller-provided end-user identifier.
SafetyIdentifier string `json:"safety_identifier,omitempty"`
}

// DecisionQuestion is a predicate, choice, or score question.
// Docs: https://developers.openai.com/api/docs/guides/decisions
// SDK: https://github.com/openai/openai-go/blob/v3.73.0/decision.go
type DecisionQuestion struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
Instructions string `json:"instructions"`
Choices []DecisionChoiceOption `json:"choices,omitempty"`
Levels []DecisionScoreLevel `json:"levels,omitempty"`
}

// DecisionChoiceOption is one allowed result for a choice question. Value is raw because choice values are typed as either strings or booleans.
// SDK: https://github.com/openai/openai-go/blob/v3.73.0/decision.go
type DecisionChoiceOption struct {
Value json.RawMessage `json:"value"`
Description string `json:"description,omitempty"`
}

// DecisionScoreLevel is one ordered level for a score question.
// Docs: https://developers.openai.com/api/docs/guides/decisions
type DecisionScoreLevel struct {
Label string `json:"label"`
Description string `json:"description,omitempty"`
}

// DecisionResponse represents a response from POST /v1/decisions.
// Docs: https://developers.openai.com/api/reference/resources/decisions/methods/create
type DecisionResponse struct {
Answers []DecisionAnswer `json:"answers"`
Model string `json:"model,omitempty"`
Usage *ResponseUsage `json:"usage,omitempty"`
}

// DecisionAnswer is a predicate, choice, score, or refusal answer. Pointer
// numeric fields preserve valid zero values while omitting fields belonging to
// other variants.
// Docs: https://developers.openai.com/api/reference/resources/decisions/methods/create
// Docs: https://developers.openai.com/api/docs/guides/decisions
type DecisionAnswer struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
Probability *float64 `json:"probability,omitempty"`
Choice json.RawMessage `json:"choice,omitempty"`
Probabilities []DecisionProbability `json:"probabilities,omitempty"`
Confidence *float64 `json:"confidence,omitempty"`
Score *float64 `json:"score,omitempty"`
}

// DecisionProbability is one entry in a choice or score probability
// distribution. Value is raw because choices use strings or booleans, while
// score levels use numeric indices.
// Docs: https://developers.openai.com/api/docs/guides/decisions
// SDK: https://github.com/openai/openai-go/blob/v3.73.0/decision.go
type DecisionProbability struct {
Value json.RawMessage `json:"value"`
Label string `json:"label,omitempty"`
Probability float64 `json:"probability"`
}
53 changes: 53 additions & 0 deletions internal/endpointspec/endpointspec.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ type (
ImageGenerationEndpointSpec struct{}
// ResponsesEndpointSpec implements EndpointSpec for /v1/responses.
ResponsesEndpointSpec struct{}
// DecisionsEndpointSpec implements EndpointSpec for /v1/decisions.
DecisionsEndpointSpec struct{}
// MessagesEndpointSpec implements EndpointSpec for /v1/messages.
MessagesEndpointSpec struct{}
// RerankEndpointSpec implements EndpointSpec for /v2/rerank.
Expand Down Expand Up @@ -416,6 +418,57 @@ func (ResponsesEndpointSpec) RedactSensitiveInfoFromRequest(req *openai.Response
return &redacted, nil
}

// ParseBody implements [EndpointSpec.ParseBody]. Decisions is a non-streaming endpoint.
func (DecisionsEndpointSpec) ParseBody(body []byte, _ bool) (internalapi.OriginalModel, *openai.DecisionRequest, bool, []byte, error) {
var req openai.DecisionRequest
if err := json.Unmarshal(body, &req); err != nil {
return "", nil, false, nil, fmt.Errorf("%w: failed to parse JSON for /v1/decisions: %w", internalapi.ErrMalformedRequest, err)
}
return req.Model, &req, false, nil, nil
}

func (DecisionsEndpointSpec) ParseMultipartBody([]byte, string, bool) (internalapi.OriginalModel, *openai.DecisionRequest, bool, []byte, error) {
return "", nil, false, nil, errMultipartNotSupported
}

func (DecisionsEndpointSpec) GetTranslator(schema filterapi.VersionedAPISchema, modelNameOverride string) (translator.OpenAIDecisionsTranslator, error) {
switch schema.Name {
case filterapi.APISchemaOpenAI:
return translator.NewDecisionsOpenAIToOpenAITranslator(schema.OpenAIPrefix(), modelNameOverride), nil
default:
return nil, fmt.Errorf("%w: unsupported API schema: backend=%s", internalapi.ErrInvalidRequestBody, schema)
}
}

// RedactSensitiveInfoFromRequest removes evidence and rubric content while
// retaining the model, question names, and discriminators needed for debugging.
func (DecisionsEndpointSpec) RedactSensitiveInfoFromRequest(req *openai.DecisionRequest) (*openai.DecisionRequest, error) {
redacted := *req
redacted.Input = redactRawJSON(req.Input)
redacted.SafetyIdentifier = redaction.RedactString(req.SafetyIdentifier)
redacted.Questions = make([]openai.DecisionQuestion, len(req.Questions))
for i := range req.Questions {
q := req.Questions[i]
q.Instructions = redaction.RedactString(q.Instructions)
q.Choices = make([]openai.DecisionChoiceOption, len(req.Questions[i].Choices))
for j := range req.Questions[i].Choices {
q.Choices[j] = openai.DecisionChoiceOption{
Value: redactRawJSON(req.Questions[i].Choices[j].Value),
Description: redaction.RedactString(req.Questions[i].Choices[j].Description),
}
}
q.Levels = make([]openai.DecisionScoreLevel, len(req.Questions[i].Levels))
for j := range req.Questions[i].Levels {
q.Levels[j] = openai.DecisionScoreLevel{
Label: redaction.RedactString(req.Questions[i].Levels[j].Label),
Description: redaction.RedactString(req.Questions[i].Levels[j].Description),
}
}
redacted.Questions[i] = q
}
return &redacted, nil
}

// ParseBody implements [EndpointSpec.ParseBody].
func (MessagesEndpointSpec) ParseBody(
body []byte,
Expand Down
69 changes: 69 additions & 0 deletions internal/endpointspec/endpointspec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,75 @@ func TestResponsesEndpointSpec_GetTranslator(t *testing.T) {
require.ErrorContains(t, err, "unsupported API schema")
}

func TestDecisionsEndpointSpec(t *testing.T) {
spec := DecisionsEndpointSpec{}

t.Run("parse string input", func(t *testing.T) {
body := []byte(`{"model":"gpt-6-luna","input":"charged twice","questions":[{"type":"choice","name":"department","instructions":"route it","choices":[{"value":"billing","description":"payment issues"}]}]}`)
model, req, stream, mutated, err := spec.ParseBody(body, false)
require.NoError(t, err)
require.Equal(t, "gpt-6-luna", model)
require.False(t, stream)
require.Nil(t, mutated)
require.JSONEq(t, `"charged twice"`, string(req.Input))
require.JSONEq(t, `"billing"`, string(req.Questions[0].Choices[0].Value))
})

t.Run("parse boolean choice values", func(t *testing.T) {
body := []byte(`{"model":"gpt-6-luna","input":"confirm","questions":[{"type":"choice","instructions":"answer it","choices":[{"value":true},{"value":false}]}]}`)
_, req, _, _, err := spec.ParseBody(body, false)
require.NoError(t, err)
require.JSONEq(t, `true`, string(req.Questions[0].Choices[0].Value))
require.JSONEq(t, `false`, string(req.Questions[0].Choices[1].Value))
})

t.Run("parse image input", func(t *testing.T) {
body := []byte(`{"model":"gpt-6-luna","input":[{"role":"user","content":[{"type":"input_text","text":"inspect"},{"type":"input_image","image_url":"data:image/png;base64,AAAA"}]}],"questions":[{"type":"predicate","name":"damage","instructions":"visible damage?"}]}`)
_, req, _, _, err := spec.ParseBody(body, false)
require.NoError(t, err)
require.JSONEq(t, `[{"role":"user","content":[{"type":"input_text","text":"inspect"},{"type":"input_image","image_url":"data:image/png;base64,AAAA"}]}]`, string(req.Input))
})

t.Run("invalid json", func(t *testing.T) {
_, _, _, _, err := spec.ParseBody([]byte("{"), false)
require.ErrorIs(t, err, internalapi.ErrMalformedRequest)
require.ErrorContains(t, err, "/v1/decisions")
})

t.Run("multipart unsupported", func(t *testing.T) {
_, _, _, _, err := spec.ParseMultipartBody(nil, "multipart/form-data", false)
require.ErrorIs(t, err, errMultipartNotSupported)
})

t.Run("translator support", func(t *testing.T) {
_, err := spec.GetTranslator(filterapi.VersionedAPISchema{Name: filterapi.APISchemaOpenAI}, "override")
require.NoError(t, err)

_, err = spec.GetTranslator(filterapi.VersionedAPISchema{Name: filterapi.APISchemaTypeSafe}, "override")
require.ErrorIs(t, err, internalapi.ErrInvalidRequestBody)
})
}

func TestDecisionsEndpointSpec_RedactSensitiveInfoFromRequest(t *testing.T) {
body := []byte(`{"model":"gpt-6-luna","input":"customer complaint","safety_identifier":"user-hash-123","questions":[{"type":"choice","name":"department","instructions":"route this complaint","choices":[{"value":"billing","description":"payment issues"}]},{"type":"score","name":"severity","instructions":"score it","levels":[{"label":"critical","description":"no workaround"}]}]}`)
_, req, _, _, err := DecisionsEndpointSpec{}.ParseBody(body, false)
require.NoError(t, err)
redacted, err := DecisionsEndpointSpec{}.RedactSensitiveInfoFromRequest(req)
require.NoError(t, err)

encoded, err := json.Marshal(redacted)
require.NoError(t, err)
text := string(encoded)
require.Contains(t, text, "[REDACTED LENGTH=")
require.NotContains(t, text, "customer complaint")
require.NotContains(t, text, "payment issues")
require.NotContains(t, text, "critical")
require.NotContains(t, text, "user-hash-123")
require.Contains(t, text, `"safety_identifier":"[REDACTED LENGTH=`)
require.Contains(t, text, `"model":"gpt-6-luna"`)
require.Contains(t, text, `"name":"department"`)
}

func TestTokenizeEndpointSpec_ParseBody(t *testing.T) {
spec := TokenizeEndpointSpec{}

Expand Down
1 change: 1 addition & 0 deletions internal/metrics/genai.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const (
GenAIOperationMessages GenAIOperation = "messages"
GenAIOperationImageGeneration GenAIOperation = "image_generation"
GenAIOperationResponses GenAIOperation = "responses"
GenAIOperationDecisions GenAIOperation = "decisions"
GenAIOperationSpeech GenAIOperation = "speech"
GenAIOperationTranscription GenAIOperation = "transcription"
GenAIOperationTranslation GenAIOperation = "translation"
Expand Down
97 changes: 97 additions & 0 deletions internal/tracing/openinference/openai/decisions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright Envoy AI Gateway Authors
// SPDX-License-Identifier: Apache-2.0
// The full text of the Apache license is available in the LICENSE file at
// the root of the repo.

package openai

import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"

openaischema "github.com/envoyproxy/ai-gateway/internal/apischema/openai"
"github.com/envoyproxy/ai-gateway/internal/json"
"github.com/envoyproxy/ai-gateway/internal/tracing/openinference"
"github.com/envoyproxy/ai-gateway/internal/tracing/tracingapi"
)

// DecisionsRecorder records OpenInference attributes for Decisions requests.
type DecisionsRecorder struct {
tracingapi.NoopChunkRecorder[struct{}]
traceConfig *openinference.TraceConfig
}

func NewDecisionsRecorder(config *openinference.TraceConfig) tracingapi.DecisionsRecorder {
if config == nil {
config = openinference.NewTraceConfigFromEnv()
}
return &DecisionsRecorder{traceConfig: config}
}

func (*DecisionsRecorder) StartParams(*openaischema.DecisionRequest, []byte) (string, []trace.SpanStartOption) {
return "Decisions", []trace.SpanStartOption{trace.WithSpanKind(trace.SpanKindInternal)}
}

func (r *DecisionsRecorder) RecordRequest(span trace.Span, req *openaischema.DecisionRequest, body []byte) {
attrs := []attribute.KeyValue{
attribute.String(openinference.LLMSystem, openinference.LLMSystemOpenAI),
attribute.String(openinference.SpanKind, openinference.SpanKindLLM),
}
if req.Model != "" {
attrs = append(attrs, attribute.String(openinference.LLMModelName, req.Model))
}
if r.traceConfig.HideInputs {
attrs = append(attrs, attribute.String(openinference.InputValue, openinference.RedactedValue))
} else {
modifiedBody, err := redactImageFromResponseRequestParameters(
body, r.traceConfig.HideInputImages, r.traceConfig.Base64ImageMaxLength,
)
if err == nil {
attrs = append(attrs, attribute.String(openinference.InputValue, string(modifiedBody)))
}
attrs = append(attrs, attribute.String(openinference.InputMimeType, openinference.MimeTypeJSON))
}
span.SetAttributes(attrs...)
}

func (*DecisionsRecorder) RecordResponseOnError(span trace.Span, statusCode int, body []byte) {
openinference.RecordResponseError(span, statusCode, string(body))
}

func (r *DecisionsRecorder) RecordResponse(span trace.Span, resp *openaischema.DecisionResponse) {
body := openinference.RedactedValue
attrs := []attribute.KeyValue{}
if resp.Model != "" {
attrs = append(attrs, attribute.String(openinference.LLMModelName, resp.Model))
}
if u := resp.Usage; u != nil {
if u.InputTokens > 0 {
attrs = append(attrs, attribute.Int(openinference.LLMTokenCountPrompt, int(u.InputTokens)))
}
if u.OutputTokens > 0 {
attrs = append(attrs, attribute.Int(openinference.LLMTokenCountCompletion, int(u.OutputTokens)))
}
if u.TotalTokens > 0 {
attrs = append(attrs, attribute.Int(openinference.LLMTokenCountTotal, int(u.TotalTokens)))
}
if u.InputTokensDetails.CachedTokens > 0 {
attrs = append(attrs, attribute.Int(openinference.LLMTokenCountPromptCacheHit, int(u.InputTokensDetails.CachedTokens)))
}
if cacheWriteTokens := u.InputTokensDetails.CacheWriteTokensValue(); cacheWriteTokens > 0 {
attrs = append(attrs, attribute.Int(openinference.LLMTokenCountPromptCacheWrite, int(cacheWriteTokens)))
}
if u.OutputTokensDetails.ReasoningTokens > 0 {
attrs = append(attrs, attribute.Int(openinference.LLMTokenCountCompletionReasoning, int(u.OutputTokensDetails.ReasoningTokens)))
}
}
if !r.traceConfig.HideOutputs {
if marshaled, err := json.Marshal(resp); err == nil {
body = string(marshaled)
}
attrs = append(attrs, attribute.String(openinference.OutputMimeType, openinference.MimeTypeJSON))
}
attrs = append(attrs, attribute.String(openinference.OutputValue, body))
span.SetAttributes(attrs...)
span.SetStatus(codes.Ok, "")
}
Loading
Loading