From e7de10ec73711fb7a09ffedbdbbbc6cf3d99ee9d Mon Sep 17 00:00:00 2001 From: tbphp Date: Tue, 11 Aug 2026 17:42:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(route):=20=E6=94=AF=E6=8C=81=E6=9C=89?= =?UTF-8?q?=E6=8D=9F=E5=8D=8F=E8=AE=AE=E8=BD=AC=E6=8D=A2=E4=B8=8E=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E8=BF=BD=E8=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/channel/channel.go | 78 +- .../channel/final_provider_contract_test.go | 2 +- internal/channel/presets.go | 78 +- internal/channel/registry_test.go | 60 +- internal/control/discover_executor_test.go | 8 - .../control/execution_test_helpers_test.go | 4 - internal/control/group_collection.go | 3 +- internal/control/request_logs.go | 44 +- internal/control/request_logs_test.go | 58 ++ internal/control/route_inspect.go | 92 +- internal/control/route_inspect_test.go | 128 ++- internal/control/validation_test.go | 4 - internal/dialect/anthropic.go | 4 +- internal/dialect/dialect.go | 2 +- internal/dialect/gemini.go | 4 +- internal/dialect/openai.go | 4 +- internal/dialect/openai_responses.go | 6 +- internal/dialect/request_execution.go | 483 +++++----- internal/dialect/request_execution_test.go | 232 +++-- internal/execution/bifrost/converted.go | 56 +- .../bifrost/converted_protocols_test.go | 17 + internal/execution/bifrost/executor.go | 87 +- internal/execution/bifrost/executor_test.go | 161 +++- internal/execution/bifrost/mapping.go | 66 ++ internal/execution/bifrost/mapping_test.go | 57 ++ .../bifrost/responses_passthrough_test.go | 8 +- internal/execution/bifrost/runtime.go | 67 -- internal/execution/bifrost/runtime_manager.go | 23 +- .../execution/bifrost/runtime_manager_test.go | 4 - .../execution/bifrost/test_runtime_test.go | 4 - internal/execution/bifrost/wire_reasoning.go | 133 ++- .../execution/bifrost/wire_reasoning_test.go | 54 +- internal/execution/contracts.go | 280 ++---- internal/execution/contracts_test.go | 191 ++-- internal/execution/validation.go | 26 +- internal/gateway/execution_forward.go | 15 +- internal/gateway/execution_forward_test.go | 16 +- internal/gateway/forward.go | 13 +- internal/gateway/handler.go | 40 +- internal/gateway/handler_test.go | 201 ++++ internal/gateway/reason.go | 23 +- internal/gateway/request_log.go | 18 + internal/gateway/request_log_test.go | 12 + internal/health/classification.go | 3 + internal/health/execution_judge.go | 20 + internal/health/execution_judge_test.go | 27 + internal/parametertrace/trace.go | 880 ++++++++++++++++++ internal/parametertrace/trace_test.go | 290 ++++++ internal/reasoning/config.go | 13 - internal/requestlog/mapper.go | 79 ++ internal/requestlog/mapper_test.go | 105 +++ internal/requestlog/query.go | 40 + internal/requestlog/query_test.go | 56 +- internal/requestlog/types.go | 3 + internal/scheduler/channel_scheduler_test.go | 191 +++- internal/scheduler/inspect.go | 82 +- internal/scheduler/scheduler.go | 6 +- internal/state/snapshot.go | 3 +- internal/storage/db_test.go | 1 + .../storage/migration_0001_initial_v2_test.go | 3 +- ...ation_0003_request_log_conversion_trace.go | 108 +++ ..._0003_request_log_conversion_trace_test.go | 77 ++ internal/storage/migrations.go | 5 + internal/storage/models/request.go | 4 +- internal/telemetry/requestlog.go | 20 +- internal/telemetry/requestlog_test.go | 2 + web/src/app/resources/request-logs.ts | 121 ++- web/src/app/resources/route-inspection.ts | 43 +- web/src/features/monitor/InspectorForm.vue | 75 +- web/src/features/monitor/InspectorTab.vue | 82 +- .../features/monitor/LogConversionTrace.vue | 337 ++++++- web/src/features/monitor/LogDetailDrawer.vue | 4 + web/src/features/monitor/log-filters.ts | 1 + web/src/features/monitor/monitor-route.ts | 27 +- web/src/i18n/locales/en-US/monitor.ts | 39 +- web/src/i18n/locales/ja-JP/monitor.ts | 39 +- web/src/i18n/locales/zh-CN/monitor.ts | 39 +- 77 files changed, 4305 insertions(+), 1386 deletions(-) create mode 100644 internal/parametertrace/trace.go create mode 100644 internal/parametertrace/trace_test.go create mode 100644 internal/storage/migration_0003_request_log_conversion_trace.go create mode 100644 internal/storage/migration_0003_request_log_conversion_trace_test.go diff --git a/internal/channel/channel.go b/internal/channel/channel.go index a3542b53..d4b66dc9 100644 --- a/internal/channel/channel.go +++ b/internal/channel/channel.go @@ -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 { diff --git a/internal/channel/final_provider_contract_test.go b/internal/channel/final_provider_contract_test.go index e0e6dbff..bd165137 100644 --- a/internal/channel/final_provider_contract_test.go +++ b/internal/channel/final_provider_contract_test.go @@ -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") } }) diff --git a/internal/channel/presets.go b/internal/channel/presets.go index 7e5be812..c8d4d84d 100644 --- a/internal/channel/presets.go +++ b/internal/channel/presets.go @@ -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) } } diff --git a/internal/channel/registry_test.go b/internal/channel/registry_test.go index ec8ed561..5b3b3247 100644 --- a/internal/channel/registry_test.go +++ b/internal/channel/registry_test.go @@ -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 } diff --git a/internal/control/discover_executor_test.go b/internal/control/discover_executor_test.go index 19023a95..9e51e85e 100644 --- a/internal/control/discover_executor_test.go +++ b/internal/control/discover_executor_test.go @@ -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) } diff --git a/internal/control/execution_test_helpers_test.go b/internal/control/execution_test_helpers_test.go index 0aac372e..4b021702 100644 --- a/internal/control/execution_test_helpers_test.go +++ b/internal/control/execution_test_helpers_test.go @@ -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, diff --git a/internal/control/group_collection.go b/internal/control/group_collection.go index a031b1df..f45546e1 100644 --- a/internal/control/group_collection.go +++ b/internal/control/group_collection.go @@ -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 { diff --git a/internal/control/request_logs.go b/internal/control/request_logs.go index 8707e875..0bc71200 100644 --- a/internal/control/request_logs.go +++ b/internal/control/request_logs.go @@ -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) } diff --git a/internal/control/request_logs_test.go b/internal/control/request_logs_test.go index ea18eff1..53f1299f 100644 --- a/internal/control/request_logs_test.go +++ b/internal/control/request_logs_test.go @@ -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 = ¶metertrace.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, diff --git a/internal/control/route_inspect.go b/internal/control/route_inspect.go index 4dd7863d..85c0576a 100644 --- a/internal/control/route_inspect.go +++ b/internal/control/route_inspect.go @@ -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) diff --git a/internal/control/route_inspect_test.go b/internal/control/route_inspect_test.go index e3adb1ba..5eb23c40 100644 --- a/internal/control/route_inspect_test.go +++ b/internal/control/route_inspect_test.go @@ -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"` diff --git a/internal/control/validation_test.go b/internal/control/validation_test.go index 306fa123..007b1cbd 100644 --- a/internal/control/validation_test.go +++ b/internal/control/validation_test.go @@ -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, diff --git a/internal/dialect/anthropic.go b/internal/dialect/anthropic.go index 04825a89..2fb30511 100644 --- a/internal/dialect/anthropic.go +++ b/internal/dialect/anthropic.go @@ -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 } diff --git a/internal/dialect/dialect.go b/internal/dialect/dialect.go index d61e277c..13d6e0d5 100644 --- a/internal/dialect/dialect.go +++ b/internal/dialect/dialect.go @@ -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 diff --git a/internal/dialect/gemini.go b/internal/dialect/gemini.go index f0dffc3b..2fcdddd1 100644 --- a/internal/dialect/gemini.go +++ b/internal/dialect/gemini.go @@ -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 } diff --git a/internal/dialect/openai.go b/internal/dialect/openai.go index 69f12298..29e21e37 100644 --- a/internal/dialect/openai.go +++ b/internal/dialect/openai.go @@ -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 } diff --git a/internal/dialect/openai_responses.go b/internal/dialect/openai_responses.go index efd88732..cec4be8d 100644 --- a/internal/dialect/openai_responses.go +++ b/internal/dialect/openai_responses.go @@ -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 } diff --git a/internal/dialect/request_execution.go b/internal/dialect/request_execution.go index e277ed0f..497805d7 100644 --- a/internal/dialect/request_execution.go +++ b/internal/dialect/request_execution.go @@ -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, "_", "") diff --git a/internal/dialect/request_execution_test.go b/internal/dialect/request_execution_test.go index bbc08244..81481c9e 100644 --- a/internal/dialect/request_execution_test.go +++ b/internal/dialect/request_execution_test.go @@ -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) } }) } diff --git a/internal/execution/bifrost/converted.go b/internal/execution/bifrost/converted.go index 1d052247..69bba54b 100644 --- a/internal/execution/bifrost/converted.go +++ b/internal/execution/bifrost/converted.go @@ -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 != "" { diff --git a/internal/execution/bifrost/converted_protocols_test.go b/internal/execution/bifrost/converted_protocols_test.go index aaba613f..07c23392 100644 --- a/internal/execution/bifrost/converted_protocols_test.go +++ b/internal/execution/bifrost/converted_protocols_test.go @@ -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() diff --git a/internal/execution/bifrost/executor.go b/internal/execution/bifrost/executor.go index cf51d767..4650c527 100644 --- a/internal/execution/bifrost/executor.go +++ b/internal/execution/bifrost/executor.go @@ -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: diff --git a/internal/execution/bifrost/executor_test.go b/internal/execution/bifrost/executor_test.go index 51802539..d8f09a45 100644 --- a/internal/execution/bifrost/executor_test.go +++ b/internal/execution/bifrost/executor_test.go @@ -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 { diff --git a/internal/execution/bifrost/mapping.go b/internal/execution/bifrost/mapping.go index 890734f5..c0b49323 100644 --- a/internal/execution/bifrost/mapping.go +++ b/internal/execution/bifrost/mapping.go @@ -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, diff --git a/internal/execution/bifrost/mapping_test.go b/internal/execution/bifrost/mapping_test.go index cdffba32..e9c111c4 100644 --- a/internal/execution/bifrost/mapping_test.go +++ b/internal/execution/bifrost/mapping_test.go @@ -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) + } +} diff --git a/internal/execution/bifrost/responses_passthrough_test.go b/internal/execution/bifrost/responses_passthrough_test.go index 0cba2d89..90be71a9 100644 --- a/internal/execution/bifrost/responses_passthrough_test.go +++ b/internal/execution/bifrost/responses_passthrough_test.go @@ -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) } } diff --git a/internal/execution/bifrost/runtime.go b/internal/execution/bifrost/runtime.go index 4f7a5f24..81e6346f 100644 --- a/internal/execution/bifrost/runtime.go +++ b/internal/execution/bifrost/runtime.go @@ -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{} { diff --git a/internal/execution/bifrost/runtime_manager.go b/internal/execution/bifrost/runtime_manager.go index 42afd5d7..ac74c9a4 100644 --- a/internal/execution/bifrost/runtime_manager.go +++ b/internal/execution/bifrost/runtime_manager.go @@ -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) diff --git a/internal/execution/bifrost/runtime_manager_test.go b/internal/execution/bifrost/runtime_manager_test.go index 9281523a..7efa0077 100644 --- a/internal/execution/bifrost/runtime_manager_test.go +++ b/internal/execution/bifrost/runtime_manager_test.go @@ -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) diff --git a/internal/execution/bifrost/test_runtime_test.go b/internal/execution/bifrost/test_runtime_test.go index dd0a4240..92350379 100644 --- a/internal/execution/bifrost/test_runtime_test.go +++ b/internal/execution/bifrost/test_runtime_test.go @@ -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() } diff --git a/internal/execution/bifrost/wire_reasoning.go b/internal/execution/bifrost/wire_reasoning.go index 31f2d5eb..3f6e5ccb 100644 --- a/internal/execution/bifrost/wire_reasoning.go +++ b/internal/execution/bifrost/wire_reasoning.go @@ -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 = ¶meters + return spec.ClientParameters + } + parameters := parametertrace.ProjectJSON(spec.Body) + spec.ClientParameters = ¶meters + 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: diff --git a/internal/execution/bifrost/wire_reasoning_test.go b/internal/execution/bifrost/wire_reasoning_test.go index c859f7f0..61acd9a5 100644 --- a/internal/execution/bifrost/wire_reasoning_test.go +++ b/internal/execution/bifrost/wire_reasoning_test.go @@ -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) + } } diff --git a/internal/execution/contracts.go b/internal/execution/contracts.go index 6c64fc02..c8125b78 100644 --- a/internal/execution/contracts.go +++ b/internal/execution/contracts.go @@ -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 = ¶meters + } 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 } diff --git a/internal/execution/contracts_test.go b/internal/execution/contracts_test.go index 593a4754..f6788872 100644 --- a/internal/execution/contracts_test.go +++ b/internal/execution/contracts_test.go @@ -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 = ¶metertrace.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 := ¶metertrace.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 = ¶metertrace.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 := ¶metertrace.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{} } diff --git a/internal/execution/validation.go b/internal/execution/validation.go index ce501ed0..c4c7673c 100644 --- a/internal/execution/validation.go +++ b/internal/execution/validation.go @@ -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( diff --git a/internal/gateway/execution_forward.go b/internal/gateway/execution_forward.go index 468888f8..655e2803 100644 --- a/internal/gateway/execution_forward.go +++ b/internal/gateway/execution_forward.go @@ -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, diff --git a/internal/gateway/execution_forward_test.go b/internal/gateway/execution_forward_test.go index e2220b74..a460fd1a 100644 --- a/internal/gateway/execution_forward_test.go +++ b/internal/gateway/execution_forward_test.go @@ -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 -} diff --git a/internal/gateway/forward.go b/internal/gateway/forward.go index 0c618107..6022c258 100644 --- a/internal/gateway/forward.go +++ b/internal/gateway/forward.go @@ -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 { diff --git a/internal/gateway/handler.go b/internal/gateway/handler.go index 18070835..27177df4 100644 --- a/internal/gateway/handler.go +++ b/internal/gateway/handler.go @@ -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) } diff --git a/internal/gateway/handler_test.go b/internal/gateway/handler_test.go index 80fa45bc..3ae54d30 100644 --- a/internal/gateway/handler_test.go +++ b/internal/gateway/handler_test.go @@ -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 +} diff --git a/internal/gateway/reason.go b/internal/gateway/reason.go index 9fd6e798..d0db998f 100644 --- a/internal/gateway/reason.go +++ b/internal/gateway/reason.go @@ -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.", diff --git a/internal/gateway/request_log.go b/internal/gateway/request_log.go index 09c566ca..4be9e978 100644 --- a/internal/gateway/request_log.go +++ b/internal/gateway/request_log.go @@ -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": diff --git a/internal/gateway/request_log_test.go b/internal/gateway/request_log_test.go index 1e7f6c98..f76d4881 100644 --- a/internal/gateway/request_log_test.go +++ b/internal/gateway/request_log_test.go @@ -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 { diff --git a/internal/health/classification.go b/internal/health/classification.go index 4331d91e..30d24110 100644 --- a/internal/health/classification.go +++ b/internal/health/classification.go @@ -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: diff --git a/internal/health/execution_judge.go b/internal/health/execution_judge.go index 597a7a6a..31755858 100644 --- a/internal/health/execution_judge.go +++ b/internal/health/execution_judge.go @@ -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} } diff --git a/internal/health/execution_judge_test.go b/internal/health/execution_judge_test.go index cb80cb55..62f5344a 100644 --- a/internal/health/execution_judge_test.go +++ b/internal/health/execution_judge_test.go @@ -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{ diff --git a/internal/parametertrace/trace.go b/internal/parametertrace/trace.go new file mode 100644 index 00000000..7e15a53b --- /dev/null +++ b/internal/parametertrace/trace.go @@ -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 +} diff --git a/internal/parametertrace/trace_test.go b/internal/parametertrace/trace_test.go new file mode 100644 index 00000000..2085b407 --- /dev/null +++ b/internal/parametertrace/trace_test.go @@ -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") + } +} diff --git a/internal/reasoning/config.go b/internal/reasoning/config.go index 42585011..638e02cb 100644 --- a/internal/reasoning/config.go +++ b/internal/reasoning/config.go @@ -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" -} diff --git a/internal/requestlog/mapper.go b/internal/requestlog/mapper.go index 803fb5db..1722e37a 100644 --- a/internal/requestlog/mapper.go +++ b/internal/requestlog/mapper.go @@ -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 = "" diff --git a/internal/requestlog/mapper_test.go b/internal/requestlog/mapper_test.go index 3feae2a1..b6c14eb0 100644 --- a/internal/requestlog/mapper_test.go +++ b/internal/requestlog/mapper_test.go @@ -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") diff --git a/internal/requestlog/query.go b/internal/requestlog/query.go index e3448d81..e7d14cab 100644 --- a/internal/requestlog/query.go +++ b/internal/requestlog/query.go @@ -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 != "" diff --git a/internal/requestlog/query_test.go b/internal/requestlog/query_test.go index 30e70c01..6886a159 100644 --- a/internal/requestlog/query_test.go +++ b/internal/requestlog/query_test.go @@ -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) } } diff --git a/internal/requestlog/types.go b/internal/requestlog/types.go index 45ce0615..e2960c77 100644 --- a/internal/requestlog/types.go +++ b/internal/requestlog/types.go @@ -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 diff --git a/internal/scheduler/channel_scheduler_test.go b/internal/scheduler/channel_scheduler_test.go index 5bba5256..34fdb65a 100644 --- a/internal/scheduler/channel_scheduler_test.go +++ b/internal/scheduler/channel_scheduler_test.go @@ -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 -} diff --git a/internal/scheduler/inspect.go b/internal/scheduler/inspect.go index 85552dd6..e13e1e1d 100644 --- a/internal/scheduler/inspect.go +++ b/internal/scheduler/inspect.go @@ -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 { diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 8b87424a..aa1012d1 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -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), diff --git a/internal/state/snapshot.go b/internal/state/snapshot.go index 3ecd283a..a901fa49 100644 --- a/internal/state/snapshot.go +++ b/internal/state/snapshot.go @@ -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 } diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index b78e09e6..d07cb0b8 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -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) diff --git a/internal/storage/migration_0001_initial_v2_test.go b/internal/storage/migration_0001_initial_v2_test.go index 44a1552e..1837d278 100644 --- a/internal/storage/migration_0001_initial_v2_test.go +++ b/internal/storage/migration_0001_initial_v2_test.go @@ -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", diff --git a/internal/storage/migration_0003_request_log_conversion_trace.go b/internal/storage/migration_0003_request_log_conversion_trace.go new file mode 100644 index 00000000..e463f823 --- /dev/null +++ b/internal/storage/migration_0003_request_log_conversion_trace.go @@ -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 +} diff --git a/internal/storage/migration_0003_request_log_conversion_trace_test.go b/internal/storage/migration_0003_request_log_conversion_trace_test.go new file mode 100644 index 00000000..fbd75256 --- /dev/null +++ b/internal/storage/migration_0003_request_log_conversion_trace_test.go @@ -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) + } +} diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 72a70197..167371dc 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -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 { diff --git a/internal/storage/models/request.go b/internal/storage/models/request.go index 97f1a661..e87e2b8d 100644 --- a/internal/storage/models/request.go +++ b/internal/storage/models/request.go @@ -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"` } diff --git a/internal/telemetry/requestlog.go b/internal/telemetry/requestlog.go index bbfc92ee..90173fde 100644 --- a/internal/telemetry/requestlog.go +++ b/internal/telemetry/requestlog.go @@ -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 } diff --git a/internal/telemetry/requestlog_test.go b/internal/telemetry/requestlog_test.go index 3093554e..3e2c446c 100644 --- a/internal/telemetry/requestlog_test.go +++ b/internal/telemetry/requestlog_test.go @@ -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", diff --git a/web/src/app/resources/request-logs.ts b/web/src/app/resources/request-logs.ts index fa7533d0..b316d63a 100644 --- a/web/src/app/resources/request-logs.ts +++ b/web/src/app/resources/request-logs.ts @@ -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 { diff --git a/web/src/app/resources/route-inspection.ts b/web/src/app/resources/route-inspection.ts index d3412f05..50312036 100644 --- a/web/src/app/resources/route-inspection.ts +++ b/web/src/app/resources/route-inspection.ts @@ -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), diff --git a/web/src/features/monitor/InspectorForm.vue b/web/src/features/monitor/InspectorForm.vue index a87a2a52..5395c74d 100644 --- a/web/src/features/monitor/InspectorForm.vue +++ b/web/src/features/monitor/InspectorForm.vue @@ -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()) -} -
- {{ t('monitor.inspector.form.features') }} -