feat(route): 支持有损协议转换与参数追踪

This commit is contained in:
tbphp committed 2026-08-11 17:42:10 +08:00
1 parent 3e948eedbe
commit e7de10ec73
77 files changed
+4305 -1386

No files matched your search

+36 -42
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/url"
"sort"
"strings"
"gpt-load/internal/execution"
@@ -149,24 +150,7 @@ type ResolvedTarget struct {
TargetConfig json.RawMessage `json:"-"`
CatalogProviderID string `json:"-"`
capabilities map[protocol.Protocol]execution.CapabilitySet
modes map[protocol.Protocol]map[execution.Operation]RouteMode
}
// Supports reports whether one client protocol can execute the operation with
// every required feature through this target.
func (t ResolvedTarget) Supports(
clientProtocol protocol.Protocol,
operation execution.Operation,
required execution.FeatureSet,
) bool {
capabilities, ok := t.capabilities[clientProtocol]
return ok && capabilities.Supports(operation, required)
}
// Capabilities returns an independent capability set for one client protocol.
func (t ResolvedTarget) Capabilities(clientProtocol protocol.Protocol) execution.CapabilitySet {
return t.capabilities[clientProtocol].Clone()
modes map[protocol.Protocol]map[execution.Operation]RouteMode
}
// Mode returns how an operation reaches this channel.
@@ -182,6 +166,34 @@ func (t ResolvedTarget) Mode(
return mode, ok
}
// Operations returns the stable operation set declared for a client protocol.
func (t ResolvedTarget) Operations(clientProtocol protocol.Protocol) []execution.Operation {
byOperation := t.modes[clientProtocol]
operations := make([]execution.Operation, 0, len(byOperation))
for operation := range byOperation {
operations = append(operations, operation)
}
sort.Slice(operations, func(i, j int) bool { return operations[i] < operations[j] })
return operations
}
// SupportsResponsesLifecycle reports whether stored Responses resources can
// be retrieved and managed through this target.
func (t ResolvedTarget) SupportsResponsesLifecycle() bool {
for _, operation := range []execution.Operation{
execution.OperationResponsesRetrieve,
execution.OperationResponsesDelete,
execution.OperationResponsesCancel,
execution.OperationResponsesInputItems,
} {
mode, ok := t.Mode(protocol.OpenAIResponses, operation)
if !ok || mode != RouteNative {
return false
}
}
return true
}
// Registry is an immutable code-owned channel directory.
type Registry struct {
byID map[ID]definition
@@ -292,7 +304,6 @@ func (r *Registry) Resolve(id ID, raw json.RawMessage) (ResolvedTarget, error) {
ProviderKind: definition.providerKind,
TargetConfig: append(json.RawMessage(nil), targetConfig...),
CatalogProviderID: definition.catalogProviderID,
capabilities: cloneCapabilityMatrix(definition.capabilities),
modes: cloneRouteModes(definition.modes),
}, nil
}
@@ -405,7 +416,6 @@ type definition struct {
providerKind ProviderKind
compatible bool
fixedTargetConfig json.RawMessage
capabilities map[protocol.Protocol]execution.CapabilitySet
modes map[protocol.Protocol]map[execution.Operation]RouteMode
}
@@ -432,7 +442,6 @@ func newRegistry(definitions []definition) (*Registry, error) {
}
definition.descriptor = cloneDescriptor(definition.descriptor)
definition.searchTerms = append([]string(nil), definition.searchTerms...)
definition.capabilities = cloneCapabilityMatrix(definition.capabilities)
definition.modes = cloneRouteModes(definition.modes)
definition.fixedTargetConfig = append(json.RawMessage(nil), definition.fixedTargetConfig...)
registry.byID[id] = definition
@@ -468,28 +477,21 @@ func validateDefinition(definition definition) error {
seen[key] = struct{}{}
}
}
if len(definition.capabilities) == 0 {
if len(definition.modes) == 0 {
return fmt.Errorf("channel %q has no client protocols", id)
}
for clientProtocol, capabilities := range definition.capabilities {
for clientProtocol, modes := range definition.modes {
if !clientProtocol.Valid() {
return fmt.Errorf("channel %q has invalid client protocol %q", id, clientProtocol)
}
if err := capabilities.Validate(); err != nil {
return fmt.Errorf("channel %q capabilities: %w", id, err)
if len(modes) == 0 {
return fmt.Errorf("channel %q has no operations for %q", id, clientProtocol)
}
modes := definition.modes[clientProtocol]
for _, operation := range capabilities.Operations() {
mode, exists := modes[operation]
if !exists || !mode.Valid() {
for operation, mode := range modes {
if !operation.Valid() || !mode.Valid() {
return fmt.Errorf("channel %q has no valid route mode for %q/%q", id, clientProtocol, operation)
}
}
for operation := range modes {
if !capabilities.Has(operation) {
return fmt.Errorf("channel %q has route mode for unsupported %q/%q", id, clientProtocol, operation)
}
}
}
return nil
}
@@ -509,14 +511,6 @@ func cloneDescriptor(source Descriptor) Descriptor {
return source
}
func cloneCapabilityMatrix(source map[protocol.Protocol]execution.CapabilitySet) map[protocol.Protocol]execution.CapabilitySet {
clone := make(map[protocol.Protocol]execution.CapabilitySet, len(source))
for clientProtocol, capabilities := range source {
clone[clientProtocol] = capabilities.Clone()
}
return clone
}
func cloneRouteModes(source map[protocol.Protocol]map[execution.Operation]RouteMode) map[protocol.Protocol]map[execution.Operation]RouteMode {
clone := make(map[protocol.Protocol]map[execution.Operation]RouteMode, len(source))
for clientProtocol, modes := range source {
@@ -70,7 +70,7 @@ func TestNativeProviderChannelsResolveWithoutCompatibleFallback(t *testing.T) {
if mode, ok := target.Mode(protocol.OpenAIResponses, execution.OperationResponsesCreate); !ok || mode != test.responsesMode {
t.Fatalf("Responses mode = %q, %t, want %q", mode, ok, test.responsesMode)
}
if target.Supports(protocol.OpenAIResponses, execution.OperationResponsesRetrieve, execution.FeatureSet{}) {
if _, ok := target.Mode(protocol.OpenAIResponses, execution.OperationResponsesRetrieve); ok {
t.Fatal("target unexpectedly supports Responses lifecycle")
}
})
+7 -71
View File
@@ -323,7 +323,7 @@ func newDefinition(
nativeProtocols map[protocol.Protocol]bool,
responsesLifecycle bool,
) definition {
capabilities, modes := buildRouteMatrix(nativeProtocols, responsesLifecycle)
modes := buildRouteMatrix(nativeProtocols, responsesLifecycle)
return definition{
descriptor: Descriptor{
ID: id,
@@ -332,7 +332,7 @@ func newDefinition(
Description: description,
ParamFields: params.descriptors(),
CredentialFields: credentials.descriptors(),
ClientProtocols: orderedProtocols(capabilities),
ClientProtocols: orderedProtocols(modes),
},
searchTerms: append([]string(nil), searchTerms...),
params: append(objectSchema(nil), params...),
@@ -340,7 +340,6 @@ func newDefinition(
catalogProviderID: catalogProviderID,
providerKind: providerKind,
compatible: compatible,
capabilities: capabilities,
modes: modes,
}
}
@@ -348,113 +347,50 @@ func newDefinition(
func buildRouteMatrix(
nativeProtocols map[protocol.Protocol]bool,
responsesLifecycle bool,
) (
map[protocol.Protocol]execution.CapabilitySet,
map[protocol.Protocol]map[execution.Operation]RouteMode,
) {
capabilities := make(map[protocol.Protocol]execution.CapabilitySet)
) map[protocol.Protocol]map[execution.Operation]RouteMode {
modes := make(map[protocol.Protocol]map[execution.Operation]RouteMode)
for _, clientProtocol := range protocol.DataPlaneProtocols() {
native := nativeProtocols[clientProtocol]
mode := RouteConverted
features := mustFeatureSet(execution.FeatureStreaming)
if native {
mode = RouteNative
features = mustFeatureSet(
execution.FeatureStreaming,
execution.FeatureTools,
execution.FeatureReasoning,
execution.FeatureMultimodal,
execution.FeatureStructuredOutput,
)
}
operation := execution.OperationChatCompletion
if clientProtocol == protocol.OpenAIResponses {
operation = execution.OperationResponsesCreate
if native && responsesLifecycle {
features = mustFeatureSet(
execution.FeatureStreaming,
execution.FeatureTools,
execution.FeatureReasoning,
execution.FeatureMultimodal,
execution.FeatureStructuredOutput,
execution.FeatureNativeResourceSemantics,
)
}
}
items := []execution.Capability{
{Operation: operation, Features: features},
{Operation: execution.OperationListModels},
{Operation: execution.OperationProbe},
}
operationModes := map[execution.Operation]RouteMode{
operation: mode,
execution.OperationListModels: mode,
execution.OperationProbe: mode,
}
if clientProtocol == protocol.OpenAIResponses && native && responsesLifecycle {
nativeFeatures := mustFeatureSet(
execution.FeatureTools,
execution.FeatureReasoning,
execution.FeatureMultimodal,
execution.FeatureStructuredOutput,
)
for _, nativeOperation := range []execution.Operation{
execution.OperationResponsesCompact,
execution.OperationResponsesInputTokens,
} {
items = append(items, execution.Capability{
Operation: nativeOperation,
Features: nativeFeatures,
})
operationModes[nativeOperation] = RouteNative
}
resourceFeatures := mustFeatureSet(execution.FeatureNativeResourceSemantics)
for _, resourceOperation := range []execution.Operation{
execution.OperationResponsesRetrieve,
execution.OperationResponsesDelete,
execution.OperationResponsesCancel,
execution.OperationResponsesInputItems,
} {
items = append(items, execution.Capability{Operation: resourceOperation, Features: resourceFeatures})
operationModes[resourceOperation] = RouteNative
}
items = append(items, execution.Capability{
Operation: execution.OperationResponsesPassthrough,
Features: mustFeatureSet(
execution.FeatureStreaming,
execution.FeatureNativeResourceSemantics,
),
})
operationModes[execution.OperationResponsesPassthrough] = RouteNative
}
capabilities[clientProtocol] = mustCapabilitySet(items...)
modes[clientProtocol] = operationModes
}
return capabilities, modes
return modes
}
func mustFeatureSet(features ...execution.Feature) execution.FeatureSet {
set, err := execution.NewFeatureSet(features...)
if err != nil {
panic(fmt.Sprintf("build channel feature set: %v", err))
}
return set
}
func mustCapabilitySet(capabilities ...execution.Capability) execution.CapabilitySet {
set, err := execution.NewCapabilitySet(capabilities...)
if err != nil {
panic(fmt.Sprintf("build channel capability set: %v", err))
}
return set
}
func orderedProtocols(capabilities map[protocol.Protocol]execution.CapabilitySet) []protocol.Protocol {
ordered := make([]protocol.Protocol, 0, len(capabilities))
func orderedProtocols(modes map[protocol.Protocol]map[execution.Operation]RouteMode) []protocol.Protocol {
ordered := make([]protocol.Protocol, 0, len(modes))
for _, clientProtocol := range protocol.DataPlaneProtocols() {
if _, ok := capabilities[clientProtocol]; ok {
if _, ok := modes[clientProtocol]; ok {
ordered = append(ordered, clientProtocol)
}
}
+22 -38
View File
@@ -235,7 +235,7 @@ func TestCloudTargetsCarryOnlyNeutralPresetMetadata(t *testing.T) {
if mode, ok := target.Mode(protocol.OpenAICompletions, execution.OperationChatCompletion); !ok || mode != RouteConverted {
t.Fatalf("OpenAI chat mode = %q, %t", mode, ok)
}
if target.Supports(protocol.OpenAIResponses, execution.OperationResponsesRetrieve, execution.FeatureSet{}) {
if _, ok := target.Mode(protocol.OpenAIResponses, execution.OperationResponsesRetrieve); ok {
t.Fatal("cloud converted target unexpectedly exposes native Responses lifecycle")
}
})
@@ -329,7 +329,7 @@ func TestRegistryStrictlyValidatesCredentialsWithoutLeakingSecrets(t *testing.T)
}
}
func TestResolvedTargetsUseExactCatalogMappingAndProtocolSpecificCapabilities(t *testing.T) {
func TestResolvedTargetsUseExactCatalogMappingAndProtocolSpecificRouteModes(t *testing.T) {
registry := NewRegistry()
openAI, err := registry.Resolve(OpenAI, nil)
if err != nil {
@@ -339,24 +339,18 @@ func TestResolvedTargetsUseExactCatalogMappingAndProtocolSpecificCapabilities(t
openAI.CatalogProviderID != "openai" || string(openAI.TargetConfig) != `{}` {
t.Fatalf("openai target = %#v", openAI)
}
if !openAI.Supports(protocol.OpenAIResponses, execution.OperationResponsesRetrieve, featureSet(t, execution.FeatureNativeResourceSemantics)) {
t.Fatal("openai target should support native Responses retrieval")
if mode, ok := openAI.Mode(protocol.OpenAIResponses, execution.OperationResponsesRetrieve); !ok || mode != RouteNative {
t.Fatalf("openai Responses retrieval mode = %q, %t", mode, ok)
}
for _, operation := range []execution.Operation{
execution.OperationResponsesCompact,
execution.OperationResponsesInputTokens,
execution.OperationResponsesPassthrough,
} {
if !openAI.Supports(protocol.OpenAIResponses, operation, execution.FeatureSet{}) {
t.Fatalf("openai target should support native %q", operation)
}
if mode, ok := openAI.Mode(protocol.OpenAIResponses, operation); !ok || mode != RouteNative {
t.Fatalf("openai %q mode = %q, %t", operation, mode, ok)
}
}
if !openAI.Supports(protocol.OpenAICompletions, execution.OperationChatCompletion, featureSet(t, execution.FeatureReasoning)) {
t.Fatal("openai native completions should support reasoning")
}
if mode, ok := openAI.Mode(protocol.OpenAICompletions, execution.OperationChatCompletion); !ok || mode != RouteNative {
t.Fatalf("openai completions mode = %q, %t", mode, ok)
}
@@ -368,20 +362,14 @@ func TestResolvedTargetsUseExactCatalogMappingAndProtocolSpecificCapabilities(t
if anthropic.ProviderKind != ProviderAnthropic || anthropic.CatalogProviderID != "anthropic" {
t.Fatalf("anthropic catalog provider = %q", anthropic.CatalogProviderID)
}
if anthropic.Supports(protocol.OpenAIResponses, execution.OperationResponsesRetrieve, execution.FeatureSet{}) {
if _, ok := anthropic.Mode(protocol.OpenAIResponses, execution.OperationResponsesRetrieve); ok {
t.Fatal("anthropic target unexpectedly supports native Responses retrieval")
}
if anthropic.Supports(protocol.OpenAICompletions, execution.OperationChatCompletion, featureSet(t, execution.FeatureReasoning)) {
t.Fatal("converted OpenAI-to-Anthropic route unexpectedly advertises reasoning")
}
if !anthropic.Supports(protocol.OpenAICompletions, execution.OperationChatCompletion, featureSet(t, execution.FeatureStreaming)) {
t.Fatal("converted OpenAI-to-Anthropic route should support streaming")
}
if mode, ok := anthropic.Mode(protocol.OpenAICompletions, execution.OperationChatCompletion); !ok || mode != RouteConverted {
t.Fatalf("converted OpenAI-to-Anthropic mode = %q, %t", mode, ok)
}
if !anthropic.Supports(protocol.Anthropic, execution.OperationChatCompletion, featureSet(t, execution.FeatureReasoning)) {
t.Fatal("native Anthropic route should support reasoning")
if mode, ok := anthropic.Mode(protocol.Anthropic, execution.OperationChatCompletion); !ok || mode != RouteNative {
t.Fatalf("native Anthropic mode = %q, %t", mode, ok)
}
gemini, err := registry.Resolve(Gemini, nil)
@@ -395,14 +383,14 @@ func TestResolvedTargetsUseExactCatalogMappingAndProtocolSpecificCapabilities(t
if mode, ok := openAICompatible.Mode(protocol.OpenAIResponses, execution.OperationResponsesCreate); !ok || mode != RouteConverted {
t.Fatalf("OpenAI-compatible Responses create mode = %q, %t", mode, ok)
}
if openAICompatible.Supports(protocol.OpenAIResponses, execution.OperationResponsesRetrieve, featureSet(t, execution.FeatureNativeResourceSemantics)) {
if _, ok := openAICompatible.Mode(protocol.OpenAIResponses, execution.OperationResponsesRetrieve); ok {
t.Fatal("generic OpenAI-compatible target must not advertise Responses lifecycle")
}
for _, operation := range []execution.Operation{
execution.OperationResponsesCompact,
execution.OperationResponsesInputTokens,
} {
if openAICompatible.Supports(protocol.OpenAIResponses, operation, execution.FeatureSet{}) {
if _, ok := openAICompatible.Mode(protocol.OpenAIResponses, operation); ok {
t.Fatalf("generic OpenAI-compatible target unexpectedly supports %q", operation)
}
}
@@ -413,17 +401,13 @@ func TestResolvedTargetsUseExactCatalogMappingAndProtocolSpecificCapabilities(t
if mode, ok := openAICompatibleNonV1.Mode(protocol.OpenAICompletions, execution.OperationChatCompletion); !ok || mode != RouteNative {
t.Fatalf("non-v1 OpenAI-compatible completions mode = %q, %t", mode, ok)
}
if !openAICompatibleNonV1.Supports(protocol.OpenAICompletions, execution.OperationChatCompletion, featureSet(t, execution.FeatureTools)) {
t.Fatal("OpenAI-compatible native Chat route should support tools")
operations := openAI.Operations(protocol.OpenAIResponses)
if !containsOperation(operations, execution.OperationResponsesRetrieve) {
t.Fatal("Operations() omitted native operation")
}
capabilities := openAI.Capabilities(protocol.OpenAIResponses)
if !capabilities.Has(execution.OperationResponsesRetrieve) {
t.Fatal("Capabilities() omitted native operation")
}
capabilities = execution.CapabilitySet{}
if !openAI.Supports(protocol.OpenAIResponses, execution.OperationResponsesRetrieve, execution.FeatureSet{}) {
t.Fatal("mutating returned capability value changed target")
operations[0] = execution.Operation("mutated")
if !containsOperation(openAI.Operations(protocol.OpenAIResponses), execution.OperationResponsesRetrieve) {
t.Fatal("mutating returned operations changed target")
}
}
@@ -515,7 +499,7 @@ func TestOpenRouterUsesNativeChatAndResponsesWithoutLifecycle(t *testing.T) {
if mode, ok := target.Mode(protocol.OpenAIResponses, execution.OperationResponsesCreate); !ok || mode != RouteNative {
t.Fatalf("OpenRouter Responses create mode = %q, %t", mode, ok)
}
if target.Supports(protocol.OpenAIResponses, execution.OperationResponsesRetrieve, execution.FeatureSet{}) {
if _, ok := target.Mode(protocol.OpenAIResponses, execution.OperationResponsesRetrieve); ok {
t.Fatal("OpenRouter unexpectedly advertises Responses lifecycle")
}
}
@@ -528,11 +512,11 @@ func descriptorIDs(descriptors []Descriptor) []ID {
return ids
}
func featureSet(t *testing.T, features ...execution.Feature) execution.FeatureSet {
t.Helper()
set, err := execution.NewFeatureSet(features...)
if err != nil {
t.Fatalf("NewFeatureSet() error = %v", err)
func containsOperation(operations []execution.Operation, want execution.Operation) bool {
for _, operation := range operations {
if operation == want {
return true
}
}
return set
return false
}
@@ -50,10 +50,6 @@ func newRecordingDiscoveryExecutor(values ...*recordingDiscoveryExecutorTarget)
return &recordingDiscoveryExecutor{byProtocol: byProtocol}
}
func (*recordingDiscoveryExecutor) Capabilities() execution.CapabilitySet {
return execution.CapabilitySet{}
}
func (executor *recordingDiscoveryExecutor) Execute(
ctx context.Context,
spec execution.AttemptSpec,
@@ -340,10 +336,6 @@ type scriptedDiscoveryExecutor struct {
execute func(context.Context, execution.AttemptSpec) execution.AttemptResult
}
func (scriptedDiscoveryExecutor) Capabilities() execution.CapabilitySet {
return execution.CapabilitySet{}
}
func (executor scriptedDiscoveryExecutor) Execute(ctx context.Context, spec execution.AttemptSpec) execution.AttemptResult {
return executor.execute(ctx, spec)
}
@@ -19,10 +19,6 @@ type controlHTTPExecutor struct {
client *http.Client
}
func (controlHTTPExecutor) Capabilities() execution.CapabilitySet {
return execution.CapabilitySet{}
}
func (executor controlHTTPExecutor) Execute(
ctx context.Context,
spec execution.AttemptSpec,
+1 -2
View File
@@ -277,10 +277,9 @@ func mapGroupCollectionRecords(
if err != nil {
return nil, groupCollectionDataError("resolve group %d channel: %v", group.ID, err)
}
supportsModelOptionalRequests = resolvedTarget.Supports(
_, supportsModelOptionalRequests = resolvedTarget.Mode(
protocol.OpenAIResponses,
execution.OperationResponsesRetrieve,
execution.FeatureSet{},
)
var groupModels []GroupModel
if err := json.Unmarshal(group.Models, &groupModels); err != nil {
+42 -2
View File
@@ -16,6 +16,7 @@ import (
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/parametertrace"
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/platform/response"
"gpt-load/internal/pricing"
@@ -84,6 +85,7 @@ type requestLogAttemptResponse struct {
ErrorSummary string `json:"error_summary"`
Committed bool `json:"committed"`
PricingReceipt *requestLogPricingReceiptResponse `json:"pricing_receipt"`
ConversionTrace *parametertrace.Trace `json:"conversion_trace"`
}
type requestLogPricingIdentityResponse struct {
@@ -156,7 +158,8 @@ type requestLogItemResponse struct {
type requestLogDetailResponse struct {
requestLogItemResponse
Attempts []requestLogAttemptResponse `json:"attempts"`
ClientParameters *parametertrace.Snapshot `json:"client_parameters"`
Attempts []requestLogAttemptResponse `json:"attempts"`
}
type requestLogListResponse struct {
@@ -308,6 +311,7 @@ func sanitizeAccessKeyRequestLog(record requestlog.Record) requestlog.Record {
record.CredentialID = 0
record.RouteMode = ""
record.UpstreamAPI = ""
record.ClientParameters = nil
record.Attempts = []requestlog.Attempt{}
return record
}
@@ -481,6 +485,7 @@ func parseRequestLogQuery(rawQuery string) (requestlog.ListQuery, *app_errors.AP
telemetry.FailureCategoryInvalidKey,
telemetry.FailureCategoryUpstreamHost,
telemetry.FailureCategoryClientError,
telemetry.FailureCategoryConversionUnsupported,
telemetry.FailureCategoryDownstreamCancel,
telemetry.FailureCategoryAmbiguous:
query.FailureCategory = category
@@ -927,6 +932,10 @@ func checkedRequestLogInputTokens(record requestlog.Record) (int64, bool) {
}
func mapRequestLogDetailResponse(record requestlog.Record) (requestLogDetailResponse, error) {
clientParameters, err := mapRequestLogParameterSnapshot(record.ClientParameters)
if err != nil {
return requestLogDetailResponse{}, fmt.Errorf("map request log client parameters: %w", err)
}
usageCost, err := mapRequestLogUsageCost(record)
if err != nil {
return requestLogDetailResponse{}, err
@@ -943,7 +952,11 @@ func mapRequestLogDetailResponse(record requestlog.Record) (requestLogDetailResp
}
attempts = append(attempts, mapped)
}
return requestLogDetailResponse{requestLogItemResponse: item, Attempts: attempts}, nil
return requestLogDetailResponse{
requestLogItemResponse: item,
ClientParameters: clientParameters,
Attempts: attempts,
}, nil
}
func mapRequestLogAttempt(attempt requestlog.Attempt) (requestLogAttemptResponse, error) {
@@ -975,6 +988,10 @@ func mapRequestLogAttempt(attempt requestlog.Attempt) (requestLogAttemptResponse
if err != nil {
return requestLogAttemptResponse{}, err
}
conversionTrace, err := mapRequestLogConversionTrace(attempt.ConversionTrace)
if err != nil {
return requestLogAttemptResponse{}, fmt.Errorf("map request log conversion trace: %w", err)
}
return requestLogAttemptResponse{
Sequence: attempt.Sequence,
GroupID: attempt.GroupID,
@@ -998,9 +1015,32 @@ func mapRequestLogAttempt(attempt requestlog.Attempt) (requestLogAttemptResponse
ErrorSummary: attempt.ErrorSummary,
Committed: attempt.Committed,
PricingReceipt: receipt,
ConversionTrace: conversionTrace,
}, nil
}
func mapRequestLogParameterSnapshot(value *parametertrace.Snapshot) (*parametertrace.Snapshot, error) {
if value == nil {
return nil, nil
}
if err := value.Validate(); err != nil {
return nil, err
}
clone := parametertrace.CloneSnapshot(*value)
return &clone, nil
}
func mapRequestLogConversionTrace(value *parametertrace.Trace) (*parametertrace.Trace, error) {
if value == nil {
return nil, nil
}
if err := value.Validate(); err != nil {
return nil, err
}
clone := parametertrace.CloneTrace(*value)
return &clone, nil
}
func requestLogAttemptAction(action telemetry.Action) string {
return string(action)
}
+58
View File
@@ -18,6 +18,7 @@ import (
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/parametertrace"
"gpt-load/internal/platform/config"
"gpt-load/internal/pricing"
"gpt-load/internal/protocol"
@@ -752,6 +753,63 @@ func TestRequestLogDetailProjectsEmptyAttemptReasoningAsNull(t *testing.T) {
}
}
func TestRequestLogDetailProjectsConversionParametersOnlyForPrivilegedDetail(t *testing.T) {
client := parametertrace.ProjectJSON([]byte(`{"thinking":{"type":"enabled","budget_tokens":4096}}`))
target := parametertrace.ProjectJSON([]byte(`{"reasoning_effort":"high"}`))
trace := parametertrace.Compare(client, target)
record := requestlog.Record{
RequestID: "00000000-0000-4000-8000-000000000590",
Protocol: protocol.Anthropic, ModelConsistency: telemetry.ModelConsistencyNotApplicable,
Status: telemetry.RequestStatusSuccess, UsageState: usage.StateNotApplicable,
CostState: pricing.CostStateNotApplicable, PricingCompleteness: pricing.CompletenessNotApplicable,
ClientParameters: &client,
Attempts: []requestlog.Attempt{{
Sequence: 1, GroupID: 1, GroupName: "group",
FailureCategory: telemetry.FailureCategoryOK, Action: telemetry.ActionTerminate,
ConversionTrace: &trace,
}},
}
detail, err := mapRequestLogDetailResponse(record)
if err != nil {
t.Fatal(err)
}
detailJSON, _ := json.Marshal(detail)
if !bytes.Contains(detailJSON, []byte(`"client_parameters":{"schema_version":1,"state":"captured"`)) ||
!bytes.Contains(detailJSON, []byte(`"conversion_trace":{"schema_version":1,"state":"captured"`)) {
t.Fatalf("detail omitted conversion parameters: %s", detailJSON)
}
list, err := mapRequestLogListResponse(requestlog.Page{Items: []requestlog.Record{record}})
if err != nil {
t.Fatal(err)
}
listJSON, _ := json.Marshal(list)
if bytes.Contains(listJSON, []byte("client_parameters")) || bytes.Contains(listJSON, []byte("conversion_trace")) {
t.Fatalf("list exposed conversion parameters: %s", listJSON)
}
sanitized := sanitizeAccessKeyRequestLog(record)
if sanitized.ClientParameters != nil || len(sanitized.Attempts) != 0 {
t.Fatalf("self-scoped record retained conversion parameters: %#v", sanitized)
}
invalid := record
invalid.ClientParameters = &parametertrace.Snapshot{
SchemaVersion: parametertrace.SchemaVersion,
State: parametertrace.CaptureState("unknown"),
Entries: []parametertrace.Entry{},
}
if _, err := mapRequestLogDetailResponse(invalid); err == nil {
t.Fatal("detail projection accepted invalid client parameters")
}
invalid = record
invalidTrace := parametertrace.CloneTrace(trace)
invalidTrace.State = parametertrace.CaptureState("unknown")
invalid.Attempts = append([]requestlog.Attempt(nil), record.Attempts...)
invalid.Attempts[0].ConversionTrace = &invalidTrace
if _, err := mapRequestLogDetailResponse(invalid); err == nil {
t.Fatal("detail projection accepted invalid conversion trace")
}
}
func TestRequestLogUsageCostProjectionRejectsUnsafeValues(t *testing.T) {
base := requestlog.Record{
UsageState: usage.StateComplete, CostState: pricing.CostStatePriced,
+48 -44
View File
@@ -16,11 +16,11 @@ import (
)
type routeInspectRequest struct {
Protocol protocol.Protocol `json:"protocol"`
Operation execution.Operation `json:"operation"`
RequiredFeatures []execution.Feature `json:"required_features"`
ExternalModel *string `json:"external_model"`
AccessKeyID uint `json:"access_key_id"`
Protocol protocol.Protocol `json:"protocol"`
Operation execution.Operation `json:"operation"`
RouteRequirement execution.RouteRequirement `json:"route_requirement"`
ExternalModel *string `json:"external_model"`
AccessKeyID uint `json:"access_key_id"`
}
type routeInspectAccessKeyResponse struct {
@@ -40,17 +40,17 @@ type routeInspectCredentialResponse struct {
}
type routeInspectGroupResponse struct {
GroupID uint `json:"group_id"`
GroupName string `json:"group_name"`
ChannelID channel.ID `json:"channel_id"`
RouteMode execution.RouteMode `json:"route_mode"`
CapabilitySupported bool `json:"capability_supported"`
UpstreamModel *string `json:"upstream_model"`
WeightManual *int `json:"weight_manual"`
Included bool `json:"included"`
Routable bool `json:"routable"`
ReasonCode *scheduler.ReasonCode `json:"reason_code"`
Credentials []routeInspectCredentialResponse `json:"credentials"`
GroupID uint `json:"group_id"`
GroupName string `json:"group_name"`
ChannelID channel.ID `json:"channel_id"`
RouteMode execution.RouteMode `json:"route_mode"`
RouteRequirementSatisfied bool `json:"route_requirement_satisfied"`
UpstreamModel *string `json:"upstream_model"`
WeightManual *int `json:"weight_manual"`
Included bool `json:"included"`
Routable bool `json:"routable"`
ReasonCode *scheduler.ReasonCode `json:"reason_code"`
Credentials []routeInspectCredentialResponse `json:"credentials"`
}
type routeInspectResponse struct {
@@ -58,7 +58,7 @@ type routeInspectResponse struct {
SnapshotRevision uint64 `json:"snapshot_revision"`
Protocol protocol.Protocol `json:"protocol"`
Operation execution.Operation `json:"operation"`
RequiredFeatures []execution.Feature `json:"required_features"`
RouteRequirement execution.RouteRequirement `json:"route_requirement"`
ExternalModel *string `json:"external_model"`
AccessKey routeInspectAccessKeyResponse `json:"access_key"`
Routable bool `json:"routable"`
@@ -78,19 +78,14 @@ func validateRouteInspectRequest(request routeInspectRequest) error {
if !request.Protocol.DataPlaneEnabled() ||
request.AccessKeyID == 0 ||
!request.Operation.Valid() ||
request.RequiredFeatures == nil ||
(request.RouteRequirement != execution.RouteRequirementAny &&
request.RouteRequirement != execution.RouteRequirementNative) ||
!routeInspectOperationMatchesProtocol(request.Protocol, request.Operation) {
return app_errors.ErrValidation
}
seenFeatures := make(map[execution.Feature]struct{}, len(request.RequiredFeatures))
for _, feature := range request.RequiredFeatures {
if !feature.Valid() {
return app_errors.ErrValidation
}
if _, duplicate := seenFeatures[feature]; duplicate {
return app_errors.ErrValidation
}
seenFeatures[feature] = struct{}{}
if routeInspectOperationRequiresNativeRoute(request.Operation) &&
request.RouteRequirement != execution.RouteRequirementNative {
return app_errors.ErrValidation
}
if request.ExternalModel != nil && !validUsageModel(*request.ExternalModel) {
return app_errors.ErrValidation
@@ -102,6 +97,19 @@ func validateRouteInspectRequest(request routeInspectRequest) error {
return nil
}
func routeInspectOperationRequiresNativeRoute(operation execution.Operation) bool {
switch operation {
case execution.OperationResponsesRetrieve,
execution.OperationResponsesDelete,
execution.OperationResponsesCancel,
execution.OperationResponsesInputItems,
execution.OperationResponsesPassthrough:
return true
default:
return false
}
}
func routeInspectOperationMatchesProtocol(
clientProtocol protocol.Protocol,
operation execution.Operation,
@@ -150,17 +158,13 @@ func (service *Service) InspectRoute(
if !exists {
return routeInspectResponse{}, app_errors.ErrResourceNotFound
}
requiredFeatures, err := execution.NewFeatureSet(request.RequiredFeatures...)
if err != nil {
return routeInspectResponse{}, app_errors.ErrValidation
}
explanation, err := scheduler.Inspect(
observation.snapshot,
observation.keys,
scheduler.Query{
ClientProtocol: request.Protocol,
Operation: request.Operation,
RequiredFeatures: requiredFeatures,
RouteRequirement: request.RouteRequirement,
ExternalModel: cloneRouteModel(request.ExternalModel),
AccessKey: accessKey,
},
@@ -198,7 +202,7 @@ func mapRouteInspectResponse(
SnapshotRevision: observation.snapshot.Revision,
Protocol: request.Protocol,
Operation: explanation.Operation,
RequiredFeatures: append([]execution.Feature{}, explanation.RequiredFeatures...),
RouteRequirement: explanation.RouteRequirement,
ExternalModel: cloneRouteModel(request.ExternalModel),
AccessKey: routeInspectAccessKeyResponse{
ID: accessKey.ID, Name: accessKey.Name, Status: accessKey.Status,
@@ -209,17 +213,17 @@ func mapRouteInspectResponse(
}
for _, group := range explanation.Groups {
groupResponse := routeInspectGroupResponse{
GroupID: group.GroupID,
GroupName: group.GroupName,
ChannelID: group.ChannelID,
RouteMode: group.RouteMode,
CapabilitySupported: group.CapabilitySupported,
UpstreamModel: cloneRouteModel(group.UpstreamModelID),
WeightManual: cloneInt(group.WeightManual),
Included: group.Included,
Routable: group.Routable,
ReasonCode: optionalReason(group.Reason),
Credentials: []routeInspectCredentialResponse{},
GroupID: group.GroupID,
GroupName: group.GroupName,
ChannelID: group.ChannelID,
RouteMode: group.RouteMode,
RouteRequirementSatisfied: group.RouteRequirementSatisfied,
UpstreamModel: cloneRouteModel(group.UpstreamModelID),
WeightManual: cloneInt(group.WeightManual),
Included: group.Included,
Routable: group.Routable,
ReasonCode: optionalReason(group.Reason),
Credentials: []routeInspectCredentialResponse{},
}
for _, credential := range group.Credentials {
cooldownUntilMS, err := optionalSafeEpochMilliseconds(credential.CooldownUntil)
+99 -29
View File
@@ -120,7 +120,7 @@ func TestRouteInspectEndpointRejectsMalformedAndInvalidRequests(t *testing.T) {
},
{
name: "invalid protocol",
body: `{"protocol":"invalid","operation":"chat_completion","required_features":[],"external_model":"model","access_key_id":1}`,
body: `{"protocol":"invalid","operation":"chat_completion","route_requirement":"any","external_model":"model","access_key_id":1}`,
wantCode: app_errors.ErrValidation.Code,
},
{
@@ -135,47 +135,57 @@ func TestRouteInspectEndpointRejectsMalformedAndInvalidRequests(t *testing.T) {
},
{
name: "missing operation",
body: `{"protocol":"openai-completions","required_features":[],"external_model":"model","access_key_id":1}`,
body: `{"protocol":"openai-completions","route_requirement":"any","external_model":"model","access_key_id":1}`,
wantCode: app_errors.ErrValidation.Code,
},
{
name: "missing required features",
name: "missing route requirement",
body: `{"protocol":"openai-completions","operation":"chat_completion","external_model":"model","access_key_id":1}`,
wantCode: app_errors.ErrValidation.Code,
},
{
name: "invalid operation",
body: `{"protocol":"openai-completions","operation":"unknown","required_features":[],"external_model":"model","access_key_id":1}`,
body: `{"protocol":"openai-completions","operation":"unknown","route_requirement":"any","external_model":"model","access_key_id":1}`,
wantCode: app_errors.ErrValidation.Code,
},
{
name: "invalid feature",
body: `{"protocol":"openai-completions","operation":"chat_completion","required_features":["unknown"],"external_model":"model","access_key_id":1}`,
name: "legacy required features field",
body: `{"protocol":"openai-completions","operation":"chat_completion","route_requirement":"any","required_features":[],"external_model":"model","access_key_id":1}`,
wantCode: app_errors.ErrInvalidJSON.Code,
},
{
name: "invalid route requirement",
body: `{"protocol":"openai-completions","operation":"chat_completion","route_requirement":"converted","external_model":"model","access_key_id":1}`,
wantCode: app_errors.ErrValidation.Code,
},
{
name: "duplicate feature",
body: `{"protocol":"openai-completions","operation":"chat_completion","required_features":["tools","tools"],"external_model":"model","access_key_id":1}`,
wantCode: app_errors.ErrValidation.Code,
name: "route requirement wrong type",
body: `{"protocol":"openai-completions","operation":"chat_completion","route_requirement":true,"external_model":"model","access_key_id":1}`,
wantCode: app_errors.ErrInvalidJSON.Code,
},
{
name: "operation protocol mismatch",
body: `{"protocol":"anthropic","operation":"responses_create","required_features":[],"external_model":"model","access_key_id":1}`,
body: `{"protocol":"anthropic","operation":"responses_create","route_requirement":"any","external_model":"model","access_key_id":1}`,
wantCode: app_errors.ErrValidation.Code,
},
{
name: "model required by operation",
body: `{"protocol":"openai-responses","operation":"responses_create","required_features":[],"access_key_id":1}`,
body: `{"protocol":"openai-responses","operation":"responses_create","route_requirement":"native","access_key_id":1}`,
wantCode: app_errors.ErrValidation.Code,
},
{
name: "model forbidden by lifecycle operation",
body: `{"protocol":"openai-responses","operation":"responses_retrieve","required_features":[],"external_model":"model","access_key_id":1}`,
body: `{"protocol":"openai-responses","operation":"responses_retrieve","route_requirement":"native","external_model":"model","access_key_id":1}`,
wantCode: app_errors.ErrValidation.Code,
},
{
name: "resource operation requires native route",
body: `{"protocol":"openai-responses","operation":"responses_retrieve","route_requirement":"any","access_key_id":1}`,
wantCode: app_errors.ErrValidation.Code,
},
{
name: "empty model",
body: `{"protocol":"openai-completions","operation":"chat_completion","required_features":[],"external_model":"","access_key_id":1}`,
body: `{"protocol":"openai-completions","operation":"chat_completion","route_requirement":"any","external_model":"","access_key_id":1}`,
wantCode: app_errors.ErrValidation.Code,
},
{
@@ -267,8 +277,8 @@ func TestRouteInspectSupportsResponsesWithoutModel(t *testing.T) {
).RegisterRoutes(engine)
for _, body := range []string{
`{"protocol":"openai-responses","operation":"responses_retrieve","required_features":["native_resource_semantics"],"access_key_id":10}`,
`{"protocol":"openai-responses","operation":"responses_retrieve","required_features":["native_resource_semantics"],"external_model":null,"access_key_id":10}`,
`{"protocol":"openai-responses","operation":"responses_retrieve","route_requirement":"native","access_key_id":10}`,
`{"protocol":"openai-responses","operation":"responses_retrieve","route_requirement":"native","external_model":null,"access_key_id":10}`,
} {
recorder := performRouteInspectRequest(
engine,
@@ -315,7 +325,7 @@ func TestRouteInspectWithoutModelReturnsSharedFilterReason(t *testing.T) {
result, err := fixture.service.InspectRoute(routeInspectRequest{
Protocol: protocol.OpenAIResponses,
Operation: execution.OperationResponsesRetrieve,
RequiredFeatures: []execution.Feature{execution.FeatureNativeResourceSemantics},
RouteRequirement: execution.RouteRequirementNative,
AccessKeyID: 10,
})
if err != nil {
@@ -329,6 +339,66 @@ func TestRouteInspectWithoutModelReturnsSharedFilterReason(t *testing.T) {
}
}
func TestRouteInspectRouteRequirementExcludesOnlyConvertedTargets(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
if _, err := fixture.manager.Publish(state.CompileInput{
ChannelRegistry: fixture.channelRegistry,
Groups: []state.GroupConfig{
{
ID: 1, Name: "native", ChannelID: channel.OpenAI, Params: json.RawMessage(`{}`),
Models: []state.ModelConfig{{ID: "native-model", Alias: "public-model"}}, Enabled: true,
},
{
ID: 2, Name: "converted", ChannelID: channel.OpenAICompatible,
Params: json.RawMessage(`{"base_url":"https://compatible.example/v1"}`),
Models: []state.ModelConfig{{ID: "converted-model", Alias: "public-model"}}, Enabled: true,
},
},
AccessKeys: []state.AccessKeyConfig{{
ID: 10, Name: "client", KeyHash: "hash", Status: state.AccessKeyStatusActive,
}},
}); err != nil {
t.Fatalf("Publish() error = %v", err)
}
if err := fixture.registry.ReplaceCredentials([]state.CredentialEntry{
{ID: 11, GroupID: 1, Version: 1, IdentityGeneration: 11, Fingerprint: "native", Status: state.CredentialStatusActive, EncryptedValue: "native"},
{ID: 21, GroupID: 2, Version: 1, IdentityGeneration: 21, Fingerprint: "converted", Status: state.CredentialStatusActive, EncryptedValue: "converted"},
}); err != nil {
t.Fatalf("ReplaceCredentials() error = %v", err)
}
request := routeInspectRequest{
Protocol: protocol.OpenAIResponses, Operation: execution.OperationResponsesCreate,
RouteRequirement: execution.RouteRequirementAny,
ExternalModel: stringPointer("public-model"), AccessKeyID: 10,
}
anyResult, err := fixture.service.InspectRoute(request)
if err != nil {
t.Fatalf("InspectRoute(any) error = %v", err)
}
if !anyResult.Routable || anyResult.RouteRequirement != execution.RouteRequirementAny ||
len(anyResult.Groups) != 2 || !anyResult.Groups[0].RouteRequirementSatisfied ||
!anyResult.Groups[1].RouteRequirementSatisfied || !anyResult.Groups[1].Included {
t.Fatalf("any result = %#v", anyResult)
}
request.RouteRequirement = execution.RouteRequirementNative
nativeResult, err := fixture.service.InspectRoute(request)
if err != nil {
t.Fatalf("InspectRoute(native) error = %v", err)
}
if !nativeResult.Routable || nativeResult.RouteRequirement != execution.RouteRequirementNative ||
len(nativeResult.Groups) != 2 || !nativeResult.Groups[0].RouteRequirementSatisfied ||
!nativeResult.Groups[0].Included || nativeResult.Groups[1].RouteRequirementSatisfied ||
nativeResult.Groups[1].Included || nativeResult.Groups[1].ReasonCode == nil ||
*nativeResult.Groups[1].ReasonCode != scheduler.ReasonNativeRouteRequired ||
len(nativeResult.Groups[1].Credentials) != 0 {
t.Fatalf("native result = %#v", nativeResult)
}
}
func TestRouteInspectEndpointReturnsCurrentSafeExplanation(t *testing.T) {
initControlI18n(t)
fixture := newServiceFixture(t)
@@ -382,14 +452,14 @@ func TestRouteInspectEndpointReturnsCurrentSafeExplanation(t *testing.T) {
recorder := performRouteInspectRequest(
engine,
"test-auth-key",
`{"protocol":"openai-completions","operation":"chat_completion","required_features":["tools"],"external_model":"public-model","access_key_id":10}`,
`{"protocol":"openai-completions","operation":"chat_completion","route_requirement":"any","external_model":"public-model","access_key_id":10}`,
)
got := decodeRouteInspectSuccess(t, recorder)
if got.ObservedAtMS != now.UnixMilli() ||
got.SnapshotRevision != fixture.manager.Current().Revision ||
got.Protocol != protocol.OpenAICompletions ||
got.Operation != execution.OperationChatCompletion ||
!reflect.DeepEqual(got.RequiredFeatures, []execution.Feature{execution.FeatureTools}) ||
got.RouteRequirement != execution.RouteRequirementAny ||
routeModelValue(got.ExternalModel) != "public-model" ||
got.AccessKey != (routeInspectAccessKeyResponse{
ID: 10, Name: "production", Status: state.AccessKeyStatusActive,
@@ -405,7 +475,7 @@ func TestRouteInspectEndpointReturnsCurrentSafeExplanation(t *testing.T) {
if primary.GroupName != "primary" ||
primary.ChannelID != channel.OpenAI ||
primary.RouteMode != execution.RouteNative ||
!primary.CapabilitySupported ||
!primary.RouteRequirementSatisfied ||
routeModelValue(primary.UpstreamModel) != "provider-model" ||
primary.WeightManual != nil || !primary.Included ||
!primary.Routable || primary.ReasonCode != nil ||
@@ -528,7 +598,7 @@ func TestRouteInspectEndpointReturnsFilterExplanations(t *testing.T) {
recorder := performRouteInspectRequest(
engine,
"test-auth-key",
`{"protocol":"openai-completions","operation":"chat_completion","required_features":[],"external_model":"public-model","access_key_id":10}`,
`{"protocol":"openai-completions","operation":"chat_completion","route_requirement":"any","external_model":"public-model","access_key_id":10}`,
)
got := decodeRouteInspectSuccess(t, recorder)
if got.ObservedAtMS != now.UnixMilli() ||
@@ -589,7 +659,7 @@ func TestRouteInspectEndpointReturnsNoRouteTargetExplanation(t *testing.T) {
recorder := performRouteInspectRequest(
engine,
"test-auth-key",
`{"protocol":"openai-completions","operation":"chat_completion","required_features":[],"external_model":"missing-model","access_key_id":10}`,
`{"protocol":"openai-completions","operation":"chat_completion","route_requirement":"any","external_model":"missing-model","access_key_id":10}`,
)
got := decodeRouteInspectSuccess(t, recorder)
if got.ObservedAtMS != now.UnixMilli() ||
@@ -655,7 +725,7 @@ func TestRouteInspectEndpointReturnsNoAvailableKeyExplanation(t *testing.T) {
recorder := performRouteInspectRequest(
engine,
"test-auth-key",
`{"protocol":"openai-completions","operation":"chat_completion","required_features":[],"external_model":"public-model","access_key_id":10}`,
`{"protocol":"openai-completions","operation":"chat_completion","route_requirement":"any","external_model":"public-model","access_key_id":10}`,
)
got := decodeRouteInspectSuccess(t, recorder)
if got.ObservedAtMS != now.UnixMilli() ||
@@ -734,7 +804,7 @@ func TestRouteInspectReturnsDisabledAccessKeyAsExplanation(t *testing.T) {
}
result, err := fixture.service.InspectRoute(routeInspectRequest{
Protocol: protocol.OpenAICompletions, Operation: execution.OperationChatCompletion,
RequiredFeatures: []execution.Feature{}, ExternalModel: stringPointer("model"), AccessKeyID: 12,
RouteRequirement: execution.RouteRequirementAny, ExternalModel: stringPointer("model"), AccessKeyID: 12,
})
if err != nil {
t.Fatalf("InspectRoute() error = %v", err)
@@ -749,7 +819,7 @@ func TestRouteInspectReturnsDisabledAccessKeyAsExplanation(t *testing.T) {
recorder := performRouteInspectRequest(
engine,
"test-auth-key",
`{"protocol":"openai-completions","operation":"chat_completion","required_features":[],"external_model":"model","access_key_id":12}`,
`{"protocol":"openai-completions","operation":"chat_completion","route_requirement":"any","external_model":"model","access_key_id":12}`,
)
if recorder.Code != http.StatusOK ||
!strings.Contains(recorder.Body.String(), `"reason_code":"access_key_disabled"`) ||
@@ -763,7 +833,7 @@ func TestRouteInspectMissingAccessKeyReturnsNotFound(t *testing.T) {
fixture := newServiceFixture(t)
_, err := fixture.service.InspectRoute(routeInspectRequest{
Protocol: protocol.OpenAICompletions, Operation: execution.OperationChatCompletion,
RequiredFeatures: []execution.Feature{}, ExternalModel: stringPointer("model"), AccessKeyID: 404,
RouteRequirement: execution.RouteRequirementAny, ExternalModel: stringPointer("model"), AccessKeyID: 404,
})
if !errors.Is(err, app_errors.ErrResourceNotFound) {
t.Fatalf("InspectRoute() error = %v, want NOT_FOUND", err)
@@ -773,7 +843,7 @@ func TestRouteInspectMissingAccessKeyReturnsNotFound(t *testing.T) {
recorder := performRouteInspectRequest(
engine,
"test-auth-key",
`{"protocol":"openai-completions","operation":"chat_completion","required_features":[],"external_model":"model","access_key_id":404}`,
`{"protocol":"openai-completions","operation":"chat_completion","route_requirement":"any","external_model":"model","access_key_id":404}`,
)
var envelope struct {
Code string `json:"code"`
@@ -854,7 +924,7 @@ func TestRouteInspectNeverCallsUpstreamOrMutatesRuntime(t *testing.T) {
if _, err := fixture.service.InspectRoute(routeInspectRequest{
Protocol: protocol.OpenAICompletions, Operation: execution.OperationChatCompletion,
RequiredFeatures: []execution.Feature{}, ExternalModel: stringPointer("model"), AccessKeyID: 1,
RouteRequirement: execution.RouteRequirementAny, ExternalModel: stringPointer("model"), AccessKeyID: 1,
}); err != nil {
t.Fatalf("InspectRoute() error = %v", err)
}
@@ -886,7 +956,7 @@ func TestRouteInspectEndpointRequiresManagementAuthentication(t *testing.T) {
recorder := performRouteInspectRequest(
engine,
"",
`{"protocol":"openai-completions","operation":"chat_completion","required_features":[],"external_model":"model","access_key_id":1}`,
`{"protocol":"openai-completions","operation":"chat_completion","route_requirement":"any","external_model":"model","access_key_id":1}`,
)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("response = %d %s, want 401", recorder.Code, recorder.Body.String())
@@ -920,7 +990,7 @@ func TestRouteInspectCatalogMismatchReturnsInternalServerError(t *testing.T) {
recorder := performRouteInspectRequest(
engine,
"test-auth-key",
`{"protocol":"openai-completions","operation":"chat_completion","required_features":[],"external_model":"model","access_key_id":1}`,
`{"protocol":"openai-completions","operation":"chat_completion","route_requirement":"any","external_model":"model","access_key_id":1}`,
)
var envelope struct {
Code string `json:"code"`
-4
View File
@@ -1267,10 +1267,6 @@ type validationTestExecutor struct {
probes *validationProbeRecorder
}
func (*validationTestExecutor) Capabilities() execution.CapabilitySet {
return execution.CapabilitySet{}
}
func (executor *validationTestExecutor) Execute(
ctx context.Context,
spec execution.AttemptSpec,
+1 -3
View File
@@ -29,11 +29,9 @@ func (d *Anthropic) InspectRequest(req *ParsedRequest) (RequestMetadata, error)
}
metadata.ObserveUsage = true
metadata.Reasoning = inspectAnthropicReasoning(req.Body)
metadata.Operation, metadata.RequiredFeatures = chatExecutionMetadata(
metadata.Operation, metadata.RouteRequirement = chatExecutionMetadata(
d.Protocol(),
req.Body,
metadata.Stream,
metadata.Reasoning,
)
return metadata, nil
}
+1 -1
View File
@@ -21,7 +21,7 @@ type RequestMetadata struct {
Model *string
Stream bool
Operation execution.Operation
RequiredFeatures execution.FeatureSet
RouteRequirement execution.RouteRequirement
ObserveUsage bool
UsageDiagnostics usage.Diagnostics
Reasoning reasoning.Config
+1 -3
View File
@@ -39,11 +39,9 @@ func (d *Gemini) InspectRequest(req *ParsedRequest) (RequestMetadata, error) {
ObserveUsage: true,
Reasoning: inspectGeminiReasoning(req.Body),
}
metadata.Operation, metadata.RequiredFeatures = chatExecutionMetadata(
metadata.Operation, metadata.RouteRequirement = chatExecutionMetadata(
d.Protocol(),
req.Body,
metadata.Stream,
metadata.Reasoning,
)
return metadata, nil
}
+1 -3
View File
@@ -30,11 +30,9 @@ func (d *OpenAI) InspectRequest(req *ParsedRequest) (RequestMetadata, error) {
metadata.ObserveUsage = true
metadata.UsageDiagnostics = openAIRequestPricingDiagnostics(req.Body)
metadata.Reasoning = inspectOpenAICompletionsReasoning(req.Body)
metadata.Operation, metadata.RequiredFeatures = chatExecutionMetadata(
metadata.Operation, metadata.RouteRequirement = chatExecutionMetadata(
d.Protocol(),
req.Body,
metadata.Stream,
metadata.Reasoning,
)
return metadata, nil
}
+1 -5
View File
@@ -59,11 +59,7 @@ func (d *OpenAIResponses) InspectRequest(req *ParsedRequest) (RequestMetadata, e
metadata.UsageDiagnostics = openAIRequestPricingDiagnostics(req.Body)
metadata.Reasoning = inspectOpenAIResponsesReasoning(req.Body)
}
metadata.Operation, metadata.RequiredFeatures = responsesExecutionMetadata(
req,
metadata.Stream,
metadata.Reasoning,
)
metadata.Operation, metadata.RouteRequirement = responsesExecutionMetadata(req)
return metadata, nil
}
+243 -240
View File
@@ -4,28 +4,24 @@ import (
"bytes"
"encoding/json"
"net/http"
"net/url"
"strings"
"gpt-load/internal/execution"
"gpt-load/internal/protocol"
"gpt-load/internal/reasoning"
)
func chatExecutionMetadata(
clientProtocol protocol.Protocol,
body []byte,
stream bool,
reasoningConfig reasoning.Config,
) (execution.Operation, execution.FeatureSet) {
return execution.OperationChatCompletion,
inspectRequiredFeatures(clientProtocol, body, stream, reasoningConfig.RequiresCapability(), false)
) (execution.Operation, execution.RouteRequirement) {
nativeResource := chatRequiresNativeRoute(clientProtocol, body)
return execution.OperationChatCompletion, routeRequirement(nativeResource)
}
func responsesExecutionMetadata(
request *ParsedRequest,
stream bool,
reasoningConfig reasoning.Config,
) (execution.Operation, execution.FeatureSet) {
) (execution.Operation, execution.RouteRequirement) {
operation := responsesOperation(request)
nativeResource := operation == execution.OperationResponsesRetrieve ||
operation == execution.OperationResponsesDelete ||
@@ -33,15 +29,16 @@ func responsesExecutionMetadata(
operation == execution.OperationResponsesInputItems ||
operation == execution.OperationResponsesPassthrough
if operation == execution.OperationResponsesCreate {
nativeResource = responsesCreateStoresResource(request.Body)
nativeResource = responsesCreateRequiresNativeRoute(request.Body)
}
return operation, inspectRequiredFeatures(
protocol.OpenAIResponses,
request.Body,
stream,
reasoningConfig.RequiresCapability(),
nativeResource,
)
return operation, routeRequirement(nativeResource)
}
func routeRequirement(native bool) execution.RouteRequirement {
if native {
return execution.RouteRequirementNative
}
return execution.RouteRequirementAny
}
func responsesOperation(request *ParsedRequest) execution.Operation {
@@ -76,146 +73,6 @@ func responsesOperation(request *ParsedRequest) execution.Operation {
}
}
func inspectRequiredFeatures(
clientProtocol protocol.Protocol,
body []byte,
stream bool,
reasoningPresent bool,
nativeResource bool,
) execution.FeatureSet {
features := make([]execution.Feature, 0, 6)
if stream {
features = append(features, execution.FeatureStreaming)
}
root, ok := decodeExecutionFeatureObject(body)
if ok {
historyTools, historyReasoning := inspectProtocolHistory(root, clientProtocol)
if historyTools || hasMeaningfulField(root, "tools") ||
hasMeaningfulField(root, "tool_choice") ||
hasMeaningfulField(root, "toolChoice") {
features = append(features, execution.FeatureTools)
}
if historyReasoning {
reasoningPresent = true
}
if containsMultimodalValue(root) {
features = append(features, execution.FeatureMultimodal)
}
if containsStructuredOutput(root) {
features = append(features, execution.FeatureStructuredOutput)
}
}
if reasoningPresent {
features = append(features, execution.FeatureReasoning)
}
if nativeResource {
features = append(features, execution.FeatureNativeResourceSemantics)
}
result, err := execution.NewFeatureSet(features...)
if err != nil {
return execution.FeatureSet{}
}
return result
}
func inspectProtocolHistory(root map[string]any, clientProtocol protocol.Protocol) (bool, bool) {
switch clientProtocol {
case protocol.OpenAICompletions:
return inspectOpenAIChatHistory(root)
case protocol.OpenAIResponses:
return inspectOpenAIResponsesHistory(root)
case protocol.Anthropic:
return inspectAnthropicHistory(root)
case protocol.Gemini:
return inspectGeminiHistory(root)
default:
return false, false
}
}
func inspectOpenAIChatHistory(root map[string]any) (bool, bool) {
messages, _ := root["messages"].([]any)
tools := false
reasoning := false
for _, value := range messages {
message, _ := value.(map[string]any)
role, _ := message["role"].(string)
if role == "tool" || hasMeaningfulField(message, "tool_calls") ||
hasMeaningfulField(message, "function_call") {
tools = true
}
if hasMeaningfulField(message, "reasoning_content") ||
hasMeaningfulField(message, "reasoning_details") {
reasoning = true
}
}
return tools, reasoning
}
func inspectOpenAIResponsesHistory(root map[string]any) (bool, bool) {
items, _ := root["input"].([]any)
tools := false
reasoning := false
for _, value := range items {
item, _ := value.(map[string]any)
itemType, _ := item["type"].(string)
switch normalizeFeatureName(itemType) {
case "functioncall", "functioncalloutput":
tools = true
case "reasoning":
reasoning = true
}
if hasMeaningfulField(item, "encrypted_content") {
reasoning = true
}
}
return tools, reasoning
}
func inspectAnthropicHistory(root map[string]any) (bool, bool) {
messages, _ := root["messages"].([]any)
tools := false
reasoning := false
for _, value := range messages {
message, _ := value.(map[string]any)
content, _ := message["content"].([]any)
for _, blockValue := range content {
block, _ := blockValue.(map[string]any)
blockType, _ := block["type"].(string)
switch normalizeFeatureName(blockType) {
case "tooluse", "toolresult":
tools = true
case "thinking", "redactedthinking":
reasoning = true
}
}
}
return tools, reasoning
}
func inspectGeminiHistory(root map[string]any) (bool, bool) {
contents, _ := root["contents"].([]any)
tools := false
reasoning := false
for _, value := range contents {
content, _ := value.(map[string]any)
parts, _ := content["parts"].([]any)
for _, partValue := range parts {
part, _ := partValue.(map[string]any)
if hasMeaningfulField(part, "functionCall") ||
hasMeaningfulField(part, "functionResponse") {
tools = true
}
thought, _ := part["thought"].(bool)
if thought || hasMeaningfulField(part, "thoughtSignature") {
reasoning = true
}
}
}
return tools, reasoning
}
func decodeExecutionFeatureObject(body []byte) (map[string]any, bool) {
if len(bytes.TrimSpace(body)) == 0 {
return nil, false
@@ -246,89 +103,7 @@ func hasMeaningfulField(object map[string]any, field string) bool {
}
}
func containsMultimodalValue(value any) bool {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
normalizedKey := normalizeFeatureName(key)
if normalizedKey == "inlinedata" ||
normalizedKey == "filedata" ||
normalizedKey == "imageurl" {
return true
}
if normalizedKey == "type" {
if text, ok := child.(string); ok && multimodalType(text) {
return true
}
}
if normalizedKey == "modalities" && containsNonTextModality(child) {
return true
}
if containsMultimodalValue(child) {
return true
}
}
case []any:
for _, child := range typed {
if containsMultimodalValue(child) {
return true
}
}
}
return false
}
func multimodalType(value string) bool {
switch normalizeFeatureName(value) {
case "image", "imageurl", "inputimage", "inputaudio", "file", "inputfile", "document":
return true
default:
return false
}
}
func containsNonTextModality(value any) bool {
items, ok := value.([]any)
if !ok {
return false
}
for _, item := range items {
text, ok := item.(string)
if ok && !strings.EqualFold(strings.TrimSpace(text), "text") {
return true
}
}
return false
}
func containsStructuredOutput(root map[string]any) bool {
if hasMeaningfulField(root, "response_format") {
return true
}
for _, parentName := range []string{
"text",
"output_config",
"outputConfig",
"generation_config",
"generationConfig",
} {
parent, ok := root[parentName].(map[string]any)
if !ok {
continue
}
for key, value := range parent {
switch normalizeFeatureName(key) {
case "format", "responseschema", "responsejsonschema", "responsemimetype":
if value != nil {
return true
}
}
}
}
return false
}
func responsesCreateStoresResource(body []byte) bool {
func responsesCreateRequiresNativeRoute(body []byte) bool {
root, ok := decodeExecutionFeatureObject(body)
if !ok {
return true
@@ -337,6 +112,13 @@ func responsesCreateStoresResource(body []byte) bool {
hasMeaningfulField(root, "conversation") {
return true
}
if background, ok := root["background"].(bool); ok && background {
return true
}
if responsesInputReferencesProviderResource(root["input"]) ||
responsesToolsReferenceProviderResource(root["tools"]) {
return true
}
value, exists := root["store"]
if !exists || value == nil {
return true
@@ -345,6 +127,227 @@ func responsesCreateStoresResource(body []byte) bool {
return !ok || store
}
func chatRequiresNativeRoute(clientProtocol protocol.Protocol, body []byte) bool {
root, ok := decodeExecutionFeatureObject(body)
if !ok {
return false
}
switch clientProtocol {
case protocol.OpenAICompletions:
return openAIChatMessagesReferenceProviderResource(root["messages"])
case protocol.Gemini:
return hasMeaningfulField(root, "cachedContent") ||
hasMeaningfulField(root, "cached_content") ||
geminiContentsReferenceProviderResource(root["contents"])
case protocol.Anthropic:
return hasMeaningfulField(root, "container") ||
anthropicMessagesReferenceProviderResource(root["messages"])
default:
return false
}
}
func openAIChatMessagesReferenceProviderResource(value any) bool {
messages, ok := value.([]any)
if !ok {
return false
}
for _, value := range messages {
message, ok := value.(map[string]any)
if !ok {
continue
}
blocks, _ := message["content"].([]any)
for _, value := range blocks {
block, ok := value.(map[string]any)
if !ok {
continue
}
blockType, _ := block["type"].(string)
file, _ := block["file"].(map[string]any)
if normalizeFeatureName(blockType) == "file" && hasMeaningfulField(file, "file_id") {
return true
}
}
}
return false
}
func geminiContentsReferenceProviderResource(value any) bool {
contents, ok := value.([]any)
if !ok {
return false
}
for _, value := range contents {
content, ok := value.(map[string]any)
if !ok {
continue
}
parts, _ := content["parts"].([]any)
for _, value := range parts {
part, ok := value.(map[string]any)
if !ok {
continue
}
fileData := objectField(part, "fileData", "file_data")
fileURI, _ := stringField(fileData, "fileUri", "file_uri")
if geminiProviderResourceURI(fileURI) {
return true
}
}
}
return false
}
func objectField(object map[string]any, names ...string) map[string]any {
for _, name := range names {
if value, ok := object[name].(map[string]any); ok {
return value
}
}
return nil
}
func stringField(object map[string]any, names ...string) (string, bool) {
for _, name := range names {
if value, ok := object[name].(string); ok {
return value, true
}
}
return "", false
}
func geminiProviderResourceURI(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
parsed, err := url.Parse(value)
if err == nil {
if strings.EqualFold(parsed.Scheme, "gs") {
return true
}
if strings.EqualFold(parsed.Hostname(), "generativelanguage.googleapis.com") &&
strings.Contains(strings.ToLower(parsed.Path), "/files/") {
return true
}
}
relative := strings.TrimPrefix(strings.TrimSpace(value), "/")
return strings.HasPrefix(strings.ToLower(relative), "files/") ||
strings.HasPrefix(strings.ToLower(relative), "v1beta/files/")
}
func responsesInputReferencesProviderResource(value any) bool {
items, ok := value.([]any)
if !ok {
return false
}
for _, value := range items {
item, ok := value.(map[string]any)
if !ok {
continue
}
itemType, _ := item["type"].(string)
switch normalizeFeatureName(itemType) {
case "itemreference":
if hasMeaningfulField(item, "id") {
return true
}
case "codeinterpretercall", "computer_call", "computercall", "computercalloutput":
if hasMeaningfulField(item, "container_id") {
return true
}
}
if responsesContentReferencesProviderResource(item["content"]) {
return true
}
}
return false
}
func responsesContentReferencesProviderResource(value any) bool {
blocks, ok := value.([]any)
if !ok {
return false
}
for _, value := range blocks {
block, ok := value.(map[string]any)
if !ok {
continue
}
blockType, _ := block["type"].(string)
switch normalizeFeatureName(blockType) {
case "inputfile", "inputimage":
if hasMeaningfulField(block, "file_id") {
return true
}
}
}
return false
}
func responsesToolsReferenceProviderResource(value any) bool {
tools, ok := value.([]any)
if !ok {
return false
}
for _, value := range tools {
tool, ok := value.(map[string]any)
if !ok {
continue
}
toolType, _ := tool["type"].(string)
switch normalizeFeatureName(toolType) {
case "filesearch":
if hasMeaningfulField(tool, "vector_store_ids") {
return true
}
case "codeinterpreter":
if hasMeaningfulField(tool, "container") || hasMeaningfulField(tool, "container_id") {
return true
}
case "imagegeneration":
mask, _ := tool["input_image_mask"].(map[string]any)
if hasMeaningfulField(mask, "file_id") {
return true
}
}
}
return false
}
func anthropicMessagesReferenceProviderResource(value any) bool {
messages, ok := value.([]any)
if !ok {
return false
}
for _, value := range messages {
message, ok := value.(map[string]any)
if !ok {
continue
}
blocks, _ := message["content"].([]any)
for _, value := range blocks {
block, ok := value.(map[string]any)
if !ok {
continue
}
blockType, _ := block["type"].(string)
if normalizeFeatureName(blockType) == "containerupload" &&
hasMeaningfulField(block, "file_id") {
return true
}
source, _ := block["source"].(map[string]any)
sourceType, _ := source["type"].(string)
if normalizeFeatureName(sourceType) == "file" &&
hasMeaningfulField(source, "file_id") {
return true
}
}
}
return false
}
func normalizeFeatureName(value string) string {
value = strings.ToLower(value)
value = strings.ReplaceAll(value, "_", "")
+145 -87
View File
@@ -7,16 +7,15 @@ import (
"gpt-load/internal/execution"
)
func TestInspectRequestIdentifiesExecutionOperationAndFeatures(t *testing.T) {
func TestInspectRequestIdentifiesOperationAndNativeRouteRequirement(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dialect Dialect
request *ParsedRequest
operation execution.Operation
features []execution.Feature
noFeatures []execution.Feature
name string
dialect Dialect
request *ParsedRequest
operation execution.Operation
requirement execution.RouteRequirement
}{
{
name: "OpenAI Chat",
@@ -28,13 +27,6 @@ func TestInspectRequestIdentifiesExecutionOperationAndFeatures(t *testing.T) {
"response_format":{"type":"json_schema","json_schema":{"name":"answer","schema":{"type":"object"}}}
}`)},
operation: execution.OperationChatCompletion,
features: []execution.Feature{
execution.FeatureStreaming,
execution.FeatureTools,
execution.FeatureReasoning,
execution.FeatureMultimodal,
execution.FeatureStructuredOutput,
},
},
{
name: "Anthropic Messages",
@@ -47,13 +39,6 @@ func TestInspectRequestIdentifiesExecutionOperationAndFeatures(t *testing.T) {
"output_config":{"format":{"type":"json_schema","schema":{"type":"object"}}}
}`)},
operation: execution.OperationChatCompletion,
features: []execution.Feature{
execution.FeatureStreaming,
execution.FeatureTools,
execution.FeatureReasoning,
execution.FeatureMultimodal,
execution.FeatureStructuredOutput,
},
},
{
name: "Gemini generateContent",
@@ -64,13 +49,6 @@ func TestInspectRequestIdentifiesExecutionOperationAndFeatures(t *testing.T) {
"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"},"responseSchema":{"type":"OBJECT"}}
}`)},
operation: execution.OperationChatCompletion,
features: []execution.Feature{
execution.FeatureStreaming,
execution.FeatureTools,
execution.FeatureReasoning,
execution.FeatureMultimodal,
execution.FeatureStructuredOutput,
},
},
{
name: "Responses create stored",
@@ -81,29 +59,20 @@ func TestInspectRequestIdentifiesExecutionOperationAndFeatures(t *testing.T) {
"input":[{"role":"user","content":[{"type":"input_file","file_id":"file_123"}]}],
"text":{"format":{"type":"json_schema","name":"answer","schema":{"type":"object"}}}
}`)},
operation: execution.OperationResponsesCreate,
requirement: execution.RouteRequirementNative,
},
{
name: "Responses create transient",
dialect: NewOpenAIResponses(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{"model":"gpt-5","store":false}`)},
operation: execution.OperationResponsesCreate,
features: []execution.Feature{
execution.FeatureStreaming,
execution.FeatureTools,
execution.FeatureReasoning,
execution.FeatureMultimodal,
execution.FeatureStructuredOutput,
execution.FeatureNativeResourceSemantics,
},
},
{
name: "Responses create transient",
dialect: NewOpenAIResponses(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{"model":"gpt-5","store":false}`)},
operation: execution.OperationResponsesCreate,
noFeatures: []execution.Feature{execution.FeatureNativeResourceSemantics},
},
{
name: "Responses reasoning none is not a capability requirement",
dialect: NewOpenAIResponses(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{"model":"gpt-5","reasoning":{"effort":"none"},"store":false}`)},
operation: execution.OperationResponsesCreate,
noFeatures: []execution.Feature{execution.FeatureReasoning, execution.FeatureNativeResourceSemantics},
name: "Responses reasoning none stays convertible",
dialect: NewOpenAIResponses(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{"model":"gpt-5","reasoning":{"effort":"none"},"store":false}`)},
operation: execution.OperationResponsesCreate,
},
{
name: "OpenAI Chat tool history",
@@ -116,7 +85,23 @@ func TestInspectRequestIdentifiesExecutionOperationAndFeatures(t *testing.T) {
]
}`)},
operation: execution.OperationChatCompletion,
features: []execution.Feature{execution.FeatureTools},
},
{
name: "OpenAI Chat file ID requires native semantics",
dialect: NewOpenAI(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/chat/completions", Body: []byte(`{
"model":"gpt-4o","messages":[{"role":"user","content":[{"type":"file","file":{"file_id":"file_123"}}]}]
}`)},
operation: execution.OperationChatCompletion,
requirement: execution.RouteRequirementNative,
},
{
name: "OpenAI Chat inline file stays convertible",
dialect: NewOpenAI(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/chat/completions", Body: []byte(`{
"model":"gpt-4o","messages":[{"role":"user","content":[{"type":"file","file":{"filename":"input.txt","file_data":"data:text/plain;base64,SGVsbG8="}}]}]
}`)},
operation: execution.OperationChatCompletion,
},
{
name: "Anthropic tool and reasoning history",
@@ -129,7 +114,6 @@ func TestInspectRequestIdentifiesExecutionOperationAndFeatures(t *testing.T) {
]}]
}`)},
operation: execution.OperationChatCompletion,
features: []execution.Feature{execution.FeatureTools, execution.FeatureReasoning},
},
{
name: "Gemini function history",
@@ -138,7 +122,6 @@ func TestInspectRequestIdentifiesExecutionOperationAndFeatures(t *testing.T) {
"contents":[{"role":"user","parts":[{"functionResponse":{"name":"lookup","response":{"result":"ok"}}}]}]
}`)},
operation: execution.OperationChatCompletion,
features: []execution.Feature{execution.FeatureTools},
},
{
name: "Responses tool and reasoning history",
@@ -151,10 +134,6 @@ func TestInspectRequestIdentifiesExecutionOperationAndFeatures(t *testing.T) {
]
}`)},
operation: execution.OperationResponsesCreate,
features: []execution.Feature{execution.FeatureTools, execution.FeatureReasoning},
noFeatures: []execution.Feature{
execution.FeatureNativeResourceSemantics,
},
},
{
name: "Responses previous response requires native semantics",
@@ -162,8 +141,8 @@ func TestInspectRequestIdentifiesExecutionOperationAndFeatures(t *testing.T) {
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
"model":"gpt-5","store":false,"previous_response_id":"resp_123"
}`)},
operation: execution.OperationResponsesCreate,
features: []execution.Feature{execution.FeatureNativeResourceSemantics},
operation: execution.OperationResponsesCreate,
requirement: execution.RouteRequirementNative,
},
{
name: "Responses conversation requires native semantics",
@@ -171,8 +150,96 @@ func TestInspectRequestIdentifiesExecutionOperationAndFeatures(t *testing.T) {
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
"model":"gpt-5","store":false,"conversation":"conv_123"
}`)},
operation: execution.OperationResponsesCreate,
requirement: execution.RouteRequirementNative,
},
{
name: "Responses background requires native semantics",
dialect: NewOpenAIResponses(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
"model":"gpt-5","store":false,"background":true
}`)},
operation: execution.OperationResponsesCreate,
requirement: execution.RouteRequirementNative,
},
{
name: "Responses item reference requires native semantics",
dialect: NewOpenAIResponses(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
"model":"gpt-5","store":false,"input":[{"type":"item_reference","id":"item_123"}]
}`)},
operation: execution.OperationResponsesCreate,
requirement: execution.RouteRequirementNative,
},
{
name: "Responses input file reference requires native semantics",
dialect: NewOpenAIResponses(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
"model":"gpt-5","store":false,"input":[{"role":"user","content":[{"type":"input_file","file_id":"file_123"}]}]
}`)},
operation: execution.OperationResponsesCreate,
requirement: execution.RouteRequirementNative,
},
{
name: "Responses file search reference requires native semantics",
dialect: NewOpenAIResponses(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
"model":"gpt-5","store":false,"tools":[{"type":"file_search","vector_store_ids":["vs_123"]}]
}`)},
operation: execution.OperationResponsesCreate,
requirement: execution.RouteRequirementNative,
},
{
name: "Responses code interpreter container requires native semantics",
dialect: NewOpenAIResponses(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
"model":"gpt-5","store":false,"tools":[{"type":"code_interpreter","container":"cntr_123"}]
}`)},
operation: execution.OperationResponsesCreate,
requirement: execution.RouteRequirementNative,
},
{
name: "Responses unrelated resource-like metadata stays convertible",
dialect: NewOpenAIResponses(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
"model":"gpt-5","store":false,"metadata":{"file_id":"business-file","container_id":"business-container"}
}`)},
operation: execution.OperationResponsesCreate,
features: []execution.Feature{execution.FeatureNativeResourceSemantics},
},
{
name: "Gemini cached content requires native semantics",
dialect: NewGemini(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1beta/models/gemini-2.5-pro:generateContent", Body: []byte(`{
"contents":[{"role":"user","parts":[{"text":"hello"}]}],"cachedContent":"cachedContents/abc"
}`)},
operation: execution.OperationChatCompletion,
requirement: execution.RouteRequirementNative,
},
{
name: "Gemini Files API URI requires native semantics",
dialect: NewGemini(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1beta/models/gemini-2.5-pro:generateContent", Body: []byte(`{
"contents":[{"role":"user","parts":[{"fileData":{"mimeType":"application/pdf","fileUri":"https://generativelanguage.googleapis.com/v1beta/files/abc123"}}]}]
}`)},
operation: execution.OperationChatCompletion,
requirement: execution.RouteRequirementNative,
},
{
name: "Gemini public file URL stays convertible",
dialect: NewGemini(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1beta/models/gemini-2.5-pro:generateContent", Body: []byte(`{
"contents":[{"role":"user","parts":[{"fileData":{"mimeType":"application/pdf","fileUri":"https://cdn.example.test/input.pdf"}}]}]
}`)},
operation: execution.OperationChatCompletion,
},
{
name: "Anthropic container requires native semantics",
dialect: NewAnthropic(),
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/messages", Body: []byte(`{
"model":"claude-sonnet-4","container":{"id":"container_123"},"messages":[{"role":"user","content":"hello"}]
}`)},
operation: execution.OperationChatCompletion,
requirement: execution.RouteRequirementNative,
},
}
@@ -186,15 +253,9 @@ func TestInspectRequestIdentifiesExecutionOperationAndFeatures(t *testing.T) {
if metadata.Operation != test.operation {
t.Fatalf("Operation = %q, want %q", metadata.Operation, test.operation)
}
for _, feature := range test.features {
if !metadata.RequiredFeatures.Has(feature) {
t.Errorf("RequiredFeatures missing %q", feature)
}
}
for _, feature := range test.noFeatures {
if metadata.RequiredFeatures.Has(feature) {
t.Errorf("RequiredFeatures unexpectedly contains %q", feature)
}
wantRequirement := test.requirement.Normalize()
if metadata.RouteRequirement != wantRequirement {
t.Errorf("RouteRequirement = %q, want %q", metadata.RouteRequirement, wantRequirement)
}
})
}
@@ -205,22 +266,23 @@ func TestOpenAIResponsesInspectRequestIdentifiesLifecycleOperations(t *testing.T
selected := NewOpenAIResponses()
tests := []struct {
name string
method string
path string
body string
operation execution.Operation
model string
name string
method string
path string
body string
operation execution.Operation
model string
requirement execution.RouteRequirement
}{
{name: "create", method: http.MethodPost, path: "/v1/responses", body: `{"model":"gpt-5"}`, operation: execution.OperationResponsesCreate, model: "gpt-5"},
{name: "create", method: http.MethodPost, path: "/v1/responses", body: `{"model":"gpt-5"}`, operation: execution.OperationResponsesCreate, model: "gpt-5", requirement: execution.RouteRequirementNative},
{name: "compact", method: http.MethodPost, path: "/v1/responses/compact", body: `{"model":"gpt-5","input":[]}`, operation: execution.OperationResponsesCompact, model: "gpt-5"},
{name: "input tokens", method: http.MethodPost, path: "/v1/responses/input_tokens", body: `{"model":"gpt-5","input":[]}`, operation: execution.OperationResponsesInputTokens, model: "gpt-5"},
{name: "retrieve", method: http.MethodGet, path: "/v1/responses/resp_123", operation: execution.OperationResponsesRetrieve},
{name: "delete", method: http.MethodDelete, path: "/v1/responses/resp_123", operation: execution.OperationResponsesDelete},
{name: "cancel", method: http.MethodPost, path: "/v1/responses/resp_123/cancel", operation: execution.OperationResponsesCancel},
{name: "input items", method: http.MethodGet, path: "/v1/responses/resp_123/input_items", operation: execution.OperationResponsesInputItems},
{name: "vendor extension", method: http.MethodPatch, path: "/v1/responses/vendor-extension/nested", body: `{"vendor":true}`, operation: execution.OperationResponsesPassthrough},
{name: "resource head", method: http.MethodHead, path: "/v1/responses/resp_123", operation: execution.OperationResponsesPassthrough},
{name: "retrieve", method: http.MethodGet, path: "/v1/responses/resp_123", operation: execution.OperationResponsesRetrieve, requirement: execution.RouteRequirementNative},
{name: "delete", method: http.MethodDelete, path: "/v1/responses/resp_123", operation: execution.OperationResponsesDelete, requirement: execution.RouteRequirementNative},
{name: "cancel", method: http.MethodPost, path: "/v1/responses/resp_123/cancel", operation: execution.OperationResponsesCancel, requirement: execution.RouteRequirementNative},
{name: "input items", method: http.MethodGet, path: "/v1/responses/resp_123/input_items", operation: execution.OperationResponsesInputItems, requirement: execution.RouteRequirementNative},
{name: "vendor extension", method: http.MethodPatch, path: "/v1/responses/vendor-extension/nested", body: `{"vendor":true}`, operation: execution.OperationResponsesPassthrough, requirement: execution.RouteRequirementNative},
{name: "resource head", method: http.MethodHead, path: "/v1/responses/resp_123", operation: execution.OperationResponsesPassthrough, requirement: execution.RouteRequirementNative},
}
for _, test := range tests {
@@ -244,13 +306,9 @@ func TestOpenAIResponsesInspectRequestIdentifiesLifecycleOperations(t *testing.T
if got := gotModel; got != test.model {
t.Fatalf("Model = %q, want %q", got, test.model)
}
if !metadata.RequiredFeatures.Has(execution.FeatureNativeResourceSemantics) &&
(test.operation == execution.OperationResponsesRetrieve ||
test.operation == execution.OperationResponsesDelete ||
test.operation == execution.OperationResponsesCancel ||
test.operation == execution.OperationResponsesInputItems ||
test.operation == execution.OperationResponsesPassthrough) {
t.Fatal("resource operation must require native resource semantics")
wantRequirement := test.requirement.Normalize()
if metadata.RouteRequirement != wantRequirement {
t.Fatalf("RouteRequirement = %q, want %q", metadata.RouteRequirement, wantRequirement)
}
})
}
+39 -17
View File
@@ -15,6 +15,7 @@ import (
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/parametertrace"
"gpt-load/internal/protocol"
"gpt-load/internal/reasoning"
)
@@ -29,6 +30,25 @@ type responsesStreamSDKResult struct {
err *schemas.BifrostError
}
type targetConversionError struct {
summary string
}
func (err *targetConversionError) Error() string {
if err == nil {
return "target conversion is not supported"
}
return err.summary
}
func (*targetConversionError) ConversionCode() string {
return execution.ErrorCodeTargetConversionNotSupported
}
func unsupportedTargetConversion(summary string) error {
return &targetConversionError{summary: summary}
}
func buildConvertedResponsesRequest(spec execution.AttemptSpec, provider schemas.ModelProvider) (*schemas.BifrostResponsesRequest, error) {
conversionContext := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
defer conversionContext.Cancel()
@@ -37,7 +57,7 @@ func buildConvertedResponsesRequest(spec execution.AttemptSpec, provider schemas
switch spec.ClientProtocol {
case protocol.OpenAIResponses:
if spec.Operation != execution.OperationResponsesCreate {
return nil, fmt.Errorf("converted Responses operation is not supported")
return nil, unsupportedTargetConversion("converted Responses operation is not supported")
}
var wire openai.OpenAIResponsesRequest
if err := json.Unmarshal(spec.Body, &wire); err != nil {
@@ -46,7 +66,7 @@ func buildConvertedResponsesRequest(spec execution.AttemptSpec, provider schemas
request = wire.ToBifrostResponsesRequest(conversionContext)
case protocol.Anthropic:
if spec.Operation != execution.OperationChatCompletion {
return nil, fmt.Errorf("converted Anthropic operation is not supported")
return nil, unsupportedTargetConversion("converted Anthropic operation is not supported")
}
var wire anthropic.AnthropicMessageRequest
if err := json.Unmarshal(spec.Body, &wire); err != nil {
@@ -55,7 +75,7 @@ func buildConvertedResponsesRequest(spec execution.AttemptSpec, provider schemas
request = wire.ToBifrostResponsesRequest(conversionContext)
case protocol.Gemini:
if spec.Operation != execution.OperationChatCompletion {
return nil, fmt.Errorf("converted Gemini operation is not supported")
return nil, unsupportedTargetConversion("converted Gemini operation is not supported")
}
var wire gemini.GeminiGenerationRequest
if err := json.Unmarshal(spec.Body, &wire); err != nil {
@@ -64,7 +84,7 @@ func buildConvertedResponsesRequest(spec execution.AttemptSpec, provider schemas
wire.Model = spec.ClientModel
request = wire.ToBifrostResponsesRequest(conversionContext)
default:
return nil, fmt.Errorf("converted protocol is not supported")
return nil, unsupportedTargetConversion("converted protocol is not supported")
}
if request == nil || len(request.Input) == 0 {
return nil, fmt.Errorf("converted request input is required")
@@ -96,9 +116,12 @@ func (r *Runtime) executeConvertedResponses(
spec execution.AttemptSpec,
prepared preparedAttempt,
) (result execution.AttemptResult) {
clientParameters := conversionClientParameters(&spec)
var appliedReasoning *reasoning.Config
var conversionTrace *parametertrace.Trace
defer func() {
result.AppliedReasoning = appliedReasoning
result.ConversionTrace = finalConversionTrace(clientParameters, conversionTrace)
}()
requestContext, requestCancel := boundedRequestContext(parent, spec.Timeouts.Request)
@@ -107,7 +130,7 @@ func (r *Runtime) executeConvertedResponses(
defer callCancel()
bifrostContext := r.newSDKContext(callContext, spec, prepared.directKey)
enableAppliedReasoningWireCapture(bifrostContext, prepared)
enableConvertedWireCapture(bifrostContext, prepared, clientParameters)
setTypedRequestURL(bifrostContext, prepared.typedURL)
outcomeChannel := make(chan responsesUnarySDKResult, 1)
go func() {
@@ -126,11 +149,11 @@ func (r *Runtime) executeConvertedResponses(
}
if outcome.err != nil {
appliedReasoning = takeAppliedReasoning(&outcome.err.ExtraFields.RawRequest)
return unaryErrorResult(outcome.err, bifrostContext, prepared.secrets)
captureWireObservation(&outcome.err.ExtraFields.RawRequest, clientParameters, &appliedReasoning, &conversionTrace)
return convertedUnaryErrorResult(outcome.err, bifrostContext, prepared.secrets)
}
if outcome.response != nil {
appliedReasoning = takeAppliedReasoning(&outcome.response.ExtraFields.RawRequest)
captureWireObservation(&outcome.response.ExtraFields.RawRequest, clientParameters, &appliedReasoning, &conversionTrace)
}
if failure := largeUnaryResponseFailure(bifrostContext); failure != nil {
return *failure
@@ -354,9 +377,12 @@ func (r *Runtime) executeConvertedResponsesStream(
prepared preparedAttempt,
sink execution.StreamSink,
) (result execution.StreamResult) {
clientParameters := conversionClientParameters(&spec)
var appliedReasoning *reasoning.Config
var conversionTrace *parametertrace.Trace
defer func() {
result.AppliedReasoning = appliedReasoning
result.ConversionTrace = finalConversionTrace(clientParameters, conversionTrace)
}()
requestContext, requestCancel := boundedRequestContext(parent, spec.Timeouts.Request)
@@ -367,7 +393,7 @@ func (r *Runtime) executeConvertedResponsesStream(
defer preResponse.stop()
bifrostContext := r.newStreamingSDKContext(callContext, spec, prepared.directKey)
enableAppliedReasoningWireCapture(bifrostContext, prepared)
enableConvertedWireCapture(bifrostContext, prepared, clientParameters)
setTypedRequestURL(bifrostContext, prepared.typedURL)
outcomeChannel := make(chan responsesStreamSDKResult, 1)
go func() {
@@ -386,8 +412,8 @@ func (r *Runtime) executeConvertedResponsesStream(
}
preResponse.stop()
if outcome.err != nil {
appliedReasoning = takeAppliedReasoning(&outcome.err.ExtraFields.RawRequest)
return streamErrorResult(outcome.err, bifrostContext, prepared.secrets, false, 0, nil, "", nil)
captureWireObservation(&outcome.err.ExtraFields.RawRequest, clientParameters, &appliedReasoning, &conversionTrace)
return convertedStreamErrorResult(outcome.err, bifrostContext, prepared.secrets, false, 0, nil, "", nil)
}
if outcome.stream == nil {
return attemptedStreamFailure(execution.ErrorKindInternal, "execution runtime returned no stream")
@@ -440,18 +466,14 @@ func (r *Runtime) executeConvertedResponsesStream(
if chunk == nil || chunk.BifrostResponsesStreamResponse == nil {
callCancel()
if chunk != nil && chunk.BifrostError != nil {
if captured := takeAppliedReasoning(&chunk.BifrostError.ExtraFields.RawRequest); captured != nil {
appliedReasoning = captured
}
captureWireObservation(&chunk.BifrostError.ExtraFields.RawRequest, clientParameters, &appliedReasoning, &conversionTrace)
return streamErrorResult(chunk.BifrostError, bifrostContext, prepared.secrets, true, http.StatusOK, headers, model, usageEvidence)
}
return streamErrorResult(nil, bifrostContext, prepared.secrets, true, http.StatusOK, headers, model, usageEvidence)
}
response := chunk.BifrostResponsesStreamResponse
if captured := takeAppliedReasoning(&response.ExtraFields.RawRequest); captured != nil {
appliedReasoning = captured
}
captureWireObservation(&response.ExtraFields.RawRequest, clientParameters, &appliedReasoning, &conversionTrace)
var chunkUsage *execution.UsageEvidence
if response.Response != nil {
if response.Response.Model != "" {
@@ -14,6 +14,7 @@ import (
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/parametertrace"
"gpt-load/internal/protocol"
"gpt-load/internal/reasoning"
)
@@ -253,6 +254,7 @@ func TestConvertedAttemptReportsAppliedReasoning(t *testing.T) {
result.AppliedReasoning.Effort == "high" {
t.Fatalf("result retained canonical reasoning instead of wire values: %#v", result.AppliedReasoning)
}
assertMappedReasoningTrace(t, result.ConversionTrace)
raw, err := json.Marshal(result)
if err != nil {
t.Fatalf("marshal result: %v", err)
@@ -300,6 +302,7 @@ func TestConvertedAttemptReportsWireReasoningOnProviderError(t *testing.T) {
if !reflect.DeepEqual(result.AppliedReasoning, want) {
t.Fatalf("result applied reasoning = %#v, wire reasoning = %#v", result.AppliedReasoning, want)
}
assertMappedReasoningTrace(t, result.ConversionTrace)
raw, err := json.Marshal(result)
if err != nil {
t.Fatalf("marshal result: %v", err)
@@ -634,6 +637,20 @@ func inspectAnthropicWireReasoning(t *testing.T, body []byte) *reasoning.Config
return result
}
func assertMappedReasoningTrace(t *testing.T, trace *parametertrace.Trace) {
t.Helper()
if trace == nil || (trace.State != parametertrace.CaptureCaptured && trace.State != parametertrace.CapturePartial) {
t.Fatalf("conversion trace = %#v", trace)
}
for _, change := range trace.Changes {
if change.Disposition == parametertrace.DispositionMapped &&
(strings.HasPrefix(change.SourcePath, "reasoning.") || strings.HasPrefix(change.TargetPath, "reasoning.")) {
return
}
}
t.Fatalf("conversion trace has no reasoning mapping: %#v", trace)
}
func TestConvertedOpenAIChatStreamUsesSelectedProvider(t *testing.T) {
t.Parallel()
+67 -20
View File
@@ -20,6 +20,7 @@ import (
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/parametertrace"
"gpt-load/internal/protocol"
"gpt-load/internal/reasoning"
)
@@ -55,16 +56,23 @@ type streamSDKResult struct {
// Execute executes one non-streaming attempt.
func (r *Runtime) Execute(parent context.Context, spec execution.AttemptSpec) (result execution.AttemptResult) {
clientParameters := conversionClientParameters(&spec)
prepared, preflightError := r.prepare(spec, false)
if preflightError != nil {
return *preflightError
failure := *preflightError
failure.ConversionTrace = preflightConversionTrace(spec, failure, clientParameters)
return failure
}
var appliedReasoning *reasoning.Config
var conversionTrace *parametertrace.Trace
upstreamAPI := preparedUpstreamAPI(prepared, spec)
defer func() {
if result.AppliedReasoning == nil {
result.AppliedReasoning = appliedReasoning
}
if result.ConversionTrace == nil && prepared.mode == channel.RouteConverted {
result.ConversionTrace = finalConversionTrace(clientParameters, conversionTrace)
}
result.UpstreamAPI = upstreamAPI
}()
if prepared.passthrough != nil {
@@ -83,7 +91,7 @@ func (r *Runtime) Execute(parent context.Context, spec execution.AttemptSpec) (r
defer callCancel()
bifrostContext := r.newSDKContext(callContext, spec, prepared.directKey)
enableAppliedReasoningWireCapture(bifrostContext, prepared)
enableConvertedWireCapture(bifrostContext, prepared, clientParameters)
setTypedRequestURL(bifrostContext, prepared.typedURL)
outcomeChannel := make(chan unarySDKResult, 1)
go func() {
@@ -102,11 +110,14 @@ func (r *Runtime) Execute(parent context.Context, spec execution.AttemptSpec) (r
}
if outcome.err != nil {
appliedReasoning = takeAppliedReasoning(&outcome.err.ExtraFields.RawRequest)
captureWireObservation(&outcome.err.ExtraFields.RawRequest, clientParameters, &appliedReasoning, &conversionTrace)
if prepared.mode == channel.RouteConverted {
return convertedUnaryErrorResult(outcome.err, bifrostContext, prepared.secrets)
}
return unaryErrorResult(outcome.err, bifrostContext, prepared.secrets)
}
if outcome.response != nil {
appliedReasoning = takeAppliedReasoning(&outcome.response.ExtraFields.RawRequest)
captureWireObservation(&outcome.response.ExtraFields.RawRequest, clientParameters, &appliedReasoning, &conversionTrace)
}
if failure := largeUnaryResponseFailure(bifrostContext); failure != nil {
return *failure
@@ -152,16 +163,23 @@ func (r *Runtime) ExecuteStream(
if sink == nil {
return notSentStreamFailure(execution.ErrorKindInvalidRequest, "stream sink is required")
}
clientParameters := conversionClientParameters(&spec)
prepared, preflightError := r.prepare(spec, true)
if preflightError != nil {
return streamFromAttemptFailure(*preflightError)
failure := streamFromAttemptFailure(*preflightError)
failure.ConversionTrace = preflightConversionTrace(spec, *preflightError, clientParameters)
return failure
}
var appliedReasoning *reasoning.Config
var conversionTrace *parametertrace.Trace
upstreamAPI := preparedUpstreamAPI(prepared, spec)
defer func() {
if result.AppliedReasoning == nil {
result.AppliedReasoning = appliedReasoning
}
if result.ConversionTrace == nil && prepared.mode == channel.RouteConverted {
result.ConversionTrace = finalConversionTrace(clientParameters, conversionTrace)
}
result.UpstreamAPI = upstreamAPI
}()
if prepared.passthrough != nil {
@@ -179,7 +197,7 @@ func (r *Runtime) ExecuteStream(
defer preResponse.stop()
bifrostContext := r.newStreamingSDKContext(callContext, spec, prepared.directKey)
enableAppliedReasoningWireCapture(bifrostContext, prepared)
enableConvertedWireCapture(bifrostContext, prepared, clientParameters)
setTypedRequestURL(bifrostContext, prepared.typedURL)
outcomeChannel := make(chan streamSDKResult, 1)
go func() {
@@ -198,7 +216,10 @@ func (r *Runtime) ExecuteStream(
}
preResponse.stop()
if outcome.err != nil {
appliedReasoning = takeAppliedReasoning(&outcome.err.ExtraFields.RawRequest)
captureWireObservation(&outcome.err.ExtraFields.RawRequest, clientParameters, &appliedReasoning, &conversionTrace)
if prepared.mode == channel.RouteConverted {
return convertedStreamErrorResult(outcome.err, bifrostContext, prepared.secrets, false, 0, nil, "", nil)
}
return streamErrorResult(outcome.err, bifrostContext, prepared.secrets, false, 0, nil, "", nil)
}
if outcome.stream == nil {
@@ -263,9 +284,7 @@ func (r *Runtime) ExecuteStream(
return streamErrorResult(nil, bifrostContext, prepared.secrets, true, http.StatusOK, headers, model, usageEvidence)
}
if chunk.BifrostError != nil {
if captured := takeAppliedReasoning(&chunk.BifrostError.ExtraFields.RawRequest); captured != nil {
appliedReasoning = captured
}
captureWireObservation(&chunk.BifrostError.ExtraFields.RawRequest, clientParameters, &appliedReasoning, &conversionTrace)
callCancel()
return streamErrorResult(chunk.BifrostError, bifrostContext, prepared.secrets, true, http.StatusOK, headers, model, usageEvidence)
}
@@ -275,9 +294,7 @@ func (r *Runtime) ExecuteStream(
}
response := chunk.BifrostChatResponse
if captured := takeAppliedReasoning(&response.ExtraFields.RawRequest); captured != nil {
appliedReasoning = captured
}
captureWireObservation(&response.ExtraFields.RawRequest, clientParameters, &appliedReasoning, &conversionTrace)
if response.Model != "" {
model = response.Model
}
@@ -403,14 +420,36 @@ func (r *Runtime) prepare(spec execution.AttemptSpec, stream bool) (preparedAtte
}
mode := channel.RouteMode(spec.RouteMode)
declaredMode, ok := resolved.Mode(spec.ClientProtocol, spec.Operation)
if !ok || declaredMode != mode || !resolved.Supports(spec.ClientProtocol, spec.Operation, spec.RequiredFeatures) {
failure := notSentUnaryFailure(execution.ErrorKindInvalidRequest, "channel does not support the requested route")
if !ok || declaredMode != mode {
failure := notSentConversionFailure(
execution.ErrorCodeTargetConversionNotSupported,
"channel does not support the requested route",
)
return preparedAttempt{}, &failure
}
if !spec.RouteRequirement.Allows(execution.RouteMode(mode)) {
failure := notSentConversionFailure(
execution.ErrorCodeCriticalSemanticLoss,
"converted route cannot preserve provider resource semantics",
)
return preparedAttempt{}, &failure
}
if spec.Operation == execution.OperationResponsesCreate &&
spec.RouteRequirement.Normalize() == execution.RouteRequirementNative &&
!resolved.SupportsResponsesLifecycle() {
failure := notSentConversionFailure(
execution.ErrorCodeCriticalSemanticLoss,
"target does not support Responses resource lifecycle",
)
return preparedAttempt{}, &failure
}
if spec.Operation == execution.OperationResponsesPassthrough &&
providerKind != channel.ProviderOpenAI &&
providerKind != channel.ProviderOpenAICompatible {
failure := notSentUnaryFailure(execution.ErrorKindInvalidRequest, "Responses passthrough requires an OpenAI provider")
failure := notSentConversionFailure(
execution.ErrorCodeTargetConversionNotSupported,
"Responses passthrough requires an OpenAI provider",
)
return preparedAttempt{}, &failure
}
provider := r.fixedConfig.provider
@@ -420,7 +459,10 @@ func (r *Runtime) prepare(spec execution.AttemptSpec, stream bool) (preparedAtte
}
if mode == channel.RouteNative && spec.Operation == execution.OperationListModels &&
!providerKindNativeForClient(providerKind, spec.ClientProtocol) {
failure := notSentUnaryFailure(execution.ErrorKindInvalidRequest, "native route does not match the client protocol")
failure := notSentConversionFailure(
execution.ErrorCodeTargetConversionNotSupported,
"native route does not match the client protocol",
)
return preparedAttempt{}, &failure
}
safeQuery := safeAttemptQuery(spec)
@@ -513,6 +555,11 @@ func (r *Runtime) prepare(spec execution.AttemptSpec, stream bool) (preparedAtte
if spec.ClientProtocol != protocol.OpenAICompletions || spec.Operation != execution.OperationChatCompletion {
request, conversionErr := buildConvertedResponsesRequest(spec, provider)
if conversionErr != nil {
var classified interface{ ConversionCode() string }
if errors.As(conversionErr, &classified) {
failure := notSentConversionFailure(classified.ConversionCode(), conversionErr.Error())
return preparedAttempt{}, &failure
}
failure := notSentUnaryFailure(execution.ErrorKindInvalidRequest, conversionErr.Error())
return preparedAttempt{}, &failure
}
@@ -654,11 +701,11 @@ func supportedRequestShape(spec execution.AttemptSpec, stream bool) bool {
case execution.OperationResponsesRetrieve:
return spec.Method == http.MethodGet && validResponsesResourcePath(spec.Path, "")
case execution.OperationResponsesDelete:
return spec.Method == http.MethodDelete && validResponsesResourcePath(spec.Path, "")
return !stream && spec.Method == http.MethodDelete && validResponsesResourcePath(spec.Path, "")
case execution.OperationResponsesCancel:
return spec.Method == http.MethodPost && validResponsesResourcePath(spec.Path, "cancel")
return !stream && spec.Method == http.MethodPost && validResponsesResourcePath(spec.Path, "cancel")
case execution.OperationResponsesInputItems:
return spec.Method == http.MethodGet && validResponsesResourcePath(spec.Path, "input_items")
return !stream && spec.Method == http.MethodGet && validResponsesResourcePath(spec.Path, "input_items")
case execution.OperationResponsesCompact:
return !stream && spec.Method == http.MethodPost && spec.Path == "/v1/responses/compact"
case execution.OperationResponsesInputTokens:
+128 -33
View File
@@ -19,6 +19,7 @@ import (
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/parametertrace"
"gpt-load/internal/protocol"
"gpt-load/internal/usage"
)
@@ -388,6 +389,10 @@ func TestRuntimeRejectsInvalidAttemptBeforeDispatch(t *testing.T) {
if err := result.Validate(); err != nil {
t.Fatalf("result validation: %v", err)
}
if result.ConversionTrace != nil &&
result.ConversionTrace.State == parametertrace.CapturePreflightBlocked {
t.Fatalf("invalid request was mislabeled as conversion preflight block: %#v", result.ConversionTrace)
}
assertNoPrivateLeak(t, result, testAPIKey)
})
}
@@ -396,6 +401,109 @@ func TestRuntimeRejectsInvalidAttemptBeforeDispatch(t *testing.T) {
}
}
func TestRuntimeRejectsNativeRequirementOnConvertedRouteBeforeDispatch(t *testing.T) {
t.Parallel()
var calls atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
calls.Add(1)
}))
defer server.Close()
runtime := newTestRuntime(t)
spec := openAIResponsesSpec(execution.OperationResponsesCreate, http.MethodPost, "/v1/responses")
spec.ChannelID = string(channel.OpenAICompatible)
spec.TargetConfig = json.RawMessage(`{"base_url":"` + server.URL + `/v1"}`)
spec = freezeTestAttempt(spec)
if spec.RouteMode != execution.RouteConverted {
t.Fatalf("fixture route mode = %q, want converted", spec.RouteMode)
}
spec.RouteRequirement = execution.RouteRequirementNative
result := runtime.Execute(context.Background(), spec)
if result.DispatchState != execution.DispatchNotSent || result.Error == nil ||
result.Error.Kind != execution.ErrorKind("conversion_unsupported") ||
result.Error.Code != "critical_semantic_loss" {
t.Fatalf("result = %+v", result)
}
if result.ConversionTrace == nil ||
result.ConversionTrace.State != parametertrace.CapturePreflightBlocked {
t.Fatalf("preflight conversion trace = %#v", result.ConversionTrace)
}
if calls.Load() != 0 {
t.Fatalf("upstream calls = %d, want 0", calls.Load())
}
}
func TestRuntimeRejectsStatefulResponsesOnNativeTargetWithoutLifecycle(t *testing.T) {
t.Parallel()
var calls atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
calls.Add(1)
}))
defer server.Close()
spec := openAIResponsesSpec(execution.OperationResponsesCreate, http.MethodPost, "/v1/responses")
spec.ChannelID = string(channel.OpenRouter)
spec.TargetConfig = json.RawMessage(`{"base_url":"` + server.URL + `"}`)
spec = freezeTestAttempt(spec)
spec.RouteRequirement = execution.RouteRequirementNative
result := newTestRuntime(t).Execute(context.Background(), spec)
if result.DispatchState != execution.DispatchNotSent || result.ResponseStarted || result.Error == nil ||
result.Error.Kind != execution.ErrorKindConversionUnsupported ||
result.Error.Code != execution.ErrorCodeCriticalSemanticLoss {
t.Fatalf("result = %+v", result)
}
if calls.Load() != 0 {
t.Fatalf("upstream calls = %d, want 0", calls.Load())
}
}
func TestConvertedOpenAIChatSDKPreflightFailureIsRetryable(t *testing.T) {
t.Parallel()
var calls atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
calls.Add(1)
}))
defer server.Close()
runtime := newProtocolTestRuntime(t, testRuntimeOptions{
allowPrivateNetwork: true,
anthropicBaseURL: server.URL,
})
spec := convertedSpec(
channel.Anthropic,
protocol.OpenAICompletions,
execution.OperationChatCompletion,
"/v1/chat/completions",
[]byte(`{"model":"client-model","messages":[{"role":"user","content":"hello"}],"reasoning_effort":"high","max_completion_tokens":512}`),
)
assertFailure := func(t *testing.T, dispatch execution.DispatchState, started bool, evidence *execution.ErrorEvidence) {
t.Helper()
if dispatch != execution.DispatchNotSent || started || evidence == nil ||
evidence.Kind != execution.ErrorKindConversionUnsupported ||
evidence.Code != execution.ErrorCodeTargetSerializationFailed {
t.Fatalf("converted preflight result = dispatch=%q started=%t error=%#v", dispatch, started, evidence)
}
}
t.Run("unary", func(t *testing.T) {
result := runtime.Execute(context.Background(), spec)
assertFailure(t, result.DispatchState, result.ResponseStarted, result.Error)
})
t.Run("stream", func(t *testing.T) {
result := runtime.ExecuteStream(context.Background(), spec, func(execution.StreamEvent) error { return nil })
assertFailure(t, result.DispatchState, result.ResponseStarted, result.Error)
})
if calls.Load() != 0 {
t.Fatalf("upstream calls = %d, want 0", calls.Load())
}
}
func TestRuntimeAcceptsCanonicalOfficialTargetWithoutChangingCredential(t *testing.T) {
t.Parallel()
@@ -1103,43 +1211,30 @@ func TestManagedRuntimeRequiresExplicitStart(t *testing.T) {
}
}
func TestRuntimeCapabilities(t *testing.T) {
func TestSupportedRequestShapeRejectsStreamingForNonStreamingResponsesOperations(t *testing.T) {
t.Parallel()
runtime := newTestRuntime(t)
required, err := execution.NewFeatureSet(execution.FeatureStreaming, execution.FeatureTools)
if err != nil {
t.Fatal(err)
tests := []struct {
name string
operation execution.Operation
method string
path string
}{
{name: "delete", operation: execution.OperationResponsesDelete, method: http.MethodDelete, path: "/v1/responses/resp_123"},
{name: "cancel", operation: execution.OperationResponsesCancel, method: http.MethodPost, path: "/v1/responses/resp_123/cancel"},
{name: "input items", operation: execution.OperationResponsesInputItems, method: http.MethodGet, path: "/v1/responses/resp_123/input_items"},
}
if !runtime.Capabilities().Supports(execution.OperationChatCompletion, required) {
t.Fatal("chat streaming/tools capability missing")
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
spec := openAIResponsesSpec(test.operation, test.method, test.path)
if supportedRequestShape(spec, true) {
t.Fatal("supportedRequestShape(stream=true) = true, want false")
}
if !supportedRequestShape(spec, false) {
t.Fatal("supportedRequestShape(stream=false) = false, want true")
}
})
}
if !runtime.Capabilities().Supports(execution.OperationResponsesCreate, required) {
t.Fatal("Responses streaming/tools capability missing")
}
resourceFeatures, err := execution.NewFeatureSet(execution.FeatureNativeResourceSemantics)
if err != nil {
t.Fatal(err)
}
if !runtime.Capabilities().Supports(execution.OperationResponsesRetrieve, resourceFeatures) {
t.Fatal("Responses resource capability missing")
}
if !runtime.Capabilities().Supports(execution.OperationResponsesCompact, mustTestFeatures(t, execution.FeatureTools)) {
t.Fatal("Responses compact tools capability missing")
}
if runtime.Capabilities().Supports(execution.OperationResponsesCompact, mustTestFeatures(t, execution.FeatureStreaming)) ||
runtime.Capabilities().Supports(execution.OperationResponsesInputTokens, resourceFeatures) {
t.Fatal("non-streaming Responses utility capability was overstated")
}
}
func mustTestFeatures(t *testing.T, features ...execution.Feature) execution.FeatureSet {
t.Helper()
set, err := execution.NewFeatureSet(features...)
if err != nil {
t.Fatal(err)
}
return set
}
func newTestRuntime(t *testing.T) *testRuntime {
+66
View File
@@ -428,6 +428,19 @@ func unaryErrorResult(bifrostError *schemas.BifrostError, bifrostContext *schema
return result
}
func convertedUnaryErrorResult(
bifrostError *schemas.BifrostError,
bifrostContext *schemas.BifrostContext,
secrets []string,
) execution.AttemptResult {
result := unaryErrorResult(bifrostError, bifrostContext, secrets)
if convertedSDKPreflightFailure(bifrostError, result.ResponseStarted) {
result.DispatchState = execution.DispatchNotSent
result.Error = convertedSerializationEvidence()
}
return result
}
func streamErrorResult(
bifrostError *schemas.BifrostError,
bifrostContext *schemas.BifrostContext,
@@ -457,6 +470,46 @@ func streamErrorResult(
return result
}
func convertedStreamErrorResult(
bifrostError *schemas.BifrostError,
bifrostContext *schemas.BifrostContext,
secrets []string,
alreadyStarted bool,
initialStatus int,
headers http.Header,
model string,
usageEvidence *execution.UsageEvidence,
) execution.StreamResult {
result := streamErrorResult(
bifrostError,
bifrostContext,
secrets,
alreadyStarted,
initialStatus,
headers,
model,
usageEvidence,
)
if !alreadyStarted && convertedSDKPreflightFailure(bifrostError, result.ResponseStarted) {
result.DispatchState = execution.DispatchNotSent
result.Error = convertedSerializationEvidence()
}
return result
}
func convertedSDKPreflightFailure(bifrostError *schemas.BifrostError, responseStarted bool) bool {
return !responseStarted && bifrostError != nil && bifrostError.IsBifrostError &&
strings.EqualFold(strings.TrimSpace(errorType(bifrostError)), "invalid_request_error")
}
func convertedSerializationEvidence() *execution.ErrorEvidence {
return &execution.ErrorEvidence{
Kind: execution.ErrorKindConversionUnsupported,
Code: execution.ErrorCodeTargetSerializationFailed,
Summary: "target request serialization failed",
}
}
func sdkErrorDispatchState(bifrostError *schemas.BifrostError, responseStarted bool) execution.DispatchState {
if responseStarted || bifrostError == nil || bifrostError.Error == nil || bifrostError.Error.Error == nil {
return execution.DispatchMaybeSent
@@ -615,6 +668,8 @@ func errorSummary(kind execution.ErrorKind, status int) string {
return "upstream provider failed"
case execution.ErrorKindInvalidRequest:
return "upstream rejected the request"
case execution.ErrorKindConversionUnsupported:
return "target protocol conversion is not supported"
default:
return "execution adapter failed"
}
@@ -742,6 +797,17 @@ func notSentUnaryFailure(kind execution.ErrorKind, summary string) execution.Att
}
}
func notSentConversionFailure(code, summary string) execution.AttemptResult {
return execution.AttemptResult{
DispatchState: execution.DispatchNotSent,
Error: &execution.ErrorEvidence{
Kind: execution.ErrorKindConversionUnsupported,
Code: code,
Summary: summary,
},
}
}
func attemptedUnaryFailure(kind execution.ErrorKind, summary string) execution.AttemptResult {
return execution.AttemptResult{
DispatchState: execution.DispatchMaybeSent,
@@ -13,6 +13,7 @@ import (
"github.com/maximhq/bifrost/core/schemas"
"gpt-load/internal/execution"
"gpt-load/internal/protocol"
)
func TestSDKStreamIdleTimeoutIsClassifiedAsTimeout(t *testing.T) {
@@ -32,6 +33,10 @@ func TestSDKStreamIdleTimeoutIsClassifiedAsTimeout(t *testing.T) {
}
}
type conversionCodedError interface {
ConversionCode() string
}
func TestPassthroughHTTPErrorProducesNeutralFailureHints(t *testing.T) {
tests := []struct {
name string
@@ -170,3 +175,55 @@ func TestSDKTransportErrorAfterStreamStartIsAlwaysMaybeSent(t *testing.T) {
t.Fatalf("result = %+v", result)
}
}
func TestConvertedSDKPreflightFailureIsAGroupScopedConversionFailure(t *testing.T) {
t.Parallel()
errorType := "invalid_request_error"
bifrostError := &schemas.BifrostError{
IsBifrostError: true,
Error: &schemas.ErrorField{
Type: &errorType,
Message: "cannot marshal target request",
},
}
context := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
unary := convertedUnaryErrorResult(bifrostError, context, nil)
if unary.DispatchState != execution.DispatchNotSent || unary.ResponseStarted || unary.Error == nil ||
unary.Error.Kind != execution.ErrorKindConversionUnsupported ||
unary.Error.Code != execution.ErrorCodeTargetSerializationFailed {
t.Fatalf("converted unary result = %+v", unary)
}
stream := convertedStreamErrorResult(bifrostError, context, nil, false, 0, nil, "", nil)
if stream.DispatchState != execution.DispatchNotSent || stream.ResponseStarted || stream.Error == nil ||
stream.Error.Kind != execution.ErrorKindConversionUnsupported ||
stream.Error.Code != execution.ErrorCodeTargetSerializationFailed {
t.Fatalf("converted stream result = %+v", stream)
}
}
func TestBuildConvertedRequestDistinguishesMalformedInputFromUnsupportedTargetConversion(t *testing.T) {
t.Parallel()
_, unsupportedErr := buildConvertedResponsesRequest(execution.AttemptSpec{
ClientProtocol: protocol.Anthropic,
Operation: execution.OperationResponsesCreate,
Body: []byte(`{"model":"claude","messages":[]}`),
}, schemas.OpenAI)
var classified conversionCodedError
if !errors.As(unsupportedErr, &classified) ||
classified.ConversionCode() != execution.ErrorCodeTargetConversionNotSupported {
t.Fatalf("unsupported error = %T %v", unsupportedErr, unsupportedErr)
}
_, malformedErr := buildConvertedResponsesRequest(execution.AttemptSpec{
ClientProtocol: protocol.Anthropic,
Operation: execution.OperationChatCompletion,
Body: []byte(`{"model":`),
}, schemas.OpenAI)
classified = nil
if malformedErr == nil || errors.As(malformedErr, &classified) {
t.Fatalf("malformed error = %T %v, want unclassified client error", malformedErr, malformedErr)
}
}
@@ -110,7 +110,9 @@ func TestResponsesPassthroughFailsClosedForNonOpenAIProvider(t *testing.T) {
spec.ClientModel = ""
spec.UpstreamModel = ""
result := runtime.Execute(context.Background(), spec)
if result.DispatchState != execution.DispatchNotSent || result.ResponseStarted || result.Error == nil || result.Error.Kind != execution.ErrorKindInvalidRequest {
if result.DispatchState != execution.DispatchNotSent || result.ResponseStarted || result.Error == nil ||
result.Error.Kind != execution.ErrorKindConversionUnsupported ||
result.Error.Code != execution.ErrorCodeTargetConversionNotSupported {
t.Fatalf("result = %+v", result)
}
}
@@ -129,7 +131,9 @@ func TestGenericOpenAICompatibleDoesNotAdvertiseUnknownResponsesPassthrough(t *t
spec.TargetKind = string(channel.ProviderOpenAICompatible)
spec.RouteMode = execution.RouteNative
result := newProtocolTestRuntime(t, testRuntimeOptions{allowPrivateNetwork: true}).Execute(context.Background(), spec)
if calls.Load() != 0 || result.DispatchState != execution.DispatchNotSent || result.Error == nil || result.Error.Kind != execution.ErrorKindInvalidRequest {
if calls.Load() != 0 || result.DispatchState != execution.DispatchNotSent || result.Error == nil ||
result.Error.Kind != execution.ErrorKindConversionUnsupported ||
result.Error.Code != execution.ErrorCodeTargetConversionNotSupported {
t.Fatalf("calls/result = %d/%+v", calls.Load(), result)
}
}
-67
View File
@@ -30,7 +30,6 @@ type Runtime struct {
core *core.Bifrost
account *directAccount
registry *channel.Registry
capabilities execution.CapabilitySet
allowPrivate bool
maxUnaryResponseBodyBytes int64
stateMu sync.Mutex
@@ -102,67 +101,9 @@ func newRuntimeShell(options runtimeOptions, registry *channel.Registry) (*Runti
if maxUnaryResponseBodyBytes <= 0 {
maxUnaryResponseBodyBytes = defaultMaxUnaryResponseBodyBytes
}
features, err := execution.NewFeatureSet(
execution.FeatureStreaming,
execution.FeatureTools,
execution.FeatureReasoning,
execution.FeatureMultimodal,
execution.FeatureStructuredOutput,
)
if err != nil {
return nil, fmt.Errorf("initialize execution capabilities: %w", err)
}
resourceFeatures, err := execution.NewFeatureSet(execution.FeatureNativeResourceSemantics)
if err != nil {
return nil, fmt.Errorf("initialize resource capabilities: %w", err)
}
responsesFeatures, err := execution.NewFeatureSet(
execution.FeatureStreaming,
execution.FeatureTools,
execution.FeatureReasoning,
execution.FeatureMultimodal,
execution.FeatureStructuredOutput,
execution.FeatureNativeResourceSemantics,
)
if err != nil {
return nil, fmt.Errorf("initialize responses capabilities: %w", err)
}
passthroughFeatures, err := execution.NewFeatureSet(
execution.FeatureStreaming,
execution.FeatureNativeResourceSemantics,
)
if err != nil {
return nil, fmt.Errorf("initialize passthrough capabilities: %w", err)
}
responsesUtilityFeatures, err := execution.NewFeatureSet(
execution.FeatureTools,
execution.FeatureReasoning,
execution.FeatureMultimodal,
execution.FeatureStructuredOutput,
)
if err != nil {
return nil, fmt.Errorf("initialize Responses utility capabilities: %w", err)
}
capabilities, err := execution.NewCapabilitySet(
execution.Capability{Operation: execution.OperationChatCompletion, Features: features},
execution.Capability{Operation: execution.OperationResponsesCreate, Features: responsesFeatures},
execution.Capability{Operation: execution.OperationResponsesRetrieve, Features: resourceFeatures},
execution.Capability{Operation: execution.OperationResponsesDelete, Features: resourceFeatures},
execution.Capability{Operation: execution.OperationResponsesCancel, Features: resourceFeatures},
execution.Capability{Operation: execution.OperationResponsesInputItems, Features: resourceFeatures},
execution.Capability{Operation: execution.OperationResponsesCompact, Features: responsesUtilityFeatures},
execution.Capability{Operation: execution.OperationResponsesInputTokens, Features: responsesUtilityFeatures},
execution.Capability{Operation: execution.OperationResponsesPassthrough, Features: passthroughFeatures},
execution.Capability{Operation: execution.OperationListModels},
execution.Capability{Operation: execution.OperationProbe},
)
if err != nil {
return nil, fmt.Errorf("initialize execution capabilities: %w", err)
}
return &Runtime{
account: newDirectAccount(),
registry: registry,
capabilities: capabilities,
allowPrivate: options.allowPrivateNetwork,
maxUnaryResponseBodyBytes: maxUnaryResponseBodyBytes,
shutdownDone: make(chan struct{}),
@@ -202,14 +143,6 @@ func (r *Runtime) Start(ctx context.Context) error {
return nil
}
// Capabilities returns an independent capability snapshot.
func (r *Runtime) Capabilities() execution.CapabilitySet {
if r == nil {
return execution.CapabilitySet{}
}
return r.capabilities.Clone()
}
// BeginShutdown rejects new execution and starts releasing the shared SDK
// runtime without waiting for provider workers that may be blocked in the SDK.
func (r *Runtime) BeginShutdown() <-chan struct{} {
+6 -17
View File
@@ -145,10 +145,9 @@ type runtimeManager struct {
// RuntimeManager owns lazily-created Bifrost cores keyed by canonical provider
// construction config.
type RuntimeManager struct {
registry *channel.Registry
options runtimeOptions
pool *runtimeManager
capabilities execution.CapabilitySet
registry *channel.Registry
options runtimeOptions
pool *runtimeManager
mu sync.Mutex
started bool
@@ -159,14 +158,12 @@ type RuntimeManager struct {
func newRuntimeManager(options runtimeOptions, registry *channel.Registry) (*RuntimeManager, error) {
options = normalizeRuntimeOptions(options)
template, err := newRuntimeShell(options, registry)
if err != nil {
if _, err := newRuntimeShell(options, registry); err != nil {
return nil, err
}
manager := &RuntimeManager{
registry: registry,
options: options,
capabilities: template.capabilities.Clone(),
registry: registry,
options: options,
}
manager.pool = newRuntimeManagerPool(func(_ context.Context, config effectiveProviderConfig) (managedProviderRuntime, error) {
manager.mu.Lock()
@@ -405,14 +402,6 @@ func (manager *RuntimeManager) Start(ctx context.Context) error {
return nil
}
// Capabilities returns an independent adapter capability snapshot.
func (manager *RuntimeManager) Capabilities() execution.CapabilitySet {
if manager == nil {
return execution.CapabilitySet{}
}
return manager.capabilities.Clone()
}
// Execute acquires exactly one runtime lease for the frozen attempt target.
func (manager *RuntimeManager) Execute(parent context.Context, spec execution.AttemptSpec) execution.AttemptResult {
config, failure := manager.configForAttempt(spec)
@@ -747,10 +747,6 @@ func (*fakeManagedRuntime) ExecuteStream(context.Context, execution.AttemptSpec,
return execution.StreamResult{}
}
func (*fakeManagedRuntime) Capabilities() execution.CapabilitySet {
return execution.CapabilitySet{}
}
func (runtime *fakeManagedRuntime) BeginShutdown() <-chan struct{} {
runtime.once.Do(func() {
runtime.shutdowns.Add(1)
@@ -60,10 +60,6 @@ func (runtime *testRuntime) ExecuteStream(
return runtime.manager.ExecuteStream(ctx, runtime.withBaseURL(spec), sink)
}
func (runtime *testRuntime) Capabilities() execution.CapabilitySet {
return runtime.manager.Capabilities()
}
func (runtime *testRuntime) Shutdown() {
runtime.manager.Shutdown()
}
+118 -15
View File
@@ -9,16 +9,22 @@ import (
"github.com/maximhq/bifrost/core/schemas"
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/parametertrace"
"gpt-load/internal/reasoning"
)
const maxAppliedReasoningValueBytes = 64
// enableAppliedReasoningWireCapture asks Bifrost to return the final serialized
// provider request. The caller must remove RawRequest immediately after reading
// the small reasoning projection from it.
func enableAppliedReasoningWireCapture(ctx *schemas.BifrostContext, prepared preparedAttempt) {
if ctx == nil || !preparedNeedsAppliedReasoningWireCapture(prepared) {
// enableConvertedWireCapture asks Bifrost to return the final serialized
// provider request for one bounded converted attempt. The caller must remove
// RawRequest immediately after extracting the safe projection.
func enableConvertedWireCapture(
ctx *schemas.BifrostContext,
prepared preparedAttempt,
client *parametertrace.Snapshot,
) {
if ctx == nil || prepared.mode != channel.RouteConverted || !capturableSnapshot(client) {
return
}
ctx.SetValue(schemas.BifrostContextKeyAllowPerRequestRawOverride, true)
@@ -26,16 +32,9 @@ func enableAppliedReasoningWireCapture(ctx *schemas.BifrostContext, prepared pre
ctx.SetValue(schemas.BifrostContextKeySendBackRawResponse, false)
}
func preparedNeedsAppliedReasoningWireCapture(prepared preparedAttempt) bool {
if prepared.mode != channel.RouteConverted {
return false
}
if prepared.responsesRequest != nil && prepared.responsesRequest.Params != nil &&
prepared.responsesRequest.Params.Reasoning != nil {
return true
}
return prepared.request != nil && prepared.request.Params != nil &&
prepared.request.Params.Reasoning != nil
func capturableSnapshot(snapshot *parametertrace.Snapshot) bool {
return snapshot != nil &&
(snapshot.State == parametertrace.CaptureCaptured || snapshot.State == parametertrace.CapturePartial)
}
// takeAppliedReasoning clears the sensitive raw request before parsing it and
@@ -54,6 +53,110 @@ func takeAppliedReasoning(rawRequest *any) *reasoning.Config {
return inspectWireAppliedReasoning(body)
}
// takeWireObservation clears the sensitive raw request before parsing it and
// returns only the bounded target parameter projection plus the legacy
// reasoning projection used elsewhere in request-log summaries.
func takeWireObservation(
rawRequest *any,
client *parametertrace.Snapshot,
) (*reasoning.Config, *parametertrace.Trace) {
if rawRequest == nil || *rawRequest == nil {
return nil, defaultConversionTrace(client)
}
raw := *rawRequest
*rawRequest = nil
body, ok := rawRequestJSON(raw)
if !ok {
return nil, defaultConversionTrace(client)
}
reasoningConfig := inspectWireAppliedReasoning(body)
if client == nil {
return reasoningConfig, nil
}
target := parametertrace.ProjectJSON(body)
trace := parametertrace.Compare(*client, target)
return reasoningConfig, &trace
}
func defaultConversionTrace(client *parametertrace.Snapshot) *parametertrace.Trace {
if client == nil {
return nil
}
state := parametertrace.CaptureUnavailable
if client.State == parametertrace.CaptureSkippedOversize {
state = parametertrace.CaptureSkippedOversize
}
trace := parametertrace.Trace{
SchemaVersion: parametertrace.SchemaVersion,
State: state,
Target: parametertrace.Snapshot{
SchemaVersion: parametertrace.SchemaVersion,
State: state,
Entries: []parametertrace.Entry{},
},
Changes: []parametertrace.Change{},
}
return &trace
}
func conversionClientParameters(spec *execution.AttemptSpec) *parametertrace.Snapshot {
if spec == nil || spec.RouteMode != execution.RouteConverted {
return nil
}
if spec.ClientParameters != nil {
parameters := parametertrace.CloneSnapshot(*spec.ClientParameters)
spec.ClientParameters = &parameters
return spec.ClientParameters
}
parameters := parametertrace.ProjectJSON(spec.Body)
spec.ClientParameters = &parameters
return spec.ClientParameters
}
func captureWireObservation(
rawRequest *any,
client *parametertrace.Snapshot,
appliedReasoning **reasoning.Config,
conversionTrace **parametertrace.Trace,
) {
if rawRequest == nil || *rawRequest == nil {
return
}
reasoningConfig, trace := takeWireObservation(rawRequest, client)
if reasoningConfig != nil {
*appliedReasoning = reasoningConfig
}
if trace != nil {
*conversionTrace = trace
}
}
func finalConversionTrace(
client *parametertrace.Snapshot,
trace *parametertrace.Trace,
) *parametertrace.Trace {
if trace != nil {
clone := parametertrace.CloneTrace(*trace)
return &clone
}
return defaultConversionTrace(client)
}
func preflightConversionTrace(
spec execution.AttemptSpec,
failure execution.AttemptResult,
client *parametertrace.Snapshot,
) *parametertrace.Trace {
if spec.RouteMode != execution.RouteConverted {
return nil
}
if failure.Error == nil || failure.Error.Kind != execution.ErrorKindConversionUnsupported {
return defaultConversionTrace(client)
}
trace := parametertrace.PreflightBlocked()
return &trace
}
func rawRequestJSON(raw any) ([]byte, bool) {
switch value := raw.(type) {
case json.RawMessage:
@@ -9,6 +9,7 @@ import (
"github.com/maximhq/bifrost/core/schemas"
"gpt-load/internal/channel"
"gpt-load/internal/parametertrace"
"gpt-load/internal/reasoning"
)
@@ -90,35 +91,36 @@ func TestTakeAppliedReasoningAlwaysClearsRawRequest(t *testing.T) {
}
}
func TestEnableAppliedReasoningWireCaptureRequiresConvertedReasoning(t *testing.T) {
func TestEnableConvertedWireCaptureRequiresBoundedConvertedRequest(t *testing.T) {
t.Parallel()
client := parametertrace.ProjectJSON([]byte(`{"messages":[{"role":"user","content":"secret"}]}`))
native := schemas.NewBifrostContext(t.Context(), schemas.NoDeadline)
defer native.Cancel()
enableAppliedReasoningWireCapture(native, preparedAttempt{
enableConvertedWireCapture(native, preparedAttempt{
mode: channel.RouteNative,
request: &schemas.BifrostChatRequest{Params: &schemas.ChatParameters{Reasoning: &schemas.ChatReasoning{}}},
})
}, &client)
if _, exists := native.Value(schemas.BifrostContextKeySendBackRawRequest).(bool); exists {
t.Fatal("native request enabled raw capture")
}
withoutReasoning := schemas.NewBifrostContext(t.Context(), schemas.NoDeadline)
defer withoutReasoning.Cancel()
enableAppliedReasoningWireCapture(withoutReasoning, preparedAttempt{
enableConvertedWireCapture(withoutReasoning, preparedAttempt{
mode: channel.RouteConverted,
request: &schemas.BifrostChatRequest{Params: &schemas.ChatParameters{}},
})
if _, exists := withoutReasoning.Value(schemas.BifrostContextKeySendBackRawRequest).(bool); exists {
t.Fatal("converted request without reasoning enabled raw capture")
}, &client)
if value, _ := withoutReasoning.Value(schemas.BifrostContextKeySendBackRawRequest).(bool); !value {
t.Fatal("bounded converted request did not enable raw capture")
}
converted := schemas.NewBifrostContext(t.Context(), schemas.NoDeadline)
defer converted.Cancel()
enableAppliedReasoningWireCapture(converted, preparedAttempt{
enableConvertedWireCapture(converted, preparedAttempt{
mode: channel.RouteConverted,
request: &schemas.BifrostChatRequest{Params: &schemas.ChatParameters{Reasoning: &schemas.ChatReasoning{}}},
})
}, &client)
for _, key := range []schemas.BifrostContextKey{
schemas.BifrostContextKeyAllowPerRequestRawOverride,
schemas.BifrostContextKeySendBackRawRequest,
@@ -130,4 +132,38 @@ func TestEnableAppliedReasoningWireCaptureRequiresConvertedReasoning(t *testing.
if value, ok := converted.Value(schemas.BifrostContextKeySendBackRawResponse).(bool); !ok || value {
t.Fatalf("raw response override = %#v", converted.Value(schemas.BifrostContextKeySendBackRawResponse))
}
oversize := parametertrace.Snapshot{
SchemaVersion: parametertrace.SchemaVersion,
State: parametertrace.CaptureSkippedOversize,
Entries: []parametertrace.Entry{},
}
skipped := schemas.NewBifrostContext(t.Context(), schemas.NoDeadline)
defer skipped.Cancel()
enableConvertedWireCapture(skipped, preparedAttempt{mode: channel.RouteConverted}, &oversize)
if _, exists := skipped.Value(schemas.BifrostContextKeySendBackRawRequest).(bool); exists {
t.Fatal("oversize request enabled raw capture")
}
}
func TestTakeWireObservationClearsRawAndComparesSafeParameters(t *testing.T) {
client := parametertrace.ProjectJSON([]byte(`{"thinking":{"type":"enabled","budget_tokens":4096},"messages":[{"role":"user","content":"secret prompt"}]}`))
raw := any(json.RawMessage(`{"reasoning_effort":"high","messages":[{"role":"user","content":"secret prompt"}]}`))
reasoningConfig, trace := takeWireObservation(&raw, &client)
if raw != nil {
t.Fatalf("raw request was retained: %#v", raw)
}
if !reflect.DeepEqual(reasoningConfig, &reasoning.Config{Effort: "high"}) {
t.Fatalf("reasoning = %#v", reasoningConfig)
}
if trace == nil || trace.State != parametertrace.CaptureCaptured || len(trace.Changes) == 0 {
t.Fatalf("trace = %#v", trace)
}
encoded, err := json.Marshal(trace)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), "secret prompt") {
t.Fatalf("trace leaked prompt: %s", encoded)
}
}
+80 -200
View File
@@ -7,9 +7,9 @@ import (
"fmt"
"net/http"
"net/url"
"sort"
"time"
"gpt-load/internal/parametertrace"
"gpt-load/internal/protocol"
"gpt-load/internal/reasoning"
"gpt-load/internal/usage"
@@ -113,184 +113,41 @@ func (m RouteMode) Valid() bool {
return m == RouteNative || m == RouteConverted
}
// Feature identifies an optional execution capability.
type Feature string
// RouteRequirement describes whether a request may use a converted route.
// It protects operation and provider-resource semantics, not optional model
// capabilities such as tools, reasoning, or multimodal input.
type RouteRequirement string
const (
FeatureStreaming Feature = "streaming"
FeatureTools Feature = "tools"
FeatureReasoning Feature = "reasoning"
FeatureMultimodal Feature = "multimodal"
FeatureStructuredOutput Feature = "structured_output"
FeatureNativeResourceSemantics Feature = "native_resource_semantics"
RouteRequirementAny RouteRequirement = "any"
RouteRequirementNative RouteRequirement = "native"
)
// Valid reports whether the feature is recognized.
func (f Feature) Valid() bool {
switch f {
case FeatureStreaming,
FeatureTools,
FeatureReasoning,
FeatureMultimodal,
FeatureStructuredOutput,
FeatureNativeResourceSemantics:
// Normalize maps the internal zero value to the default best-effort policy.
func (r RouteRequirement) Normalize() RouteRequirement {
if r == "" {
return RouteRequirementAny
}
return r
}
// Valid reports whether the normalized requirement is recognized.
func (r RouteRequirement) Valid() bool {
switch r.Normalize() {
case RouteRequirementAny, RouteRequirementNative:
return true
default:
return false
}
}
// FeatureSet is an immutable set of optional execution capabilities.
type FeatureSet struct {
features map[Feature]struct{}
}
// NewFeatureSet constructs a feature set.
func NewFeatureSet(features ...Feature) (FeatureSet, error) {
set := FeatureSet{features: make(map[Feature]struct{}, len(features))}
for _, feature := range features {
if !feature.Valid() {
return FeatureSet{}, &ValidationError{Field: "feature", Reason: "unsupported value"}
}
set.features[feature] = struct{}{}
}
return set, nil
}
// Has reports whether feature is present.
func (s FeatureSet) Has(feature Feature) bool {
_, ok := s.features[feature]
return ok
}
// Features returns a stable snapshot of the set.
func (s FeatureSet) Features() []Feature {
features := make([]Feature, 0, len(s.features))
for feature := range s.features {
features = append(features, feature)
}
sort.Slice(features, func(i, j int) bool {
return features[i] < features[j]
})
return features
}
// Clone returns an independent feature set.
func (s FeatureSet) Clone() FeatureSet {
clone := FeatureSet{features: make(map[Feature]struct{}, len(s.features))}
for feature := range s.features {
clone.features[feature] = struct{}{}
}
return clone
}
// Validate validates all features in the set.
func (s FeatureSet) Validate() error {
for feature := range s.features {
if !feature.Valid() {
return &ValidationError{Field: "feature", Reason: "unsupported value"}
}
}
return nil
}
// Capability binds an operation to the optional features supported for it.
type Capability struct {
Operation Operation
Features FeatureSet
}
// CapabilitySet is an immutable set of supported operations and features.
type CapabilitySet struct {
capabilities map[Operation]FeatureSet
}
// NewCapabilitySet constructs a capability set.
func NewCapabilitySet(capabilities ...Capability) (CapabilitySet, error) {
set := CapabilitySet{capabilities: make(map[Operation]FeatureSet, len(capabilities))}
for _, capability := range capabilities {
if !capability.Operation.Valid() {
return CapabilitySet{}, &ValidationError{Field: "operation", Reason: "unsupported value"}
}
if err := capability.Features.Validate(); err != nil {
return CapabilitySet{}, err
}
features, ok := set.capabilities[capability.Operation]
if !ok {
features = FeatureSet{features: make(map[Feature]struct{})}
}
for feature := range capability.Features.features {
features.features[feature] = struct{}{}
}
set.capabilities[capability.Operation] = features
}
return set, nil
}
// Has reports whether operation is supported.
func (s CapabilitySet) Has(operation Operation) bool {
_, ok := s.capabilities[operation]
return ok
}
// Supports reports whether operation and all required features are supported.
func (s CapabilitySet) Supports(operation Operation, required FeatureSet) bool {
if !operation.Valid() || required.Validate() != nil {
// Allows reports whether the selected wire route mode satisfies the native
// requirement. Target operation coverage is validated separately.
func (r RouteRequirement) Allows(mode RouteMode) bool {
if !mode.Valid() || !r.Valid() {
return false
}
available, ok := s.capabilities[operation]
if !ok {
return false
}
for feature := range required.features {
if !available.Has(feature) {
return false
}
}
return true
}
// Features returns an independent set of features supported for operation.
func (s CapabilitySet) Features(operation Operation) FeatureSet {
features, ok := s.capabilities[operation]
if !ok {
return FeatureSet{features: make(map[Feature]struct{})}
}
return features.Clone()
}
// Operations returns a stable snapshot of supported operations.
func (s CapabilitySet) Operations() []Operation {
operations := make([]Operation, 0, len(s.capabilities))
for operation := range s.capabilities {
operations = append(operations, operation)
}
sort.Slice(operations, func(i, j int) bool {
return operations[i] < operations[j]
})
return operations
}
// Clone returns an independent capability set.
func (s CapabilitySet) Clone() CapabilitySet {
clone := CapabilitySet{capabilities: make(map[Operation]FeatureSet, len(s.capabilities))}
for operation, features := range s.capabilities {
clone.capabilities[operation] = features.Clone()
}
return clone
}
// Validate validates all operations and features in the set.
func (s CapabilitySet) Validate() error {
for operation, features := range s.capabilities {
if !operation.Valid() {
return &ValidationError{Field: "operation", Reason: "unsupported value"}
}
if err := features.Validate(); err != nil {
return err
}
}
return nil
return r.Normalize() == RouteRequirementAny || mode == RouteNative
}
// CredentialSnapshot is the exact logical credential selected for an attempt.
@@ -357,7 +214,7 @@ type AttemptSpec struct {
RouteMode RouteMode `json:"route_mode"`
ClientProtocol protocol.Protocol `json:"client_protocol"`
Operation Operation `json:"operation"`
RequiredFeatures FeatureSet `json:"-"`
RouteRequirement RouteRequirement `json:"route_requirement"`
ClientModel string `json:"client_model,omitempty"`
UpstreamModel string `json:"upstream_model,omitempty"`
Method string `json:"method"`
@@ -368,6 +225,9 @@ type AttemptSpec struct {
RawQuery string `json:"raw_query,omitempty"`
Header http.Header `json:"header,omitempty"`
Body []byte `json:"body,omitempty"`
// ClientParameters is a bounded, content-free projection captured once at
// the gateway boundary for conversion diagnostics.
ClientParameters *parametertrace.Snapshot `json:"client_parameters,omitempty"`
// IncludeUsage asks the executor to request provider usage details when the
// selected operation supports an explicit wire option.
IncludeUsage bool `json:"include_usage,omitempty"`
@@ -379,18 +239,22 @@ type AttemptSpec struct {
// NewAttemptSpec takes ownership of an independent clone of spec.
func NewAttemptSpec(spec AttemptSpec) AttemptSpec {
spec.RouteRequirement = spec.RouteRequirement.Normalize()
return spec.Clone()
}
// Clone returns an independent attempt specification.
func (s AttemptSpec) Clone() AttemptSpec {
clone := s
clone.RequiredFeatures = s.RequiredFeatures.Clone()
clone.Query = cloneValues(s.Query)
clone.Header = cloneHeader(s.Header)
clone.Body = cloneBytes(s.Body)
clone.TargetConfig = cloneRawMessage(s.TargetConfig)
clone.Credential = s.Credential.Clone()
if s.ClientParameters != nil {
parameters := parametertrace.CloneSnapshot(*s.ClientParameters)
clone.ClientParameters = &parameters
}
return clone
}
@@ -414,13 +278,20 @@ const MaxErrorSummaryLength = 4096
type ErrorKind string
const (
ErrorKindTransport ErrorKind = "transport"
ErrorKindTimeout ErrorKind = "timeout"
ErrorKindCanceled ErrorKind = "canceled"
ErrorKindHTTP ErrorKind = "http"
ErrorKindProvider ErrorKind = "provider"
ErrorKindInvalidRequest ErrorKind = "invalid_request"
ErrorKindInternal ErrorKind = "internal"
ErrorKindTransport ErrorKind = "transport"
ErrorKindTimeout ErrorKind = "timeout"
ErrorKindCanceled ErrorKind = "canceled"
ErrorKindHTTP ErrorKind = "http"
ErrorKindProvider ErrorKind = "provider"
ErrorKindInvalidRequest ErrorKind = "invalid_request"
ErrorKindConversionUnsupported ErrorKind = "conversion_unsupported"
ErrorKindInternal ErrorKind = "internal"
)
const (
ErrorCodeCriticalSemanticLoss = "critical_semantic_loss"
ErrorCodeTargetConversionNotSupported = "target_conversion_not_supported"
ErrorCodeTargetSerializationFailed = "target_serialization_failed"
)
// Valid reports whether the error kind is recognized.
@@ -432,6 +303,7 @@ func (k ErrorKind) Valid() bool {
ErrorKindHTTP,
ErrorKindProvider,
ErrorKindInvalidRequest,
ErrorKindConversionUnsupported,
ErrorKindInternal:
return true
default:
@@ -499,17 +371,18 @@ func (e UsageEvidence) Clone() UsageEvidence {
// AttemptResult is the terminal result of a non-streaming attempt.
// The result owns Header, Body, Usage, and Error after return from Executor.
type AttemptResult struct {
DispatchState DispatchState `json:"dispatch_state"`
ResponseStarted bool `json:"response_started"`
UpstreamAPI UpstreamAPI `json:"upstream_api,omitempty"`
AppliedReasoning *reasoning.Config `json:"applied_reasoning,omitempty"`
StatusCode int `json:"status_code,omitempty"`
Header http.Header `json:"header,omitempty"`
Body []byte `json:"body,omitempty"`
Model string `json:"model,omitempty"`
UpstreamRequestID string `json:"upstream_request_id,omitempty"`
Usage *UsageEvidence `json:"usage,omitempty"`
Error *ErrorEvidence `json:"error,omitempty"`
DispatchState DispatchState `json:"dispatch_state"`
ResponseStarted bool `json:"response_started"`
UpstreamAPI UpstreamAPI `json:"upstream_api,omitempty"`
AppliedReasoning *reasoning.Config `json:"applied_reasoning,omitempty"`
ConversionTrace *parametertrace.Trace `json:"conversion_trace,omitempty"`
StatusCode int `json:"status_code,omitempty"`
Header http.Header `json:"header,omitempty"`
Body []byte `json:"body,omitempty"`
Model string `json:"model,omitempty"`
UpstreamRequestID string `json:"upstream_request_id,omitempty"`
Usage *UsageEvidence `json:"usage,omitempty"`
Error *ErrorEvidence `json:"error,omitempty"`
}
// Clone returns an independent attempt result.
@@ -529,6 +402,10 @@ func (r AttemptResult) Clone() AttemptResult {
reasoningConfig := r.AppliedReasoning.Clone()
clone.AppliedReasoning = &reasoningConfig
}
if r.ConversionTrace != nil {
trace := parametertrace.CloneTrace(*r.ConversionTrace)
clone.ConversionTrace = &trace
}
return clone
}
@@ -574,16 +451,17 @@ func (e StreamEvent) Clone() StreamEvent {
// StreamResult is the terminal metadata returned after streaming ends.
// The result owns Header, Usage, and Error after return from Executor.
type StreamResult struct {
DispatchState DispatchState `json:"dispatch_state"`
ResponseStarted bool `json:"response_started"`
UpstreamAPI UpstreamAPI `json:"upstream_api,omitempty"`
AppliedReasoning *reasoning.Config `json:"applied_reasoning,omitempty"`
StatusCode int `json:"status_code,omitempty"`
Header http.Header `json:"header,omitempty"`
Model string `json:"model,omitempty"`
UpstreamRequestID string `json:"upstream_request_id,omitempty"`
Usage *UsageEvidence `json:"usage,omitempty"`
Error *ErrorEvidence `json:"error,omitempty"`
DispatchState DispatchState `json:"dispatch_state"`
ResponseStarted bool `json:"response_started"`
UpstreamAPI UpstreamAPI `json:"upstream_api,omitempty"`
AppliedReasoning *reasoning.Config `json:"applied_reasoning,omitempty"`
ConversionTrace *parametertrace.Trace `json:"conversion_trace,omitempty"`
StatusCode int `json:"status_code,omitempty"`
Header http.Header `json:"header,omitempty"`
Model string `json:"model,omitempty"`
UpstreamRequestID string `json:"upstream_request_id,omitempty"`
Usage *UsageEvidence `json:"usage,omitempty"`
Error *ErrorEvidence `json:"error,omitempty"`
}
// Clone returns an independent streaming result.
@@ -602,6 +480,10 @@ func (r StreamResult) Clone() StreamResult {
reasoningConfig := r.AppliedReasoning.Clone()
clone.AppliedReasoning = &reasoningConfig
}
if r.ConversionTrace != nil {
trace := parametertrace.CloneTrace(*r.ConversionTrace)
clone.ConversionTrace = &trace
}
return clone
}
@@ -609,10 +491,8 @@ func (r StreamResult) Clone() StreamResult {
type StreamSink func(StreamEvent) error
// Executor executes exactly the selected attempt described by AttemptSpec.
// Implementations must return an independent capability snapshot and must not
// mutate spec or retain reference-backed values from it.
// Implementations must not mutate spec or retain reference-backed values from it.
type Executor interface {
Capabilities() CapabilitySet
Execute(context.Context, AttemptSpec) AttemptResult
ExecuteStream(context.Context, AttemptSpec, StreamSink) StreamResult
}
+83 -108
View File
@@ -10,6 +10,7 @@ import (
"testing"
"time"
"gpt-load/internal/parametertrace"
"gpt-load/internal/protocol"
"gpt-load/internal/reasoning"
"gpt-load/internal/usage"
@@ -45,6 +46,18 @@ func TestOperationAndDispatchEnums(t *testing.T) {
if RouteMode("fallback").Valid() {
t.Fatal("expected unknown route mode to be invalid")
}
for _, requirement := range []RouteRequirement{"", RouteRequirementAny, RouteRequirementNative} {
if !requirement.Valid() {
t.Fatalf("expected route requirement %q to be valid", requirement)
}
}
if RouteRequirement("converted-only").Valid() {
t.Fatal("expected unknown route requirement to be invalid")
}
if !RouteRequirementAny.Allows(RouteConverted) || !RouteRequirementNative.Allows(RouteNative) ||
RouteRequirementNative.Allows(RouteConverted) {
t.Fatal("route requirement mode policy is invalid")
}
for _, upstreamAPI := range []UpstreamAPI{
UpstreamAPIOpenAIChatCompletions,
UpstreamAPIOpenAIResponses,
@@ -64,22 +77,6 @@ func TestOperationAndDispatchEnums(t *testing.T) {
if UpstreamAPI("unknown").Valid() {
t.Fatal("expected unknown upstream API to be invalid")
}
for _, feature := range []Feature{
FeatureStreaming,
FeatureTools,
FeatureReasoning,
FeatureMultimodal,
FeatureStructuredOutput,
FeatureNativeResourceSemantics,
} {
if !feature.Valid() {
t.Fatalf("expected feature %q to be valid", feature)
}
}
if Feature("unknown").Valid() {
t.Fatal("expected unknown feature to be invalid")
}
for _, state := range []DispatchState{DispatchNotSent, DispatchMaybeSent} {
if !state.Valid() {
t.Fatalf("expected dispatch state %q to be valid", state)
@@ -101,79 +98,14 @@ func TestOperationAndDispatchEnums(t *testing.T) {
t.Fatalf("expected error kind %q to be valid", kind)
}
}
if !ErrorKind("conversion_unsupported").Valid() {
t.Fatal("conversion_unsupported error kind must be valid")
}
if ErrorKind("unknown").Valid() {
t.Fatal("expected unknown error kind to be invalid")
}
}
func TestCapabilitySetIsValidatedAndDefensivelyCopied(t *testing.T) {
chatFeatures, err := NewFeatureSet(FeatureStreaming, FeatureTools, FeatureStreaming)
if err != nil {
t.Fatalf("NewFeatureSet() error = %v", err)
}
set, err := NewCapabilitySet(
Capability{Operation: OperationResponsesCreate},
Capability{Operation: OperationChatCompletion, Features: chatFeatures},
Capability{Operation: OperationResponsesCreate},
)
if err != nil {
t.Fatalf("NewCapabilitySet() error = %v", err)
}
if err := set.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
if !set.Has(OperationChatCompletion) || !set.Has(OperationResponsesCreate) {
t.Fatal("expected configured operations to be present")
}
if set.Has(OperationProbe) {
t.Fatal("expected an unconfigured operation to be absent")
}
requiredStreaming, err := NewFeatureSet(FeatureStreaming)
if err != nil {
t.Fatalf("NewFeatureSet() error = %v", err)
}
requiredReasoning, err := NewFeatureSet(FeatureReasoning)
if err != nil {
t.Fatalf("NewFeatureSet() error = %v", err)
}
if !set.Supports(OperationChatCompletion, requiredStreaming) {
t.Fatal("expected chat completion streaming to be supported")
}
if set.Supports(OperationChatCompletion, requiredReasoning) {
t.Fatal("expected unsupported reasoning feature to be rejected")
}
if !set.Features(OperationChatCompletion).Has(FeatureTools) {
t.Fatal("expected operation features to be returned")
}
want := []Operation{OperationChatCompletion, OperationResponsesCreate}
got := set.Operations()
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("Operations() = %v, want %v", got, want)
}
got[0] = OperationProbe
if !set.Has(OperationChatCompletion) || set.Has(OperationProbe) {
t.Fatal("mutating Operations() result changed the set")
}
clone := set.Clone()
clone.capabilities[OperationProbe] = FeatureSet{}
clone.capabilities[OperationChatCompletion].features[FeatureReasoning] = struct{}{}
if set.Has(OperationProbe) {
t.Fatal("mutating clone changed the original set")
}
if set.Features(OperationChatCompletion).Has(FeatureReasoning) {
t.Fatal("mutating cloned features changed the original set")
}
if _, err := NewCapabilitySet(Capability{Operation: Operation("unknown")}); err == nil {
t.Fatal("expected an invalid operation to be rejected")
}
if _, err := NewFeatureSet(Feature("unknown")); err == nil {
t.Fatal("expected an invalid feature to be rejected")
}
}
func TestCredentialSnapshotProtectsSecretDataAndJSON(t *testing.T) {
secret := []byte(`{"api_key":"sk-secret-value"}`)
credential := NewCredentialSnapshot(41, 7, 3, secret)
@@ -209,12 +141,19 @@ func TestCredentialSnapshotProtectsSecretDataAndJSON(t *testing.T) {
func TestAttemptSpecOwnsReferenceBackedValues(t *testing.T) {
credentialData := []byte(`{"api_key":"sk-secret-value"}`)
raw := validAttemptSpec(credentialData)
raw.RouteRequirement = RouteRequirementNative
raw.ClientParameters = &parametertrace.Snapshot{
SchemaVersion: parametertrace.SchemaVersion,
State: parametertrace.CaptureCaptured,
Entries: []parametertrace.Entry{{Path: "stream", Kind: parametertrace.ValueBoolean, Value: "false"}},
}
owned := NewAttemptSpec(raw)
raw.Header.Set("X-Test", "mutated")
raw.Query.Set("api-version", "mutated")
raw.Body[0] = 'X'
raw.TargetConfig[0] = 'Y'
raw.ClientParameters.Entries[0].Value = "true"
credentialData[0] = 'Y'
if got := owned.Header.Get("X-Test"); got != "original" {
t.Fatalf("owned header = %q, want original", got)
@@ -228,6 +167,12 @@ func TestAttemptSpecOwnsReferenceBackedValues(t *testing.T) {
if got := string(owned.TargetConfig); got != `{"base_url":"https://upstream.example"}` {
t.Fatalf("owned target config = %q", got)
}
if owned.RouteRequirement != RouteRequirementNative {
t.Fatalf("owned route requirement = %q", owned.RouteRequirement)
}
if owned.ClientParameters == nil || owned.ClientParameters.Entries[0].Value != "false" {
t.Fatalf("owned client parameters = %#v", owned.ClientParameters)
}
if got := string(owned.Credential.Data()); got != `{"api_key":"sk-secret-value"}` {
t.Fatalf("owned credential = %q", got)
}
@@ -237,8 +182,8 @@ func TestAttemptSpecOwnsReferenceBackedValues(t *testing.T) {
clone.Query.Set("api-version", "clone")
clone.Body[0] = 'Z'
clone.TargetConfig[0] = 'Q'
clone.RequiredFeatures.features[FeatureReasoning] = struct{}{}
clone.Credential.data[0] = 'Q'
clone.ClientParameters.Entries[0].Value = "true"
if owned.Header.Get("X-Test") != "original" || owned.Query.Get("api-version") != "2026-01-01" {
t.Fatal("mutating clone changed original maps")
}
@@ -248,10 +193,9 @@ func TestAttemptSpecOwnsReferenceBackedValues(t *testing.T) {
if string(owned.TargetConfig) != `{"base_url":"https://upstream.example"}` {
t.Fatal("mutating clone changed original raw messages")
}
if owned.RequiredFeatures.Has(FeatureReasoning) {
t.Fatal("mutating clone changed original required features")
if owned.ClientParameters.Entries[0].Value != "false" {
t.Fatal("mutating clone changed original client parameters")
}
encoded, err := json.Marshal(owned)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
@@ -306,7 +250,6 @@ func TestAttemptSpecRawQueryPreservesOpaqueBytesAndRejectsUnsafeShapes(t *testin
func TestProbeAttemptIsSemanticAndDoesNotCarryProviderWireShape(t *testing.T) {
spec := validAttemptSpec([]byte(`{"api_key":"sk-secret-value"}`))
spec.Operation = OperationProbe
spec.RequiredFeatures = FeatureSet{}
spec.Method = ""
spec.Path = ""
spec.Query = nil
@@ -333,10 +276,22 @@ func TestProbeAttemptIsSemanticAndDoesNotCarryProviderWireShape(t *testing.T) {
func TestAttemptAndStreamResultsAreDefensivelyCopied(t *testing.T) {
reasoningBudget := int64(4096)
conversionTrace := &parametertrace.Trace{
SchemaVersion: parametertrace.SchemaVersion,
State: parametertrace.CaptureCaptured,
Target: parametertrace.Snapshot{SchemaVersion: parametertrace.SchemaVersion, State: parametertrace.CaptureCaptured, Entries: []parametertrace.Entry{{
Path: "reasoning.effort", Kind: parametertrace.ValueEnum, Value: "high",
}}},
Changes: []parametertrace.Change{{
Disposition: parametertrace.DispositionMapped,
SourcePath: "reasoning.budget_tokens", TargetPath: "reasoning.effort",
}},
}
result := AttemptResult{
DispatchState: DispatchMaybeSent,
UpstreamAPI: UpstreamAPIAnthropicMessages,
AppliedReasoning: &reasoning.Config{Mode: "enabled", BudgetTokens: &reasoningBudget},
ConversionTrace: conversionTrace,
StatusCode: http.StatusOK,
Header: http.Header{"X-Request-Id": {"request-1"}},
Body: []byte("response"),
@@ -358,6 +313,8 @@ func TestAttemptAndStreamResultsAreDefensivelyCopied(t *testing.T) {
clone.Usage.Raw[0] = 'Y'
clone.Error.Header.Set("Retry-After", "2")
*clone.AppliedReasoning.BudgetTokens = 2048
clone.ConversionTrace.Target.Entries[0].Value = "low"
clone.ConversionTrace.Changes[0].Disposition = parametertrace.DispositionDropped
if result.Header.Get("X-Request-ID") != "request-1" || string(result.Body) != "response" {
t.Fatal("mutating attempt result clone changed original response data")
}
@@ -368,6 +325,10 @@ func TestAttemptAndStreamResultsAreDefensivelyCopied(t *testing.T) {
*result.AppliedReasoning.BudgetTokens != reasoningBudget {
t.Fatal("mutating attempt result clone changed original reasoning")
}
if result.ConversionTrace.Target.Entries[0].Value != "high" ||
result.ConversionTrace.Changes[0].Disposition != parametertrace.DispositionMapped {
t.Fatal("mutating attempt result clone changed original conversion trace")
}
event := StreamEvent{
Sequence: 1,
@@ -387,6 +348,7 @@ func TestAttemptAndStreamResultsAreDefensivelyCopied(t *testing.T) {
ResponseStarted: true,
UpstreamAPI: UpstreamAPIOpenAIResponses,
AppliedReasoning: &reasoning.Config{Effort: "high", BudgetTokens: &reasoningBudget},
ConversionTrace: conversionTrace,
StatusCode: http.StatusOK,
Header: http.Header{"X-Request-Id": {"request-1"}},
UpstreamRequestID: "upstream-1",
@@ -398,6 +360,7 @@ func TestAttemptAndStreamResultsAreDefensivelyCopied(t *testing.T) {
streamClone.Usage.Raw[0] = 'X'
streamClone.Error.Header.Set("Retry-After", "2")
*streamClone.AppliedReasoning.BudgetTokens = 1024
streamClone.ConversionTrace.Target.Entries[0].Value = "minimal"
if streamResult.Header.Get("X-Request-ID") != "request-1" || string(streamResult.Usage.Raw) != "usage" || streamResult.Error.Header.Get("Retry-After") != "1" {
t.Fatal("mutating stream result clone changed original")
}
@@ -405,6 +368,9 @@ func TestAttemptAndStreamResultsAreDefensivelyCopied(t *testing.T) {
*streamResult.AppliedReasoning.BudgetTokens != reasoningBudget {
t.Fatal("mutating stream result clone changed original reasoning")
}
if streamResult.ConversionTrace.Target.Entries[0].Value != "high" {
t.Fatal("mutating stream result clone changed original conversion trace")
}
}
func TestValidationAcceptsValidContractsAndRejectsInvalidFields(t *testing.T) {
@@ -424,9 +390,7 @@ func TestValidationAcceptsValidContractsAndRejectsInvalidFields(t *testing.T) {
{name: "channel", mutate: func(s *AttemptSpec) { s.ChannelID = "" }, field: "channel_id"},
{name: "target kind", mutate: func(s *AttemptSpec) { s.TargetKind = "" }, field: "target_kind"},
{name: "route mode", mutate: func(s *AttemptSpec) { s.RouteMode = RouteMode("fallback") }, field: "route_mode"},
{name: "required features", mutate: func(s *AttemptSpec) {
s.RequiredFeatures.features[Feature("unknown")] = struct{}{}
}, field: "required_features"},
{name: "route requirement", mutate: func(s *AttemptSpec) { s.RouteRequirement = RouteRequirement("converted-only") }, field: "route_requirement"},
{name: "protocol", mutate: func(s *AttemptSpec) { s.ClientProtocol = protocol.Protocol("unknown") }, field: "client_protocol"},
{name: "operation", mutate: func(s *AttemptSpec) { s.Operation = Operation("unknown") }, field: "operation"},
{name: "model", mutate: func(s *AttemptSpec) { s.UpstreamModel = "" }, field: "upstream_model"},
@@ -437,6 +401,9 @@ func TestValidationAcceptsValidContractsAndRejectsInvalidFields(t *testing.T) {
{name: "timeout", mutate: func(s *AttemptSpec) { s.Timeouts.Request = -time.Second }, field: "timeouts.request"},
{name: "stream idle timeout", mutate: func(s *AttemptSpec) { s.Timeouts.StreamIdle = -time.Second }, field: "timeouts.stream_idle"},
{name: "target config", mutate: func(s *AttemptSpec) { s.TargetConfig = json.RawMessage("not-json") }, field: "target_config"},
{name: "client parameters", mutate: func(s *AttemptSpec) {
s.ClientParameters = &parametertrace.Snapshot{State: parametertrace.CaptureState("unknown"), Entries: []parametertrace.Entry{}}
}, field: "client_parameters"},
{name: "credential", mutate: func(s *AttemptSpec) { s.Credential = NewCredentialSnapshot(0, 7, 3, []byte("secret")) }, field: "credential.id"},
}
for _, tt := range tests {
@@ -514,6 +481,22 @@ func TestValidationAcceptsValidContractsAndRejectsInvalidFields(t *testing.T) {
if err := (StreamResult{DispatchState: DispatchNotSent, UpstreamAPI: UpstreamAPI("unknown")}).Validate(); err == nil {
t.Fatal("expected invalid stream result upstream API to be rejected")
}
invalidTrace := &parametertrace.Trace{
SchemaVersion: parametertrace.SchemaVersion,
State: parametertrace.CaptureState("unknown"),
Target: parametertrace.Snapshot{
SchemaVersion: parametertrace.SchemaVersion,
State: parametertrace.CaptureUnavailable,
Entries: []parametertrace.Entry{},
},
Changes: []parametertrace.Change{},
}
if err := (AttemptResult{DispatchState: DispatchMaybeSent, ResponseStarted: true, StatusCode: http.StatusOK, ConversionTrace: invalidTrace}).Validate(); err == nil {
t.Fatal("expected invalid attempt conversion trace to be rejected")
}
if err := (StreamResult{DispatchState: DispatchNotSent, ConversionTrace: invalidTrace, Error: &ErrorEvidence{Kind: ErrorKindTransport, Summary: "failed"}}).Validate(); err == nil {
t.Fatal("expected invalid stream conversion trace to be rejected")
}
invalidResults := []AttemptResult{
{DispatchState: DispatchMaybeSent, StatusCode: http.StatusOK},
{DispatchState: DispatchMaybeSent, ResponseStarted: true},
@@ -608,17 +591,14 @@ func validAttemptSpec(credentialData []byte) AttemptSpec {
RouteMode: RouteNative,
ClientProtocol: protocol.OpenAICompletions,
Operation: OperationChatCompletion,
RequiredFeatures: FeatureSet{features: map[Feature]struct{}{
FeatureStreaming: {},
}},
ClientModel: "client-model",
UpstreamModel: "upstream-model",
Method: http.MethodPost,
Path: "/v1/chat/completions",
Query: url.Values{"api-version": {"2026-01-01"}},
Header: http.Header{"X-Test": {"original"}},
Body: []byte(`{"model":"client-model"}`),
TargetConfig: json.RawMessage(`{"base_url":"https://upstream.example"}`),
ClientModel: "client-model",
UpstreamModel: "upstream-model",
Method: http.MethodPost,
Path: "/v1/chat/completions",
Query: url.Values{"api-version": {"2026-01-01"}},
Header: http.Header{"X-Test": {"original"}},
Body: []byte(`{"model":"client-model"}`),
TargetConfig: json.RawMessage(`{"base_url":"https://upstream.example"}`),
Timeouts: AttemptTimeouts{
FirstByte: 10 * time.Second,
Request: 30 * time.Second,
@@ -630,11 +610,6 @@ func validAttemptSpec(credentialData []byte) AttemptSpec {
type contractExecutor struct{}
func (contractExecutor) Capabilities() CapabilitySet {
capabilities, _ := NewCapabilitySet(Capability{Operation: OperationChatCompletion})
return capabilities
}
func (contractExecutor) Execute(context.Context, AttemptSpec) AttemptResult {
return AttemptResult{}
}
+22 -4
View File
@@ -66,8 +66,8 @@ func (s AttemptSpec) Validate() error {
if !s.Operation.Valid() {
return validationError("operation", "unsupported value")
}
if err := s.RequiredFeatures.Validate(); err != nil {
return validationError("required_features", "contains an unsupported value")
if !s.RouteRequirement.Valid() {
return validationError("route_requirement", "unsupported value")
}
if operationRequiresModel(s.Operation) {
if strings.TrimSpace(s.ClientModel) == "" {
@@ -106,6 +106,11 @@ func (s AttemptSpec) Validate() error {
if len(s.TargetConfig) > 0 && !json.Valid(s.TargetConfig) {
return validationError("target_config", "must contain valid JSON")
}
if s.ClientParameters != nil {
if err := s.ClientParameters.Validate(); err != nil {
return validationError("client_parameters", "must contain a valid safe projection")
}
}
if err := s.Timeouts.Validate(); err != nil {
return err
}
@@ -171,6 +176,11 @@ func (r AttemptResult) Validate() error {
if !r.ResponseStarted && len(r.Body) > 0 {
return validationError("body", "requires response_started")
}
if r.ConversionTrace != nil {
if err := r.ConversionTrace.Validate(); err != nil {
return validationError("conversion_trace", "must contain a valid safe projection")
}
}
return nil
}
@@ -228,14 +238,22 @@ func (r StreamResult) Validate() error {
if r.UpstreamAPI != "" && !r.UpstreamAPI.Valid() {
return validationError("upstream_api", "unsupported value")
}
return validateResultMetadata(
if err := validateResultMetadata(
r.DispatchState,
r.ResponseStarted,
r.StatusCode,
r.Header,
r.UpstreamRequestID,
r.Error,
)
); err != nil {
return err
}
if r.ConversionTrace != nil {
if err := r.ConversionTrace.Validate(); err != nil {
return validationError("conversion_trace", "must contain a valid safe projection")
}
}
return nil
}
func validateResultMetadata(
+14 -1
View File
@@ -11,6 +11,7 @@ import (
"gpt-load/internal/dialect"
"gpt-load/internal/execution"
"gpt-load/internal/parametertrace"
"gpt-load/internal/platform/redact"
"gpt-load/internal/protocol"
"gpt-load/internal/usage"
@@ -544,6 +545,7 @@ func executionRepresentationFailure(result UpstreamResult, err error) UpstreamRe
ResponseStarted: result.ResponseStarted,
UpstreamAPI: result.UpstreamAPI,
AppliedReasoning: result.AppliedReasoning.Clone(),
ConversionTrace: cloneConversionTrace(result.ConversionTrace),
UpstreamRequestID: result.UpstreamRequestID,
}
}
@@ -571,7 +573,7 @@ func newExecutionAttemptSpec(input ForwardInput) (execution.AttemptSpec, error)
RouteMode: input.RouteMode,
ClientProtocol: input.ClientProtocol,
Operation: input.Operation,
RequiredFeatures: input.RequiredFeatures,
RouteRequirement: input.RouteRequirement,
ClientModel: input.ExternalModel,
UpstreamModel: input.UpstreamModelID,
Method: input.Request.Method,
@@ -579,6 +581,7 @@ func newExecutionAttemptSpec(input ForwardInput) (execution.AttemptSpec, error)
RawQuery: input.Request.RawQuery,
Header: headers,
Body: input.Request.Body,
ClientParameters: cloneParameterSnapshot(input.ClientParameters),
IncludeUsage: input.ObserveUsage && input.Group.InjectUsageOptions,
TargetConfig: input.TargetConfig,
Timeouts: execution.AttemptTimeouts{
@@ -605,6 +608,7 @@ func upstreamFromExecutionResult(
if result.AppliedReasoning != nil {
upstream.AppliedReasoning = result.AppliedReasoning.Clone()
}
upstream.ConversionTrace = cloneConversionTrace(result.ConversionTrace)
upstream.UpstreamAPI = result.UpstreamAPI
upstream.Body = append([]byte(nil), result.Body...)
upstream.ClassificationBody = append([]byte(nil), result.Body...)
@@ -630,6 +634,7 @@ func upstreamFromExecutionStreamResult(
if result.AppliedReasoning != nil {
upstream.AppliedReasoning = result.AppliedReasoning.Clone()
}
upstream.ConversionTrace = cloneConversionTrace(result.ConversionTrace)
upstream.UpstreamAPI = result.UpstreamAPI
if !result.ResponseStarted && result.Error != nil {
upstream.Err = executionFailureError(ctx, result.Error)
@@ -637,6 +642,14 @@ func upstreamFromExecutionStreamResult(
return upstream
}
func cloneParameterSnapshot(value *parametertrace.Snapshot) *parametertrace.Snapshot {
if value == nil {
return nil
}
clone := parametertrace.CloneSnapshot(*value)
return &clone
}
func baseExecutionResult(
input ForwardInput,
dispatchState execution.DispatchState,
+2 -14
View File
@@ -24,10 +24,6 @@ type fakeExecutionExecutor struct {
stream func(context.Context, execution.AttemptSpec, execution.StreamSink) execution.StreamResult
}
func (fakeExecutionExecutor) Capabilities() execution.CapabilitySet {
return execution.CapabilitySet{}
}
func (value fakeExecutionExecutor) Execute(
ctx context.Context,
spec execution.AttemptSpec,
@@ -54,7 +50,7 @@ func TestExecutionForwarderBuildsFrozenAttemptAndMapsUnaryResult(t *testing.T) {
if spec.RequestID != "request-1" || spec.AttemptID != "attempt-1" || spec.Sequence != 2 ||
spec.ChannelID != "openai" || spec.TargetKind != "openai" ||
spec.RouteMode != execution.RouteNative ||
!spec.RequiredFeatures.Has(execution.FeatureTools) ||
spec.RouteRequirement != execution.RouteRequirementNative ||
spec.ClientProtocol != protocol.OpenAICompletions ||
spec.Operation != execution.OperationChatCompletion || spec.ClientModel != "public" ||
spec.UpstreamModel != "upstream" || spec.Method != http.MethodPost ||
@@ -774,7 +770,7 @@ func executionForwardInput() ForwardInput {
ChannelID: "openai",
TargetKind: "openai",
RouteMode: execution.RouteNative,
RequiredFeatures: gatewayFeatureSet(execution.FeatureTools),
RouteRequirement: execution.RouteRequirementNative,
TargetConfig: []byte(`{}`),
APIKey: "secret",
Credential: execution.NewCredentialSnapshot(
@@ -817,11 +813,3 @@ func responsesExecutionForwardInput() ForwardInput {
input.Request.Body = []byte(`{"model":"public","stream":true}`)
return input
}
func gatewayFeatureSet(features ...execution.Feature) execution.FeatureSet {
set, err := execution.NewFeatureSet(features...)
if err != nil {
panic(err)
}
return set
}
+12 -1
View File
@@ -14,6 +14,7 @@ import (
"gpt-load/internal/dialect"
"gpt-load/internal/execution"
"gpt-load/internal/parametertrace"
"gpt-load/internal/platform/contentcoding"
platformheader "gpt-load/internal/platform/httpheader"
"gpt-load/internal/protocol"
@@ -41,12 +42,13 @@ type ForwardInput struct {
AttemptSequence uint32
ClientProtocol protocol.Protocol
Operation execution.Operation
RouteRequirement execution.RouteRequirement
ChannelID string
TargetKind string
RouteMode execution.RouteMode
RequiredFeatures execution.FeatureSet
TargetConfig json.RawMessage
Credential execution.CredentialSnapshot
ClientParameters *parametertrace.Snapshot
}
// UpstreamResult is the gateway's stable view of one logical execution
@@ -72,6 +74,15 @@ type UpstreamResult struct {
AppliedReasoning reasoning.Config
UpstreamRequestID string
ExecutionError *execution.ErrorEvidence
ConversionTrace *parametertrace.Trace
}
func cloneConversionTrace(value *parametertrace.Trace) *parametertrace.Trace {
if value == nil {
return nil
}
clone := parametertrace.CloneTrace(*value)
return &clone
}
func (result UpstreamResult) HasResponse() bool {
+35 -5
View File
@@ -20,6 +20,7 @@ import (
"gpt-load/internal/execution"
"gpt-load/internal/health"
"gpt-load/internal/httplifecycle"
"gpt-load/internal/parametertrace"
"gpt-load/internal/platform/contentcoding"
"gpt-load/internal/platform/encryption"
platformheader "gpt-load/internal/platform/httpheader"
@@ -403,7 +404,7 @@ func (handler *Handler) Handle(ginContext *gin.Context) {
query := scheduler.Query{
ClientProtocol: selectedRoute.Protocol,
Operation: metadata.Operation,
RequiredFeatures: metadata.RequiredFeatures,
RouteRequirement: metadata.RouteRequirement,
ExternalModel: metadata.Model,
AccessKey: accessKey,
}
@@ -417,6 +418,8 @@ func (handler *Handler) Handle(ginContext *gin.Context) {
recorder.setOperation(metadata.Operation)
recorder.setStream(metadata.Stream)
recorder.setReasoning(metadata.Reasoning)
clientParameters := parametertrace.ProjectJSON(body)
recorder.setClientParameters(clientParameters)
recorder.setUsageApplicable(metadata.ObserveUsage)
recorder.setUsageDiagnostics(metadata.UsageDiagnostics)
@@ -436,7 +439,8 @@ func (handler *Handler) Handle(ginContext *gin.Context) {
metadata.Stream,
metadata.ObserveUsage,
metadata.Operation,
metadata.RequiredFeatures,
metadata.RouteRequirement,
clientParameters,
recorder,
)
}
@@ -543,7 +547,8 @@ func (handler *Handler) executeAttempts(
stream bool,
observeUsage bool,
operation execution.Operation,
requiredFeatures execution.FeatureSet,
routeRequirement execution.RouteRequirement,
clientParameters parametertrace.Snapshot,
recorder *requestRecorder,
) {
type deferredAttempt struct {
@@ -554,6 +559,7 @@ func (handler *Handler) executeAttempts(
}
var lastResponse *deferredAttempt
var lastTransport *deferredAttempt
var lastConversion *deferredAttempt
var lastProviderError *deferredAttempt
lastAttemptIndex := -1
attempts := 0
@@ -608,10 +614,10 @@ func (handler *Handler) executeAttempts(
AttemptSequence: uint32(attempts),
ClientProtocol: selectedDialect.Protocol(),
Operation: operation,
RouteRequirement: routeRequirement,
ChannelID: string(selection.ChannelID),
TargetKind: string(selection.ResolvedTarget.ProviderKind),
RouteMode: execution.RouteMode(selection.RouteMode),
RequiredFeatures: requiredFeatures,
TargetConfig: selection.ResolvedTarget.TargetConfig,
Credential: execution.NewCredentialSnapshot(
selection.CredentialID,
@@ -619,6 +625,7 @@ func (handler *Handler) executeAttempts(
ref.IdentityGeneration,
normalizedCredential.payload,
),
ClientParameters: cloneParameterSnapshot(&clientParameters),
OnStreamReady: func() {
handler.recordCredentialSuccess(selectedCredentialID, handler.now())
},
@@ -733,9 +740,14 @@ func (handler *Handler) executeAttempts(
return
}
if shouldRetryAcrossCandidates(input.Operation, input.Request.Method, result, decision) {
lastTransport = &deferredAttempt{
deferred := &deferredAttempt{
result: result, decision: decision, upstreamModel: optionalModelValue(selection.UpstreamModelID), attemptIndex: recordedAttempt,
}
if isConversionUnsupportedResult(result) {
lastConversion = deferred
} else {
lastTransport = deferred
}
recorder.retryIfAnotherForward(recordedAttempt)
continue
}
@@ -783,6 +795,14 @@ func (handler *Handler) executeAttempts(
}
return
}
if lastConversion != nil {
value := reasonProtocolConversionUnsupported
recorder.completeTransport(value, lastConversion.upstreamModel, lastConversion.attemptIndex)
if err := handler.writeReason(ginContext, value); err != nil {
handler.completeWriteTerminal(ginContext, recorder, value.Status)
}
return
}
if iterator.StaticReason() == scheduler.ReasonModelRequiredByFilter {
handler.completeReason(ginContext, recorder, reasonModelRequiredByFilter)
return
@@ -840,6 +860,11 @@ func updateDebugHeaders(headers http.Header, group string, attempts int) {
func transportReason(result UpstreamResult) reason {
switch {
case isConversionUnsupportedResult(result):
return reasonProtocolConversionUnsupported
case result.DispatchState == execution.DispatchNotSent && result.ExecutionError != nil &&
result.ExecutionError.Kind == execution.ErrorKindInvalidRequest:
return reasonInvalidProtocolRequest
case errors.Is(result.Err, ErrUpstreamProtocol):
return reasonUpstreamProtocol
case isTimeoutError(result.Err):
@@ -849,6 +874,11 @@ func transportReason(result UpstreamResult) reason {
}
}
func isConversionUnsupportedResult(result UpstreamResult) bool {
return result.DispatchState == execution.DispatchNotSent && result.ExecutionError != nil &&
result.ExecutionError.Kind == execution.ErrorKindConversionUnsupported
}
func (handler *Handler) writeUpstreamResponse(ginContext *gin.Context, result UpstreamResult) error {
return handler.writeBufferedResponse(ginContext, result.StatusCode, result.Header, result.Body)
}
+201
View File
@@ -2778,6 +2778,126 @@ func TestHandlerUsesClassifierForNonStreamingNonSuccess(t *testing.T) {
})
}
func TestHandlerRetriesAnotherGroupAfterLocalConversionFailure(t *testing.T) {
t.Parallel()
conversionFailure := UpstreamResult{
Err: fmt.Errorf("%w: target conversion failed", ErrUpstreamProtocol),
DispatchState: execution.DispatchNotSent,
ExecutionError: &execution.ErrorEvidence{
Kind: execution.ErrorKind("conversion_unsupported"),
Code: "target_conversion_not_supported",
Summary: "target conversion is not supported",
},
ErrorSummary: "target conversion is not supported",
}
t.Run("falls back to another group without mutating credential health", func(t *testing.T) {
forwarder := &scriptedForwarder{results: []UpstreamResult{
conversionFailure,
{StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true},
}}
engine, registry := newConvertedFallbackHandlerTestRuntime(t, forwarder)
before := registry.Snapshot()
request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString(
`{"model":"claude-client","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`,
))
request.Header.Set("Authorization", "Bearer gl-client")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Body.String() != `{"ok":true}` || len(forwarder.inputs) != 2 {
t.Fatalf("response/attempts = %d %s / %d, want 200 and two groups", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
}
if forwarder.inputs[0].Group.ID == forwarder.inputs[1].Group.ID {
t.Fatalf("attempts stayed in group %d", forwarder.inputs[0].Group.ID)
}
if after := registry.Snapshot(); !reflect.DeepEqual(after, before) {
t.Fatalf("credential health changed: before=%#v after=%#v", before, after)
}
})
t.Run("returns stable 422 after every target rejects conversion", func(t *testing.T) {
forwarder := &scriptedForwarder{results: []UpstreamResult{conversionFailure, conversionFailure}}
engine, registry := newConvertedFallbackHandlerTestRuntime(t, forwarder)
before := registry.Snapshot()
request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString(
`{"model":"claude-client","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`,
))
request.Header.Set("Authorization", "Bearer gl-client")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var body struct {
Code string `json:"code"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if recorder.Code != http.StatusUnprocessableEntity || body.Code != "protocol_conversion_unsupported" || len(forwarder.inputs) != 2 {
t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
}
if after := registry.Snapshot(); !reflect.DeepEqual(after, before) {
t.Fatalf("credential health changed: before=%#v after=%#v", before, after)
}
})
t.Run("malformed client request remains 400 and is not retried", func(t *testing.T) {
forwarder := &scriptedForwarder{results: []UpstreamResult{{
Err: fmt.Errorf("%w: invalid request", ErrUpstreamProtocol),
DispatchState: execution.DispatchNotSent,
ExecutionError: &execution.ErrorEvidence{
Kind: execution.ErrorKindInvalidRequest,
Summary: "invalid Anthropic request body",
},
}}}
engine, _ := newConvertedFallbackHandlerTestRuntime(t, forwarder)
request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString(
`{"model":"claude-client","messages":[{"role":"user","content":"hello"}]}`,
))
request.Header.Set("Authorization", "Bearer gl-client")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest ||
!strings.Contains(recorder.Body.String(), `"code":"invalid_protocol_request"`) ||
len(forwarder.inputs) != 1 {
t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
}
})
t.Run("upstream unsupported model 400 is passed through once", func(t *testing.T) {
forwarder := &scriptedForwarder{results: []UpstreamResult{{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": {"application/json"}},
Body: []byte(`{"error":{"code":"unsupported_model"}}`),
RequestWritten: true,
DispatchState: execution.DispatchMaybeSent,
ResponseStarted: true,
ExecutionError: &execution.ErrorEvidence{
Kind: execution.ErrorKindHTTP,
Hint: execution.FailureHintModelUnavailable,
StatusCode: http.StatusBadRequest,
Code: "unsupported_model",
Summary: "request capability is unavailable",
},
}}}
engine, registry := newConvertedFallbackHandlerTestRuntime(t, forwarder)
before := registry.Snapshot()
request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString(
`{"model":"claude-client","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`,
))
request.Header.Set("Authorization", "Bearer gl-client")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || recorder.Body.String() != `{"error":{"code":"unsupported_model"}}` || len(forwarder.inputs) != 1 {
t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
}
if after := registry.Snapshot(); !reflect.DeepEqual(after, before) {
t.Fatalf("credential health changed: before=%#v after=%#v", before, after)
}
})
}
func TestHandlerAppliesExactCooldownDeadline(t *testing.T) {
attemptNow := time.Date(2026, time.July, 21, 12, 0, 0, 0, time.UTC)
tests := []struct {
@@ -3003,6 +3123,19 @@ func TestCrossCandidateRetryRespectsReplaySafety(t *testing.T) {
result: UpstreamResult{DispatchState: execution.DispatchNotSent},
decision: retry, want: true,
},
{
name: "local conversion failure was not sent", operation: execution.OperationChatCompletion,
method: http.MethodPost,
result: UpstreamResult{
DispatchState: execution.DispatchNotSent,
ExecutionError: &execution.ErrorEvidence{
Kind: execution.ErrorKind("conversion_unsupported"),
Summary: "target conversion is not supported",
},
},
decision: health.Result{Category: health.FailureCategoryConversionUnsupported, Action: health.ActionSkipGroup},
want: true,
},
{
name: "explicit credential rejection", operation: execution.OperationResponsesCreate,
method: http.MethodPost,
@@ -3898,3 +4031,71 @@ func newHandlerForTestWithStats(
handler.newRandom = func() *rand.Rand { return rand.New(rand.NewSource(1)) }
return handler, manager, registry
}
func newConvertedFallbackHandlerTestRuntime(
t *testing.T,
forwarder AttemptForwarder,
) (*gin.Engine, *state.CredentialRegistry) {
t.Helper()
gin.SetMode(gin.TestMode)
keyService, err := encryption.NewService("handler-conversion-fallback-test-key")
if err != nil {
t.Fatalf("NewService() error = %v", err)
}
channelRegistry := channel.NewRegistry()
manager := state.NewManager()
groups := []state.GroupConfig{
{
ID: 1, Name: "compatible-one", ChannelID: channel.OpenAICompatible,
Params: json.RawMessage(`{"base_url":"https://one.example/v1"}`), Enabled: true,
Models: []state.ModelConfig{{ID: "upstream-one", Alias: "claude-client"}},
},
{
ID: 2, Name: "compatible-two", ChannelID: channel.OpenAICompatible,
Params: json.RawMessage(`{"base_url":"https://two.example/v1"}`), Enabled: true,
Models: []state.ModelConfig{{ID: "upstream-two", Alias: "claude-client"}},
},
}
credentials := []state.CredentialConfig{
{ID: 1, GroupID: 1, Status: state.CredentialStatusActive, Version: 1, IdentityGeneration: 1, Fingerprint: "credential-one"},
{ID: 2, GroupID: 2, Status: state.CredentialStatusActive, Version: 1, IdentityGeneration: 2, Fingerprint: "credential-two"},
}
if _, err := manager.Publish(state.CompileInput{
ChannelRegistry: channelRegistry,
Groups: groups,
Credentials: credentials,
AccessKeys: []state.AccessKeyConfig{{
ID: 1, Name: "client", KeyHash: keyService.Hash("gl-client"), Status: state.AccessKeyStatusActive,
}},
}); err != nil {
t.Fatalf("Publish() error = %v", err)
}
registry := state.NewCredentialRegistry()
entries := make([]state.CredentialEntry, 0, len(credentials))
for _, credential := range credentials {
plaintext := fmt.Sprintf(`{"api_key":"sk-%d"}`, credential.ID)
encrypted, err := keyService.Encrypt(plaintext)
if err != nil {
t.Fatalf("Encrypt() error = %v", err)
}
entries = append(entries, state.CredentialEntry{
ID: credential.ID, GroupID: credential.GroupID, Status: credential.Status,
Version: credential.Version, IdentityGeneration: credential.IdentityGeneration,
Fingerprint: credential.Fingerprint, EncryptedValue: encrypted,
})
}
if err := registry.ReplaceCredentials(entries); err != nil {
t.Fatalf("ReplaceCredentials() error = %v", err)
}
handler := NewHandler(
manager, registry, keyService, forwarder, dialect.NewSet(dialect.NewAnthropic()),
health.NewStatsStore(), health.NewMutationCoordinator(),
nil, nil, nil,
)
handler.channels = channelRegistry
handler.newRandom = func() *rand.Rand { return rand.New(zeroSource{}) }
engine := gin.New()
bindGatewayRoutesForTest(t, engine, handler)
return engine, registry
}
+12 -11
View File
@@ -14,17 +14,18 @@ type reason struct {
}
var (
reasonInvalidAccessKey = reason{Status: http.StatusUnauthorized, Code: "invalid_access_key", Message: "Invalid access key."}
reasonEndpointNotFound = reason{Status: http.StatusNotFound, Code: "protocol_endpoint_not_found", Message: "Protocol endpoint not found."}
reasonMethodNotAllowed = reason{Status: http.StatusMethodNotAllowed, Code: "method_not_allowed", Message: "Method not allowed."}
reasonInvalidProtocolRequest = reason{Status: http.StatusBadRequest, Code: "invalid_protocol_request", Message: "Invalid protocol request."}
reasonModelRequiredByFilter = reason{Status: http.StatusBadRequest, Code: "model_required_by_filter", Message: "A model is required by the access key filter."}
reasonNoCandidate = reason{Status: http.StatusServiceUnavailable, Code: "no_available_candidate", Message: "No available upstream candidate."}
reasonUpstreamConnect = reason{Status: http.StatusBadGateway, Code: "upstream_connect_failed", Message: "Could not connect to an upstream service."}
reasonUpstreamTimeout = reason{Status: http.StatusGatewayTimeout, Code: "upstream_timeout", Message: "Upstream request timed out."}
reasonUpstreamProtocol = reason{Status: http.StatusBadGateway, Code: "upstream_protocol_error", Message: "Upstream returned an unsupported response."}
reasonRequestTooLarge = reason{Status: http.StatusRequestEntityTooLarge, Code: "request_too_large", Message: "Request body is too large."}
reasonUnsupportedContentEncoding = reason{
reasonInvalidAccessKey = reason{Status: http.StatusUnauthorized, Code: "invalid_access_key", Message: "Invalid access key."}
reasonEndpointNotFound = reason{Status: http.StatusNotFound, Code: "protocol_endpoint_not_found", Message: "Protocol endpoint not found."}
reasonMethodNotAllowed = reason{Status: http.StatusMethodNotAllowed, Code: "method_not_allowed", Message: "Method not allowed."}
reasonInvalidProtocolRequest = reason{Status: http.StatusBadRequest, Code: "invalid_protocol_request", Message: "Invalid protocol request."}
reasonModelRequiredByFilter = reason{Status: http.StatusBadRequest, Code: "model_required_by_filter", Message: "A model is required by the access key filter."}
reasonNoCandidate = reason{Status: http.StatusServiceUnavailable, Code: "no_available_candidate", Message: "No available upstream candidate."}
reasonUpstreamConnect = reason{Status: http.StatusBadGateway, Code: "upstream_connect_failed", Message: "Could not connect to an upstream service."}
reasonUpstreamTimeout = reason{Status: http.StatusGatewayTimeout, Code: "upstream_timeout", Message: "Upstream request timed out."}
reasonUpstreamProtocol = reason{Status: http.StatusBadGateway, Code: "upstream_protocol_error", Message: "Upstream returned an unsupported response."}
reasonProtocolConversionUnsupported = reason{Status: http.StatusUnprocessableEntity, Code: "protocol_conversion_unsupported", Message: "No upstream target could preserve or convert the request."}
reasonRequestTooLarge = reason{Status: http.StatusRequestEntityTooLarge, Code: "request_too_large", Message: "Request body is too large."}
reasonUnsupportedContentEncoding = reason{
Status: http.StatusUnsupportedMediaType,
Code: "unsupported_content_encoding",
Message: "Unsupported Content-Encoding.",
+18
View File
@@ -12,6 +12,7 @@ import (
"gpt-load/internal/execution"
"gpt-load/internal/health"
"gpt-load/internal/parametertrace"
"gpt-load/internal/platform/redact"
"gpt-load/internal/pricing"
"gpt-load/internal/protocol"
@@ -57,6 +58,7 @@ type requestRecorder struct {
stream bool
firstResponseMs *int64
reasoning reasoning.Config
clientParameters *parametertrace.Snapshot
usageApplicable bool
usageDiagnostics usage.Diagnostics
attempts []telemetry.Attempt
@@ -134,6 +136,7 @@ func (recorder *requestRecorder) emit() {
DurationMs: duration.Milliseconds(),
AffinityHit: false,
Reasoning: recorder.reasoning,
ClientParameters: cloneParameterSnapshot(recorder.clientParameters),
Operation: recorder.operation,
Attempts: append([]telemetry.Attempt(nil), recorder.attempts...),
Usage: recorder.usage,
@@ -184,6 +187,14 @@ func (recorder *requestRecorder) setReasoning(config reasoning.Config) {
recorder.reasoning = config
}
func (recorder *requestRecorder) setClientParameters(value parametertrace.Snapshot) {
if recorder == nil {
return
}
clone := parametertrace.CloneSnapshot(value)
recorder.clientParameters = &clone
}
func (recorder *requestRecorder) recordFirstResponse() {
if recorder == nil || !recorder.stream || recorder.firstResponseMs != nil || recorder.now == nil {
return
@@ -325,6 +336,7 @@ func (recorder *requestRecorder) appendAttempt(
ErrorCode: errorCode,
ErrorSummary: errorSummary,
Committed: result.Committed,
ConversionTrace: cloneConversionTrace(result.ConversionTrace),
}
if attempt.ErrorCode != "" && attempt.ErrorSummary == "" {
attempt.ErrorSummary = fixedErrorSummary(attempt.ErrorCode)
@@ -605,6 +617,8 @@ func telemetryFailureCategory(value health.FailureCategory) telemetry.FailureCat
return telemetry.FailureCategoryUpstreamHost
case health.FailureCategoryClientError:
return telemetry.FailureCategoryClientError
case health.FailureCategoryConversionUnsupported:
return telemetry.FailureCategoryConversionUnsupported
case health.FailureCategoryDownstreamCancel:
return telemetry.FailureCategoryDownstreamCancel
default:
@@ -653,6 +667,8 @@ func upstreamErrorCode(result UpstreamResult, category health.FailureCategory) s
return "upstream_host_error"
case health.FailureCategoryClientError:
return "upstream_client_error"
case health.FailureCategoryConversionUnsupported:
return "protocol_conversion_unsupported"
case health.FailureCategoryDownstreamCancel:
return "client_canceled"
default:
@@ -678,6 +694,8 @@ func fixedErrorSummary(code string) string {
return "Upstream request timed out."
case "upstream_protocol_error":
return "Upstream returned an unsupported response."
case "protocol_conversion_unsupported":
return "No upstream target could preserve or convert the request."
case "upstream_sse_error":
return "Upstream stream reported an error."
case "upstream_stream_terminated":
+12
View File
@@ -25,6 +25,7 @@ import (
"gpt-load/internal/execution"
"gpt-load/internal/health"
"gpt-load/internal/httplifecycle"
"gpt-load/internal/parametertrace"
"gpt-load/internal/platform/encryption"
"gpt-load/internal/platform/redact"
"gpt-load/internal/pricing"
@@ -1254,6 +1255,10 @@ func TestRequestRecorderEmitsOperationAndAppliedAttemptObservation(t *testing.T)
func() time.Time { return startedAt.Add(time.Second) },
)
recorder.setOperation(execution.OperationResponsesCreate)
clientParameters := parametertrace.ProjectJSON([]byte(`{"reasoning":{"effort":"high"}}`))
targetParameters := parametertrace.ProjectJSON([]byte(`{"thinking":{"type":"enabled","budget_tokens":4096}}`))
conversionTrace := parametertrace.Compare(clientParameters, targetParameters)
recorder.setClientParameters(clientParameters)
selection := requestLogSelection(7, 8, "converted")
selection.RouteMode = channel.RouteConverted
recorder.appendAttempt(
@@ -1263,6 +1268,7 @@ func TestRequestRecorderEmitsOperationAndAppliedAttemptObservation(t *testing.T)
AppliedReasoning: reasoning.Config{
Mode: "enabled", BudgetTokens: &budget,
},
ConversionTrace: &conversionTrace,
},
telemetry.FailureCategoryOK,
telemetry.ActionTerminate,
@@ -1275,6 +1281,8 @@ func TestRequestRecorderEmitsOperationAndAppliedAttemptObservation(t *testing.T)
status: telemetry.RequestStatusSuccess, statusCode: http.StatusOK,
}
budget = 1
clientParameters.Entries[0].Value = "mutated"
conversionTrace.Target.Entries[0].Value = "mutated"
recorder.emit()
events := sink.snapshot()
@@ -1288,6 +1296,10 @@ func TestRequestRecorderEmitsOperationAndAppliedAttemptObservation(t *testing.T)
*attempt.Reasoning.BudgetTokens != 4096 {
t.Fatalf("attempt observation = %#v", attempt)
}
if events[0].ClientParameters == nil || events[0].ClientParameters.Entries[0].Value == "mutated" ||
attempt.ConversionTrace == nil || attempt.ConversionTrace.Target.Entries[0].Value == "mutated" {
t.Fatalf("conversion observation ownership = %#v / %#v", events[0].ClientParameters, attempt.ConversionTrace)
}
}
type observingReadCloser struct {
+3
View File
@@ -10,6 +10,7 @@ const (
FailureCategoryInvalidKey
FailureCategoryUpstreamHostError
FailureCategoryClientError
FailureCategoryConversionUnsupported
FailureCategoryDownstreamCancel
)
@@ -27,6 +28,8 @@ func (category FailureCategory) String() string {
return "upstream_host_error"
case FailureCategoryClientError:
return "client_error"
case FailureCategoryConversionUnsupported:
return "conversion_unsupported"
case FailureCategoryDownstreamCancel:
return "downstream_cancel"
default:
+20
View File
@@ -52,6 +52,8 @@ func JudgeExecution(attempt ExecutionAttempt) Result {
switch attempt.Evidence.Kind {
case execution.ErrorKindTransport, execution.ErrorKindTimeout:
return Result{Category: FailureCategoryUpstreamHostError, Action: ActionSkipGroup}
case execution.ErrorKindConversionUnsupported:
return Result{Category: FailureCategoryConversionUnsupported, Action: ActionSkipGroup}
case execution.ErrorKindInvalidRequest:
return Result{Category: FailureCategoryClientError, Action: ActionTerminate}
default:
@@ -81,6 +83,9 @@ func classifyExecutionEvidence(attempt ExecutionAttempt) FailureCategory {
}
markers := ""
if attempt.Evidence != nil {
if structuredUnsupportedModelClientError(statusCode, attempt.Evidence) {
return FailureCategoryClientError
}
switch attempt.Evidence.Hint {
case execution.FailureHintInvalidCredential:
return FailureCategoryInvalidKey
@@ -122,6 +127,19 @@ func classifyExecutionEvidence(attempt ExecutionAttempt) FailureCategory {
}
}
func structuredUnsupportedModelClientError(statusCode int, evidence *execution.ErrorEvidence) bool {
if evidence == nil || statusCode < http.StatusBadRequest || statusCode >= http.StatusInternalServerError {
return false
}
for _, value := range []string{evidence.Type, evidence.Code} {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "unsupported_model" || normalized == "unsupported-model" {
return true
}
}
return false
}
func resultForExecutionCategory(category FailureCategory, attempt ExecutionAttempt) Result {
switch category {
case FailureCategoryRateLimited:
@@ -150,6 +168,8 @@ func resultForExecutionCategory(category FailureCategory, attempt ExecutionAttem
return Result{Category: category, Action: ActionFailCredential}
case FailureCategoryUpstreamHostError:
return Result{Category: category, Action: ActionSkipGroup}
case FailureCategoryConversionUnsupported:
return Result{Category: category, Action: ActionSkipGroup}
default:
return Result{Category: category, Action: ActionTerminate}
}
+27
View File
@@ -198,6 +198,33 @@ func TestJudgeExecutionUsesNeutralEvidenceAndReplayBoundary(t *testing.T) {
},
want: Result{Category: FailureCategoryClientError, Action: ActionTerminate},
},
{
name: "local conversion failure skips group without credential health mutation",
attempt: ExecutionAttempt{
DispatchState: execution.DispatchNotSent,
Evidence: &execution.ErrorEvidence{
Kind: execution.ErrorKind("conversion_unsupported"),
Code: "target_conversion_not_supported",
Summary: "target conversion is not supported",
},
},
want: Result{Category: FailureCategoryConversionUnsupported, Action: ActionSkipGroup},
},
{
name: "structured unsupported model 400 is a client error",
attempt: ExecutionAttempt{
DispatchState: execution.DispatchMaybeSent,
StatusCode: http.StatusBadRequest,
Evidence: &execution.ErrorEvidence{
Kind: execution.ErrorKindHTTP,
Hint: execution.FailureHintModelUnavailable,
StatusCode: http.StatusBadRequest,
Code: "unsupported_model",
Summary: "request capability is unavailable",
},
},
want: Result{Category: FailureCategoryClientError, Action: ActionTerminate},
},
{
name: "clean success terminates",
attempt: ExecutionAttempt{
+880
View File
@@ -0,0 +1,880 @@
// Package parametertrace extracts bounded, non-content request parameter
// observations for protocol-conversion diagnostics.
package parametertrace
import (
"bytes"
"encoding/json"
"fmt"
"io"
"sort"
"strconv"
"strings"
"unicode/utf8"
"gpt-load/internal/platform/redact"
)
const (
SchemaVersion = 1
MaxSourceBytes = 256 << 10
MaxSnapshotBytes = 4 << 10
MaxTraceBytes = 16 << 10
MaxEventBytes = 16 << 10
maxEntries = 128
maxTraversalDepth = 8
maxValueBytes = 256
truncatedMarker = "...[truncated]"
)
type CaptureState string
const (
CaptureCaptured CaptureState = "captured"
CapturePartial CaptureState = "partial"
CaptureUnavailable CaptureState = "unavailable"
CaptureSkippedOversize CaptureState = "skipped_oversize"
CapturePreflightBlocked CaptureState = "preflight_blocked"
)
type ValueKind string
const (
ValueBoolean ValueKind = "boolean"
ValueNumber ValueKind = "number"
ValueEnum ValueKind = "enum"
ValueSummary ValueKind = "summary"
)
type Entry struct {
Path string `json:"path"`
Kind ValueKind `json:"kind"`
Value string `json:"value"`
}
type Snapshot struct {
SchemaVersion int `json:"schema_version"`
State CaptureState `json:"state"`
Entries []Entry `json:"entries"`
Truncated bool `json:"truncated"`
}
type Disposition string
const (
DispositionPreserved Disposition = "preserved"
DispositionMapped Disposition = "mapped"
DispositionNormalized Disposition = "normalized"
DispositionDropped Disposition = "dropped"
DispositionAdded Disposition = "added"
)
type Change struct {
Disposition Disposition `json:"disposition"`
SourcePath string `json:"source_path,omitempty"`
TargetPath string `json:"target_path,omitempty"`
}
type Trace struct {
SchemaVersion int `json:"schema_version"`
State CaptureState `json:"state"`
Target Snapshot `json:"target"`
Changes []Change `json:"changes"`
}
func ProjectJSON(body []byte) Snapshot {
if len(body) > MaxSourceBytes {
return emptySnapshot(CaptureSkippedOversize)
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
var value any
if len(body) == 0 || decoder.Decode(&value) != nil {
return emptySnapshot(CaptureUnavailable)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return emptySnapshot(CaptureUnavailable)
}
root, ok := value.(map[string]any)
if !ok {
return emptySnapshot(CaptureUnavailable)
}
builder := newProjectionBuilder()
builder.projectScalars(root)
builder.projectReasoning(root)
builder.projectTools(root)
builder.projectStructuredOutput(root)
builder.projectModalities(root)
return builder.snapshot()
}
func Compare(client Snapshot, target Snapshot) Trace {
trace := Trace{
SchemaVersion: SchemaVersion,
State: target.State,
Target: CloneSnapshot(target),
Changes: []Change{},
}
if client.State != CaptureCaptured && client.State != CapturePartial {
trace.State = client.State
return trace
}
if target.State != CaptureCaptured && target.State != CapturePartial {
return trace
}
if client.State == CapturePartial || target.State == CapturePartial ||
client.Truncated || target.Truncated {
trace.State = CapturePartial
}
source := entriesByPath(client.Entries)
destination := entriesByPath(target.Entries)
for _, pair := range [][2]string{
{"reasoning.budget_tokens", "reasoning.effort"},
{"reasoning.effort", "reasoning.budget_tokens"},
} {
if _, sourceExists := source[pair[0]]; !sourceExists {
continue
}
if _, targetExists := destination[pair[1]]; !targetExists {
continue
}
trace.Changes = append(trace.Changes, Change{
Disposition: DispositionMapped,
SourcePath: pair[0],
TargetPath: pair[1],
})
delete(source, pair[0])
delete(destination, pair[1])
}
paths := sortedEntryPaths(source)
for _, path := range paths {
sourceEntry := source[path]
if targetEntry, exists := destination[path]; exists {
disposition := DispositionPreserved
if sourceEntry.Kind != targetEntry.Kind || sourceEntry.Value != targetEntry.Value {
disposition = DispositionNormalized
}
trace.Changes = append(trace.Changes, Change{
Disposition: disposition,
SourcePath: path,
TargetPath: path,
})
delete(destination, path)
continue
}
trace.Changes = append(trace.Changes, Change{
Disposition: DispositionDropped,
SourcePath: path,
})
}
for _, path := range sortedEntryPaths(destination) {
trace.Changes = append(trace.Changes, Change{
Disposition: DispositionAdded,
TargetPath: path,
})
}
return boundTrace(trace)
}
func PreflightBlocked() Trace {
return Trace{
SchemaVersion: SchemaVersion,
State: CapturePreflightBlocked,
Target: Snapshot{
SchemaVersion: SchemaVersion,
State: CapturePreflightBlocked,
Entries: []Entry{},
},
Changes: []Change{},
}
}
func CloneSnapshot(value Snapshot) Snapshot {
clone := value
clone.Entries = append([]Entry(nil), value.Entries...)
return clone
}
func CloneTrace(value Trace) Trace {
clone := value
clone.Target = CloneSnapshot(value.Target)
clone.Changes = append([]Change(nil), value.Changes...)
return clone
}
func BoundTrace(value Trace, maxBytes int) Trace {
trace := CloneTrace(value)
if maxBytes <= 0 || maxBytes > MaxTraceBytes {
maxBytes = MaxTraceBytes
}
sort.Slice(trace.Target.Entries, func(left, right int) bool {
return trace.Target.Entries[left].Path < trace.Target.Entries[right].Path
})
for {
encoded, err := json.Marshal(trace.Target)
if err == nil && len(encoded) <= MaxSnapshotBytes {
break
}
if len(trace.Target.Entries) == 0 {
markTracePartial(&trace)
break
}
trace.Target.Entries = trace.Target.Entries[:len(trace.Target.Entries)-1]
markTracePartial(&trace)
}
if len(trace.Changes) > maxEntries {
trace.Changes = trace.Changes[:maxEntries]
markTracePartial(&trace)
}
for {
encoded, err := json.Marshal(trace)
if err == nil && len(encoded) <= maxBytes {
return trace
}
if len(trace.Changes) > 0 {
trace.Changes = trace.Changes[:len(trace.Changes)-1]
markTracePartial(&trace)
continue
}
if len(trace.Target.Entries) > 0 {
trace.Target.Entries = trace.Target.Entries[:len(trace.Target.Entries)-1]
markTracePartial(&trace)
continue
}
return Trace{SchemaVersion: SchemaVersion, State: CapturePartial, Target: Snapshot{
SchemaVersion: SchemaVersion,
State: CapturePartial,
Entries: []Entry{},
Truncated: true,
}, Changes: []Change{}}
}
}
func markTracePartial(trace *Trace) {
trace.State = CapturePartial
trace.Target.State = CapturePartial
trace.Target.Truncated = true
}
func MarshalSnapshot(value Snapshot) ([]byte, error) { return json.Marshal(value) }
func MarshalTrace(value Trace) ([]byte, error) { return json.Marshal(value) }
func (snapshot Snapshot) Validate() error {
if snapshot.SchemaVersion != SchemaVersion {
return fmt.Errorf("unsupported schema version %d", snapshot.SchemaVersion)
}
if !snapshot.State.valid() {
return fmt.Errorf("unsupported capture state %q", snapshot.State)
}
if len(snapshot.Entries) > maxEntries {
return fmt.Errorf("too many parameter entries")
}
if snapshot.State == CaptureCaptured && snapshot.Truncated {
return fmt.Errorf("captured snapshot cannot be truncated")
}
if snapshot.State == CapturePartial && !snapshot.Truncated {
return fmt.Errorf("partial snapshot must be truncated")
}
if snapshot.State != CaptureCaptured && snapshot.State != CapturePartial &&
(len(snapshot.Entries) != 0 || snapshot.Truncated) {
return fmt.Errorf("non-captured snapshot must be empty")
}
previous := ""
for _, entry := range snapshot.Entries {
if !safePath(entry.Path) || entry.Path <= previous {
return fmt.Errorf("parameter paths must be safe, unique, and sorted")
}
previous = entry.Path
if !entry.Kind.valid() || entry.Value == "" || len(entry.Value) > maxValueBytes ||
!utf8.ValidString(entry.Value) || strings.ContainsAny(entry.Value, "\r\n\x00") {
return fmt.Errorf("invalid parameter value for %q", entry.Path)
}
}
encoded, err := json.Marshal(snapshot)
if err != nil || len(encoded) > MaxSnapshotBytes {
return fmt.Errorf("parameter snapshot exceeds size limit")
}
return nil
}
func (trace Trace) Validate() error {
if trace.SchemaVersion != SchemaVersion {
return fmt.Errorf("unsupported schema version %d", trace.SchemaVersion)
}
if !trace.State.valid() {
return fmt.Errorf("unsupported trace state %q", trace.State)
}
if err := trace.Target.Validate(); err != nil {
return fmt.Errorf("invalid target snapshot: %w", err)
}
if len(trace.Changes) > maxEntries {
return fmt.Errorf("too many parameter changes")
}
for _, change := range trace.Changes {
if !change.Disposition.valid() {
return fmt.Errorf("unsupported change disposition %q", change.Disposition)
}
sourceSafe := change.SourcePath == "" || safePath(change.SourcePath)
targetSafe := change.TargetPath == "" || safePath(change.TargetPath)
if !sourceSafe || !targetSafe {
return fmt.Errorf("unsafe change path")
}
switch change.Disposition {
case DispositionDropped:
if change.SourcePath == "" || change.TargetPath != "" {
return fmt.Errorf("dropped change has invalid paths")
}
case DispositionAdded:
if change.SourcePath != "" || change.TargetPath == "" {
return fmt.Errorf("added change has invalid paths")
}
default:
if change.SourcePath == "" || change.TargetPath == "" {
return fmt.Errorf("paired change has invalid paths")
}
}
}
encoded, err := json.Marshal(trace)
if err != nil || len(encoded) > MaxTraceBytes {
return fmt.Errorf("conversion trace exceeds size limit")
}
return nil
}
func (state CaptureState) valid() bool {
switch state {
case CaptureCaptured, CapturePartial, CaptureUnavailable, CaptureSkippedOversize, CapturePreflightBlocked:
return true
default:
return false
}
}
func (kind ValueKind) valid() bool {
return kind == ValueBoolean || kind == ValueNumber || kind == ValueEnum || kind == ValueSummary
}
func (disposition Disposition) valid() bool {
switch disposition {
case DispositionPreserved, DispositionMapped, DispositionNormalized, DispositionDropped, DispositionAdded:
return true
default:
return false
}
}
func safePath(value string) bool {
if value == "" || len(value) > 128 {
return false
}
for _, character := range value {
if character >= 'a' && character <= 'z' || character >= '0' && character <= '9' ||
character == '_' || character == '.' {
continue
}
return false
}
return true
}
func emptySnapshot(state CaptureState) Snapshot {
return Snapshot{SchemaVersion: SchemaVersion, State: state, Entries: []Entry{}}
}
type projectionBuilder struct {
entries map[string]Entry
truncated bool
}
func newProjectionBuilder() *projectionBuilder {
return &projectionBuilder{entries: make(map[string]Entry)}
}
func (builder *projectionBuilder) add(path string, kind ValueKind, value string) {
if path == "" || value == "" {
return
}
if len(builder.entries) >= maxEntries {
if _, exists := builder.entries[path]; !exists {
builder.truncated = true
return
}
}
if len(value) > maxValueBytes {
value = truncateUTF8(value, maxValueBytes-len(truncatedMarker)) + truncatedMarker
builder.truncated = true
}
builder.entries[path] = Entry{Path: path, Kind: kind, Value: value}
}
func (builder *projectionBuilder) addBool(path string, value any) {
if typed, ok := value.(bool); ok {
builder.add(path, ValueBoolean, strconv.FormatBool(typed))
}
}
func (builder *projectionBuilder) addNumber(path string, value any) {
switch typed := value.(type) {
case json.Number:
if _, err := typed.Float64(); err == nil {
builder.add(path, ValueNumber, typed.String())
}
case float64:
builder.add(path, ValueNumber, strconv.FormatFloat(typed, 'g', -1, 64))
}
}
func (builder *projectionBuilder) addEnum(path string, value any) {
if typed, ok := value.(string); ok {
normalized := normalizeEnum(typed)
if normalized != "" {
builder.add(path, ValueEnum, normalized)
}
}
}
func (builder *projectionBuilder) projectScalars(root map[string]any) {
builder.addBool("stream", lookup(root, "stream"))
for _, key := range []string{"max_output_tokens", "max_completion_tokens", "max_tokens"} {
if value, exists := root[key]; exists {
builder.addNumber("limits.output_tokens", value)
break
}
}
for key, path := range map[string]string{
"temperature": "sampling.temperature",
"top_p": "sampling.top_p",
"top_k": "sampling.top_k",
"n": "sampling.candidate_count",
"candidate_count": "sampling.candidate_count",
"seed": "sampling.seed",
"presence_penalty": "sampling.presence_penalty",
"frequency_penalty": "sampling.frequency_penalty",
} {
builder.addNumber(path, lookup(root, key))
}
builder.addBool("tools.parallel_calls", lookup(root, "parallel_tool_calls"))
builder.addEnum("tools.choice", lookup(root, "tool_choice"))
if config := objectAt(root, "generationConfig", "generation_config"); config != nil {
builder.addNumber("limits.output_tokens", lookup(config, "maxOutputTokens", "max_output_tokens"))
builder.addNumber("sampling.temperature", lookup(config, "temperature"))
builder.addNumber("sampling.top_p", lookup(config, "topP", "top_p"))
builder.addNumber("sampling.top_k", lookup(config, "topK", "top_k"))
builder.addNumber("sampling.candidate_count", lookup(config, "candidateCount", "candidate_count"))
builder.addNumber("sampling.seed", lookup(config, "seed"))
}
}
func (builder *projectionBuilder) projectReasoning(root map[string]any) {
builder.addEnum("reasoning.effort", lookup(root, "reasoning_effort"))
for _, object := range []map[string]any{
objectAt(root, "reasoning"),
objectAt(root, "thinking"),
objectAt(root, "output_config", "outputConfig"),
} {
builder.projectReasoningObject(object)
}
if generationConfig := objectAt(root, "generationConfig", "generation_config"); generationConfig != nil {
builder.projectReasoningObject(objectAt(generationConfig, "thinkingConfig", "thinking_config"))
}
if additional := objectAt(root, "additionalModelRequestFields", "additional_model_request_fields"); additional != nil {
for _, object := range []map[string]any{
objectAt(additional, "thinking"),
objectAt(additional, "output_config", "outputConfig"),
objectAt(additional, "reasoningConfig", "reasoning_config"),
} {
builder.projectReasoningObject(object)
}
}
}
func (builder *projectionBuilder) projectReasoningObject(object map[string]any) {
if object == nil {
return
}
builder.addEnum("reasoning.mode", lookup(object, "type", "mode"))
builder.addEnum("reasoning.effort", lookup(
object, "effort", "thinkingLevel", "thinking_level", "maxReasoningEffort",
))
builder.addNumber("reasoning.budget_tokens", lookup(
object,
"budget_tokens", "max_tokens", "thinkingBudget", "thinking_budget", "maxReasoningEffort",
))
builder.addBool("reasoning.include", lookup(object, "includeThoughts", "include_thoughts"))
}
func (builder *projectionBuilder) projectTools(root map[string]any) {
tools, ok := lookup(root, "tools").([]any)
if !ok || len(tools) == 0 {
return
}
types := make(map[string]struct{})
names := make(map[string]struct{})
count := 0
for index, value := range tools {
if index >= maxEntries {
builder.truncated = true
break
}
tool, ok := value.(map[string]any)
if !ok {
continue
}
if declarations, ok := lookup(tool, "functionDeclarations", "function_declarations").([]any); ok {
for declarationIndex, declarationValue := range declarations {
if count >= maxEntries || declarationIndex >= maxEntries {
builder.truncated = true
break
}
declaration, ok := declarationValue.(map[string]any)
if !ok {
continue
}
count++
types["function"] = struct{}{}
addSafeName(names, lookup(declaration, "name"))
}
}
hadSpecialTool := false
for key := range tool {
if toolType := canonicalToolType(key); toolType != "" && toolType != "function" {
count++
types[toolType] = struct{}{}
hadSpecialTool = true
}
}
if _, hasDeclarations := lookup(tool, "functionDeclarations", "function_declarations").([]any); hasDeclarations {
continue
}
if hadSpecialTool {
continue
}
count++
typeValue, _ := lookup(tool, "type").(string)
toolType := canonicalToolType(typeValue)
if toolType == "" {
toolType = "function"
}
types[toolType] = struct{}{}
if function := objectAt(tool, "function"); function != nil {
addSafeName(names, lookup(function, "name"))
} else {
addSafeName(names, lookup(tool, "name"))
}
}
if count == 0 {
return
}
builder.add("tools.count", ValueNumber, strconv.Itoa(count))
sortedTypes := sortedKeys(types)
builder.add("tools.types", ValueSummary, strings.Join(sortedTypes, ","))
for _, toolType := range sortedTypes {
builder.add("tools.type."+toolType, ValueBoolean, "true")
}
if len(names) > 0 {
builder.add("tools.names", ValueSummary, strings.Join(sortedKeys(names), ","))
}
}
func (builder *projectionBuilder) projectStructuredOutput(root map[string]any) {
var format map[string]any
if value := objectAt(root, "response_format"); value != nil {
format = value
if nested := objectAt(value, "json_schema"); nested != nil {
builder.addEnum("structured_output.type", lookup(value, "type"))
format = nested
}
}
if text := objectAt(root, "text"); text != nil {
if nested := objectAt(text, "format"); nested != nil {
format = nested
}
}
if outputConfig := objectAt(root, "output_config", "outputConfig"); outputConfig != nil {
if nested := objectAt(outputConfig, "format"); nested != nil {
format = nested
}
}
if format != nil {
builder.addEnum("structured_output.type", lookup(format, "type"))
if value, ok := lookup(format, "name").(string); ok {
builder.add("structured_output.name", ValueSummary, safeIdentifier(value))
}
builder.addBool("structured_output.strict", lookup(format, "strict"))
if _, exists := format["schema"]; exists {
builder.add("structured_output.schema_present", ValueBoolean, "true")
}
}
if config := objectAt(root, "generationConfig", "generation_config"); config != nil {
mime, _ := lookup(config, "responseMimeType", "response_mime_type").(string)
_, schema := firstExisting(config, "responseSchema", "response_schema", "responseJsonSchema", "response_json_schema")
if schema || strings.EqualFold(mime, "application/json") {
builder.add("structured_output.type", ValueEnum, "json_schema")
}
if schema {
builder.add("structured_output.schema_present", ValueBoolean, "true")
}
}
}
func (builder *projectionBuilder) projectModalities(root map[string]any) {
counts := make(map[string]int)
visited := 0
var walk func(any, int)
walk = func(value any, depth int) {
if visited >= maxEntries {
builder.truncated = true
return
}
if depth > maxTraversalDepth {
builder.truncated = true
return
}
visited++
switch typed := value.(type) {
case []any:
for _, item := range typed {
walk(item, depth+1)
}
case map[string]any:
if rawType, ok := lookup(typed, "type").(string); ok {
if modality := canonicalModality(rawType); modality != "" {
counts[modality]++
}
}
for key, item := range typed {
normalized := normalizeKey(key)
if normalized == "inlinedata" || normalized == "filedata" {
if object, ok := item.(map[string]any); ok {
mime, _ := lookup(object, "mimeType", "mime_type").(string)
if modality := mimeModality(mime); modality != "" {
counts[modality]++
}
}
}
walk(item, depth+1)
}
}
}
walk(root, 0)
for _, modality := range []string{"text", "image", "audio", "file", "video"} {
if counts[modality] > 0 {
builder.add("modalities."+modality+"_count", ValueNumber, strconv.Itoa(counts[modality]))
}
}
}
func (builder *projectionBuilder) snapshot() Snapshot {
entries := make([]Entry, 0, len(builder.entries))
for _, entry := range builder.entries {
entries = append(entries, entry)
}
sort.Slice(entries, func(left, right int) bool { return entries[left].Path < entries[right].Path })
state := CaptureCaptured
if builder.truncated {
state = CapturePartial
}
snapshot := Snapshot{
SchemaVersion: SchemaVersion,
State: state,
Entries: entries,
Truncated: builder.truncated,
}
for {
encoded, err := json.Marshal(snapshot)
if err == nil && len(encoded) <= MaxSnapshotBytes {
return snapshot
}
if len(snapshot.Entries) == 0 {
return emptySnapshot(CapturePartial)
}
snapshot.Entries = snapshot.Entries[:len(snapshot.Entries)-1]
snapshot.State = CapturePartial
snapshot.Truncated = true
}
}
func entriesByPath(entries []Entry) map[string]Entry {
result := make(map[string]Entry, len(entries))
for _, entry := range entries {
result[entry.Path] = entry
}
return result
}
func sortedEntryPaths(entries map[string]Entry) []string {
paths := make([]string, 0, len(entries))
for path := range entries {
paths = append(paths, path)
}
sort.Strings(paths)
return paths
}
func boundTrace(trace Trace) Trace {
return BoundTrace(trace, MaxTraceBytes)
}
func lookup(object map[string]any, names ...string) any {
value, _ := firstExisting(object, names...)
return value
}
func firstExisting(object map[string]any, names ...string) (any, bool) {
for _, name := range names {
if value, exists := object[name]; exists {
return value, true
}
}
for key, value := range object {
for _, name := range names {
if normalizeKey(key) == normalizeKey(name) {
return value, true
}
}
}
return nil, false
}
func objectAt(object map[string]any, names ...string) map[string]any {
value, _ := firstExisting(object, names...)
result, _ := value.(map[string]any)
return result
}
func canonicalToolType(value string) string {
normalized := normalizeKey(value)
switch {
case normalized == "function", normalized == "functiondeclarations":
return "function"
case strings.Contains(normalized, "websearch"), normalized == "googlesearch":
return "web_search"
case strings.Contains(normalized, "filesearch"):
return "file_search"
case strings.Contains(normalized, "computer"):
return "computer"
case strings.Contains(normalized, "code"):
return "code_interpreter"
case strings.Contains(normalized, "mcp"):
return "mcp"
case strings.Contains(normalized, "imagegeneration"):
return "image_generation"
case strings.Contains(normalized, "urlcontext"):
return "url_context"
default:
return ""
}
}
func canonicalModality(value string) string {
normalized := normalizeKey(value)
switch {
case normalized == "text", strings.Contains(normalized, "inputtext"), strings.Contains(normalized, "outputtext"):
return "text"
case strings.Contains(normalized, "image"):
return "image"
case strings.Contains(normalized, "audio"):
return "audio"
case strings.Contains(normalized, "video"):
return "video"
case strings.Contains(normalized, "file"), strings.Contains(normalized, "document"):
return "file"
default:
return ""
}
}
func mimeModality(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
switch {
case strings.HasPrefix(value, "image/"):
return "image"
case strings.HasPrefix(value, "audio/"):
return "audio"
case strings.HasPrefix(value, "video/"):
return "video"
case value != "":
return "file"
default:
return ""
}
}
func addSafeName(names map[string]struct{}, value any) {
name, ok := value.(string)
if !ok || name == "" {
return
}
names[safeIdentifier(name)] = struct{}{}
}
func safeIdentifier(value string) string {
if len(value) > 128 || !utf8.ValidString(value) {
return "[redacted]"
}
for _, character := range value {
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9' || strings.ContainsRune("_.:-", character) {
continue
}
return "[redacted]"
}
redacted := redact.New().String(value)
if redacted != value {
return redact.Placeholder
}
return value
}
func normalizeEnum(value string) string {
value = strings.TrimSpace(value)
if value == "" || len(value) > 64 || !utf8.ValidString(value) {
return ""
}
if redact.New().String(value) != value {
return ""
}
for _, character := range value {
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9' || strings.ContainsRune("_.:-/", character) {
continue
}
return ""
}
return strings.ToLower(value)
}
func normalizeKey(value string) string {
value = strings.ToLower(value)
value = strings.ReplaceAll(value, "_", "")
value = strings.ReplaceAll(value, "-", "")
return value
}
func sortedKeys(values map[string]struct{}) []string {
result := make([]string, 0, len(values))
for value := range values {
result = append(result, value)
}
sort.Strings(result)
return result
}
func truncateUTF8(value string, limit int) string {
if limit <= 0 {
return ""
}
if len(value) <= limit {
return value
}
value = value[:limit]
for !utf8.ValidString(value) && len(value) > 0 {
value = value[:len(value)-1]
}
return value
}
+290
View File
@@ -0,0 +1,290 @@
package parametertrace
import (
"strings"
"testing"
)
func TestProjectJSONCapturesOnlyBoundedSafeParameters(t *testing.T) {
body := []byte(`{
"model":"secret-model-value",
"messages":[{"role":"user","content":"top secret prompt","metadata":{"tenant":"private"}}],
"stream":true,
"max_tokens":2048,
"temperature":0.2,
"thinking":{"type":"enabled","budget_tokens":4096},
"tools":[
{"name":"lookup_weather","description":"secret description","input_schema":{"type":"object","properties":{"city":{"type":"string"}}}},
{"type":"web_search_20250305","name":"web_search"}
],
"output_config":{"effort":"high","format":{"type":"json_schema","name":"weather_result","strict":true,"schema":{"type":"object","description":"secret schema"}}},
"content":[
{"type":"text","text":"secret text"},
{"type":"image","source":{"type":"base64","data":"secret binary"}},
{"type":"document","source":{"type":"url","url":"https://secret.invalid/file"}}
],
"metadata":{"user_id":"secret-user"}
}`)
got := ProjectJSON(body)
if got.SchemaVersion != SchemaVersion || got.State != CaptureCaptured || got.Truncated {
t.Fatalf("ProjectJSON() version/state/truncated = %d/%q/%v", got.SchemaVersion, got.State, got.Truncated)
}
want := map[string]string{
"stream": "true",
"limits.output_tokens": "2048",
"sampling.temperature": "0.2",
"reasoning.mode": "enabled",
"reasoning.budget_tokens": "4096",
"reasoning.effort": "high",
"tools.count": "2",
"tools.types": "function,web_search",
"tools.type.function": "true",
"tools.type.web_search": "true",
"tools.names": "lookup_weather,web_search",
"structured_output.type": "json_schema",
"structured_output.name": "weather_result",
"structured_output.strict": "true",
"structured_output.schema_present": "true",
"modalities.text_count": "1",
"modalities.image_count": "1",
"modalities.file_count": "1",
}
if len(got.Entries) != len(want) {
t.Fatalf("entries = %#v, want %d entries", got.Entries, len(want))
}
for _, entry := range got.Entries {
if want[entry.Path] != entry.Value {
t.Errorf("entry %q = %q, want %q", entry.Path, entry.Value, want[entry.Path])
}
}
encoded, err := MarshalSnapshot(got)
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{
"top secret prompt", "secret description", "secret schema", "secret binary",
"secret.invalid", "secret-user", "secret-model-value",
} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("snapshot leaked %q: %s", forbidden, encoded)
}
}
}
func TestProjectJSONSupportsOpenAIAndGeminiParameterShapes(t *testing.T) {
tests := []struct {
name string
body string
want map[string]string
}{
{
name: "openai responses",
body: `{"max_output_tokens":1024,"parallel_tool_calls":false,"reasoning":{"effort":"high"},"text":{"format":{"type":"json_schema","name":"answer","strict":false,"schema":{"type":"object"}}},"tools":[{"type":"function","name":"lookup"}]}`,
want: map[string]string{
"limits.output_tokens": "1024", "tools.parallel_calls": "false",
"reasoning.effort": "high", "tools.count": "1", "tools.types": "function",
"tools.type.function": "true",
"tools.names": "lookup", "structured_output.type": "json_schema",
"structured_output.name": "answer", "structured_output.strict": "false",
"structured_output.schema_present": "true",
},
},
{
name: "gemini",
body: `{"generationConfig":{"maxOutputTokens":512,"topP":0.8,"thinkingConfig":{"thinkingBudget":256},"responseMimeType":"application/json","responseSchema":{"type":"OBJECT"}},"tools":[{"functionDeclarations":[{"name":"lookup"}]},{"googleSearch":{}}]}`,
want: map[string]string{
"limits.output_tokens": "512", "sampling.top_p": "0.8",
"reasoning.budget_tokens": "256", "tools.count": "2",
"tools.types": "function,web_search", "tools.names": "lookup",
"tools.type.function": "true", "tools.type.web_search": "true",
"structured_output.type": "json_schema", "structured_output.schema_present": "true",
},
},
{
name: "bedrock additional model fields",
body: `{"additionalModelRequestFields":{"thinking":{"type":"enabled","budget_tokens":8192},"output_config":{"effort":"medium"}}}`,
want: map[string]string{
"reasoning.mode": "enabled", "reasoning.budget_tokens": "8192", "reasoning.effort": "medium",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := ProjectJSON([]byte(test.body))
for path, want := range test.want {
found := false
for _, entry := range got.Entries {
if entry.Path == path {
found = true
if entry.Value != want {
t.Errorf("%s = %q, want %q", path, entry.Value, want)
}
}
}
if !found {
t.Errorf("missing %s in %#v", path, got.Entries)
}
}
})
}
}
func TestProjectJSONDoesNotTreatMessageOrToolContentAsReasoningConfiguration(t *testing.T) {
body := []byte(`{
"contents":[{"role":"user","parts":[{"functionResponse":{"name":"lookup","response":{"reasoning":{"effort":"customer_case_123"},"thinking":{"type":"private_case"}}}}]}],
"messages":[{"role":"user","content":{"thinking":{"budget_tokens":987654}}}]
}`)
got := ProjectJSON(body)
for _, entry := range got.Entries {
if strings.HasPrefix(entry.Path, "reasoning.") {
t.Fatalf("content value was projected as reasoning parameter: %#v", got.Entries)
}
}
encoded, err := MarshalSnapshot(got)
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"customer_case_123", "private_case", "987654"} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("snapshot leaked content value %q: %s", forbidden, encoded)
}
}
}
func TestCompareClassifiesLossyProtocolConversion(t *testing.T) {
client := Snapshot{SchemaVersion: SchemaVersion, State: CaptureCaptured, Entries: []Entry{
{Path: "stream", Kind: ValueBoolean, Value: "true"},
{Path: "limits.output_tokens", Kind: ValueNumber, Value: "4096"},
{Path: "reasoning.budget_tokens", Kind: ValueNumber, Value: "4096"},
{Path: "tools.types", Kind: ValueSummary, Value: "function,web_search"},
{Path: "structured_output.strict", Kind: ValueBoolean, Value: "true"},
}}
target := Snapshot{SchemaVersion: SchemaVersion, State: CaptureCaptured, Entries: []Entry{
{Path: "stream", Kind: ValueBoolean, Value: "true"},
{Path: "limits.output_tokens", Kind: ValueNumber, Value: "2048"},
{Path: "reasoning.effort", Kind: ValueEnum, Value: "high"},
{Path: "tools.types", Kind: ValueSummary, Value: "function"},
{Path: "tools.parallel_calls", Kind: ValueBoolean, Value: "true"},
}}
got := Compare(client, target)
if got.SchemaVersion != SchemaVersion || got.Target.SchemaVersion != SchemaVersion {
t.Fatalf("trace schema versions = %d/%d", got.SchemaVersion, got.Target.SchemaVersion)
}
want := map[string]Disposition{
"stream->stream": DispositionPreserved,
"limits.output_tokens->limits.output_tokens": DispositionNormalized,
"reasoning.budget_tokens->reasoning.effort": DispositionMapped,
"tools.types->tools.types": DispositionNormalized,
"structured_output.strict->": DispositionDropped,
"->tools.parallel_calls": DispositionAdded,
}
if len(got.Changes) != len(want) {
t.Fatalf("changes = %#v, want %d", got.Changes, len(want))
}
for _, change := range got.Changes {
key := change.SourcePath + "->" + change.TargetPath
if want[key] != change.Disposition {
t.Errorf("change %s = %q, want %q", key, change.Disposition, want[key])
}
}
}
func TestCompareMarksFilteredToolSubtypeAsDropped(t *testing.T) {
client := ProjectJSON([]byte(`{"tools":[{"type":"function","function":{"name":"lookup"}},{"type":"web_search"}]}`))
target := ProjectJSON([]byte(`{"tools":[{"type":"function","function":{"name":"lookup"}}]}`))
trace := Compare(client, target)
for _, change := range trace.Changes {
if change.Disposition == DispositionDropped &&
change.SourcePath == "tools.type.web_search" && change.TargetPath == "" {
return
}
}
t.Fatalf("trace did not report filtered web_search tool: %#v", trace)
}
func TestProjectJSONRedactsCredentialLikeToolNamesBeforeLeavingExecution(t *testing.T) {
snapshot := ProjectJSON([]byte(`{"tools":[{"name":"sk-supersecret99"}]}`))
for _, entry := range snapshot.Entries {
if entry.Path == "tools.names" {
if entry.Value != "[REDACTED]" {
t.Fatalf("tool name = %q, want redacted", entry.Value)
}
return
}
}
t.Fatalf("tool names entry missing: %#v", snapshot)
}
func TestProjectJSONSkipsOversizeAndBoundsComplexInput(t *testing.T) {
oversize := []byte(`{"messages":"` + strings.Repeat("x", MaxSourceBytes) + `"}`)
if got := ProjectJSON(oversize); got.State != CaptureSkippedOversize || len(got.Entries) != 0 {
t.Fatalf("oversize snapshot = %#v", got)
}
for _, invalid := range []string{`{"stream":true} {}`, `{"stream":true} trailing`} {
if got := ProjectJSON([]byte(invalid)); got.State != CaptureUnavailable {
t.Fatalf("invalid JSON snapshot = %#v", got)
}
}
parts := make([]string, 0, 180)
for index := 0; index < 180; index++ {
parts = append(parts, `{"type":"function","name":"tool_`+strings.Repeat("x", 80)+`"}`)
}
got := ProjectJSON([]byte(`{"tools":[` + strings.Join(parts, ",") + `]}`))
if got.State != CapturePartial || !got.Truncated || len(got.Entries) > 128 {
t.Fatalf("bounded snapshot = %#v", got)
}
encoded, err := MarshalSnapshot(got)
if err != nil {
t.Fatal(err)
}
if len(encoded) > 4<<10 {
t.Fatalf("snapshot size = %d, want <= 4096", len(encoded))
}
}
func TestBoundTraceFitsRequestEventBudget(t *testing.T) {
entries := make([]Entry, 0, 80)
changes := make([]Change, 0, 80)
for index := 0; index < 80; index++ {
path := "tools.parameter_" + strings.Repeat("x", index%20) + string(rune('a'+index%26))
entries = append(entries, Entry{Path: path, Kind: ValueSummary, Value: strings.Repeat("v", 180)})
changes = append(changes, Change{Disposition: DispositionAdded, TargetPath: path})
}
trace := Trace{
SchemaVersion: SchemaVersion,
State: CaptureCaptured,
Target: Snapshot{SchemaVersion: SchemaVersion, State: CaptureCaptured, Entries: entries},
Changes: changes,
}
bounded := BoundTrace(trace, 2048)
encoded, err := MarshalTrace(bounded)
if err != nil {
t.Fatal(err)
}
if len(encoded) > 2048 || bounded.State != CapturePartial || !bounded.Target.Truncated {
t.Fatalf("bounded trace size/state = %d/%#v", len(encoded), bounded)
}
if err := bounded.Validate(); err != nil {
t.Fatalf("bounded trace validation: %v", err)
}
}
func TestValidateRejectsMissingOrUnknownSchemaVersion(t *testing.T) {
for _, snapshot := range []Snapshot{
{State: CaptureCaptured, Entries: []Entry{}},
{SchemaVersion: SchemaVersion + 1, State: CaptureCaptured, Entries: []Entry{}},
} {
if err := snapshot.Validate(); err == nil {
t.Fatalf("Snapshot.Validate() accepted schema version %d", snapshot.SchemaVersion)
}
}
trace := PreflightBlocked()
trace.SchemaVersion = 0
if err := trace.Validate(); err == nil {
t.Fatal("Trace.Validate() accepted missing schema version")
}
}
-13
View File
@@ -21,16 +21,3 @@ func (config Config) Clone() Config {
func (config Config) Present() bool {
return config.Mode != "" || config.Effort != "" || config.BudgetTokens != nil
}
// RequiresCapability reports whether the request asks the upstream to perform
// reasoning. An explicit "none" setting is a valid opt-out and must not make
// routing require a reasoning-capable target.
func (config Config) RequiresCapability() bool {
if config.BudgetTokens != nil {
return true
}
if config.Mode != "" && config.Mode != "none" {
return true
}
return config.Effort != "" && config.Effort != "none"
}
+79
View File
@@ -7,6 +7,7 @@ import (
"unicode"
"unicode/utf8"
"gpt-load/internal/parametertrace"
"gpt-load/internal/platform/epochms"
"gpt-load/internal/platform/redact"
"gpt-load/internal/pricing"
@@ -41,12 +42,41 @@ func mapEvent(
if err != nil {
return models.RequestLog{}, fmt.Errorf("map request event pricing receipt: %w", err)
}
clientParameters, err := encodeParameterSnapshot(redactor, event.ClientParameters)
if err != nil {
return models.RequestLog{}, fmt.Errorf("map request event client parameters: %w", err)
}
conversionTraceCount := 0
for _, attempt := range event.Attempts {
if attempt.ConversionTrace != nil {
conversionTraceCount++
}
}
remainingConversionBytes := parametertrace.MaxEventBytes - len(clientParameters) - 512
if remainingConversionBytes < 0 {
remainingConversionBytes = 0
}
attempts := make([]models.RequestLogAttempt, 0, len(event.Attempts))
for _, attempt := range event.Attempts {
attemptReceipt := models.JSON(nil)
if attempt.Sequence == event.Usage.AttemptSequence {
attemptReceipt = receipt
}
traceBudget := remainingConversionBytes
if conversionTraceCount > 0 {
traceBudget /= conversionTraceCount
}
conversionTrace, err := encodeConversionTrace(redactor, attempt.ConversionTrace, traceBudget)
if err != nil {
return models.RequestLog{}, fmt.Errorf("map request attempt conversion trace: %w", err)
}
remainingConversionBytes -= len(conversionTrace)
if remainingConversionBytes < 0 {
remainingConversionBytes = 0
}
if attempt.ConversionTrace != nil {
conversionTraceCount--
}
attempts = append(attempts, models.RequestLogAttempt{
RequestID: event.RequestID,
Sequence: attempt.Sequence,
@@ -74,6 +104,7 @@ func mapEvent(
ErrorSummary: sanitizeSummary(redactor, attempt.ErrorSummary),
Committed: attempt.Committed,
PricingReceipt: attemptReceipt,
ConversionTrace: conversionTrace,
})
}
@@ -105,6 +136,7 @@ func mapEvent(
ReasoningMode: event.Reasoning.Mode,
ReasoningEffort: event.Reasoning.Effort,
ReasoningBudgetTokens: event.Reasoning.BudgetTokens,
ClientParameters: clientParameters,
UncachedInputTokens: result.Tokens.UncachedInput,
OutputTokens: result.Tokens.Output,
CacheReadTokens: result.Tokens.CacheRead,
@@ -119,6 +151,53 @@ func mapEvent(
}, nil
}
func encodeParameterSnapshot(
redactor *redact.Redactor,
value *parametertrace.Snapshot,
) (models.JSON, error) {
if value == nil {
return nil, nil
}
clone := parametertrace.CloneSnapshot(*value)
for index := range clone.Entries {
clone.Entries[index].Value = redactor.String(clone.Entries[index].Value)
}
if err := clone.Validate(); err != nil {
return nil, err
}
encoded, err := parametertrace.MarshalSnapshot(clone)
return models.JSON(encoded), err
}
func encodeConversionTrace(
redactor *redact.Redactor,
value *parametertrace.Trace,
maxBytes int,
) (models.JSON, error) {
if value == nil {
return nil, nil
}
if maxBytes <= 0 {
return nil, nil
}
clone := parametertrace.CloneTrace(*value)
for index := range clone.Target.Entries {
clone.Target.Entries[index].Value = redactor.String(clone.Target.Entries[index].Value)
}
clone = parametertrace.BoundTrace(clone, maxBytes)
if err := clone.Validate(); err != nil {
return nil, err
}
encoded, err := parametertrace.MarshalTrace(clone)
if err == nil && len(encoded) > maxBytes {
// A valid trace has a non-zero structural floor. When the remaining
// request-event budget cannot hold it, omit this attempt's optional
// diagnostic instead of violating the global bound.
return nil, nil
}
return models.JSON(encoded), err
}
func normalizeModelObservation(event telemetry.RequestEvent) telemetry.RequestEvent {
if event.Status != telemetry.RequestStatusSuccess || event.UpstreamModel == "" {
event.UpstreamReportedModel = ""
+105
View File
@@ -1,6 +1,8 @@
package requestlog
import (
"encoding/json"
"fmt"
"math"
"strings"
"testing"
@@ -8,6 +10,7 @@ import (
"unicode/utf8"
"gpt-load/internal/execution"
"gpt-load/internal/parametertrace"
"gpt-load/internal/platform/redact"
"gpt-load/internal/pricing"
"gpt-load/internal/protocol"
@@ -88,6 +91,108 @@ func TestMapEventPersistsFrozenUsagePricingAndAttribution(t *testing.T) {
}
}
func TestMapEventPersistsOnlySafeConversionObservations(t *testing.T) {
event := testEvent("00000000-0000-4000-8000-000000000199")
client := parametertrace.ProjectJSON([]byte(`{"messages":[{"role":"user","content":"secret prompt"}],"thinking":{"type":"enabled","budget_tokens":4096},"tools":[{"name":"sk-supersecret99"}]}`))
target := parametertrace.ProjectJSON([]byte(`{"messages":[{"role":"user","content":"secret prompt"}],"reasoning_effort":"high","tools":[{"type":"function","function":{"name":"sk-supersecret99"}}]}`))
trace := parametertrace.Compare(client, target)
event.ClientParameters = &client
event.Attempts[0].ConversionTrace = &trace
event.Attempts[0].FailureCategory = telemetry.FailureCategoryConversionUnsupported
row := mustMapEvent(t, redact.New(), event)
if len(row.ClientParameters) == 0 || len(row.AttemptRows) != 1 ||
len(row.AttemptRows[0].ConversionTrace) == 0 {
t.Fatalf("conversion observations were not persisted: %#v / %#v", row.ClientParameters, row.AttemptRows)
}
for _, encoded := range [][]byte{row.ClientParameters, row.AttemptRows[0].ConversionTrace} {
if strings.Contains(string(encoded), "secret prompt") ||
strings.Contains(string(encoded), "sk-supersecret99") ||
!strings.Contains(string(encoded), "[REDACTED]") ||
len(encoded) > parametertrace.MaxTraceBytes {
t.Fatalf("unsafe or oversized conversion observation: %s", encoded)
}
}
}
func TestMapEventBoundsAllConversionObservationsToRequestEventBudget(t *testing.T) {
event := testEvent("00000000-0000-4000-8000-000000000198")
client := parametertrace.ProjectJSON([]byte(`{"thinking":{"type":"enabled","budget_tokens":4096}}`))
event.ClientParameters = &client
entries := make([]parametertrace.Entry, 0, 80)
changes := make([]parametertrace.Change, 0, 80)
for index := 0; index < 80; index++ {
path := fmt.Sprintf("tools.parameter_%03d", index)
entries = append(entries, parametertrace.Entry{
Path: path, Kind: parametertrace.ValueSummary, Value: strings.Repeat("v", 180),
})
changes = append(changes, parametertrace.Change{
Disposition: parametertrace.DispositionAdded, TargetPath: path,
})
}
largeTrace := parametertrace.Trace{
SchemaVersion: parametertrace.SchemaVersion,
State: parametertrace.CaptureCaptured,
Target: parametertrace.Snapshot{
SchemaVersion: parametertrace.SchemaVersion,
State: parametertrace.CaptureCaptured,
Entries: entries,
},
Changes: changes,
}
baseAttempt := event.Attempts[0]
event.Attempts = make([]telemetry.Attempt, 3)
for index := range event.Attempts {
event.Attempts[index] = baseAttempt
event.Attempts[index].Sequence = index + 1
event.Attempts[index].ConversionTrace = &largeTrace
}
row := mustMapEvent(t, redact.New(), event)
total := len(row.ClientParameters)
for _, attempt := range row.AttemptRows {
total += len(attempt.ConversionTrace)
var trace parametertrace.Trace
if err := json.Unmarshal(attempt.ConversionTrace, &trace); err != nil {
t.Fatal(err)
}
if trace.State != parametertrace.CapturePartial || !trace.Target.Truncated {
t.Fatalf("trace was not marked partial: %#v", trace)
}
}
if total > parametertrace.MaxEventBytes {
t.Fatalf("conversion observation bytes = %d, want <= %d", total, parametertrace.MaxEventBytes)
}
}
func TestMapEventDropsTraceWhenTooManyAttemptsCannotFitEventBudget(t *testing.T) {
event := testEvent("00000000-0000-4000-8000-000000000197")
client := parametertrace.ProjectJSON([]byte(`{"thinking":{"type":"enabled","budget_tokens":4096}}`))
target := parametertrace.ProjectJSON([]byte(`{"reasoning_effort":"high"}`))
trace := parametertrace.Compare(client, target)
event.ClientParameters = &client
baseAttempt := event.Attempts[0]
event.Attempts = make([]telemetry.Attempt, 200)
for index := range event.Attempts {
event.Attempts[index] = baseAttempt
event.Attempts[index].Sequence = index + 1
event.Attempts[index].ConversionTrace = &trace
}
row := mustMapEvent(t, redact.New(), event)
total := len(row.ClientParameters)
omitted := 0
for _, attempt := range row.AttemptRows {
total += len(attempt.ConversionTrace)
if len(attempt.ConversionTrace) == 0 {
omitted++
}
}
if total > parametertrace.MaxEventBytes || omitted == 0 {
t.Fatalf("conversion bytes/omitted = %d/%d, want <= %d with omitted traces", total, omitted, parametertrace.MaxEventBytes)
}
}
func TestMapEventRejectsInvalidAttemptUpstreamAPI(t *testing.T) {
event := testEvent("invalid-upstream-api")
event.Attempts[0].UpstreamAPI = execution.UpstreamAPI("private-sdk-name")
+40
View File
@@ -10,6 +10,7 @@ import (
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/parametertrace"
"gpt-load/internal/pricing"
"gpt-load/internal/protocol"
"gpt-load/internal/reasoning"
@@ -28,6 +29,7 @@ func (service *Service) List(ctx context.Context, input ListQuery) (Page, error)
query := service.db.WithContext(ctx).
Model(&models.RequestLog{}).
Omit("ClientParameters").
Order("completed_at_ms DESC").
Order("id DESC").
Limit(limit + 1)
@@ -235,6 +237,10 @@ func (service *Service) Get(ctx context.Context, requestID string) (Record, erro
func decodeAttemptRows(rows []models.RequestLogAttempt) ([]Attempt, error) {
attempts := make([]Attempt, 0, len(rows))
for _, row := range rows {
conversionTrace, err := decodeConversionTrace(row.ConversionTrace)
if err != nil {
return nil, fmt.Errorf("decode request log attempt conversion trace: %w", err)
}
var receipt *pricing.Receipt
if len(row.PricingReceipt) > 0 && string(row.PricingReceipt) != "null" {
var decoded pricing.Receipt
@@ -279,6 +285,7 @@ func decodeAttemptRows(rows []models.RequestLogAttempt) ([]Attempt, error) {
ErrorSummary: row.ErrorSummary,
Committed: row.Committed,
PricingReceipt: receipt,
ConversionTrace: conversionTrace,
})
}
return attempts, nil
@@ -287,6 +294,10 @@ func decodeAttemptRows(rows []models.RequestLogAttempt) ([]Attempt, error) {
func decodeRequestLogRows(rows []models.RequestLog) ([]Record, error) {
records := make([]Record, 0, len(rows))
for _, row := range rows {
clientParameters, err := decodeParameterSnapshot(row.ClientParameters)
if err != nil {
return nil, fmt.Errorf("decode request log client parameters: %w", err)
}
if err := validateStoredModelObservation(row); err != nil {
return nil, err
}
@@ -317,6 +328,7 @@ func decodeRequestLogRows(rows []models.RequestLog) ([]Record, error) {
Effort: row.ReasoningEffort,
BudgetTokens: row.ReasoningBudgetTokens,
},
ClientParameters: clientParameters,
Attempts: nil,
GroupID: row.GroupID,
ChannelID: channel.ID(row.ChannelID),
@@ -336,6 +348,34 @@ func decodeRequestLogRows(rows []models.RequestLog) ([]Record, error) {
return records, nil
}
func decodeParameterSnapshot(value models.JSON) (*parametertrace.Snapshot, error) {
if len(value) == 0 || string(value) == "null" {
return nil, nil
}
var snapshot parametertrace.Snapshot
if err := json.Unmarshal(value, &snapshot); err != nil {
return nil, err
}
if err := snapshot.Validate(); err != nil {
return nil, err
}
return &snapshot, nil
}
func decodeConversionTrace(value models.JSON) (*parametertrace.Trace, error) {
if len(value) == 0 || string(value) == "null" {
return nil, nil
}
var trace parametertrace.Trace
if err := json.Unmarshal(value, &trace); err != nil {
return nil, err
}
if err := trace.Validate(); err != nil {
return nil, err
}
return &trace, nil
}
func validateStoredModelObservation(row models.RequestLog) error {
successfulModeledRequest := row.Status == string(telemetry.RequestStatusSuccess) &&
row.UpstreamModel != ""
+54 -2
View File
@@ -12,6 +12,7 @@ import (
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/parametertrace"
"gpt-load/internal/platform/redact"
"gpt-load/internal/pricing"
"gpt-load/internal/protocol"
@@ -119,6 +120,47 @@ func TestDecodeAttemptRowsExposesOnlyNormalizedSafeFields(t *testing.T) {
}
}
func TestDecodeRequestLogConversionObservations(t *testing.T) {
client := parametertrace.ProjectJSON([]byte(`{"thinking":{"type":"enabled","budget_tokens":4096}}`))
target := parametertrace.ProjectJSON([]byte(`{"reasoning_effort":"high"}`))
trace := parametertrace.Compare(client, target)
clientJSON, _ := json.Marshal(client)
traceJSON, _ := json.Marshal(trace)
records, err := decodeRequestLogRows([]models.RequestLog{{
ID: "00000000-0000-4000-8000-000000000699", CompletedAtMS: 1,
Protocol: string(protocol.Anthropic), ModelConsistency: string(telemetry.ModelConsistencyNotApplicable),
Status: string(telemetry.RequestStatusError), UsageState: string(usage.StateNotApplicable),
CostState: string(pricing.CostStateNotApplicable), PricingCompleteness: string(pricing.CompletenessNotApplicable),
ClientParameters: clientJSON,
}})
if err != nil || len(records) != 1 || records[0].ClientParameters == nil ||
records[0].ClientParameters.State != parametertrace.CaptureCaptured {
t.Fatalf("decoded client parameters = %#v, error = %v", records, err)
}
attempts, err := decodeAttemptRows([]models.RequestLogAttempt{{
RequestID: "00000000-0000-4000-8000-000000000699", Sequence: 1,
GroupID: 7, GroupName: "primary", CredentialID: 8,
FailureCategory: string(telemetry.FailureCategoryConversionUnsupported),
Action: string(telemetry.ActionSkipGroup), ConversionTrace: traceJSON,
}})
if err != nil || len(attempts) != 1 || attempts[0].ConversionTrace == nil ||
attempts[0].ConversionTrace.State != parametertrace.CaptureCaptured {
t.Fatalf("decoded conversion trace = %#v, error = %v", attempts, err)
}
bad := models.JSON(`{"state":"captured","entries":[{"path":"unsafe path","kind":"enum","value":"secret"}],"truncated":false}`)
if _, err := decodeRequestLogRows([]models.RequestLog{{
ID: "00000000-0000-4000-8000-000000000698", CompletedAtMS: 1,
Protocol: string(protocol.Anthropic), ModelConsistency: string(telemetry.ModelConsistencyNotApplicable),
Status: string(telemetry.RequestStatusError), UsageState: string(usage.StateNotApplicable),
CostState: string(pricing.CostStateNotApplicable), PricingCompleteness: string(pricing.CompletenessNotApplicable),
ClientParameters: bad,
}}); err == nil {
t.Fatal("decodeRequestLogRows accepted malformed client parameters")
}
}
func TestDecodeRequestLogRowsRejectsInvalidUsageCostValues(t *testing.T) {
base := models.RequestLog{
ID: "00000000-0000-4000-8000-000000000602",
@@ -680,6 +722,11 @@ func TestServiceListBatchLoadsCurrentAccessKeyNames(t *testing.T) {
func TestServiceListOmitsAttemptsAndDetailLoadsThem(t *testing.T) {
reasoningBudget := int64(4096)
clientParameters := parametertrace.ProjectJSON([]byte(`{"thinking":{"type":"enabled","budget_tokens":4096}}`))
targetParameters := parametertrace.ProjectJSON([]byte(`{"reasoning_effort":"high"}`))
conversionTrace := parametertrace.Compare(clientParameters, targetParameters)
clientJSON, _ := json.Marshal(clientParameters)
traceJSON, _ := json.Marshal(conversionTrace)
db := openRequestLogQueryDB(t)
row := requestLogQueryRow(
"00000000-0000-4000-8000-000000000401",
@@ -706,7 +753,9 @@ func TestServiceListOmitsAttemptsAndDetailLoadsThem(t *testing.T) {
DurationMs: 10,
FailureCategory: string(telemetry.FailureCategoryOK),
Action: string(telemetry.ActionTerminate),
ConversionTrace: traceJSON,
}}
row.ClientParameters = clientJSON
row.GroupID = 7
row.ChannelID = string(channel.Anthropic)
row.CredentialID = 9
@@ -718,7 +767,8 @@ func TestServiceListOmitsAttemptsAndDetailLoadsThem(t *testing.T) {
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(page.Items) != 1 || page.Items[0].Attempts != nil || page.Items[0].AttemptCount != 1 {
if len(page.Items) != 1 || page.Items[0].Attempts != nil ||
page.Items[0].ClientParameters != nil || page.Items[0].AttemptCount != 1 {
t.Fatalf("list item = %#v, want lightweight item without attempts", page.Items)
}
detail, err := newRequestLogTestService(db).Get(context.Background(), row.ID)
@@ -730,7 +780,9 @@ func TestServiceListOmitsAttemptsAndDetailLoadsThem(t *testing.T) {
detail.UpstreamAPI != execution.UpstreamAPIAnthropicMessages ||
detail.Operation != execution.OperationResponsesCreate ||
detail.Attempts[0].Reasoning.BudgetTokens == nil ||
*detail.Attempts[0].Reasoning.BudgetTokens != reasoningBudget {
*detail.Attempts[0].Reasoning.BudgetTokens != reasoningBudget ||
detail.ClientParameters == nil || detail.Attempts[0].ConversionTrace == nil ||
detail.Attempts[0].ConversionTrace.State != parametertrace.CaptureCaptured {
t.Fatalf("detail attempts = %#v", detail.Attempts)
}
}
+3
View File
@@ -6,6 +6,7 @@ import (
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/parametertrace"
"gpt-load/internal/pricing"
"gpt-load/internal/protocol"
"gpt-load/internal/reasoning"
@@ -50,6 +51,7 @@ type Attempt struct {
ErrorSummary string `json:"error_summary"`
Committed bool `json:"committed"`
PricingReceipt *pricing.Receipt `json:"pricing_receipt,omitempty"`
ConversionTrace *parametertrace.Trace `json:"conversion_trace,omitempty"`
}
type RetryState string
@@ -129,6 +131,7 @@ type Record struct {
ErrorSummary string
AffinityHit bool
Reasoning reasoning.Config
ClientParameters *parametertrace.Snapshot
Attempts []Attempt
GroupID uint
ChannelID channel.ID
+152 -39
View File
@@ -166,11 +166,10 @@ func TestCandidateGroupIDsForQuerySupportsModelLessResourceOperation(t *testing.
if err != nil {
t.Fatalf("Compile() error = %v", err)
}
required := schedulerFeatureSet(t, execution.FeatureNativeResourceSemantics)
got := CandidateGroupIDsForQuery(snapshot, Query{
ClientProtocol: protocol.OpenAIResponses,
Operation: execution.OperationResponsesRetrieve,
RequiredFeatures: required,
RouteRequirement: execution.RouteRequirementNative,
ExternalModel: nil,
AccessKey: state.AccessKeyView{Status: state.AccessKeyStatusActive},
})
@@ -179,6 +178,96 @@ func TestCandidateGroupIDsForQuerySupportsModelLessResourceOperation(t *testing.
}
}
func TestRouteRequirementKeepsStatefulResponsesOnNativeTargets(t *testing.T) {
t.Parallel()
snapshot, err := state.Compile(state.CompileInput{
ChannelRegistry: channel.NewRegistry(),
Groups: []state.GroupConfig{
{
ID: 7, Name: "official", ChannelID: channel.OpenAI,
Params: json.RawMessage(`{}`), Enabled: true,
Models: []state.ModelConfig{{ID: "gpt-native", Alias: "gpt"}},
},
{
ID: 8, Name: "compatible", ChannelID: channel.OpenAICompatible,
Params: json.RawMessage(`{"base_url":"https://compatible.example/v1"}`), Enabled: true,
Models: []state.ModelConfig{{ID: "gpt-converted", Alias: "gpt"}},
},
},
})
if err != nil {
t.Fatalf("Compile() error = %v", err)
}
nativeQuery := Query{
ClientProtocol: protocol.OpenAIResponses,
Operation: execution.OperationResponsesCreate,
RouteRequirement: execution.RouteRequirementNative,
ExternalModel: modelPointer("gpt"),
AccessKey: state.AccessKeyView{Status: state.AccessKeyStatusActive},
}
if got := CandidateGroupIDsForQuery(snapshot, nativeQuery); !slices.Equal(got, []uint{7}) {
t.Fatalf("native CandidateGroupIDsForQuery() = %#v, want [7]", got)
}
inspection, err := Inspect(snapshot, []CredentialRuntimeView{
{ID: 71, GroupID: 7, Status: state.CredentialStatusActive},
{ID: 81, GroupID: 8, Status: state.CredentialStatusActive},
}, nativeQuery, time.Unix(100, 0))
if err != nil {
t.Fatalf("Inspect() error = %v", err)
}
if inspection.RouteRequirement != execution.RouteRequirementNative || len(inspection.Groups) != 2 {
t.Fatalf("Inspection = %#v", inspection)
}
if !inspection.Groups[0].RouteRequirementSatisfied || inspection.Groups[0].RouteMode != channel.RouteNative {
t.Fatalf("native group = %#v", inspection.Groups[0])
}
if inspection.Groups[1].RouteRequirementSatisfied || inspection.Groups[1].Reason != ReasonNativeRouteRequired {
t.Fatalf("converted group = %#v", inspection.Groups[1])
}
}
func TestStatefulResponsesCreateRequiresLifecycleTargetEvenWhenWireIsNative(t *testing.T) {
t.Parallel()
snapshot, err := state.Compile(state.CompileInput{
ChannelRegistry: channel.NewRegistry(),
Groups: []state.GroupConfig{
{
ID: 7, Name: "openai", ChannelID: channel.OpenAI,
Params: json.RawMessage(`{}`), Enabled: true,
Models: []state.ModelConfig{{ID: "gpt-openai", Alias: "gpt"}},
},
{
ID: 8, Name: "openrouter", ChannelID: channel.OpenRouter,
Params: json.RawMessage(`{}`), Enabled: true,
Models: []state.ModelConfig{{ID: "openai/gpt", Alias: "gpt"}},
},
{
ID: 9, Name: "xai", ChannelID: channel.XAI,
Params: json.RawMessage(`{}`), Enabled: true,
Models: []state.ModelConfig{{ID: "grok", Alias: "gpt"}},
},
},
})
if err != nil {
t.Fatalf("Compile() error = %v", err)
}
got := CandidateGroupIDsForQuery(snapshot, Query{
ClientProtocol: protocol.OpenAIResponses,
Operation: execution.OperationResponsesCreate,
RouteRequirement: execution.RouteRequirementNative,
ExternalModel: modelPointer("gpt"),
AccessKey: state.AccessKeyView{Status: state.AccessKeyStatusActive},
})
if !slices.Equal(got, []uint{7}) {
t.Fatalf("CandidateGroupIDsForQuery() = %#v, want lifecycle-capable OpenAI group [7]", got)
}
}
func TestOpenRouterRoutesResponsesWithReasoningOptOut(t *testing.T) {
t.Parallel()
@@ -202,15 +291,15 @@ func TestOpenRouterRoutesResponsesWithReasoningOptOut(t *testing.T) {
if err != nil {
t.Fatalf("InspectRequest() error = %v", err)
}
if metadata.RequiredFeatures.Has(execution.FeatureReasoning) {
t.Fatal("reasoning effort none must not require reasoning capability")
if metadata.RouteRequirement != execution.RouteRequirementAny {
t.Fatalf("RouteRequirement = %q, want any", metadata.RouteRequirement)
}
selection, err := New(snapshot, fakeCredentialSource{keys: []state.CredentialMeta{{
ID: 91, GroupID: 9,
}}}, Query{
ClientProtocol: protocol.OpenAIResponses,
Operation: metadata.Operation,
RequiredFeatures: metadata.RequiredFeatures,
RouteRequirement: metadata.RouteRequirement,
ExternalModel: modelPointer("gpt-5.6-luna"),
AccessKey: state.AccessKeyView{Status: state.AccessKeyStatusActive},
}, rand.New(zeroRandSource{})).Next()
@@ -224,36 +313,74 @@ func TestOpenRouterRoutesResponsesWithReasoningOptOut(t *testing.T) {
}
}
func TestCandidateGroupIDsForQueryRejectsUnsupportedCapability(t *testing.T) {
func TestCandidateGroupIDsForQueryRoutesAnthropicThinkingToOpenAICompatible(t *testing.T) {
t.Parallel()
got := CandidateGroupIDsForQuery(channelSchedulerSnapshot(t), Query{
ClientProtocol: protocol.OpenAICompletions,
Operation: execution.OperationChatCompletion,
RequiredFeatures: schedulerFeatureSet(t, execution.FeatureReasoning),
ExternalModel: modelPointer("converted-only"),
snapshot, err := state.Compile(state.CompileInput{
ChannelRegistry: channel.NewRegistry(),
Groups: []state.GroupConfig{{
ID: 10, Name: "compatible", ChannelID: channel.OpenAICompatible,
Params: json.RawMessage(`{"base_url":"https://compatible.example/v1"}`), Enabled: true,
Models: []state.ModelConfig{{ID: "reasoning-model", Alias: "claude-sonnet"}},
}},
})
if err != nil {
t.Fatalf("Compile() error = %v", err)
}
metadata, err := dialect.NewAnthropic().InspectRequest(&dialect.ParsedRequest{
Method: http.MethodPost,
Path: "/v1/messages",
Body: []byte(`{
"model":"claude-sonnet",
"thinking":{"type":"enabled","budget_tokens":4096},
"messages":[{"role":"user","content":"hello"}]
}`),
})
if err != nil {
t.Fatalf("InspectRequest() error = %v", err)
}
if metadata.RouteRequirement != execution.RouteRequirementAny {
t.Fatalf("Anthropic thinking RouteRequirement = %q, want any", metadata.RouteRequirement)
}
got := CandidateGroupIDsForQuery(snapshot, Query{
ClientProtocol: protocol.Anthropic,
Operation: metadata.Operation,
RouteRequirement: metadata.RouteRequirement,
ExternalModel: modelPointer("claude-sonnet"),
AccessKey: state.AccessKeyView{Status: state.AccessKeyStatusActive},
})
if len(got) != 0 {
t.Fatalf("CandidateGroupIDsForQuery() = %#v, want empty for unsupported capability", got)
if !slices.Equal(got, []uint{10}) {
t.Fatalf("CandidateGroupIDsForQuery() = %#v, want converted OpenAI-compatible group [10]", got)
}
}
func TestCapabilityUnsupportedIsStableAndInspectionIsNeutral(t *testing.T) {
func TestCandidateGroupIDsForQueryDoesNotApplyOrdinaryCapabilityGate(t *testing.T) {
t.Parallel()
got := CandidateGroupIDsForQuery(channelSchedulerSnapshot(t), Query{
ClientProtocol: protocol.OpenAICompletions,
Operation: execution.OperationChatCompletion,
ExternalModel: modelPointer("converted-only"),
AccessKey: state.AccessKeyView{Status: state.AccessKeyStatusActive},
})
if !slices.Equal(got, []uint{1}) {
t.Fatalf("CandidateGroupIDsForQuery() = %#v, want converted group [1]", got)
}
}
func TestOperationUnsupportedIsStableAndInspectionIsNeutral(t *testing.T) {
t.Parallel()
snapshot := channelSchedulerSnapshot(t)
required := schedulerFeatureSet(t, execution.FeatureReasoning)
query := Query{
ClientProtocol: protocol.OpenAICompletions,
Operation: execution.OperationChatCompletion,
RequiredFeatures: required,
ExternalModel: modelPointer("converted-only"),
AccessKey: state.AccessKeyView{Status: state.AccessKeyStatusActive},
ClientProtocol: protocol.Anthropic,
Operation: execution.OperationResponsesRetrieve,
AccessKey: state.AccessKeyView{Status: state.AccessKeyStatusActive},
}
iterator := New(snapshot, fakeCredentialSource{keys: []state.CredentialMeta{{ID: 11, GroupID: 1}}}, query, rand.New(zeroRandSource{}))
if iterator.StaticReason() != ReasonCapabilityUnsupported {
t.Fatalf("StaticReason() = %q, want %q", iterator.StaticReason(), ReasonCapabilityUnsupported)
if iterator.StaticReason() != ReasonOperationUnsupported {
t.Fatalf("StaticReason() = %q, want %q", iterator.StaticReason(), ReasonOperationUnsupported)
}
if _, err := iterator.Next(); !errors.Is(err, ErrExhausted) {
t.Fatalf("Next() error = %v, want ErrExhausted", err)
@@ -265,16 +392,11 @@ func TestCapabilityUnsupportedIsStableAndInspectionIsNeutral(t *testing.T) {
if err != nil {
t.Fatalf("Inspect() error = %v", err)
}
if inspection.Routable || inspection.Reason != ReasonCapabilityUnsupported ||
inspection.ClientProtocol != protocol.OpenAICompletions || inspection.Operation != execution.OperationChatCompletion ||
len(inspection.RequiredFeatures) != 1 || inspection.RequiredFeatures[0] != execution.FeatureReasoning || len(inspection.Groups) != 1 {
if inspection.Routable || inspection.Reason != ReasonOperationUnsupported ||
inspection.ClientProtocol != protocol.Anthropic || inspection.Operation != execution.OperationResponsesRetrieve ||
len(inspection.Groups) != 0 {
t.Fatalf("Inspection = %#v", inspection)
}
group := inspection.Groups[0]
if group.ChannelID != channel.Anthropic || group.RouteMode != channel.RouteConverted || group.CapabilitySupported ||
group.Reason != ReasonCapabilityUnsupported || len(group.Credentials) != 0 {
t.Fatalf("GroupInspection = %#v", group)
}
encoded, err := json.Marshal(inspection)
if err != nil {
t.Fatalf("json.Marshal(Inspection) error = %v", err)
@@ -343,12 +465,3 @@ func channelSchedulerSnapshot(t *testing.T) *state.ConfigSnapshot {
}
return snapshot
}
func schedulerFeatureSet(t *testing.T, features ...execution.Feature) execution.FeatureSet {
t.Helper()
set, err := execution.NewFeatureSet(features...)
if err != nil {
t.Fatalf("NewFeatureSet() error = %v", err)
}
return set
}
+45 -37
View File
@@ -21,7 +21,8 @@ const (
ReasonProtocolFiltered ReasonCode = "protocol_filtered"
ReasonModelFiltered ReasonCode = "model_filtered"
ReasonModelRequiredByFilter ReasonCode = "model_required_by_filter"
ReasonCapabilityUnsupported ReasonCode = "capability_unsupported"
ReasonOperationUnsupported ReasonCode = "operation_unsupported"
ReasonNativeRouteRequired ReasonCode = "native_route_required"
ReasonNoRouteTarget ReasonCode = "no_route_target"
ReasonGroupDisabled ReasonCode = "group_disabled"
ReasonGroupFiltered ReasonCode = "group_filtered"
@@ -39,7 +40,7 @@ const (
type Inspection struct {
ClientProtocol protocol.Protocol
Operation execution.Operation
RequiredFeatures []execution.Feature
RouteRequirement execution.RouteRequirement
ExternalModel *string
Routable bool
Reason ReasonCode
@@ -47,17 +48,17 @@ type Inspection struct {
}
type GroupInspection struct {
GroupID uint
GroupName string
ChannelID channel.ID
RouteMode channel.RouteMode
CapabilitySupported bool
UpstreamModelID *string
WeightManual *int
Included bool
Routable bool
Reason ReasonCode
Credentials []CredentialInspection
GroupID uint
GroupName string
ChannelID channel.ID
RouteMode channel.RouteMode
RouteRequirementSatisfied bool
UpstreamModelID *string
WeightManual *int
Included bool
Routable bool
Reason ReasonCode
Credentials []CredentialInspection
}
type CredentialInspection struct {
@@ -74,11 +75,11 @@ type CredentialInspection struct {
type CredentialRuntimeView = state.CredentialRuntimeView
type targetDecision struct {
target state.RouteTarget
group state.GroupCatalogView
capabilityOK bool
included bool
reason ReasonCode
target state.RouteTarget
group state.GroupCatalogView
requirementOK bool
included bool
reason ReasonCode
}
func cloneWeight(weight *int) *int {
@@ -113,8 +114,8 @@ func evaluateTargets(
return []targetDecision{}, ReasonModelFiltered, nil
}
}
if !query.clientProtocol.Valid() || !query.operation.Valid() || query.requiredFeatures.Validate() != nil {
return []targetDecision{}, ReasonCapabilityUnsupported, nil
if !query.clientProtocol.Valid() || !query.operation.Valid() || !query.routeRequirement.Valid() {
return []targetDecision{}, ReasonOperationUnsupported, nil
}
byOperation := index[query.clientProtocol]
if len(byOperation) == 0 {
@@ -122,7 +123,7 @@ func evaluateTargets(
}
byModel, operationSupported := byOperation[query.operation]
if !operationSupported {
return []targetDecision{}, ReasonCapabilityUnsupported, nil
return []targetDecision{}, ReasonOperationUnsupported, nil
}
modelKey := state.NoModelRouteKey
if query.externalModel != nil {
@@ -149,14 +150,10 @@ func evaluateTargets(
route.GroupID,
)
}
capabilityOK := route.ResolvedTarget.Supports(
query.clientProtocol,
query.operation,
query.requiredFeatures,
)
requirementOK := routeRequirementSatisfied(query, route)
decision := targetDecision{
target: cloneRouteTarget(route), group: group,
capabilityOK: capabilityOK, included: true,
requirementOK: requirementOK, included: true,
}
groupFiltered := false
if len(query.accessKey.Filters.Groups) > 0 {
@@ -164,15 +161,15 @@ func evaluateTargets(
groupFiltered = !allowed
}
switch {
case !requirementOK:
decision.included = false
decision.reason = ReasonNativeRouteRequired
case !group.Enabled:
decision.included = false
decision.reason = ReasonGroupDisabled
case groupFiltered:
decision.included = false
decision.reason = ReasonGroupFiltered
case !capabilityOK:
decision.included = false
decision.reason = ReasonCapabilityUnsupported
}
if decision.included {
included++
@@ -191,6 +188,17 @@ func evaluateTargets(
return decisions, reason, nil
}
func routeRequirementSatisfied(query normalizedQuery, route state.RouteTarget) bool {
if !query.routeRequirement.Allows(execution.RouteMode(route.Mode)) {
return false
}
if query.operation != execution.OperationResponsesCreate ||
query.routeRequirement.Normalize() != execution.RouteRequirementNative {
return true
}
return route.ResolvedTarget.SupportsResponsesLifecycle()
}
func accessKeyAllowsGroup(accessKey state.AccessKeyView, groupID uint) bool {
if len(accessKey.Filters.Groups) == 0 {
return true
@@ -277,7 +285,7 @@ func Inspect(
result := Inspection{
ClientProtocol: normalized.clientProtocol,
Operation: normalized.operation,
RequiredFeatures: normalized.requiredFeatures.Features(),
RouteRequirement: normalized.routeRequirement,
ExternalModel: cloneString(normalized.externalModel),
Groups: []GroupInspection{},
}
@@ -320,12 +328,12 @@ func Inspect(
for _, decision := range decisions {
groupResult := GroupInspection{
GroupID: decision.group.ID, GroupName: decision.group.Name,
ChannelID: decision.target.ResolvedTarget.ChannelID,
RouteMode: decision.target.Mode,
CapabilitySupported: decision.capabilityOK,
UpstreamModelID: optionalModel(decision.target.UpstreamModelID),
WeightManual: cloneWeight(decision.group.WeightManual),
Included: decision.included, Reason: decision.reason,
ChannelID: decision.target.ResolvedTarget.ChannelID,
RouteMode: decision.target.Mode,
RouteRequirementSatisfied: decision.requirementOK,
UpstreamModelID: optionalModel(decision.target.UpstreamModelID),
WeightManual: cloneWeight(decision.group.WeightManual),
Included: decision.included, Reason: decision.reason,
Credentials: []CredentialInspection{},
}
if !decision.included {
+3 -3
View File
@@ -21,7 +21,7 @@ type CredentialSource interface {
type Query struct {
ClientProtocol protocol.Protocol
Operation execution.Operation
RequiredFeatures execution.FeatureSet
RouteRequirement execution.RouteRequirement
ExternalModel *string
AccessKey state.AccessKeyView
AllowedCredentialIDs map[uint]struct{}
@@ -62,7 +62,7 @@ type Iterator struct {
type normalizedQuery struct {
clientProtocol protocol.Protocol
operation execution.Operation
requiredFeatures execution.FeatureSet
routeRequirement execution.RouteRequirement
externalModel *string
accessKey state.AccessKeyView
allowedCredentialIDs map[uint]struct{}
@@ -270,7 +270,7 @@ func normalizeQuery(query Query) normalizedQuery {
return normalizedQuery{
clientProtocol: clientProtocol,
operation: operation,
requiredFeatures: query.RequiredFeatures.Clone(),
routeRequirement: query.RouteRequirement.Normalize(),
externalModel: cloneString(query.ExternalModel),
accessKey: query.AccessKey,
allowedCredentialIDs: cloneAllowedCredentialIDs(query),
+1 -2
View File
@@ -244,8 +244,7 @@ func appendExecutionTargets(
return fmt.Errorf("compile group %d channel: unknown channel %q", group.ID, group.ChannelID)
}
for _, clientProtocol := range descriptor.ClientProtocols {
capabilities := target.Capabilities(clientProtocol)
for _, operation := range capabilities.Operations() {
for _, operation := range target.Operations(clientProtocol) {
if operation == execution.OperationListModels || operation == execution.OperationProbe {
continue
}
+1
View File
@@ -614,6 +614,7 @@ func TestAutoMigrateCreatesUsageJournalAndMigrationLedger(t *testing.T) {
wantMigrationIDs := []string{
"0001_final_v2",
"0002_request_log_execution_observation",
"0003_request_log_conversion_trace",
}
if !reflect.DeepEqual(migrationIDs, wantMigrationIDs) {
t.Fatalf("schema_migrations IDs = %v, want %v", migrationIDs, wantMigrationIDs)
@@ -73,7 +73,7 @@ func TestAutoMigrateCreatesNormalizedRequestLogInitialV2(t *testing.T) {
for _, name := range []string{
"stream", "first_response_ms", "attempt_count",
"upstream_reported_model", "model_consistency",
"operation",
"operation", "client_parameters_json",
} {
if _, ok := requestColumns[name]; !ok {
t.Errorf("request_logs.%s is missing", name)
@@ -87,6 +87,7 @@ func TestAutoMigrateCreatesNormalizedRequestLogInitialV2(t *testing.T) {
"channel_id", "credential_id", "operation", "route_mode", "upstream_model",
"upstream_request_id", "dispatch_state", "response_started", "upstream_api",
"reasoning_mode", "reasoning_effort", "reasoning_budget_tokens",
"conversion_trace_json",
"status_code", "duration_ms",
"failure_category", "action", "will_retry", "error_code", "error_summary",
"committed", "pricing_receipt",
@@ -0,0 +1,108 @@
package storage
import (
"fmt"
"strings"
"gorm.io/gorm"
"gpt-load/internal/storage/models"
)
// Migration-local models freeze the nullable conversion-observation columns.
type requestLogConversionTraceMigration struct {
ClientParameters models.JSON `gorm:"column:client_parameters_json;type:json"`
}
func (requestLogConversionTraceMigration) TableName() string { return "request_logs" }
type requestLogAttemptConversionTraceMigration struct {
ConversionTrace models.JSON `gorm:"column:conversion_trace_json;type:json"`
}
func (requestLogAttemptConversionTraceMigration) TableName() string {
return "request_log_attempts"
}
func addRequestLogConversionTrace(db *gorm.DB) error {
request := &requestLogConversionTraceMigration{}
if err := addMigrationColumns(db, request, "ClientParameters"); err != nil {
return fmt.Errorf("add request log client parameter projection: %w", err)
}
attempt := &requestLogAttemptConversionTraceMigration{}
if err := addMigrationColumns(db, attempt, "ConversionTrace"); err != nil {
return fmt.Errorf("add request log attempt conversion trace: %w", err)
}
return widenRequestLogAttemptFailureCategory(db)
}
func widenRequestLogAttemptFailureCategory(db *gorm.DB) error {
if db == nil || db.Dialector == nil {
return fmt.Errorf("widen request log attempt failure category: database is nil")
}
if !strings.EqualFold(db.Dialector.Name(), "sqlite") {
// GPT-Load 2.0 is SQLite-only; legacy dialect hooks do not participate in
// the runtime migration contract.
return nil
}
var createSQL string
if err := db.Raw(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?",
"request_log_attempts",
).Scan(&createSQL).Error; err != nil {
return fmt.Errorf("read request log attempt schema: %w", err)
}
if strings.Contains(createSQL, "'conversion_unsupported'") {
return nil
}
const temporaryTable = "request_log_attempts__conversion_trace_migration"
temporarySQL, ok := renameSQLiteCreateTable(createSQL, "request_log_attempts", temporaryTable)
if !ok {
return fmt.Errorf("widen request log attempt failure category: unsupported SQLite table definition")
}
temporarySQL = strings.Replace(
temporarySQL,
"'client_error','downstream_cancel'",
"'client_error','conversion_unsupported','downstream_cancel'",
1,
)
if !strings.Contains(temporarySQL, "'conversion_unsupported'") {
return fmt.Errorf("widen request log attempt failure category: existing constraint was not recognized")
}
var schemaObjects []struct {
SQL string
}
if err := db.Raw(
"SELECT sql FROM sqlite_master WHERE tbl_name = ? AND type IN ('index','trigger') AND sql IS NOT NULL ORDER BY type, name",
"request_log_attempts",
).Scan(&schemaObjects).Error; err != nil {
return fmt.Errorf("read request log attempt indexes: %w", err)
}
statements := []string{
temporarySQL,
`INSERT INTO "` + temporaryTable + `" SELECT * FROM "request_log_attempts"`,
`DROP TABLE "request_log_attempts"`,
`ALTER TABLE "` + temporaryTable + `" RENAME TO "request_log_attempts"`,
}
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
return fmt.Errorf("rebuild request log attempt constraint: %w", err)
}
}
for _, object := range schemaObjects {
if err := db.Exec(object.SQL).Error; err != nil {
return fmt.Errorf("restore request log attempt schema object: %w", err)
}
}
return nil
}
func renameSQLiteCreateTable(source, oldName, newName string) (string, bool) {
for _, quote := range []string{"`", `"`, ""} {
oldToken := "CREATE TABLE " + quote + oldName + quote
if strings.HasPrefix(strings.ToUpper(source), strings.ToUpper(oldToken)) {
return "CREATE TABLE " + quote + newName + quote + source[len(oldToken):], true
}
}
return "", false
}
@@ -0,0 +1,77 @@
package storage
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestAddRequestLogConversionTraceUpgradesExistingConstraintWithoutLosingRows(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:conversion-trace-migration?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
statements := []string{
`PRAGMA foreign_keys = ON`,
`CREATE TABLE request_logs (id TEXT PRIMARY KEY NOT NULL)`,
`CREATE TABLE request_log_attempts (
request_id TEXT NOT NULL,
sequence INTEGER NOT NULL,
failure_category TEXT NOT NULL CONSTRAINT chk_request_log_attempt_failure_category
CHECK (failure_category IN ('ok','rate_limited','model_unavailable','invalid_key','upstream_host_error','client_error','downstream_cancel','ambiguous')),
PRIMARY KEY (request_id, sequence),
CONSTRAINT fk_attempt_request FOREIGN KEY (request_id) REFERENCES request_logs(id) ON DELETE CASCADE
)`,
`CREATE INDEX idx_attempt_failure ON request_log_attempts(failure_category)`,
`INSERT INTO request_logs(id) VALUES ('request-1')`,
`INSERT INTO request_log_attempts(request_id, sequence, failure_category) VALUES ('request-1', 1, 'ok')`,
}
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("execute fixture schema: %v", err)
}
}
if err := db.Transaction(addRequestLogConversionTrace); err != nil {
t.Fatalf("addRequestLogConversionTrace() error = %v", err)
}
for table, column := range map[string]string{
"request_logs": "client_parameters_json",
"request_log_attempts": "conversion_trace_json",
} {
if !db.Migrator().HasColumn(table, column) {
t.Errorf("%s.%s is missing", table, column)
}
}
var category string
if err := db.Raw(
"SELECT failure_category FROM request_log_attempts WHERE request_id = ? AND sequence = ?",
"request-1", 1,
).Scan(&category).Error; err != nil || category != "ok" {
t.Fatalf("historical row category = %q, error = %v", category, err)
}
if err := db.Exec(
"UPDATE request_log_attempts SET failure_category = ? WHERE request_id = ? AND sequence = ?",
"conversion_unsupported", "request-1", 1,
).Error; err != nil {
t.Fatalf("widened failure category rejected conversion_unsupported: %v", err)
}
if !db.Migrator().HasIndex("request_log_attempts", "idx_attempt_failure") {
t.Fatal("request log attempt index was lost")
}
var foreignKeys []struct {
Table string
From string
To string
OnDelete string `gorm:"column:on_delete"`
}
if err := db.Raw("PRAGMA foreign_key_list('request_log_attempts')").Scan(&foreignKeys).Error; err != nil {
t.Fatal(err)
}
if len(foreignKeys) != 1 || foreignKeys[0].Table != "request_logs" ||
foreignKeys[0].From != "request_id" || foreignKeys[0].To != "id" ||
foreignKeys[0].OnDelete != "CASCADE" {
t.Fatalf("foreign keys = %#v", foreignKeys)
}
}
+5
View File
@@ -17,6 +17,7 @@ const (
migrationLedgerTable = "schema_migrations"
initialV2MigrationID = "0001_final_v2"
requestLogExecutionObservationMigrationID = "0002_request_log_execution_observation"
requestLogConversionTraceMigrationID = "0003_request_log_conversion_trace"
)
const (
@@ -46,6 +47,10 @@ var migrations = []migration{
ID: requestLogExecutionObservationMigrationID,
Up: addRequestLogExecutionObservation,
},
{
ID: requestLogConversionTraceMigrationID,
Up: addRequestLogConversionTrace,
},
}
func applyMigrations(db *gorm.DB) error {
+3 -1
View File
@@ -26,6 +26,7 @@ type RequestLog struct {
ReasoningMode string `gorm:"type:varchar(64);not null;default:''"`
ReasoningEffort string `gorm:"type:varchar(64);not null;default:''"`
ReasoningBudgetTokens *int64 `gorm:"column:reasoning_budget_tokens"`
ClientParameters JSON `gorm:"column:client_parameters_json;type:json"`
UncachedInputTokens int64 `gorm:"column:uncached_input_tokens;not null;default:0;check:chk_request_log_uncached_input,uncached_input_tokens >= 0"`
OutputTokens int64 `gorm:"not null;default:0;check:chk_request_log_output,output_tokens >= 0"`
CacheReadTokens int64 `gorm:"not null;default:0;check:chk_request_log_cache_read,cache_read_tokens >= 0"`
@@ -62,13 +63,14 @@ type RequestLogAttempt struct {
ReasoningBudgetTokens *int64 `gorm:"column:reasoning_budget_tokens"`
StatusCode int `gorm:"not null;index:idx_request_log_attempts_status_completed_request,priority:1"`
DurationMs int64 `gorm:"not null;check:chk_request_log_attempt_duration,duration_ms >= 0"`
FailureCategory string `gorm:"type:varchar(32);not null;check:chk_request_log_attempt_failure_category,failure_category IN ('ok','rate_limited','model_unavailable','invalid_key','upstream_host_error','client_error','downstream_cancel','ambiguous');index:idx_request_log_attempts_failure_completed_request,priority:1"`
FailureCategory string `gorm:"type:varchar(32);not null;check:chk_request_log_attempt_failure_category,failure_category IN ('ok','rate_limited','model_unavailable','invalid_key','upstream_host_error','client_error','conversion_unsupported','downstream_cancel','ambiguous');index:idx_request_log_attempts_failure_completed_request,priority:1"`
Action string `gorm:"type:varchar(32);not null;check:chk_request_log_attempt_action,action IN ('terminate','retry','cooldown_credential','fail_credential','skip_group')"`
WillRetry bool `gorm:"not null;default:false"`
ErrorCode string `gorm:"type:varchar(64);not null;default:'';index:idx_request_log_attempts_error_completed_request,priority:1"`
ErrorSummary string `gorm:"type:text;not null"`
Committed bool `gorm:"not null;default:false"`
PricingReceipt JSON `gorm:"type:json"`
ConversionTrace JSON `gorm:"column:conversion_trace_json;type:json"`
RequestLog *RequestLog `gorm:"foreignKey:RequestID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
+12 -8
View File
@@ -5,6 +5,7 @@ import (
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/parametertrace"
"gpt-load/internal/protocol"
"gpt-load/internal/reasoning"
"gpt-load/internal/usage"
@@ -31,14 +32,15 @@ const (
type FailureCategory string
const (
FailureCategoryOK FailureCategory = "ok"
FailureCategoryRateLimited FailureCategory = "rate_limited"
FailureCategoryModelUnavailable FailureCategory = "model_unavailable"
FailureCategoryInvalidKey FailureCategory = "invalid_key"
FailureCategoryUpstreamHost FailureCategory = "upstream_host_error"
FailureCategoryClientError FailureCategory = "client_error"
FailureCategoryDownstreamCancel FailureCategory = "downstream_cancel"
FailureCategoryAmbiguous FailureCategory = "ambiguous"
FailureCategoryOK FailureCategory = "ok"
FailureCategoryRateLimited FailureCategory = "rate_limited"
FailureCategoryModelUnavailable FailureCategory = "model_unavailable"
FailureCategoryInvalidKey FailureCategory = "invalid_key"
FailureCategoryUpstreamHost FailureCategory = "upstream_host_error"
FailureCategoryClientError FailureCategory = "client_error"
FailureCategoryConversionUnsupported FailureCategory = "conversion_unsupported"
FailureCategoryDownstreamCancel FailureCategory = "downstream_cancel"
FailureCategoryAmbiguous FailureCategory = "ambiguous"
)
type Action string
@@ -73,6 +75,7 @@ type Attempt struct {
ErrorCode string
ErrorSummary string
Committed bool
ConversionTrace *parametertrace.Trace
}
// PricingObservation is the frozen, dependency-neutral quote selected by the
@@ -113,6 +116,7 @@ type RequestEvent struct {
DurationMs int64
AffinityHit bool
Reasoning reasoning.Config
ClientParameters *parametertrace.Snapshot
Attempts []Attempt
Usage UsageObservation
}
+2
View File
@@ -35,6 +35,7 @@ func TestRequestTelemetryContractUsesExactFieldAllowlist(t *testing.T) {
"DurationMs",
"AffinityHit",
"Reasoning",
"ClientParameters",
"Attempts",
"Usage",
},
@@ -60,6 +61,7 @@ func TestRequestTelemetryContractUsesExactFieldAllowlist(t *testing.T) {
"ErrorCode",
"ErrorSummary",
"Committed",
"ConversionTrace",
},
reflect.TypeOf(PricingObservation{}): {
"UpstreamModel",
+117 -4
View File
@@ -53,6 +53,37 @@ export type RequestLogOperation =
export type RequestLogRouteMode = 'native' | 'converted'
export type RequestLogDispatchState = 'not_sent' | 'maybe_sent'
export type RequestLogUpstreamAPI = UpstreamAPIValue
export type RequestLogFailureCategory = FailureCategory | 'conversion_unsupported'
export type ParameterCaptureState =
'captured' | 'partial' | 'unavailable' | 'skipped_oversize' | 'preflight_blocked'
export type ParameterValueKind = 'boolean' | 'number' | 'enum' | 'summary'
export type ParameterDisposition = 'preserved' | 'mapped' | 'normalized' | 'dropped' | 'added'
export interface RequestParameterEntryDto {
path: string
kind: ParameterValueKind
value: string
}
export interface RequestParameterSnapshotDto {
schema_version: 1
state: ParameterCaptureState
entries: RequestParameterEntryDto[]
truncated: boolean
}
export interface RequestParameterChangeDto {
disposition: ParameterDisposition
source_path: string | null
target_path: string | null
}
export interface RequestConversionTraceDto {
schema_version: 1
state: ParameterCaptureState
target: RequestParameterSnapshotDto
changes: RequestParameterChangeDto[]
}
export type { FailureCategory } from '@/api/control/types'
@@ -76,7 +107,7 @@ export interface RequestLogFilters {
pricing_completeness?: RequestLogPricingCompleteness
cache_present?: boolean
attempt_status_code?: number
failure_category?: FailureCategory
failure_category?: RequestLogFailureCategory
error_code?: string
retry_state?: RequestLogRetryState
retry_count_min?: number
@@ -129,13 +160,14 @@ export interface RequestLogAttemptDto {
reasoning: RequestLogReasoningDto | null
status_code: number
duration_ms: number
failure_category: FailureCategory
failure_category: RequestLogFailureCategory
action: RequestLogAction
will_retry: boolean
error_code: string
error_summary: string
committed: boolean
pricing_receipt: RequestLogPricingReceiptDto | null
conversion_trace: RequestConversionTraceDto | null
}
export interface RequestLogReasoningDto {
@@ -182,6 +214,7 @@ export interface RequestLogItemDto {
}
export interface RequestLogDetailDto extends RequestLogItemDto {
client_parameters: RequestParameterSnapshotDto | null
attempts: RequestLogAttemptDto[]
}
@@ -200,6 +233,7 @@ const failureCategories = [
'invalid_key',
'upstream_host_error',
'client_error',
'conversion_unsupported',
'downstream_cancel',
'ambiguous',
] as const
@@ -224,6 +258,15 @@ const operations = [
'probe',
] as const
const routeModes = ['native', 'converted'] as const
const parameterCaptureStates = [
'captured',
'partial',
'unavailable',
'skipped_oversize',
'preflight_blocked',
] as const
const parameterValueKinds = ['boolean', 'number', 'enum', 'summary'] as const
const parameterDispositions = ['preserved', 'mapped', 'normalized', 'dropped', 'added'] as const
const dispatchStates = ['not_sent', 'maybe_sent'] as const
const usageStates = ['complete', 'partial', 'missing', 'not_applicable'] as const
const costStates = ['priced', 'unpriced', 'not_applicable'] as const
@@ -409,6 +452,7 @@ function projectAttempt(value: unknown): RequestLogAttemptDto {
'error_summary',
'committed',
'pricing_receipt',
'conversion_trace',
])
return {
sequence: projectSafeInteger(record.sequence, { minimum: 1 }),
@@ -438,6 +482,70 @@ function projectAttempt(value: unknown): RequestLogAttemptDto {
error_summary: projectString(record.error_summary, { allowEmpty: true }),
committed: projectBoolean(record.committed),
pricing_receipt: projectPricingReceipt(record.pricing_receipt),
conversion_trace: projectConversionTrace(record.conversion_trace),
}
}
function projectParameterSnapshot(value: unknown): RequestParameterSnapshotDto {
const record = projectRecord(value)
assertNoSecretLikeFields(record, ['schema_version', 'state', 'entries', 'truncated'])
const schemaVersion = projectSafeInteger(record.schema_version, { minimum: 1, maximum: 1 }) as 1
const state = projectEnum(record.state, parameterCaptureStates)
const truncated = projectBoolean(record.truncated)
const entries = projectArray(record.entries, (entryValue): RequestParameterEntryDto => {
const entry = projectRecord(entryValue)
assertNoSecretLikeFields(entry, ['path', 'kind', 'value'])
const path = projectString(entry.path)
const value = projectString(entry.value)
if (!/^[a-z0-9_.]{1,128}$/u.test(path) || value.length === 0 || value.length > 256) {
invalidResponse()
}
return { path, kind: projectEnum(entry.kind, parameterValueKinds), value }
})
if (
entries.length > 128 ||
(state === 'captured' && truncated) ||
(state === 'partial' && !truncated) ||
((state === 'unavailable' || state === 'skipped_oversize' || state === 'preflight_blocked') &&
(entries.length !== 0 || truncated))
) {
invalidResponse()
}
for (let index = 1; index < entries.length; index += 1) {
if (entries[index - 1].path >= entries[index].path) invalidResponse()
}
return { schema_version: schemaVersion, state, entries, truncated }
}
function projectConversionTrace(value: unknown): RequestConversionTraceDto | null {
if (value === null) return null
const record = projectRecord(value)
assertNoSecretLikeFields(record, ['schema_version', 'state', 'target', 'changes'])
const schemaVersion = projectSafeInteger(record.schema_version, { minimum: 1, maximum: 1 }) as 1
const changes = projectArray(record.changes, (changeValue): RequestParameterChangeDto => {
const change = projectRecord(changeValue)
assertNoSecretLikeFields(change, ['disposition', 'source_path', 'target_path'])
const disposition = projectEnum(change.disposition, parameterDispositions)
const sourcePath = change.source_path === undefined ? null : projectString(change.source_path)
const targetPath = change.target_path === undefined ? null : projectString(change.target_path)
if (
(sourcePath !== null && !/^[a-z0-9_.]{1,128}$/u.test(sourcePath)) ||
(targetPath !== null && !/^[a-z0-9_.]{1,128}$/u.test(targetPath)) ||
(disposition === 'dropped' && (sourcePath === null || targetPath !== null)) ||
(disposition === 'added' && (sourcePath !== null || targetPath === null)) ||
((disposition === 'preserved' || disposition === 'mapped' || disposition === 'normalized') &&
(sourcePath === null || targetPath === null))
) {
invalidResponse()
}
return { disposition, source_path: sourcePath, target_path: targetPath }
})
if (changes.length > 128) invalidResponse()
return {
schema_version: schemaVersion,
state: projectEnum(record.state, parameterCaptureStates),
target: projectParameterSnapshot(record.target),
changes,
}
}
@@ -553,8 +661,13 @@ export function projectRequestLogItem(value: unknown): RequestLogItemDto {
export function projectRequestLogDetail(value: unknown): RequestLogDetailDto {
const record = projectRecord(value)
assertNoSecretLikeFields(record, [...itemFields, 'attempts'])
return { ...projectItemRecord(record), attempts: projectArray(record.attempts, projectAttempt) }
assertNoSecretLikeFields(record, [...itemFields, 'client_parameters', 'attempts'])
return {
...projectItemRecord(record),
client_parameters:
record.client_parameters === null ? null : projectParameterSnapshot(record.client_parameters),
attempts: projectArray(record.attempts, projectAttempt),
}
}
export function projectRequestLogPage(value: unknown): RequestLogPageDto {
+13 -30
View File
@@ -20,7 +20,8 @@ export type RouteInspectReasonCode =
| 'protocol_filtered'
| 'model_filtered'
| 'model_required_by_filter'
| 'capability_unsupported'
| 'operation_unsupported'
| 'native_route_required'
| 'no_route_target'
| 'group_disabled'
| 'group_filtered'
@@ -37,7 +38,7 @@ export type RouteInspectReasonCode =
export interface RouteInspectRequest {
protocol: AccessProtocol
operation: RouteInspectOperation
required_features: RouteInspectFeature[]
route_requirement: RouteInspectRequirement
external_model?: string | null
access_key_id: number
}
@@ -52,15 +53,7 @@ export type RouteInspectOperation =
| 'responses_compact'
| 'responses_input_tokens'
| 'responses_passthrough'
| 'list_models'
| 'probe'
export type RouteInspectFeature =
| 'streaming'
| 'tools'
| 'reasoning'
| 'multimodal'
| 'structured_output'
| 'native_resource_semantics'
export type RouteInspectRequirement = 'any' | 'native'
export type RouteInspectMode = 'native' | 'converted'
export interface RouteInspectCredentialDto {
@@ -78,7 +71,7 @@ export interface RouteInspectGroupDto {
group_name: string
channel_id: string
route_mode: RouteInspectMode
capability_supported: boolean
route_requirement_satisfied: boolean
upstream_model: string | null
weight_manual: number | null
included: boolean
@@ -92,7 +85,7 @@ export interface RouteInspectResponseDto {
snapshot_revision: number
protocol: AccessProtocol
operation: RouteInspectOperation
required_features: RouteInspectFeature[]
route_requirement: RouteInspectRequirement
external_model: string | null
access_key: {
id: number
@@ -115,24 +108,16 @@ export const routeInspectOperations = [
'responses_compact',
'responses_input_tokens',
'responses_passthrough',
'list_models',
'probe',
] as const
export const routeInspectFeatures = [
'streaming',
'tools',
'reasoning',
'multimodal',
'structured_output',
'native_resource_semantics',
] as const
export const routeInspectRequirements = ['any', 'native'] as const
const routeModes = ['native', 'converted'] as const
const reasonCodes = [
'access_key_disabled',
'protocol_filtered',
'model_filtered',
'model_required_by_filter',
'capability_unsupported',
'operation_unsupported',
'native_route_required',
'no_route_target',
'group_disabled',
'group_filtered',
@@ -198,7 +183,7 @@ function projectRouteGroup(value: unknown): RouteInspectGroupDto {
'group_name',
'channel_id',
'route_mode',
'capability_supported',
'route_requirement_satisfied',
'upstream_model',
'weight_manual',
'included',
@@ -211,7 +196,7 @@ function projectRouteGroup(value: unknown): RouteInspectGroupDto {
group_name: projectNonBlankString(record.group_name),
channel_id: projectNonBlankString(record.channel_id),
route_mode: projectEnum(record.route_mode, routeModes),
capability_supported: projectBoolean(record.capability_supported),
route_requirement_satisfied: projectBoolean(record.route_requirement_satisfied),
upstream_model: projectNullableNonBlankString(record.upstream_model),
weight_manual: projectNullableWeight(record.weight_manual),
included: projectBoolean(record.included),
@@ -238,7 +223,7 @@ export function projectRouteInspection(value: unknown): RouteInspectResponseDto
'snapshot_revision',
'protocol',
'operation',
'required_features',
'route_requirement',
'external_model',
'access_key',
'routable',
@@ -250,9 +235,7 @@ export function projectRouteInspection(value: unknown): RouteInspectResponseDto
snapshot_revision: projectSafeInteger(record.snapshot_revision, { minimum: 1 }),
protocol: projectEnum(record.protocol, enabledDataProtocols),
operation: projectEnum(record.operation, routeInspectOperations),
required_features: projectArray(record.required_features, (feature) =>
projectEnum(feature, routeInspectFeatures),
),
route_requirement: projectEnum(record.route_requirement, routeInspectRequirements),
external_model: projectNullableNonBlankString(record.external_model),
access_key: projectAccessKey(record.access_key),
routable: projectBoolean(record.routable),
+20 -55
View File
@@ -21,12 +21,12 @@ interface SelectOption {
const props = defineProps<{
protocol: string
operation: string
features: string[]
routeRequirement: string
model: string
accessKeyId: string
protocolOptions: SelectOption[]
operationOptions: SelectOption[]
featureOptions: SelectOption[]
routeRequirementOptions: SelectOption[]
modelRequired: boolean
modelAllowed: boolean
accessKeyOptions: SelectOption[]
@@ -39,7 +39,7 @@ const props = defineProps<{
const emit = defineEmits<{
'update:protocol': [value: string]
'update:operation': [value: string]
'update:features': [value: string[]]
'update:routeRequirement': [value: string]
'update:model': [value: string]
'update:accessKeyId': [value: string]
submit: []
@@ -52,13 +52,6 @@ function error(field: InspectorField): string | undefined {
const key = props.errors[field]
return key ? t(key) : undefined
}
function setFeature(value: string, checked: boolean): void {
const next = new Set(props.features)
if (checked) next.add(value)
else next.delete(value)
emit('update:features', [...next].sort())
}
</script>
<template>
@@ -169,17 +162,24 @@ function setFeature(value: string, checked: boolean): void {
</template>
</FormField>
<fieldset class="inspector-features">
<legend>{{ t('monitor.inspector.form.features') }}</legend>
<label v-for="option in featureOptions" :key="option.value">
<input
type="checkbox"
:checked="features.includes(option.value)"
@change="setFeature(option.value, ($event.target as HTMLInputElement).checked)"
<FormField
id="inspector-route-requirement"
size="compact"
:label="t('monitor.inspector.form.routeRequirement')"
required
>
<template #default="{ describedBy }">
<AppSelect
id="inspector-route-requirement"
:model-value="routeRequirement"
:label="t('monitor.inspector.form.routeRequirement')"
:options="routeRequirementOptions"
:aria-describedby="describedBy"
size="compact"
@update:model-value="emit('update:routeRequirement', $event)"
/>
<span>{{ option.label }}</span>
</label>
</fieldset>
</template>
</FormField>
<AppButton
class="inspector-form__submit"
@@ -251,37 +251,6 @@ function setFeature(value: string, checked: boolean): void {
margin-top: var(--space-1);
}
.inspector-features {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
min-width: 0;
margin: 0;
border: 0;
padding: 0;
}
.inspector-features legend {
width: 100%;
margin-bottom: 4px;
color: var(--color-text-muted);
font-size: var(--text-meta);
}
.inspector-features label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
.inspector-features input {
width: 15px;
height: 15px;
margin: 0;
}
.inspector-inline-error {
margin: 0;
border: 1px solid color-mix(in srgb, var(--color-danger) 34%, var(--color-border-subtle));
@@ -316,10 +285,6 @@ function setFeature(value: string, checked: boolean): void {
.inspector-form-panel__body > :deep(.inline-feedback) {
grid-column: 1 / -1;
}
.inspector-features {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
+58 -24
View File
@@ -13,13 +13,13 @@ import { RequestCancelledError } from '@/api/errors'
import { accessKeyOptionsQueryOptions } from '@/app/resources/access-keys'
import {
inspectRoute,
routeInspectFeatures,
routeInspectRequirements,
type RouteInspectCredentialDto,
type RouteInspectFeature,
type RouteInspectGroupDto,
type RouteInspectOperation,
type RouteInspectReasonCode,
type RouteInspectRequest,
type RouteInspectRequirement,
type RouteInspectResponseDto,
} from '@/app/resources/route-inspection'
import { groupDetailLocation, monitorLocation } from '@/app/route-locations'
@@ -53,7 +53,8 @@ const knownReasons = new Set<RouteInspectReasonCode>([
'protocol_filtered',
'model_filtered',
'model_required_by_filter',
'capability_unsupported',
'operation_unsupported',
'native_route_required',
'no_route_target',
'group_disabled',
'group_filtered',
@@ -77,6 +78,13 @@ const responsesOperations: readonly RouteInspectOperation[] = [
'responses_input_tokens',
'responses_passthrough',
]
const nativeRouteOperations = new Set<RouteInspectOperation>([
'responses_retrieve',
'responses_delete',
'responses_cancel',
'responses_input_items',
'responses_passthrough',
])
const client = useApiClient()
const route = useRoute()
@@ -87,7 +95,10 @@ const draftProtocol = ref(readProtocol(routeState.value.protocol))
const draftOperation = ref(
readOperation(routeState.value.operation) ?? defaultOperation(draftProtocol.value),
)
const draftFeatures = ref<RouteInspectFeature[]>([...routeState.value.requiredFeatures])
const draftRouteRequirement = ref<RouteInspectRequirement>(
readRouteRequirement(routeState.value.routeRequirement) ??
defaultRouteRequirement(draftOperation.value),
)
const draftModel = ref(readText(routeState.value.externalModel))
const draftAccessKeyID = ref(readPositiveID(routeState.value.accessKeyID))
const fieldErrors = ref<InspectorErrors>({})
@@ -120,11 +131,17 @@ const operationOptions = computed(() =>
label: t(`monitor.inspector.operations.${value}`),
})),
)
const featureOptions = computed(() =>
routeInspectFeatures.map((value) => ({
value,
label: t(`monitor.inspector.features.${value}`),
})),
const routeRequirementOptions = computed(() =>
routeInspectRequirements
.filter(
(value) =>
value === 'native' ||
!nativeRouteOperations.has(draftOperation.value as RouteInspectOperation),
)
.map((value) => ({
value,
label: t(`monitor.inspector.routeRequirements.${value}`),
})),
)
const modelRequired = computed(() => operationRequiresModel(draftOperation.value))
const modelAllowed = computed(() => operationAllowsModel(draftOperation.value))
@@ -162,7 +179,7 @@ const inputChanged = computed(() => {
return (
draftProtocol.value !== previous.protocol ||
draftOperation.value !== previous.operation ||
draftFeatures.value.join(',') !== previous.required_features.join(',') ||
draftRouteRequirement.value !== previous.route_requirement ||
draftModel.value !== (previous.external_model ?? '') ||
draftAccessKeyID.value !== String(previous.access_key_id)
)
@@ -212,6 +229,20 @@ function defaultOperation(value: AccessProtocol | ''): RouteInspectOperation | '
return operationsForProtocol(value)[0] ?? ''
}
function defaultRouteRequirement(operation: RouteInspectOperation | ''): RouteInspectRequirement {
return operation === 'responses_create' ||
nativeRouteOperations.has(operation as RouteInspectOperation)
? 'native'
: 'any'
}
function readRouteRequirement(raw: unknown): RouteInspectRequirement | undefined {
return typeof raw === 'string' &&
routeInspectRequirements.includes(raw as RouteInspectRequirement)
? (raw as RouteInspectRequirement)
: undefined
}
function readOperation(raw: unknown): RouteInspectOperation | undefined {
return typeof raw === 'string' && responsesOperations.includes(raw as RouteInspectOperation)
? (raw as RouteInspectOperation)
@@ -248,21 +279,22 @@ watch(
[
routeState.value.protocol,
routeState.value.operation,
routeState.value.requiredFeatures.join(','),
routeState.value.routeRequirement,
routeState.value.externalModel,
routeState.value.accessKeyID,
routeState.value.run,
] as const,
([rawProtocol, rawOperation, rawFeatures, rawModel, rawAccessKeyID, run]) => {
([rawProtocol, rawOperation, rawRouteRequirement, rawModel, rawAccessKeyID, run]) => {
const protocol = readProtocol(rawProtocol)
const operation = readOperation(rawOperation) ?? defaultOperation(protocol)
const features = rawFeatures === '' ? [] : [...routeState.value.requiredFeatures]
const routeRequirement =
readRouteRequirement(rawRouteRequirement) ?? defaultRouteRequirement(operation)
const model = readText(rawModel)
const accessKeyID = readPositiveID(rawAccessKeyID)
const fieldsChanged =
protocol !== draftProtocol.value ||
operation !== draftOperation.value ||
features.join(',') !== draftFeatures.value.join(',') ||
routeRequirement !== draftRouteRequirement.value ||
model !== draftModel.value ||
accessKeyID !== draftAccessKeyID.value
if (!fieldsChanged && run) return
@@ -272,7 +304,7 @@ watch(
controller = undefined
draftProtocol.value = protocol
draftOperation.value = operation
draftFeatures.value = features
draftRouteRequirement.value = routeRequirement
draftModel.value = model
draftAccessKeyID.value = accessKeyID
fieldErrors.value = {}
@@ -315,7 +347,7 @@ function validatedRequest(): RouteInspectRequest | undefined {
const request: RouteInspectRequest = {
protocol: draftProtocol.value as AccessProtocol,
operation: draftOperation.value as RouteInspectOperation,
required_features: [...draftFeatures.value].sort(),
route_requirement: draftRouteRequirement.value,
access_key_id: accessKeyID,
}
if (draftModel.value !== '') request.external_model = draftModel.value
@@ -331,11 +363,13 @@ function setDraftProtocol(value: string): void {
) {
draftOperation.value = defaultOperation(draftProtocol.value)
}
draftRouteRequirement.value = defaultRouteRequirement(draftOperation.value)
if (!operationAllowsModel(draftOperation.value)) draftModel.value = ''
}
function setDraftOperation(value: string): void {
draftOperation.value = value as RouteInspectOperation | ''
draftRouteRequirement.value = defaultRouteRequirement(draftOperation.value)
if (!operationAllowsModel(draftOperation.value)) draftModel.value = ''
}
@@ -346,7 +380,7 @@ async function inspect(): Promise<void> {
const nextState: InspectorMonitorState = {
protocol: request.protocol,
operation: request.operation,
requiredFeatures: request.required_features,
routeRequirement: request.route_requirement,
externalModel: request.external_model ?? undefined,
accessKeyID: String(request.access_key_id),
run: true,
@@ -357,7 +391,7 @@ async function inspect(): Promise<void> {
current.run &&
current.protocol === nextState.protocol &&
current.operation === nextState.operation &&
current.requiredFeatures.join(',') === nextState.requiredFeatures.join(',') &&
current.routeRequirement === nextState.routeRequirement &&
current.externalModel === nextState.externalModel &&
current.accessKeyID === nextState.accessKeyID
) {
@@ -408,7 +442,7 @@ watch(
() => routeState.value.run,
() => routeState.value.protocol,
() => routeState.value.operation,
() => routeState.value.requiredFeatures.join(','),
() => routeState.value.routeRequirement,
() => routeState.value.externalModel,
() => routeState.value.accessKeyID,
() => accessKeyOptionsQuery.data.value,
@@ -421,7 +455,7 @@ watch(
pending.value &&
submitted.value?.protocol === request.protocol &&
submitted.value.operation === request.operation &&
submitted.value.required_features.join(',') === request.required_features.join(',') &&
submitted.value.route_requirement === request.route_requirement &&
submitted.value.access_key_id === request.access_key_id &&
submitted.value.external_model === request.external_model
) {
@@ -431,7 +465,7 @@ watch(
observation.value !== undefined &&
submitted.value?.protocol === request.protocol &&
submitted.value.operation === request.operation &&
submitted.value.required_features.join(',') === request.required_features.join(',') &&
submitted.value.route_requirement === request.route_requirement &&
submitted.value.access_key_id === request.access_key_id &&
submitted.value.external_model === request.external_model
) {
@@ -558,12 +592,12 @@ onBeforeUnmount(() => {
<InspectorForm
:protocol="draftProtocol"
:operation="draftOperation"
:features="draftFeatures"
:route-requirement="draftRouteRequirement"
:model="draftModel"
:access-key-id="draftAccessKeyID"
:protocol-options="protocolOptions"
:operation-options="operationOptions"
:feature-options="featureOptions"
:route-requirement-options="routeRequirementOptions"
:model-required="modelRequired"
:model-allowed="modelAllowed"
:access-key-options="accessKeyOptions"
@@ -574,7 +608,7 @@ onBeforeUnmount(() => {
:submit-pending="pending"
@update:protocol="setDraftProtocol"
@update:operation="setDraftOperation"
@update:features="draftFeatures = $event as RouteInspectFeature[]"
@update:route-requirement="draftRouteRequirement = $event as RouteInspectRequirement"
@update:model="draftModel = $event"
@update:access-key-id="draftAccessKeyID = $event"
@submit="inspect"
+335 -2
View File
@@ -1,10 +1,18 @@
<script setup lang="ts">
import { ArrowRight } from '@lucide/vue'
import { ArrowRight, ChevronRight } from '@lucide/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AccessProtocol } from '@/api/control/types'
import type { RequestLogReasoningDto, RequestLogUpstreamAPI } from '@/app/resources/request-logs'
import type {
RequestConversionTraceDto,
RequestLogReasoningDto,
RequestLogUpstreamAPI,
RequestParameterChangeDto,
RequestParameterEntryDto,
RequestParameterSnapshotDto,
} from '@/app/resources/request-logs'
import CopyButton from '@/components/ui/CopyButton.vue'
import { formatLogReasoningBudget, reasoningBudgetSemantic } from './log-format'
@@ -15,6 +23,8 @@ const props = defineProps<{
upstreamModel: string | null
clientReasoning: RequestLogReasoningDto | null
upstreamReasoning: RequestLogReasoningDto | null
clientParameters: RequestParameterSnapshotDto | null
conversionTrace: RequestConversionTraceDto | null
}>()
const { locale, t } = useI18n()
@@ -24,6 +34,38 @@ const upstreamAPILabel = computed(() =>
)
const clientReasoningLabel = computed(() => formatReasoning(props.clientReasoning, false))
const upstreamReasoningLabel = computed(() => formatReasoning(props.upstreamReasoning, true))
const targetParameters = computed(() => props.conversionTrace?.target ?? null)
const parameterRows = computed(() => buildParameterRows())
const parameterCopyValue = computed(() =>
JSON.stringify(
{
client: props.clientParameters,
converted_upstream: props.conversionTrace,
},
null,
2,
),
)
const parameterHistoryUnavailable = computed(
() => props.clientParameters === null && props.conversionTrace === null,
)
const parameterTruncated = computed(
() => props.clientParameters?.truncated === true || targetParameters.value?.truncated === true,
)
const parameterRedacted = computed(() =>
[...(props.clientParameters?.entries ?? []), ...(targetParameters.value?.entries ?? [])].some(
({ value }) => value.includes('[REDACTED]') || value.includes('[redacted]'),
),
)
interface ParameterRow {
key: string
sourcePath: string | null
targetPath: string | null
source: RequestParameterEntryDto | null
target: RequestParameterEntryDto | null
change: RequestParameterChangeDto
}
function formatReasoning(value: RequestLogReasoningDto | null, upstream: boolean): string {
if (value === null) {
@@ -52,6 +94,51 @@ function formatReasoning(value: RequestLogReasoningDto | null, upstream: boolean
}
return details.join(' · ') || t('monitor.logs.drawer.reasoningNotSpecified')
}
function buildParameterRows(): ParameterRow[] {
const source = new Map(
(props.clientParameters?.entries ?? []).map((entry) => [entry.path, entry]),
)
const target = new Map(
(targetParameters.value?.entries ?? []).map((entry) => [entry.path, entry]),
)
const changes = props.conversionTrace?.changes ?? []
if (changes.length > 0) {
return changes.map((change, index) => ({
key: `${change.source_path ?? ''}:${change.target_path ?? ''}:${index}`,
sourcePath: change.source_path,
targetPath: change.target_path,
source: change.source_path === null ? null : (source.get(change.source_path) ?? null),
target: change.target_path === null ? null : (target.get(change.target_path) ?? null),
change,
}))
}
const paths = [...new Set([...source.keys(), ...target.keys()])].sort()
return paths.map((path) => {
const sourceEntry = source.get(path) ?? null
const targetEntry = target.get(path) ?? null
const disposition: RequestParameterChangeDto['disposition'] =
sourceEntry && targetEntry ? 'preserved' : sourceEntry ? 'dropped' : 'added'
return {
key: path,
sourcePath: sourceEntry ? path : null,
targetPath: targetEntry ? path : null,
source: sourceEntry,
target: targetEntry,
change: {
disposition,
source_path: sourceEntry ? path : null,
target_path: targetEntry ? path : null,
},
}
})
}
function captureStateLabel(snapshot: RequestParameterSnapshotDto | null, trace = false): string {
const state = trace ? props.conversionTrace?.state : snapshot?.state
if (!state) return t('monitor.logs.protocolConversion.historyUnavailable')
return t(`monitor.logs.protocolConversion.captureState.${state}`)
}
</script>
<template>
@@ -93,6 +180,83 @@ function formatReasoning(value: RequestLogReasoningDto | null, upstream: boolean
<dd>{{ upstreamReasoningLabel }}</dd>
</div>
</dl>
<details class="log-conversion-parameters">
<summary>
<ChevronRight class="log-conversion-parameters__chevron" :size="15" aria-hidden="true" />
<span>{{ t('monitor.logs.protocolConversion.parameters') }}</span>
<span
v-if="parameterHistoryUnavailable"
class="log-conversion-parameters__badge log-conversion-parameters__badge--muted"
>
{{ t('monitor.logs.protocolConversion.history') }}
</span>
<span
v-if="parameterTruncated"
class="log-conversion-parameters__badge log-conversion-parameters__badge--warning"
>
{{ t('monitor.logs.protocolConversion.truncated') }}
</span>
<span
v-if="parameterRedacted"
class="log-conversion-parameters__badge log-conversion-parameters__badge--muted"
>
{{ t('monitor.logs.protocolConversion.redacted') }}
</span>
</summary>
<div class="log-conversion-parameters__toolbar">
<div>
<span>{{ captureStateLabel(clientParameters) }}</span>
<ArrowRight :size="13" aria-hidden="true" />
<span>{{ captureStateLabel(targetParameters, true) }}</span>
</div>
<CopyButton
v-if="!parameterHistoryUnavailable"
:value="parameterCopyValue"
:label="t('monitor.logs.protocolConversion.copyParameters')"
:success-label="t('common.copied')"
:failure-label="t('common.copyFailed')"
/>
</div>
<p v-if="parameterRows.length === 0" class="log-conversion-parameters__empty">
{{
parameterHistoryUnavailable
? t('monitor.logs.protocolConversion.historyUnavailable')
: t('monitor.logs.protocolConversion.noSafeParameters')
}}
</p>
<div v-else class="log-conversion-parameters__scroll">
<div class="log-conversion-parameters__columns" aria-hidden="true">
<span>{{ t('monitor.logs.protocolConversion.parameter') }}</span>
<span>{{ t('monitor.logs.protocolConversion.clientRequest') }}</span>
<span>{{ t('monitor.logs.protocolConversion.convertedUpstream') }}</span>
<span>{{ t('monitor.logs.protocolConversion.dispositionLabel') }}</span>
</div>
<div v-for="row in parameterRows" :key="row.key" class="log-conversion-parameters__row">
<code class="log-conversion-parameters__path">
{{
row.sourcePath === row.targetPath
? row.sourcePath
: `${row.sourcePath ?? '—'} → ${row.targetPath ?? '—'}`
}}
</code>
<code :data-label="t('monitor.logs.protocolConversion.clientRequest')">
{{ row.source?.value ?? '—' }}
</code>
<code :data-label="t('monitor.logs.protocolConversion.convertedUpstream')">
{{ row.target?.value ?? '—' }}
</code>
<span
class="log-conversion-parameters__disposition"
:class="`log-conversion-parameters__disposition--${row.change.disposition}`"
>
{{ t(`monitor.logs.protocolConversion.disposition.${row.change.disposition}`) }}
</span>
</div>
</div>
</details>
</section>
</template>
@@ -150,6 +314,146 @@ function formatReasoning(value: RequestLogReasoningDto | null, upstream: boolean
color: var(--color-warning) !important;
}
.log-conversion-parameters {
margin-top: 10px;
border-top: 1px solid color-mix(in srgb, var(--color-border-subtle) 82%, transparent);
padding-top: 9px;
}
.log-conversion-parameters summary {
display: flex;
min-height: 28px;
align-items: center;
gap: 6px;
color: var(--color-text-muted);
cursor: pointer;
font-size: var(--text-label-xs);
list-style: none;
}
.log-conversion-parameters summary::-webkit-details-marker {
display: none;
}
.log-conversion-parameters__chevron {
transition: transform 140ms ease;
}
.log-conversion-parameters[open] .log-conversion-parameters__chevron {
transform: rotate(90deg);
}
.log-conversion-parameters__badge,
.log-conversion-parameters__disposition {
border-radius: 999px;
padding: 2px 7px;
font-size: 10px;
line-height: 1.35;
}
.log-conversion-parameters__badge--muted {
background: color-mix(in srgb, var(--color-text-faint) 12%, transparent);
color: var(--color-text-faint);
}
.log-conversion-parameters__badge--warning {
background: color-mix(in srgb, var(--color-warning) 14%, transparent);
color: var(--color-warning);
}
.log-conversion-parameters__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 5px 0 8px;
color: var(--color-text-faint);
font-size: 11px;
}
.log-conversion-parameters__toolbar > div {
display: flex;
align-items: center;
gap: 6px;
}
.log-conversion-parameters__scroll {
max-height: 344px;
overflow: auto;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-control);
background: var(--color-surface);
}
.log-conversion-parameters__columns,
.log-conversion-parameters__row {
display: grid;
grid-template-columns: minmax(130px, 0.9fr) minmax(100px, 1fr) minmax(100px, 1fr) minmax(
76px,
auto
);
gap: 8px;
align-items: center;
padding: 7px 9px;
}
.log-conversion-parameters__columns {
position: sticky;
top: 0;
z-index: 1;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface);
color: var(--color-text-faint);
font-size: 10px;
}
.log-conversion-parameters__row {
border-bottom: 1px solid color-mix(in srgb, var(--color-border-subtle) 65%, transparent);
font-size: 11px;
}
.log-conversion-parameters__row:last-child {
border-bottom: 0;
}
.log-conversion-parameters__row code {
min-width: 0;
overflow-wrap: anywhere;
}
.log-conversion-parameters__path {
color: var(--color-text-muted);
}
.log-conversion-parameters__disposition {
justify-self: start;
background: color-mix(in srgb, var(--color-text-faint) 10%, transparent);
color: var(--color-text-muted);
white-space: nowrap;
}
.log-conversion-parameters__disposition--mapped,
.log-conversion-parameters__disposition--normalized {
background: color-mix(in srgb, var(--color-warning) 13%, transparent);
color: var(--color-warning);
}
.log-conversion-parameters__disposition--dropped {
background: color-mix(in srgb, var(--color-danger) 11%, transparent);
color: var(--color-danger);
}
.log-conversion-parameters__disposition--added {
background: color-mix(in srgb, var(--color-success) 11%, transparent);
color: var(--color-success);
}
.log-conversion-parameters__empty {
margin: 6px 0 2px;
color: var(--color-text-faint);
font-size: 11px;
}
@media (max-width: 520px) {
.log-conversion-trace__header {
display: none;
@@ -162,5 +466,34 @@ function formatReasoning(value: RequestLogReasoningDto | null, upstream: boolean
.log-conversion-trace__row dt {
grid-column: 1 / -1;
}
.log-conversion-parameters__columns {
display: none;
}
.log-conversion-parameters__row {
grid-template-columns: minmax(0, 1fr) minmax(76px, auto);
align-items: start;
}
.log-conversion-parameters__path {
grid-column: 1 / -1;
}
.log-conversion-parameters__row code:nth-of-type(2)::before,
.log-conversion-parameters__row code:nth-of-type(3)::before {
display: block;
color: var(--color-text-faint);
content: attr(data-label);
}
.log-conversion-parameters__disposition {
grid-column: 2;
grid-row: 2 / span 2;
}
.log-conversion-parameters__toolbar {
align-items: flex-start;
}
}
</style>
@@ -487,6 +487,8 @@ function toggleAttemptErrorMessage(sequence: number): void {
:upstream-model="log.upstream_model"
:client-reasoning="log.reasoning"
:upstream-reasoning="finalAttempt?.reasoning ?? null"
:client-parameters="log.client_parameters"
:conversion-trace="finalAttempt?.conversion_trace ?? null"
/>
</div>
</dl>
@@ -636,6 +638,8 @@ function toggleAttemptErrorMessage(sequence: number): void {
:upstream-model="attempt.upstream_model"
:client-reasoning="log.reasoning"
:upstream-reasoning="attempt.reasoning"
:client-parameters="log.client_parameters"
:conversion-trace="attempt.conversion_trace"
/>
</div>
<div v-if="showAttemptOperation(attempt)">
+1
View File
@@ -69,6 +69,7 @@ export const requestLogFailureCategories = [
'invalid_key',
'upstream_host_error',
'client_error',
'conversion_unsupported',
'downstream_cancel',
'ambiguous',
] as const
+8 -19
View File
@@ -4,10 +4,10 @@ import { enabledDataProtocols } from '@/api/control/protocols'
import type { UsageFilters } from '@/app/resources/usage'
import type { RequestLogFilters } from '@/app/resources/request-logs'
import {
routeInspectFeatures,
routeInspectOperations,
type RouteInspectFeature,
routeInspectRequirements,
type RouteInspectOperation,
type RouteInspectRequirement,
} from '@/app/resources/route-inspection'
import { defaultTimeRange } from '@/lib/time'
@@ -50,7 +50,7 @@ export interface LogsMonitorState {
export interface InspectorMonitorState {
protocol?: string
operation?: RouteInspectOperation
requiredFeatures: RouteInspectFeature[]
routeRequirement?: RouteInspectRequirement
externalModel?: string
accessKeyID?: string
run: boolean
@@ -210,20 +210,21 @@ export function sameMonitorQuery(left: LocationQueryRaw, right: LocationQueryRaw
export function parseInspectorMonitorState(query: Record<string, unknown>): InspectorMonitorState {
const protocol = scalarEnum(query.protocol, enabledDataProtocols)
const operation = scalarEnum(query.operation, routeInspectOperations)
const requiredFeatures = scalarEnumList(query.features, routeInspectFeatures)
const routeRequirement = scalarEnum(query.route_requirement, routeInspectRequirements)
const externalModel = scalarText(query.external_model)
const accessKeyID = scalarPositiveID(query.access_key_id)
return {
protocol,
operation,
requiredFeatures,
routeRequirement,
externalModel,
accessKeyID,
run:
query.run === '1' &&
protocol !== undefined &&
operation !== undefined &&
routeRequirement !== undefined &&
accessKeyID !== undefined,
expandedGroupIDs: parsePositiveIDList(query.expanded_groups),
}
@@ -234,13 +235,14 @@ export function inspectorMonitorQuery(state: InspectorMonitorState): LocationQue
if (state.protocol !== undefined) normalized.protocol = state.protocol
if (state.operation !== undefined) normalized.operation = state.operation
if (state.requiredFeatures.length > 0) normalized.features = state.requiredFeatures.join(',')
if (state.routeRequirement !== undefined) normalized.route_requirement = state.routeRequirement
if (state.externalModel !== undefined) normalized.external_model = state.externalModel
if (state.accessKeyID !== undefined) normalized.access_key_id = state.accessKeyID
if (
state.run &&
state.protocol !== undefined &&
state.operation !== undefined &&
state.routeRequirement !== undefined &&
state.accessKeyID !== undefined
) {
normalized.run = '1'
@@ -290,19 +292,6 @@ function scalarEnum<T extends string>(raw: unknown, values: readonly T[]): T | u
return typeof raw === 'string' && values.includes(raw as T) ? (raw as T) : undefined
}
function scalarEnumList<T extends string>(raw: unknown, values: readonly T[]): T[] {
if (raw === undefined) return []
if (typeof raw !== 'string' || raw === '') return []
const entries = raw.split(',')
if (
entries.some((entry) => !values.includes(entry as T)) ||
new Set(entries).size !== entries.length
) {
return []
}
return entries.sort() as T[]
}
function scalarUUIDv4(raw: unknown): string | undefined {
return typeof raw === 'string' && requestIDPattern.test(raw) ? raw : undefined
}
+30 -9
View File
@@ -389,20 +389,16 @@ export default {
responses_input_tokens: 'Count input tokens',
responses_passthrough: 'Responses extension operation',
},
features: {
streaming: 'Streaming',
tools: 'Tools',
reasoning: 'Reasoning',
multimodal: 'Multimodal',
structured_output: 'Structured output',
native_resource_semantics: 'Native resource semantics',
routeRequirements: {
any: 'Allow protocol conversion (possibly lossy)',
native: 'Require a native route',
},
form: {
title: 'Simulated request',
label: 'Route inspector inputs',
protocol: 'Protocol',
operation: 'Operation',
features: 'Required capabilities',
routeRequirement: 'Route requirement',
model: 'Client model',
optional: 'Optional',
modelPlaceholder: 'Enter a client model',
@@ -526,7 +522,8 @@ export default {
protocol_filtered: 'AccessKey filters exclude this protocol',
model_filtered: 'AccessKey filters exclude this model',
model_required_by_filter: 'A model is required by the AccessKey model filter',
capability_unsupported: 'The channel does not support the requested capability',
operation_unsupported: 'The channel does not support this protocol operation',
native_route_required: 'This request requires a native route',
no_route_target: 'No route target matches this model and protocol',
group_disabled: 'Group is disabled',
group_filtered: 'AccessKey filters exclude this Group',
@@ -705,6 +702,29 @@ export default {
protocolAPI: 'Protocol / API',
model: 'Model',
reasoning: 'Reasoning',
parameters: 'Parameter details',
history: 'Historical log',
truncated: 'Truncated',
redacted: 'Redacted',
historyUnavailable: 'Conversion parameters were not recorded in this historical log',
noSafeParameters: 'No safe parameters are available to display',
copyParameters: 'Copy safe parameters',
parameter: 'Parameter',
dispositionLabel: 'Disposition',
captureState: {
captured: 'Captured',
partial: 'Partially captured',
unavailable: 'Unavailable',
skipped_oversize: 'Skipped because request was too large',
preflight_blocked: 'Blocked before conversion',
},
disposition: {
preserved: 'Preserved',
mapped: 'Mapped',
normalized: 'Normalized',
dropped: 'Dropped',
added: 'Added by conversion',
},
},
routeIdentity: {
group: 'Group: {name} (G{id})',
@@ -719,6 +739,7 @@ export default {
invalid_key: 'Invalid key',
upstream_host_error: 'Upstream host error',
client_error: 'Client error',
conversion_unsupported: 'Protocol conversion unsupported',
downstream_cancel: 'Downstream canceled',
ambiguous: 'Ambiguous',
unknown: 'Unknown category',
+30 -9
View File
@@ -389,20 +389,16 @@ export default {
responses_input_tokens: '入力 Token を計算',
responses_passthrough: 'Responses 拡張操作',
},
features: {
streaming: 'ストリーミング',
tools: 'ツール呼び出し',
reasoning: '推論',
multimodal: 'マルチモーダル',
structured_output: '構造化出力',
native_resource_semantics: 'ネイティブリソースセマンティクス',
routeRequirements: {
any: 'プロトコル変換を許可(損失の可能性あり)',
native: 'ネイティブルートを必須にする',
},
form: {
title: '模擬リクエスト条件',
label: 'ルート検査の入力',
protocol: 'プロトコル',
operation: '操作',
features: '必要な機能(複数選択可)',
routeRequirement: 'ルート要件',
model: 'クライアントモデル',
optional: '任意',
modelPlaceholder: 'クライアントモデルを入力',
@@ -524,7 +520,8 @@ export default {
protocol_filtered: 'AccessKey フィルターがこのプロトコルを除外しました',
model_filtered: 'AccessKey フィルターがこのモデルを除外しました',
model_required_by_filter: 'AccessKey のモデルフィルターによりモデルの指定が必要です',
capability_unsupported: 'チャネルはリクエストに必要な機能をサポートしていません',
operation_unsupported: 'チャネルはこのプロトコル操作をサポートしていません',
native_route_required: 'このリクエストにはネイティブルートが必要です',
no_route_target: 'このモデルとプロトコルに一致するルート先がありません',
group_disabled: 'Group は無効です',
group_filtered: 'AccessKey フィルターがこの Group を除外しました',
@@ -704,6 +701,29 @@ export default {
protocolAPI: 'プロトコル / API',
model: 'モデル',
reasoning: '推論',
parameters: 'パラメータ詳細',
history: '過去のログ',
truncated: '切り捨て済み',
redacted: 'マスク済み',
historyUnavailable: 'この過去のログには変換パラメータが記録されていません',
noSafeParameters: '表示できる安全なパラメータはありません',
copyParameters: '安全なパラメータをコピー',
parameter: 'パラメータ',
dispositionLabel: '処理',
captureState: {
captured: '記録済み',
partial: '一部記録',
unavailable: '取得不可',
skipped_oversize: 'リクエストが大きすぎるためスキップ',
preflight_blocked: '変換前にブロック',
},
disposition: {
preserved: '保持',
mapped: 'マッピング',
normalized: '正規化',
dropped: '削除',
added: '変換時に追加',
},
},
routeIdentity: {
group: 'Group:{name}(G{id})',
@@ -718,6 +738,7 @@ export default {
invalid_key: '無効なキー',
upstream_host_error: 'アップストリームホストエラー',
client_error: 'クライアントエラー',
conversion_unsupported: 'プロトコル変換は未対応',
downstream_cancel: 'ダウンストリームによるキャンセル',
ambiguous: '原因不明',
unknown: '不明な分類',
+30 -9
View File
@@ -371,20 +371,16 @@ export default {
responses_input_tokens: '计算输入 Token',
responses_passthrough: 'Responses 扩展操作',
},
features: {
streaming: '流式',
tools: '工具调用',
reasoning: '思考',
multimodal: '多模态',
structured_output: '结构化输出',
native_resource_semantics: '原生资源语义',
routeRequirements: {
any: '允许协议转换(可有损)',
native: '必须使用原生路由',
},
form: {
title: '模拟请求条件',
label: '路由检查输入',
protocol: '协议',
operation: '操作',
features: '所需能力(可多选)',
routeRequirement: '路由要求',
model: '客户端模型',
optional: '可选',
modelPlaceholder: '输入客户端模型',
@@ -501,7 +497,8 @@ export default {
protocol_filtered: 'AccessKey 筛选排除了该协议',
model_filtered: 'AccessKey 筛选排除了该模型',
model_required_by_filter: 'AccessKey 模型筛选要求请求提供模型',
capability_unsupported: '渠道不支持该请求所需能力',
operation_unsupported: '渠道不支持该协议操作',
native_route_required: '该请求必须使用原生路由',
no_route_target: '没有匹配该模型与协议的路由目标',
group_disabled: 'Group 已停用',
group_filtered: 'AccessKey 筛选排除了该 Group',
@@ -682,6 +679,29 @@ export default {
protocolAPI: '协议 / API',
model: '模型',
reasoning: '思考',
parameters: '参数详情',
history: '历史日志',
truncated: '已截断',
redacted: '已脱敏',
historyUnavailable: '历史日志未记录转换参数',
noSafeParameters: '没有可展示的安全参数',
copyParameters: '复制安全参数',
parameter: '参数',
dispositionLabel: '处理方式',
captureState: {
captured: '已记录',
partial: '部分记录',
unavailable: '无法获取',
skipped_oversize: '请求过大,已跳过',
preflight_blocked: '转换前已拦截',
},
disposition: {
preserved: '保留',
mapped: '映射',
normalized: '归一化',
dropped: '已丢弃',
added: '转换新增',
},
},
routeIdentity: {
group: 'Group:{name}(G{id})',
@@ -696,6 +716,7 @@ export default {
invalid_key: '密钥无效',
upstream_host_error: '上游主机错误',
client_error: '客户端错误',
conversion_unsupported: '协议转换不支持',
downstream_cancel: '下游取消',
ambiguous: '原因不明确',
unknown: '未知分类',