feat(route): 支持有损协议转换与参数追踪
This commit is contained in:
77 files changed
+4305
-1386
No files matched your search
+36
-42
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gpt-load/internal/execution"
|
||||
@@ -149,24 +150,7 @@ type ResolvedTarget struct {
|
||||
TargetConfig json.RawMessage `json:"-"`
|
||||
CatalogProviderID string `json:"-"`
|
||||
|
||||
capabilities map[protocol.Protocol]execution.CapabilitySet
|
||||
modes map[protocol.Protocol]map[execution.Operation]RouteMode
|
||||
}
|
||||
|
||||
// Supports reports whether one client protocol can execute the operation with
|
||||
// every required feature through this target.
|
||||
func (t ResolvedTarget) Supports(
|
||||
clientProtocol protocol.Protocol,
|
||||
operation execution.Operation,
|
||||
required execution.FeatureSet,
|
||||
) bool {
|
||||
capabilities, ok := t.capabilities[clientProtocol]
|
||||
return ok && capabilities.Supports(operation, required)
|
||||
}
|
||||
|
||||
// Capabilities returns an independent capability set for one client protocol.
|
||||
func (t ResolvedTarget) Capabilities(clientProtocol protocol.Protocol) execution.CapabilitySet {
|
||||
return t.capabilities[clientProtocol].Clone()
|
||||
modes map[protocol.Protocol]map[execution.Operation]RouteMode
|
||||
}
|
||||
|
||||
// Mode returns how an operation reaches this channel.
|
||||
@@ -182,6 +166,34 @@ func (t ResolvedTarget) Mode(
|
||||
return mode, ok
|
||||
}
|
||||
|
||||
// Operations returns the stable operation set declared for a client protocol.
|
||||
func (t ResolvedTarget) Operations(clientProtocol protocol.Protocol) []execution.Operation {
|
||||
byOperation := t.modes[clientProtocol]
|
||||
operations := make([]execution.Operation, 0, len(byOperation))
|
||||
for operation := range byOperation {
|
||||
operations = append(operations, operation)
|
||||
}
|
||||
sort.Slice(operations, func(i, j int) bool { return operations[i] < operations[j] })
|
||||
return operations
|
||||
}
|
||||
|
||||
// SupportsResponsesLifecycle reports whether stored Responses resources can
|
||||
// be retrieved and managed through this target.
|
||||
func (t ResolvedTarget) SupportsResponsesLifecycle() bool {
|
||||
for _, operation := range []execution.Operation{
|
||||
execution.OperationResponsesRetrieve,
|
||||
execution.OperationResponsesDelete,
|
||||
execution.OperationResponsesCancel,
|
||||
execution.OperationResponsesInputItems,
|
||||
} {
|
||||
mode, ok := t.Mode(protocol.OpenAIResponses, operation)
|
||||
if !ok || mode != RouteNative {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Registry is an immutable code-owned channel directory.
|
||||
type Registry struct {
|
||||
byID map[ID]definition
|
||||
@@ -292,7 +304,6 @@ func (r *Registry) Resolve(id ID, raw json.RawMessage) (ResolvedTarget, error) {
|
||||
ProviderKind: definition.providerKind,
|
||||
TargetConfig: append(json.RawMessage(nil), targetConfig...),
|
||||
CatalogProviderID: definition.catalogProviderID,
|
||||
capabilities: cloneCapabilityMatrix(definition.capabilities),
|
||||
modes: cloneRouteModes(definition.modes),
|
||||
}, nil
|
||||
}
|
||||
@@ -405,7 +416,6 @@ type definition struct {
|
||||
providerKind ProviderKind
|
||||
compatible bool
|
||||
fixedTargetConfig json.RawMessage
|
||||
capabilities map[protocol.Protocol]execution.CapabilitySet
|
||||
modes map[protocol.Protocol]map[execution.Operation]RouteMode
|
||||
}
|
||||
|
||||
@@ -432,7 +442,6 @@ func newRegistry(definitions []definition) (*Registry, error) {
|
||||
}
|
||||
definition.descriptor = cloneDescriptor(definition.descriptor)
|
||||
definition.searchTerms = append([]string(nil), definition.searchTerms...)
|
||||
definition.capabilities = cloneCapabilityMatrix(definition.capabilities)
|
||||
definition.modes = cloneRouteModes(definition.modes)
|
||||
definition.fixedTargetConfig = append(json.RawMessage(nil), definition.fixedTargetConfig...)
|
||||
registry.byID[id] = definition
|
||||
@@ -468,28 +477,21 @@ func validateDefinition(definition definition) error {
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(definition.capabilities) == 0 {
|
||||
if len(definition.modes) == 0 {
|
||||
return fmt.Errorf("channel %q has no client protocols", id)
|
||||
}
|
||||
for clientProtocol, capabilities := range definition.capabilities {
|
||||
for clientProtocol, modes := range definition.modes {
|
||||
if !clientProtocol.Valid() {
|
||||
return fmt.Errorf("channel %q has invalid client protocol %q", id, clientProtocol)
|
||||
}
|
||||
if err := capabilities.Validate(); err != nil {
|
||||
return fmt.Errorf("channel %q capabilities: %w", id, err)
|
||||
if len(modes) == 0 {
|
||||
return fmt.Errorf("channel %q has no operations for %q", id, clientProtocol)
|
||||
}
|
||||
modes := definition.modes[clientProtocol]
|
||||
for _, operation := range capabilities.Operations() {
|
||||
mode, exists := modes[operation]
|
||||
if !exists || !mode.Valid() {
|
||||
for operation, mode := range modes {
|
||||
if !operation.Valid() || !mode.Valid() {
|
||||
return fmt.Errorf("channel %q has no valid route mode for %q/%q", id, clientProtocol, operation)
|
||||
}
|
||||
}
|
||||
for operation := range modes {
|
||||
if !capabilities.Has(operation) {
|
||||
return fmt.Errorf("channel %q has route mode for unsupported %q/%q", id, clientProtocol, operation)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -509,14 +511,6 @@ func cloneDescriptor(source Descriptor) Descriptor {
|
||||
return source
|
||||
}
|
||||
|
||||
func cloneCapabilityMatrix(source map[protocol.Protocol]execution.CapabilitySet) map[protocol.Protocol]execution.CapabilitySet {
|
||||
clone := make(map[protocol.Protocol]execution.CapabilitySet, len(source))
|
||||
for clientProtocol, capabilities := range source {
|
||||
clone[clientProtocol] = capabilities.Clone()
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneRouteModes(source map[protocol.Protocol]map[execution.Operation]RouteMode) map[protocol.Protocol]map[execution.Operation]RouteMode {
|
||||
clone := make(map[protocol.Protocol]map[execution.Operation]RouteMode, len(source))
|
||||
for clientProtocol, modes := range source {
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestNativeProviderChannelsResolveWithoutCompatibleFallback(t *testing.T) {
|
||||
if mode, ok := target.Mode(protocol.OpenAIResponses, execution.OperationResponsesCreate); !ok || mode != test.responsesMode {
|
||||
t.Fatalf("Responses mode = %q, %t, want %q", mode, ok, test.responsesMode)
|
||||
}
|
||||
if target.Supports(protocol.OpenAIResponses, execution.OperationResponsesRetrieve, execution.FeatureSet{}) {
|
||||
if _, ok := target.Mode(protocol.OpenAIResponses, execution.OperationResponsesRetrieve); ok {
|
||||
t.Fatal("target unexpectedly supports Responses lifecycle")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ func TestCloudTargetsCarryOnlyNeutralPresetMetadata(t *testing.T) {
|
||||
if mode, ok := target.Mode(protocol.OpenAICompletions, execution.OperationChatCompletion); !ok || mode != RouteConverted {
|
||||
t.Fatalf("OpenAI chat mode = %q, %t", mode, ok)
|
||||
}
|
||||
if target.Supports(protocol.OpenAIResponses, execution.OperationResponsesRetrieve, execution.FeatureSet{}) {
|
||||
if _, ok := target.Mode(protocol.OpenAIResponses, execution.OperationResponsesRetrieve); ok {
|
||||
t.Fatal("cloud converted target unexpectedly exposes native Responses lifecycle")
|
||||
}
|
||||
})
|
||||
@@ -329,7 +329,7 @@ func TestRegistryStrictlyValidatesCredentialsWithoutLeakingSecrets(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedTargetsUseExactCatalogMappingAndProtocolSpecificCapabilities(t *testing.T) {
|
||||
func TestResolvedTargetsUseExactCatalogMappingAndProtocolSpecificRouteModes(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
openAI, err := registry.Resolve(OpenAI, nil)
|
||||
if err != nil {
|
||||
@@ -339,24 +339,18 @@ func TestResolvedTargetsUseExactCatalogMappingAndProtocolSpecificCapabilities(t
|
||||
openAI.CatalogProviderID != "openai" || string(openAI.TargetConfig) != `{}` {
|
||||
t.Fatalf("openai target = %#v", openAI)
|
||||
}
|
||||
if !openAI.Supports(protocol.OpenAIResponses, execution.OperationResponsesRetrieve, featureSet(t, execution.FeatureNativeResourceSemantics)) {
|
||||
t.Fatal("openai target should support native Responses retrieval")
|
||||
if mode, ok := openAI.Mode(protocol.OpenAIResponses, execution.OperationResponsesRetrieve); !ok || mode != RouteNative {
|
||||
t.Fatalf("openai Responses retrieval mode = %q, %t", mode, ok)
|
||||
}
|
||||
for _, operation := range []execution.Operation{
|
||||
execution.OperationResponsesCompact,
|
||||
execution.OperationResponsesInputTokens,
|
||||
execution.OperationResponsesPassthrough,
|
||||
} {
|
||||
if !openAI.Supports(protocol.OpenAIResponses, operation, execution.FeatureSet{}) {
|
||||
t.Fatalf("openai target should support native %q", operation)
|
||||
}
|
||||
if mode, ok := openAI.Mode(protocol.OpenAIResponses, operation); !ok || mode != RouteNative {
|
||||
t.Fatalf("openai %q mode = %q, %t", operation, mode, ok)
|
||||
}
|
||||
}
|
||||
if !openAI.Supports(protocol.OpenAICompletions, execution.OperationChatCompletion, featureSet(t, execution.FeatureReasoning)) {
|
||||
t.Fatal("openai native completions should support reasoning")
|
||||
}
|
||||
if mode, ok := openAI.Mode(protocol.OpenAICompletions, execution.OperationChatCompletion); !ok || mode != RouteNative {
|
||||
t.Fatalf("openai completions mode = %q, %t", mode, ok)
|
||||
}
|
||||
@@ -368,20 +362,14 @@ func TestResolvedTargetsUseExactCatalogMappingAndProtocolSpecificCapabilities(t
|
||||
if anthropic.ProviderKind != ProviderAnthropic || anthropic.CatalogProviderID != "anthropic" {
|
||||
t.Fatalf("anthropic catalog provider = %q", anthropic.CatalogProviderID)
|
||||
}
|
||||
if anthropic.Supports(protocol.OpenAIResponses, execution.OperationResponsesRetrieve, execution.FeatureSet{}) {
|
||||
if _, ok := anthropic.Mode(protocol.OpenAIResponses, execution.OperationResponsesRetrieve); ok {
|
||||
t.Fatal("anthropic target unexpectedly supports native Responses retrieval")
|
||||
}
|
||||
if anthropic.Supports(protocol.OpenAICompletions, execution.OperationChatCompletion, featureSet(t, execution.FeatureReasoning)) {
|
||||
t.Fatal("converted OpenAI-to-Anthropic route unexpectedly advertises reasoning")
|
||||
}
|
||||
if !anthropic.Supports(protocol.OpenAICompletions, execution.OperationChatCompletion, featureSet(t, execution.FeatureStreaming)) {
|
||||
t.Fatal("converted OpenAI-to-Anthropic route should support streaming")
|
||||
}
|
||||
if mode, ok := anthropic.Mode(protocol.OpenAICompletions, execution.OperationChatCompletion); !ok || mode != RouteConverted {
|
||||
t.Fatalf("converted OpenAI-to-Anthropic mode = %q, %t", mode, ok)
|
||||
}
|
||||
if !anthropic.Supports(protocol.Anthropic, execution.OperationChatCompletion, featureSet(t, execution.FeatureReasoning)) {
|
||||
t.Fatal("native Anthropic route should support reasoning")
|
||||
if mode, ok := anthropic.Mode(protocol.Anthropic, execution.OperationChatCompletion); !ok || mode != RouteNative {
|
||||
t.Fatalf("native Anthropic mode = %q, %t", mode, ok)
|
||||
}
|
||||
|
||||
gemini, err := registry.Resolve(Gemini, nil)
|
||||
@@ -395,14 +383,14 @@ func TestResolvedTargetsUseExactCatalogMappingAndProtocolSpecificCapabilities(t
|
||||
if mode, ok := openAICompatible.Mode(protocol.OpenAIResponses, execution.OperationResponsesCreate); !ok || mode != RouteConverted {
|
||||
t.Fatalf("OpenAI-compatible Responses create mode = %q, %t", mode, ok)
|
||||
}
|
||||
if openAICompatible.Supports(protocol.OpenAIResponses, execution.OperationResponsesRetrieve, featureSet(t, execution.FeatureNativeResourceSemantics)) {
|
||||
if _, ok := openAICompatible.Mode(protocol.OpenAIResponses, execution.OperationResponsesRetrieve); ok {
|
||||
t.Fatal("generic OpenAI-compatible target must not advertise Responses lifecycle")
|
||||
}
|
||||
for _, operation := range []execution.Operation{
|
||||
execution.OperationResponsesCompact,
|
||||
execution.OperationResponsesInputTokens,
|
||||
} {
|
||||
if openAICompatible.Supports(protocol.OpenAIResponses, operation, execution.FeatureSet{}) {
|
||||
if _, ok := openAICompatible.Mode(protocol.OpenAIResponses, operation); ok {
|
||||
t.Fatalf("generic OpenAI-compatible target unexpectedly supports %q", operation)
|
||||
}
|
||||
}
|
||||
@@ -413,17 +401,13 @@ func TestResolvedTargetsUseExactCatalogMappingAndProtocolSpecificCapabilities(t
|
||||
if mode, ok := openAICompatibleNonV1.Mode(protocol.OpenAICompletions, execution.OperationChatCompletion); !ok || mode != RouteNative {
|
||||
t.Fatalf("non-v1 OpenAI-compatible completions mode = %q, %t", mode, ok)
|
||||
}
|
||||
if !openAICompatibleNonV1.Supports(protocol.OpenAICompletions, execution.OperationChatCompletion, featureSet(t, execution.FeatureTools)) {
|
||||
t.Fatal("OpenAI-compatible native Chat route should support tools")
|
||||
operations := openAI.Operations(protocol.OpenAIResponses)
|
||||
if !containsOperation(operations, execution.OperationResponsesRetrieve) {
|
||||
t.Fatal("Operations() omitted native operation")
|
||||
}
|
||||
|
||||
capabilities := openAI.Capabilities(protocol.OpenAIResponses)
|
||||
if !capabilities.Has(execution.OperationResponsesRetrieve) {
|
||||
t.Fatal("Capabilities() omitted native operation")
|
||||
}
|
||||
capabilities = execution.CapabilitySet{}
|
||||
if !openAI.Supports(protocol.OpenAIResponses, execution.OperationResponsesRetrieve, execution.FeatureSet{}) {
|
||||
t.Fatal("mutating returned capability value changed target")
|
||||
operations[0] = execution.Operation("mutated")
|
||||
if !containsOperation(openAI.Operations(protocol.OpenAIResponses), execution.OperationResponsesRetrieve) {
|
||||
t.Fatal("mutating returned operations changed target")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,7 +499,7 @@ func TestOpenRouterUsesNativeChatAndResponsesWithoutLifecycle(t *testing.T) {
|
||||
if mode, ok := target.Mode(protocol.OpenAIResponses, execution.OperationResponsesCreate); !ok || mode != RouteNative {
|
||||
t.Fatalf("OpenRouter Responses create mode = %q, %t", mode, ok)
|
||||
}
|
||||
if target.Supports(protocol.OpenAIResponses, execution.OperationResponsesRetrieve, execution.FeatureSet{}) {
|
||||
if _, ok := target.Mode(protocol.OpenAIResponses, execution.OperationResponsesRetrieve); ok {
|
||||
t.Fatal("OpenRouter unexpectedly advertises Responses lifecycle")
|
||||
}
|
||||
}
|
||||
@@ -528,11 +512,11 @@ func descriptorIDs(descriptors []Descriptor) []ID {
|
||||
return ids
|
||||
}
|
||||
|
||||
func featureSet(t *testing.T, features ...execution.Feature) execution.FeatureSet {
|
||||
t.Helper()
|
||||
set, err := execution.NewFeatureSet(features...)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFeatureSet() error = %v", err)
|
||||
func containsOperation(operations []execution.Operation, want execution.Operation) bool {
|
||||
for _, operation := range operations {
|
||||
if operation == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return set
|
||||
return false
|
||||
}
|
||||
@@ -50,10 +50,6 @@ func newRecordingDiscoveryExecutor(values ...*recordingDiscoveryExecutorTarget)
|
||||
return &recordingDiscoveryExecutor{byProtocol: byProtocol}
|
||||
}
|
||||
|
||||
func (*recordingDiscoveryExecutor) Capabilities() execution.CapabilitySet {
|
||||
return execution.CapabilitySet{}
|
||||
}
|
||||
|
||||
func (executor *recordingDiscoveryExecutor) Execute(
|
||||
ctx context.Context,
|
||||
spec execution.AttemptSpec,
|
||||
@@ -340,10 +336,6 @@ type scriptedDiscoveryExecutor struct {
|
||||
execute func(context.Context, execution.AttemptSpec) execution.AttemptResult
|
||||
}
|
||||
|
||||
func (scriptedDiscoveryExecutor) Capabilities() execution.CapabilitySet {
|
||||
return execution.CapabilitySet{}
|
||||
}
|
||||
|
||||
func (executor scriptedDiscoveryExecutor) Execute(ctx context.Context, spec execution.AttemptSpec) execution.AttemptResult {
|
||||
return executor.execute(ctx, spec)
|
||||
}
|
||||
|
||||
@@ -19,10 +19,6 @@ type controlHTTPExecutor struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (controlHTTPExecutor) Capabilities() execution.CapabilitySet {
|
||||
return execution.CapabilitySet{}
|
||||
}
|
||||
|
||||
func (executor controlHTTPExecutor) Execute(
|
||||
ctx context.Context,
|
||||
spec execution.AttemptSpec,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "_", "")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"gpt-load/internal/channel"
|
||||
"gpt-load/internal/execution"
|
||||
"gpt-load/internal/parametertrace"
|
||||
"gpt-load/internal/protocol"
|
||||
"gpt-load/internal/reasoning"
|
||||
)
|
||||
@@ -29,6 +30,25 @@ type responsesStreamSDKResult struct {
|
||||
err *schemas.BifrostError
|
||||
}
|
||||
|
||||
type targetConversionError struct {
|
||||
summary string
|
||||
}
|
||||
|
||||
func (err *targetConversionError) Error() string {
|
||||
if err == nil {
|
||||
return "target conversion is not supported"
|
||||
}
|
||||
return err.summary
|
||||
}
|
||||
|
||||
func (*targetConversionError) ConversionCode() string {
|
||||
return execution.ErrorCodeTargetConversionNotSupported
|
||||
}
|
||||
|
||||
func unsupportedTargetConversion(summary string) error {
|
||||
return &targetConversionError{summary: summary}
|
||||
}
|
||||
|
||||
func buildConvertedResponsesRequest(spec execution.AttemptSpec, provider schemas.ModelProvider) (*schemas.BifrostResponsesRequest, error) {
|
||||
conversionContext := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
|
||||
defer conversionContext.Cancel()
|
||||
@@ -37,7 +57,7 @@ func buildConvertedResponsesRequest(spec execution.AttemptSpec, provider schemas
|
||||
switch spec.ClientProtocol {
|
||||
case protocol.OpenAIResponses:
|
||||
if spec.Operation != execution.OperationResponsesCreate {
|
||||
return nil, fmt.Errorf("converted Responses operation is not supported")
|
||||
return nil, unsupportedTargetConversion("converted Responses operation is not supported")
|
||||
}
|
||||
var wire openai.OpenAIResponsesRequest
|
||||
if err := json.Unmarshal(spec.Body, &wire); err != nil {
|
||||
@@ -46,7 +66,7 @@ func buildConvertedResponsesRequest(spec execution.AttemptSpec, provider schemas
|
||||
request = wire.ToBifrostResponsesRequest(conversionContext)
|
||||
case protocol.Anthropic:
|
||||
if spec.Operation != execution.OperationChatCompletion {
|
||||
return nil, fmt.Errorf("converted Anthropic operation is not supported")
|
||||
return nil, unsupportedTargetConversion("converted Anthropic operation is not supported")
|
||||
}
|
||||
var wire anthropic.AnthropicMessageRequest
|
||||
if err := json.Unmarshal(spec.Body, &wire); err != nil {
|
||||
@@ -55,7 +75,7 @@ func buildConvertedResponsesRequest(spec execution.AttemptSpec, provider schemas
|
||||
request = wire.ToBifrostResponsesRequest(conversionContext)
|
||||
case protocol.Gemini:
|
||||
if spec.Operation != execution.OperationChatCompletion {
|
||||
return nil, fmt.Errorf("converted Gemini operation is not supported")
|
||||
return nil, unsupportedTargetConversion("converted Gemini operation is not supported")
|
||||
}
|
||||
var wire gemini.GeminiGenerationRequest
|
||||
if err := json.Unmarshal(spec.Body, &wire); err != nil {
|
||||
@@ -64,7 +84,7 @@ func buildConvertedResponsesRequest(spec execution.AttemptSpec, provider schemas
|
||||
wire.Model = spec.ClientModel
|
||||
request = wire.ToBifrostResponsesRequest(conversionContext)
|
||||
default:
|
||||
return nil, fmt.Errorf("converted protocol is not supported")
|
||||
return nil, unsupportedTargetConversion("converted protocol is not supported")
|
||||
}
|
||||
if request == nil || len(request.Input) == 0 {
|
||||
return nil, fmt.Errorf("converted request input is required")
|
||||
@@ -96,9 +116,12 @@ func (r *Runtime) executeConvertedResponses(
|
||||
spec execution.AttemptSpec,
|
||||
prepared preparedAttempt,
|
||||
) (result execution.AttemptResult) {
|
||||
clientParameters := conversionClientParameters(&spec)
|
||||
var appliedReasoning *reasoning.Config
|
||||
var conversionTrace *parametertrace.Trace
|
||||
defer func() {
|
||||
result.AppliedReasoning = appliedReasoning
|
||||
result.ConversionTrace = finalConversionTrace(clientParameters, conversionTrace)
|
||||
}()
|
||||
|
||||
requestContext, requestCancel := boundedRequestContext(parent, spec.Timeouts.Request)
|
||||
@@ -107,7 +130,7 @@ func (r *Runtime) executeConvertedResponses(
|
||||
defer callCancel()
|
||||
|
||||
bifrostContext := r.newSDKContext(callContext, spec, prepared.directKey)
|
||||
enableAppliedReasoningWireCapture(bifrostContext, prepared)
|
||||
enableConvertedWireCapture(bifrostContext, prepared, clientParameters)
|
||||
setTypedRequestURL(bifrostContext, prepared.typedURL)
|
||||
outcomeChannel := make(chan responsesUnarySDKResult, 1)
|
||||
go func() {
|
||||
@@ -126,11 +149,11 @@ func (r *Runtime) executeConvertedResponses(
|
||||
}
|
||||
|
||||
if outcome.err != nil {
|
||||
appliedReasoning = takeAppliedReasoning(&outcome.err.ExtraFields.RawRequest)
|
||||
return unaryErrorResult(outcome.err, bifrostContext, prepared.secrets)
|
||||
captureWireObservation(&outcome.err.ExtraFields.RawRequest, clientParameters, &appliedReasoning, &conversionTrace)
|
||||
return convertedUnaryErrorResult(outcome.err, bifrostContext, prepared.secrets)
|
||||
}
|
||||
if outcome.response != nil {
|
||||
appliedReasoning = takeAppliedReasoning(&outcome.response.ExtraFields.RawRequest)
|
||||
captureWireObservation(&outcome.response.ExtraFields.RawRequest, clientParameters, &appliedReasoning, &conversionTrace)
|
||||
}
|
||||
if failure := largeUnaryResponseFailure(bifrostContext); failure != nil {
|
||||
return *failure
|
||||
@@ -354,9 +377,12 @@ func (r *Runtime) executeConvertedResponsesStream(
|
||||
prepared preparedAttempt,
|
||||
sink execution.StreamSink,
|
||||
) (result execution.StreamResult) {
|
||||
clientParameters := conversionClientParameters(&spec)
|
||||
var appliedReasoning *reasoning.Config
|
||||
var conversionTrace *parametertrace.Trace
|
||||
defer func() {
|
||||
result.AppliedReasoning = appliedReasoning
|
||||
result.ConversionTrace = finalConversionTrace(clientParameters, conversionTrace)
|
||||
}()
|
||||
|
||||
requestContext, requestCancel := boundedRequestContext(parent, spec.Timeouts.Request)
|
||||
@@ -367,7 +393,7 @@ func (r *Runtime) executeConvertedResponsesStream(
|
||||
defer preResponse.stop()
|
||||
|
||||
bifrostContext := r.newStreamingSDKContext(callContext, spec, prepared.directKey)
|
||||
enableAppliedReasoningWireCapture(bifrostContext, prepared)
|
||||
enableConvertedWireCapture(bifrostContext, prepared, clientParameters)
|
||||
setTypedRequestURL(bifrostContext, prepared.typedURL)
|
||||
outcomeChannel := make(chan responsesStreamSDKResult, 1)
|
||||
go func() {
|
||||
@@ -386,8 +412,8 @@ func (r *Runtime) executeConvertedResponsesStream(
|
||||
}
|
||||
preResponse.stop()
|
||||
if outcome.err != nil {
|
||||
appliedReasoning = takeAppliedReasoning(&outcome.err.ExtraFields.RawRequest)
|
||||
return streamErrorResult(outcome.err, bifrostContext, prepared.secrets, false, 0, nil, "", nil)
|
||||
captureWireObservation(&outcome.err.ExtraFields.RawRequest, clientParameters, &appliedReasoning, &conversionTrace)
|
||||
return convertedStreamErrorResult(outcome.err, bifrostContext, prepared.secrets, false, 0, nil, "", nil)
|
||||
}
|
||||
if outcome.stream == nil {
|
||||
return attemptedStreamFailure(execution.ErrorKindInternal, "execution runtime returned no stream")
|
||||
@@ -440,18 +466,14 @@ func (r *Runtime) executeConvertedResponsesStream(
|
||||
if chunk == nil || chunk.BifrostResponsesStreamResponse == nil {
|
||||
callCancel()
|
||||
if chunk != nil && chunk.BifrostError != nil {
|
||||
if captured := takeAppliedReasoning(&chunk.BifrostError.ExtraFields.RawRequest); captured != nil {
|
||||
appliedReasoning = captured
|
||||
}
|
||||
captureWireObservation(&chunk.BifrostError.ExtraFields.RawRequest, clientParameters, &appliedReasoning, &conversionTrace)
|
||||
return streamErrorResult(chunk.BifrostError, bifrostContext, prepared.secrets, true, http.StatusOK, headers, model, usageEvidence)
|
||||
}
|
||||
return streamErrorResult(nil, bifrostContext, prepared.secrets, true, http.StatusOK, headers, model, usageEvidence)
|
||||
}
|
||||
|
||||
response := chunk.BifrostResponsesStreamResponse
|
||||
if captured := takeAppliedReasoning(&response.ExtraFields.RawRequest); captured != nil {
|
||||
appliedReasoning = captured
|
||||
}
|
||||
captureWireObservation(&response.ExtraFields.RawRequest, clientParameters, &appliedReasoning, &conversionTrace)
|
||||
var chunkUsage *execution.UsageEvidence
|
||||
if response.Response != nil {
|
||||
if response.Response.Model != "" {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"gpt-load/internal/channel"
|
||||
"gpt-load/internal/execution"
|
||||
"gpt-load/internal/parametertrace"
|
||||
"gpt-load/internal/protocol"
|
||||
"gpt-load/internal/reasoning"
|
||||
)
|
||||
@@ -253,6 +254,7 @@ func TestConvertedAttemptReportsAppliedReasoning(t *testing.T) {
|
||||
result.AppliedReasoning.Effort == "high" {
|
||||
t.Fatalf("result retained canonical reasoning instead of wire values: %#v", result.AppliedReasoning)
|
||||
}
|
||||
assertMappedReasoningTrace(t, result.ConversionTrace)
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal result: %v", err)
|
||||
@@ -300,6 +302,7 @@ func TestConvertedAttemptReportsWireReasoningOnProviderError(t *testing.T) {
|
||||
if !reflect.DeepEqual(result.AppliedReasoning, want) {
|
||||
t.Fatalf("result applied reasoning = %#v, wire reasoning = %#v", result.AppliedReasoning, want)
|
||||
}
|
||||
assertMappedReasoningTrace(t, result.ConversionTrace)
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal result: %v", err)
|
||||
@@ -634,6 +637,20 @@ func inspectAnthropicWireReasoning(t *testing.T, body []byte) *reasoning.Config
|
||||
return result
|
||||
}
|
||||
|
||||
func assertMappedReasoningTrace(t *testing.T, trace *parametertrace.Trace) {
|
||||
t.Helper()
|
||||
if trace == nil || (trace.State != parametertrace.CaptureCaptured && trace.State != parametertrace.CapturePartial) {
|
||||
t.Fatalf("conversion trace = %#v", trace)
|
||||
}
|
||||
for _, change := range trace.Changes {
|
||||
if change.Disposition == parametertrace.DispositionMapped &&
|
||||
(strings.HasPrefix(change.SourcePath, "reasoning.") || strings.HasPrefix(change.TargetPath, "reasoning.")) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("conversion trace has no reasoning mapping: %#v", trace)
|
||||
}
|
||||
|
||||
func TestConvertedOpenAIChatStreamUsesSelectedProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -428,6 +428,19 @@ func unaryErrorResult(bifrostError *schemas.BifrostError, bifrostContext *schema
|
||||
return result
|
||||
}
|
||||
|
||||
func convertedUnaryErrorResult(
|
||||
bifrostError *schemas.BifrostError,
|
||||
bifrostContext *schemas.BifrostContext,
|
||||
secrets []string,
|
||||
) execution.AttemptResult {
|
||||
result := unaryErrorResult(bifrostError, bifrostContext, secrets)
|
||||
if convertedSDKPreflightFailure(bifrostError, result.ResponseStarted) {
|
||||
result.DispatchState = execution.DispatchNotSent
|
||||
result.Error = convertedSerializationEvidence()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func streamErrorResult(
|
||||
bifrostError *schemas.BifrostError,
|
||||
bifrostContext *schemas.BifrostContext,
|
||||
@@ -457,6 +470,46 @@ func streamErrorResult(
|
||||
return result
|
||||
}
|
||||
|
||||
func convertedStreamErrorResult(
|
||||
bifrostError *schemas.BifrostError,
|
||||
bifrostContext *schemas.BifrostContext,
|
||||
secrets []string,
|
||||
alreadyStarted bool,
|
||||
initialStatus int,
|
||||
headers http.Header,
|
||||
model string,
|
||||
usageEvidence *execution.UsageEvidence,
|
||||
) execution.StreamResult {
|
||||
result := streamErrorResult(
|
||||
bifrostError,
|
||||
bifrostContext,
|
||||
secrets,
|
||||
alreadyStarted,
|
||||
initialStatus,
|
||||
headers,
|
||||
model,
|
||||
usageEvidence,
|
||||
)
|
||||
if !alreadyStarted && convertedSDKPreflightFailure(bifrostError, result.ResponseStarted) {
|
||||
result.DispatchState = execution.DispatchNotSent
|
||||
result.Error = convertedSerializationEvidence()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func convertedSDKPreflightFailure(bifrostError *schemas.BifrostError, responseStarted bool) bool {
|
||||
return !responseStarted && bifrostError != nil && bifrostError.IsBifrostError &&
|
||||
strings.EqualFold(strings.TrimSpace(errorType(bifrostError)), "invalid_request_error")
|
||||
}
|
||||
|
||||
func convertedSerializationEvidence() *execution.ErrorEvidence {
|
||||
return &execution.ErrorEvidence{
|
||||
Kind: execution.ErrorKindConversionUnsupported,
|
||||
Code: execution.ErrorCodeTargetSerializationFailed,
|
||||
Summary: "target request serialization failed",
|
||||
}
|
||||
}
|
||||
|
||||
func sdkErrorDispatchState(bifrostError *schemas.BifrostError, responseStarted bool) execution.DispatchState {
|
||||
if responseStarted || bifrostError == nil || bifrostError.Error == nil || bifrostError.Error.Error == nil {
|
||||
return execution.DispatchMaybeSent
|
||||
@@ -615,6 +668,8 @@ func errorSummary(kind execution.ErrorKind, status int) string {
|
||||
return "upstream provider failed"
|
||||
case execution.ErrorKindInvalidRequest:
|
||||
return "upstream rejected the request"
|
||||
case execution.ErrorKindConversionUnsupported:
|
||||
return "target protocol conversion is not supported"
|
||||
default:
|
||||
return "execution adapter failed"
|
||||
}
|
||||
@@ -742,6 +797,17 @@ func notSentUnaryFailure(kind execution.ErrorKind, summary string) execution.Att
|
||||
}
|
||||
}
|
||||
|
||||
func notSentConversionFailure(code, summary string) execution.AttemptResult {
|
||||
return execution.AttemptResult{
|
||||
DispatchState: execution.DispatchNotSent,
|
||||
Error: &execution.ErrorEvidence{
|
||||
Kind: execution.ErrorKindConversionUnsupported,
|
||||
Code: code,
|
||||
Summary: summary,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func attemptedUnaryFailure(kind execution.ErrorKind, summary string) execution.AttemptResult {
|
||||
return execution.AttemptResult{
|
||||
DispatchState: execution.DispatchMaybeSent,
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/maximhq/bifrost/core/schemas"
|
||||
|
||||
"gpt-load/internal/execution"
|
||||
"gpt-load/internal/protocol"
|
||||
)
|
||||
|
||||
func TestSDKStreamIdleTimeoutIsClassifiedAsTimeout(t *testing.T) {
|
||||
@@ -32,6 +33,10 @@ func TestSDKStreamIdleTimeoutIsClassifiedAsTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type conversionCodedError interface {
|
||||
ConversionCode() string
|
||||
}
|
||||
|
||||
func TestPassthroughHTTPErrorProducesNeutralFailureHints(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -170,3 +175,55 @@ func TestSDKTransportErrorAfterStreamStartIsAlwaysMaybeSent(t *testing.T) {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertedSDKPreflightFailureIsAGroupScopedConversionFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
errorType := "invalid_request_error"
|
||||
bifrostError := &schemas.BifrostError{
|
||||
IsBifrostError: true,
|
||||
Error: &schemas.ErrorField{
|
||||
Type: &errorType,
|
||||
Message: "cannot marshal target request",
|
||||
},
|
||||
}
|
||||
context := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
|
||||
unary := convertedUnaryErrorResult(bifrostError, context, nil)
|
||||
if unary.DispatchState != execution.DispatchNotSent || unary.ResponseStarted || unary.Error == nil ||
|
||||
unary.Error.Kind != execution.ErrorKindConversionUnsupported ||
|
||||
unary.Error.Code != execution.ErrorCodeTargetSerializationFailed {
|
||||
t.Fatalf("converted unary result = %+v", unary)
|
||||
}
|
||||
|
||||
stream := convertedStreamErrorResult(bifrostError, context, nil, false, 0, nil, "", nil)
|
||||
if stream.DispatchState != execution.DispatchNotSent || stream.ResponseStarted || stream.Error == nil ||
|
||||
stream.Error.Kind != execution.ErrorKindConversionUnsupported ||
|
||||
stream.Error.Code != execution.ErrorCodeTargetSerializationFailed {
|
||||
t.Fatalf("converted stream result = %+v", stream)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConvertedRequestDistinguishesMalformedInputFromUnsupportedTargetConversion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, unsupportedErr := buildConvertedResponsesRequest(execution.AttemptSpec{
|
||||
ClientProtocol: protocol.Anthropic,
|
||||
Operation: execution.OperationResponsesCreate,
|
||||
Body: []byte(`{"model":"claude","messages":[]}`),
|
||||
}, schemas.OpenAI)
|
||||
var classified conversionCodedError
|
||||
if !errors.As(unsupportedErr, &classified) ||
|
||||
classified.ConversionCode() != execution.ErrorCodeTargetConversionNotSupported {
|
||||
t.Fatalf("unsupported error = %T %v", unsupportedErr, unsupportedErr)
|
||||
}
|
||||
|
||||
_, malformedErr := buildConvertedResponsesRequest(execution.AttemptSpec{
|
||||
ClientProtocol: protocol.Anthropic,
|
||||
Operation: execution.OperationChatCompletion,
|
||||
Body: []byte(`{"model":`),
|
||||
}, schemas.OpenAI)
|
||||
classified = nil
|
||||
if malformedErr == nil || errors.As(malformedErr, &classified) {
|
||||
t.Fatalf("malformed error = %T %v, want unclassified client error", malformedErr, malformedErr)
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,9 @@ func TestResponsesPassthroughFailsClosedForNonOpenAIProvider(t *testing.T) {
|
||||
spec.ClientModel = ""
|
||||
spec.UpstreamModel = ""
|
||||
result := runtime.Execute(context.Background(), spec)
|
||||
if result.DispatchState != execution.DispatchNotSent || result.ResponseStarted || result.Error == nil || result.Error.Kind != execution.ErrorKindInvalidRequest {
|
||||
if result.DispatchState != execution.DispatchNotSent || result.ResponseStarted || result.Error == nil ||
|
||||
result.Error.Kind != execution.ErrorKindConversionUnsupported ||
|
||||
result.Error.Code != execution.ErrorCodeTargetConversionNotSupported {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -129,7 +131,9 @@ func TestGenericOpenAICompatibleDoesNotAdvertiseUnknownResponsesPassthrough(t *t
|
||||
spec.TargetKind = string(channel.ProviderOpenAICompatible)
|
||||
spec.RouteMode = execution.RouteNative
|
||||
result := newProtocolTestRuntime(t, testRuntimeOptions{allowPrivateNetwork: true}).Execute(context.Background(), spec)
|
||||
if calls.Load() != 0 || result.DispatchState != execution.DispatchNotSent || result.Error == nil || result.Error.Kind != execution.ErrorKindInvalidRequest {
|
||||
if calls.Load() != 0 || result.DispatchState != execution.DispatchNotSent || result.Error == nil ||
|
||||
result.Error.Kind != execution.ErrorKindConversionUnsupported ||
|
||||
result.Error.Code != execution.ErrorCodeTargetConversionNotSupported {
|
||||
t.Fatalf("calls/result = %d/%+v", calls.Load(), result)
|
||||
}
|
||||
}
|
||||
@@ -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{} {
|
||||
|
||||
@@ -145,10 +145,9 @@ type runtimeManager struct {
|
||||
// RuntimeManager owns lazily-created Bifrost cores keyed by canonical provider
|
||||
// construction config.
|
||||
type RuntimeManager struct {
|
||||
registry *channel.Registry
|
||||
options runtimeOptions
|
||||
pool *runtimeManager
|
||||
capabilities execution.CapabilitySet
|
||||
registry *channel.Registry
|
||||
options runtimeOptions
|
||||
pool *runtimeManager
|
||||
|
||||
mu sync.Mutex
|
||||
started bool
|
||||
@@ -159,14 +158,12 @@ type RuntimeManager struct {
|
||||
|
||||
func newRuntimeManager(options runtimeOptions, registry *channel.Registry) (*RuntimeManager, error) {
|
||||
options = normalizeRuntimeOptions(options)
|
||||
template, err := newRuntimeShell(options, registry)
|
||||
if err != nil {
|
||||
if _, err := newRuntimeShell(options, registry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager := &RuntimeManager{
|
||||
registry: registry,
|
||||
options: options,
|
||||
capabilities: template.capabilities.Clone(),
|
||||
registry: registry,
|
||||
options: options,
|
||||
}
|
||||
manager.pool = newRuntimeManagerPool(func(_ context.Context, config effectiveProviderConfig) (managedProviderRuntime, error) {
|
||||
manager.mu.Lock()
|
||||
@@ -405,14 +402,6 @@ func (manager *RuntimeManager) Start(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Capabilities returns an independent adapter capability snapshot.
|
||||
func (manager *RuntimeManager) Capabilities() execution.CapabilitySet {
|
||||
if manager == nil {
|
||||
return execution.CapabilitySet{}
|
||||
}
|
||||
return manager.capabilities.Clone()
|
||||
}
|
||||
|
||||
// Execute acquires exactly one runtime lease for the frozen attempt target.
|
||||
func (manager *RuntimeManager) Execute(parent context.Context, spec execution.AttemptSpec) execution.AttemptResult {
|
||||
config, failure := manager.configForAttempt(spec)
|
||||
|
||||
@@ -747,10 +747,6 @@ func (*fakeManagedRuntime) ExecuteStream(context.Context, execution.AttemptSpec,
|
||||
return execution.StreamResult{}
|
||||
}
|
||||
|
||||
func (*fakeManagedRuntime) Capabilities() execution.CapabilitySet {
|
||||
return execution.CapabilitySet{}
|
||||
}
|
||||
|
||||
func (runtime *fakeManagedRuntime) BeginShutdown() <-chan struct{} {
|
||||
runtime.once.Do(func() {
|
||||
runtime.shutdowns.Add(1)
|
||||
|
||||
@@ -60,10 +60,6 @@ func (runtime *testRuntime) ExecuteStream(
|
||||
return runtime.manager.ExecuteStream(ctx, runtime.withBaseURL(spec), sink)
|
||||
}
|
||||
|
||||
func (runtime *testRuntime) Capabilities() execution.CapabilitySet {
|
||||
return runtime.manager.Capabilities()
|
||||
}
|
||||
|
||||
func (runtime *testRuntime) Shutdown() {
|
||||
runtime.manager.Shutdown()
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/maximhq/bifrost/core/schemas"
|
||||
|
||||
"gpt-load/internal/channel"
|
||||
"gpt-load/internal/parametertrace"
|
||||
"gpt-load/internal/reasoning"
|
||||
)
|
||||
|
||||
@@ -90,35 +91,36 @@ func TestTakeAppliedReasoningAlwaysClearsRawRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnableAppliedReasoningWireCaptureRequiresConvertedReasoning(t *testing.T) {
|
||||
func TestEnableConvertedWireCaptureRequiresBoundedConvertedRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := parametertrace.ProjectJSON([]byte(`{"messages":[{"role":"user","content":"secret"}]}`))
|
||||
|
||||
native := schemas.NewBifrostContext(t.Context(), schemas.NoDeadline)
|
||||
defer native.Cancel()
|
||||
enableAppliedReasoningWireCapture(native, preparedAttempt{
|
||||
enableConvertedWireCapture(native, preparedAttempt{
|
||||
mode: channel.RouteNative,
|
||||
request: &schemas.BifrostChatRequest{Params: &schemas.ChatParameters{Reasoning: &schemas.ChatReasoning{}}},
|
||||
})
|
||||
}, &client)
|
||||
if _, exists := native.Value(schemas.BifrostContextKeySendBackRawRequest).(bool); exists {
|
||||
t.Fatal("native request enabled raw capture")
|
||||
}
|
||||
|
||||
withoutReasoning := schemas.NewBifrostContext(t.Context(), schemas.NoDeadline)
|
||||
defer withoutReasoning.Cancel()
|
||||
enableAppliedReasoningWireCapture(withoutReasoning, preparedAttempt{
|
||||
enableConvertedWireCapture(withoutReasoning, preparedAttempt{
|
||||
mode: channel.RouteConverted,
|
||||
request: &schemas.BifrostChatRequest{Params: &schemas.ChatParameters{}},
|
||||
})
|
||||
if _, exists := withoutReasoning.Value(schemas.BifrostContextKeySendBackRawRequest).(bool); exists {
|
||||
t.Fatal("converted request without reasoning enabled raw capture")
|
||||
}, &client)
|
||||
if value, _ := withoutReasoning.Value(schemas.BifrostContextKeySendBackRawRequest).(bool); !value {
|
||||
t.Fatal("bounded converted request did not enable raw capture")
|
||||
}
|
||||
|
||||
converted := schemas.NewBifrostContext(t.Context(), schemas.NoDeadline)
|
||||
defer converted.Cancel()
|
||||
enableAppliedReasoningWireCapture(converted, preparedAttempt{
|
||||
enableConvertedWireCapture(converted, preparedAttempt{
|
||||
mode: channel.RouteConverted,
|
||||
request: &schemas.BifrostChatRequest{Params: &schemas.ChatParameters{Reasoning: &schemas.ChatReasoning{}}},
|
||||
})
|
||||
}, &client)
|
||||
for _, key := range []schemas.BifrostContextKey{
|
||||
schemas.BifrostContextKeyAllowPerRequestRawOverride,
|
||||
schemas.BifrostContextKeySendBackRawRequest,
|
||||
@@ -130,4 +132,38 @@ func TestEnableAppliedReasoningWireCaptureRequiresConvertedReasoning(t *testing.
|
||||
if value, ok := converted.Value(schemas.BifrostContextKeySendBackRawResponse).(bool); !ok || value {
|
||||
t.Fatalf("raw response override = %#v", converted.Value(schemas.BifrostContextKeySendBackRawResponse))
|
||||
}
|
||||
|
||||
oversize := parametertrace.Snapshot{
|
||||
SchemaVersion: parametertrace.SchemaVersion,
|
||||
State: parametertrace.CaptureSkippedOversize,
|
||||
Entries: []parametertrace.Entry{},
|
||||
}
|
||||
skipped := schemas.NewBifrostContext(t.Context(), schemas.NoDeadline)
|
||||
defer skipped.Cancel()
|
||||
enableConvertedWireCapture(skipped, preparedAttempt{mode: channel.RouteConverted}, &oversize)
|
||||
if _, exists := skipped.Value(schemas.BifrostContextKeySendBackRawRequest).(bool); exists {
|
||||
t.Fatal("oversize request enabled raw capture")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakeWireObservationClearsRawAndComparesSafeParameters(t *testing.T) {
|
||||
client := parametertrace.ProjectJSON([]byte(`{"thinking":{"type":"enabled","budget_tokens":4096},"messages":[{"role":"user","content":"secret prompt"}]}`))
|
||||
raw := any(json.RawMessage(`{"reasoning_effort":"high","messages":[{"role":"user","content":"secret prompt"}]}`))
|
||||
reasoningConfig, trace := takeWireObservation(&raw, &client)
|
||||
if raw != nil {
|
||||
t.Fatalf("raw request was retained: %#v", raw)
|
||||
}
|
||||
if !reflect.DeepEqual(reasoningConfig, &reasoning.Config{Effort: "high"}) {
|
||||
t.Fatalf("reasoning = %#v", reasoningConfig)
|
||||
}
|
||||
if trace == nil || trace.State != parametertrace.CaptureCaptured || len(trace.Changes) == 0 {
|
||||
t.Fatalf("trace = %#v", trace)
|
||||
}
|
||||
encoded, err := json.Marshal(trace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), "secret prompt") {
|
||||
t.Fatalf("trace leaked prompt: %s", encoded)
|
||||
}
|
||||
}
|
||||
+80
-200
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -2778,6 +2778,126 @@ func TestHandlerUsesClassifierForNonStreamingNonSuccess(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandlerRetriesAnotherGroupAfterLocalConversionFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conversionFailure := UpstreamResult{
|
||||
Err: fmt.Errorf("%w: target conversion failed", ErrUpstreamProtocol),
|
||||
DispatchState: execution.DispatchNotSent,
|
||||
ExecutionError: &execution.ErrorEvidence{
|
||||
Kind: execution.ErrorKind("conversion_unsupported"),
|
||||
Code: "target_conversion_not_supported",
|
||||
Summary: "target conversion is not supported",
|
||||
},
|
||||
ErrorSummary: "target conversion is not supported",
|
||||
}
|
||||
|
||||
t.Run("falls back to another group without mutating credential health", func(t *testing.T) {
|
||||
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
||||
conversionFailure,
|
||||
{StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true},
|
||||
}}
|
||||
engine, registry := newConvertedFallbackHandlerTestRuntime(t, forwarder)
|
||||
before := registry.Snapshot()
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString(
|
||||
`{"model":"claude-client","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`,
|
||||
))
|
||||
request.Header.Set("Authorization", "Bearer gl-client")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK || recorder.Body.String() != `{"ok":true}` || len(forwarder.inputs) != 2 {
|
||||
t.Fatalf("response/attempts = %d %s / %d, want 200 and two groups", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
|
||||
}
|
||||
if forwarder.inputs[0].Group.ID == forwarder.inputs[1].Group.ID {
|
||||
t.Fatalf("attempts stayed in group %d", forwarder.inputs[0].Group.ID)
|
||||
}
|
||||
if after := registry.Snapshot(); !reflect.DeepEqual(after, before) {
|
||||
t.Fatalf("credential health changed: before=%#v after=%#v", before, after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns stable 422 after every target rejects conversion", func(t *testing.T) {
|
||||
forwarder := &scriptedForwarder{results: []UpstreamResult{conversionFailure, conversionFailure}}
|
||||
engine, registry := newConvertedFallbackHandlerTestRuntime(t, forwarder)
|
||||
before := registry.Snapshot()
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString(
|
||||
`{"model":"claude-client","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`,
|
||||
))
|
||||
request.Header.Set("Authorization", "Bearer gl-client")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if recorder.Code != http.StatusUnprocessableEntity || body.Code != "protocol_conversion_unsupported" || len(forwarder.inputs) != 2 {
|
||||
t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
|
||||
}
|
||||
if after := registry.Snapshot(); !reflect.DeepEqual(after, before) {
|
||||
t.Fatalf("credential health changed: before=%#v after=%#v", before, after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed client request remains 400 and is not retried", func(t *testing.T) {
|
||||
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
||||
Err: fmt.Errorf("%w: invalid request", ErrUpstreamProtocol),
|
||||
DispatchState: execution.DispatchNotSent,
|
||||
ExecutionError: &execution.ErrorEvidence{
|
||||
Kind: execution.ErrorKindInvalidRequest,
|
||||
Summary: "invalid Anthropic request body",
|
||||
},
|
||||
}}}
|
||||
engine, _ := newConvertedFallbackHandlerTestRuntime(t, forwarder)
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString(
|
||||
`{"model":"claude-client","messages":[{"role":"user","content":"hello"}]}`,
|
||||
))
|
||||
request.Header.Set("Authorization", "Bearer gl-client")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusBadRequest ||
|
||||
!strings.Contains(recorder.Body.String(), `"code":"invalid_protocol_request"`) ||
|
||||
len(forwarder.inputs) != 1 {
|
||||
t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upstream unsupported model 400 is passed through once", func(t *testing.T) {
|
||||
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": {"application/json"}},
|
||||
Body: []byte(`{"error":{"code":"unsupported_model"}}`),
|
||||
RequestWritten: true,
|
||||
DispatchState: execution.DispatchMaybeSent,
|
||||
ResponseStarted: true,
|
||||
ExecutionError: &execution.ErrorEvidence{
|
||||
Kind: execution.ErrorKindHTTP,
|
||||
Hint: execution.FailureHintModelUnavailable,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Code: "unsupported_model",
|
||||
Summary: "request capability is unavailable",
|
||||
},
|
||||
}}}
|
||||
engine, registry := newConvertedFallbackHandlerTestRuntime(t, forwarder)
|
||||
before := registry.Snapshot()
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString(
|
||||
`{"model":"claude-client","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`,
|
||||
))
|
||||
request.Header.Set("Authorization", "Bearer gl-client")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusBadRequest || recorder.Body.String() != `{"error":{"code":"unsupported_model"}}` || len(forwarder.inputs) != 1 {
|
||||
t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
|
||||
}
|
||||
if after := registry.Snapshot(); !reflect.DeepEqual(after, before) {
|
||||
t.Fatalf("credential health changed: before=%#v after=%#v", before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandlerAppliesExactCooldownDeadline(t *testing.T) {
|
||||
attemptNow := time.Date(2026, time.July, 21, 12, 0, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
@@ -3003,6 +3123,19 @@ func TestCrossCandidateRetryRespectsReplaySafety(t *testing.T) {
|
||||
result: UpstreamResult{DispatchState: execution.DispatchNotSent},
|
||||
decision: retry, want: true,
|
||||
},
|
||||
{
|
||||
name: "local conversion failure was not sent", operation: execution.OperationChatCompletion,
|
||||
method: http.MethodPost,
|
||||
result: UpstreamResult{
|
||||
DispatchState: execution.DispatchNotSent,
|
||||
ExecutionError: &execution.ErrorEvidence{
|
||||
Kind: execution.ErrorKind("conversion_unsupported"),
|
||||
Summary: "target conversion is not supported",
|
||||
},
|
||||
},
|
||||
decision: health.Result{Category: health.FailureCategoryConversionUnsupported, Action: health.ActionSkipGroup},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "explicit credential rejection", operation: execution.OperationResponsesCreate,
|
||||
method: http.MethodPost,
|
||||
@@ -3898,3 +4031,71 @@ func newHandlerForTestWithStats(
|
||||
handler.newRandom = func() *rand.Rand { return rand.New(rand.NewSource(1)) }
|
||||
return handler, manager, registry
|
||||
}
|
||||
|
||||
func newConvertedFallbackHandlerTestRuntime(
|
||||
t *testing.T,
|
||||
forwarder AttemptForwarder,
|
||||
) (*gin.Engine, *state.CredentialRegistry) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
keyService, err := encryption.NewService("handler-conversion-fallback-test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("NewService() error = %v", err)
|
||||
}
|
||||
channelRegistry := channel.NewRegistry()
|
||||
manager := state.NewManager()
|
||||
groups := []state.GroupConfig{
|
||||
{
|
||||
ID: 1, Name: "compatible-one", ChannelID: channel.OpenAICompatible,
|
||||
Params: json.RawMessage(`{"base_url":"https://one.example/v1"}`), Enabled: true,
|
||||
Models: []state.ModelConfig{{ID: "upstream-one", Alias: "claude-client"}},
|
||||
},
|
||||
{
|
||||
ID: 2, Name: "compatible-two", ChannelID: channel.OpenAICompatible,
|
||||
Params: json.RawMessage(`{"base_url":"https://two.example/v1"}`), Enabled: true,
|
||||
Models: []state.ModelConfig{{ID: "upstream-two", Alias: "claude-client"}},
|
||||
},
|
||||
}
|
||||
credentials := []state.CredentialConfig{
|
||||
{ID: 1, GroupID: 1, Status: state.CredentialStatusActive, Version: 1, IdentityGeneration: 1, Fingerprint: "credential-one"},
|
||||
{ID: 2, GroupID: 2, Status: state.CredentialStatusActive, Version: 1, IdentityGeneration: 2, Fingerprint: "credential-two"},
|
||||
}
|
||||
if _, err := manager.Publish(state.CompileInput{
|
||||
ChannelRegistry: channelRegistry,
|
||||
Groups: groups,
|
||||
Credentials: credentials,
|
||||
AccessKeys: []state.AccessKeyConfig{{
|
||||
ID: 1, Name: "client", KeyHash: keyService.Hash("gl-client"), Status: state.AccessKeyStatusActive,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Publish() error = %v", err)
|
||||
}
|
||||
|
||||
registry := state.NewCredentialRegistry()
|
||||
entries := make([]state.CredentialEntry, 0, len(credentials))
|
||||
for _, credential := range credentials {
|
||||
plaintext := fmt.Sprintf(`{"api_key":"sk-%d"}`, credential.ID)
|
||||
encrypted, err := keyService.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt() error = %v", err)
|
||||
}
|
||||
entries = append(entries, state.CredentialEntry{
|
||||
ID: credential.ID, GroupID: credential.GroupID, Status: credential.Status,
|
||||
Version: credential.Version, IdentityGeneration: credential.IdentityGeneration,
|
||||
Fingerprint: credential.Fingerprint, EncryptedValue: encrypted,
|
||||
})
|
||||
}
|
||||
if err := registry.ReplaceCredentials(entries); err != nil {
|
||||
t.Fatalf("ReplaceCredentials() error = %v", err)
|
||||
}
|
||||
handler := NewHandler(
|
||||
manager, registry, keyService, forwarder, dialect.NewSet(dialect.NewAnthropic()),
|
||||
health.NewStatsStore(), health.NewMutationCoordinator(),
|
||||
nil, nil, nil,
|
||||
)
|
||||
handler.channels = channelRegistry
|
||||
handler.newRandom = func() *rand.Rand { return rand.New(zeroSource{}) }
|
||||
engine := gin.New()
|
||||
bindGatewayRoutesForTest(t, engine, handler)
|
||||
return engine, registry
|
||||
}
|
||||
+12
-11
@@ -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.",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 != ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -614,6 +614,7 @@ func TestAutoMigrateCreatesUsageJournalAndMigrationLedger(t *testing.T) {
|
||||
wantMigrationIDs := []string{
|
||||
"0001_final_v2",
|
||||
"0002_request_log_execution_observation",
|
||||
"0003_request_log_conversion_trace",
|
||||
}
|
||||
if !reflect.DeepEqual(migrationIDs, wantMigrationIDs) {
|
||||
t.Fatalf("schema_migrations IDs = %v, want %v", migrationIDs, wantMigrationIDs)
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestAutoMigrateCreatesNormalizedRequestLogInitialV2(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"stream", "first_response_ms", "attempt_count",
|
||||
"upstream_reported_model", "model_consistency",
|
||||
"operation",
|
||||
"operation", "client_parameters_json",
|
||||
} {
|
||||
if _, ok := requestColumns[name]; !ok {
|
||||
t.Errorf("request_logs.%s is missing", name)
|
||||
@@ -87,6 +87,7 @@ func TestAutoMigrateCreatesNormalizedRequestLogInitialV2(t *testing.T) {
|
||||
"channel_id", "credential_id", "operation", "route_mode", "upstream_model",
|
||||
"upstream_request_id", "dispatch_state", "response_started", "upstream_api",
|
||||
"reasoning_mode", "reasoning_effort", "reasoning_budget_tokens",
|
||||
"conversion_trace_json",
|
||||
"status_code", "duration_ms",
|
||||
"failure_category", "action", "will_retry", "error_code", "error_summary",
|
||||
"committed", "pricing_receipt",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"gpt-load/internal/storage/models"
|
||||
)
|
||||
|
||||
// Migration-local models freeze the nullable conversion-observation columns.
|
||||
type requestLogConversionTraceMigration struct {
|
||||
ClientParameters models.JSON `gorm:"column:client_parameters_json;type:json"`
|
||||
}
|
||||
|
||||
func (requestLogConversionTraceMigration) TableName() string { return "request_logs" }
|
||||
|
||||
type requestLogAttemptConversionTraceMigration struct {
|
||||
ConversionTrace models.JSON `gorm:"column:conversion_trace_json;type:json"`
|
||||
}
|
||||
|
||||
func (requestLogAttemptConversionTraceMigration) TableName() string {
|
||||
return "request_log_attempts"
|
||||
}
|
||||
|
||||
func addRequestLogConversionTrace(db *gorm.DB) error {
|
||||
request := &requestLogConversionTraceMigration{}
|
||||
if err := addMigrationColumns(db, request, "ClientParameters"); err != nil {
|
||||
return fmt.Errorf("add request log client parameter projection: %w", err)
|
||||
}
|
||||
attempt := &requestLogAttemptConversionTraceMigration{}
|
||||
if err := addMigrationColumns(db, attempt, "ConversionTrace"); err != nil {
|
||||
return fmt.Errorf("add request log attempt conversion trace: %w", err)
|
||||
}
|
||||
return widenRequestLogAttemptFailureCategory(db)
|
||||
}
|
||||
|
||||
func widenRequestLogAttemptFailureCategory(db *gorm.DB) error {
|
||||
if db == nil || db.Dialector == nil {
|
||||
return fmt.Errorf("widen request log attempt failure category: database is nil")
|
||||
}
|
||||
if !strings.EqualFold(db.Dialector.Name(), "sqlite") {
|
||||
// GPT-Load 2.0 is SQLite-only; legacy dialect hooks do not participate in
|
||||
// the runtime migration contract.
|
||||
return nil
|
||||
}
|
||||
var createSQL string
|
||||
if err := db.Raw(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
"request_log_attempts",
|
||||
).Scan(&createSQL).Error; err != nil {
|
||||
return fmt.Errorf("read request log attempt schema: %w", err)
|
||||
}
|
||||
if strings.Contains(createSQL, "'conversion_unsupported'") {
|
||||
return nil
|
||||
}
|
||||
const temporaryTable = "request_log_attempts__conversion_trace_migration"
|
||||
temporarySQL, ok := renameSQLiteCreateTable(createSQL, "request_log_attempts", temporaryTable)
|
||||
if !ok {
|
||||
return fmt.Errorf("widen request log attempt failure category: unsupported SQLite table definition")
|
||||
}
|
||||
temporarySQL = strings.Replace(
|
||||
temporarySQL,
|
||||
"'client_error','downstream_cancel'",
|
||||
"'client_error','conversion_unsupported','downstream_cancel'",
|
||||
1,
|
||||
)
|
||||
if !strings.Contains(temporarySQL, "'conversion_unsupported'") {
|
||||
return fmt.Errorf("widen request log attempt failure category: existing constraint was not recognized")
|
||||
}
|
||||
var schemaObjects []struct {
|
||||
SQL string
|
||||
}
|
||||
if err := db.Raw(
|
||||
"SELECT sql FROM sqlite_master WHERE tbl_name = ? AND type IN ('index','trigger') AND sql IS NOT NULL ORDER BY type, name",
|
||||
"request_log_attempts",
|
||||
).Scan(&schemaObjects).Error; err != nil {
|
||||
return fmt.Errorf("read request log attempt indexes: %w", err)
|
||||
}
|
||||
statements := []string{
|
||||
temporarySQL,
|
||||
`INSERT INTO "` + temporaryTable + `" SELECT * FROM "request_log_attempts"`,
|
||||
`DROP TABLE "request_log_attempts"`,
|
||||
`ALTER TABLE "` + temporaryTable + `" RENAME TO "request_log_attempts"`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
return fmt.Errorf("rebuild request log attempt constraint: %w", err)
|
||||
}
|
||||
}
|
||||
for _, object := range schemaObjects {
|
||||
if err := db.Exec(object.SQL).Error; err != nil {
|
||||
return fmt.Errorf("restore request log attempt schema object: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renameSQLiteCreateTable(source, oldName, newName string) (string, bool) {
|
||||
for _, quote := range []string{"`", `"`, ""} {
|
||||
oldToken := "CREATE TABLE " + quote + oldName + quote
|
||||
if strings.HasPrefix(strings.ToUpper(source), strings.ToUpper(oldToken)) {
|
||||
return "CREATE TABLE " + quote + newName + quote + source[len(oldToken):], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAddRequestLogConversionTraceUpgradesExistingConstraintWithoutLosingRows(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:conversion-trace-migration?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statements := []string{
|
||||
`PRAGMA foreign_keys = ON`,
|
||||
`CREATE TABLE request_logs (id TEXT PRIMARY KEY NOT NULL)`,
|
||||
`CREATE TABLE request_log_attempts (
|
||||
request_id TEXT NOT NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
failure_category TEXT NOT NULL CONSTRAINT chk_request_log_attempt_failure_category
|
||||
CHECK (failure_category IN ('ok','rate_limited','model_unavailable','invalid_key','upstream_host_error','client_error','downstream_cancel','ambiguous')),
|
||||
PRIMARY KEY (request_id, sequence),
|
||||
CONSTRAINT fk_attempt_request FOREIGN KEY (request_id) REFERENCES request_logs(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX idx_attempt_failure ON request_log_attempts(failure_category)`,
|
||||
`INSERT INTO request_logs(id) VALUES ('request-1')`,
|
||||
`INSERT INTO request_log_attempts(request_id, sequence, failure_category) VALUES ('request-1', 1, 'ok')`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatalf("execute fixture schema: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Transaction(addRequestLogConversionTrace); err != nil {
|
||||
t.Fatalf("addRequestLogConversionTrace() error = %v", err)
|
||||
}
|
||||
for table, column := range map[string]string{
|
||||
"request_logs": "client_parameters_json",
|
||||
"request_log_attempts": "conversion_trace_json",
|
||||
} {
|
||||
if !db.Migrator().HasColumn(table, column) {
|
||||
t.Errorf("%s.%s is missing", table, column)
|
||||
}
|
||||
}
|
||||
var category string
|
||||
if err := db.Raw(
|
||||
"SELECT failure_category FROM request_log_attempts WHERE request_id = ? AND sequence = ?",
|
||||
"request-1", 1,
|
||||
).Scan(&category).Error; err != nil || category != "ok" {
|
||||
t.Fatalf("historical row category = %q, error = %v", category, err)
|
||||
}
|
||||
if err := db.Exec(
|
||||
"UPDATE request_log_attempts SET failure_category = ? WHERE request_id = ? AND sequence = ?",
|
||||
"conversion_unsupported", "request-1", 1,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("widened failure category rejected conversion_unsupported: %v", err)
|
||||
}
|
||||
if !db.Migrator().HasIndex("request_log_attempts", "idx_attempt_failure") {
|
||||
t.Fatal("request log attempt index was lost")
|
||||
}
|
||||
var foreignKeys []struct {
|
||||
Table string
|
||||
From string
|
||||
To string
|
||||
OnDelete string `gorm:"column:on_delete"`
|
||||
}
|
||||
if err := db.Raw("PRAGMA foreign_key_list('request_log_attempts')").Scan(&foreignKeys).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(foreignKeys) != 1 || foreignKeys[0].Table != "request_logs" ||
|
||||
foreignKeys[0].From != "request_id" || foreignKeys[0].To != "id" ||
|
||||
foreignKeys[0].OnDelete != "CASCADE" {
|
||||
t.Fatalf("foreign keys = %#v", foreignKeys)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -21,12 +21,12 @@ interface SelectOption {
|
||||
const props = defineProps<{
|
||||
protocol: string
|
||||
operation: string
|
||||
features: string[]
|
||||
routeRequirement: string
|
||||
model: string
|
||||
accessKeyId: string
|
||||
protocolOptions: SelectOption[]
|
||||
operationOptions: SelectOption[]
|
||||
featureOptions: SelectOption[]
|
||||
routeRequirementOptions: SelectOption[]
|
||||
modelRequired: boolean
|
||||
modelAllowed: boolean
|
||||
accessKeyOptions: SelectOption[]
|
||||
@@ -39,7 +39,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
'update:protocol': [value: string]
|
||||
'update:operation': [value: string]
|
||||
'update:features': [value: string[]]
|
||||
'update:routeRequirement': [value: string]
|
||||
'update:model': [value: string]
|
||||
'update:accessKeyId': [value: string]
|
||||
submit: []
|
||||
@@ -52,13 +52,6 @@ function error(field: InspectorField): string | undefined {
|
||||
const key = props.errors[field]
|
||||
return key ? t(key) : undefined
|
||||
}
|
||||
|
||||
function setFeature(value: string, checked: boolean): void {
|
||||
const next = new Set(props.features)
|
||||
if (checked) next.add(value)
|
||||
else next.delete(value)
|
||||
emit('update:features', [...next].sort())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -169,17 +162,24 @@ function setFeature(value: string, checked: boolean): void {
|
||||
</template>
|
||||
</FormField>
|
||||
|
||||
<fieldset class="inspector-features">
|
||||
<legend>{{ t('monitor.inspector.form.features') }}</legend>
|
||||
<label v-for="option in featureOptions" :key="option.value">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="features.includes(option.value)"
|
||||
@change="setFeature(option.value, ($event.target as HTMLInputElement).checked)"
|
||||
<FormField
|
||||
id="inspector-route-requirement"
|
||||
size="compact"
|
||||
:label="t('monitor.inspector.form.routeRequirement')"
|
||||
required
|
||||
>
|
||||
<template #default="{ describedBy }">
|
||||
<AppSelect
|
||||
id="inspector-route-requirement"
|
||||
:model-value="routeRequirement"
|
||||
:label="t('monitor.inspector.form.routeRequirement')"
|
||||
:options="routeRequirementOptions"
|
||||
:aria-describedby="describedBy"
|
||||
size="compact"
|
||||
@update:model-value="emit('update:routeRequirement', $event)"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</template>
|
||||
</FormField>
|
||||
|
||||
<AppButton
|
||||
class="inspector-form__submit"
|
||||
@@ -251,37 +251,6 @@ function setFeature(value: string, checked: boolean): void {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.inspector-features {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.inspector-features legend {
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-meta);
|
||||
}
|
||||
|
||||
.inspector-features label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.inspector-features input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inspector-inline-error {
|
||||
margin: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 34%, var(--color-border-subtle));
|
||||
@@ -316,10 +285,6 @@ function setFeature(value: string, checked: boolean): void {
|
||||
.inspector-form-panel__body > :deep(.inline-feedback) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.inspector-features {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
|
||||
@@ -13,13 +13,13 @@ import { RequestCancelledError } from '@/api/errors'
|
||||
import { accessKeyOptionsQueryOptions } from '@/app/resources/access-keys'
|
||||
import {
|
||||
inspectRoute,
|
||||
routeInspectFeatures,
|
||||
routeInspectRequirements,
|
||||
type RouteInspectCredentialDto,
|
||||
type RouteInspectFeature,
|
||||
type RouteInspectGroupDto,
|
||||
type RouteInspectOperation,
|
||||
type RouteInspectReasonCode,
|
||||
type RouteInspectRequest,
|
||||
type RouteInspectRequirement,
|
||||
type RouteInspectResponseDto,
|
||||
} from '@/app/resources/route-inspection'
|
||||
import { groupDetailLocation, monitorLocation } from '@/app/route-locations'
|
||||
@@ -53,7 +53,8 @@ const knownReasons = new Set<RouteInspectReasonCode>([
|
||||
'protocol_filtered',
|
||||
'model_filtered',
|
||||
'model_required_by_filter',
|
||||
'capability_unsupported',
|
||||
'operation_unsupported',
|
||||
'native_route_required',
|
||||
'no_route_target',
|
||||
'group_disabled',
|
||||
'group_filtered',
|
||||
@@ -77,6 +78,13 @@ const responsesOperations: readonly RouteInspectOperation[] = [
|
||||
'responses_input_tokens',
|
||||
'responses_passthrough',
|
||||
]
|
||||
const nativeRouteOperations = new Set<RouteInspectOperation>([
|
||||
'responses_retrieve',
|
||||
'responses_delete',
|
||||
'responses_cancel',
|
||||
'responses_input_items',
|
||||
'responses_passthrough',
|
||||
])
|
||||
|
||||
const client = useApiClient()
|
||||
const route = useRoute()
|
||||
@@ -87,7 +95,10 @@ const draftProtocol = ref(readProtocol(routeState.value.protocol))
|
||||
const draftOperation = ref(
|
||||
readOperation(routeState.value.operation) ?? defaultOperation(draftProtocol.value),
|
||||
)
|
||||
const draftFeatures = ref<RouteInspectFeature[]>([...routeState.value.requiredFeatures])
|
||||
const draftRouteRequirement = ref<RouteInspectRequirement>(
|
||||
readRouteRequirement(routeState.value.routeRequirement) ??
|
||||
defaultRouteRequirement(draftOperation.value),
|
||||
)
|
||||
const draftModel = ref(readText(routeState.value.externalModel))
|
||||
const draftAccessKeyID = ref(readPositiveID(routeState.value.accessKeyID))
|
||||
const fieldErrors = ref<InspectorErrors>({})
|
||||
@@ -120,11 +131,17 @@ const operationOptions = computed(() =>
|
||||
label: t(`monitor.inspector.operations.${value}`),
|
||||
})),
|
||||
)
|
||||
const featureOptions = computed(() =>
|
||||
routeInspectFeatures.map((value) => ({
|
||||
value,
|
||||
label: t(`monitor.inspector.features.${value}`),
|
||||
})),
|
||||
const routeRequirementOptions = computed(() =>
|
||||
routeInspectRequirements
|
||||
.filter(
|
||||
(value) =>
|
||||
value === 'native' ||
|
||||
!nativeRouteOperations.has(draftOperation.value as RouteInspectOperation),
|
||||
)
|
||||
.map((value) => ({
|
||||
value,
|
||||
label: t(`monitor.inspector.routeRequirements.${value}`),
|
||||
})),
|
||||
)
|
||||
const modelRequired = computed(() => operationRequiresModel(draftOperation.value))
|
||||
const modelAllowed = computed(() => operationAllowsModel(draftOperation.value))
|
||||
@@ -162,7 +179,7 @@ const inputChanged = computed(() => {
|
||||
return (
|
||||
draftProtocol.value !== previous.protocol ||
|
||||
draftOperation.value !== previous.operation ||
|
||||
draftFeatures.value.join(',') !== previous.required_features.join(',') ||
|
||||
draftRouteRequirement.value !== previous.route_requirement ||
|
||||
draftModel.value !== (previous.external_model ?? '') ||
|
||||
draftAccessKeyID.value !== String(previous.access_key_id)
|
||||
)
|
||||
@@ -212,6 +229,20 @@ function defaultOperation(value: AccessProtocol | ''): RouteInspectOperation | '
|
||||
return operationsForProtocol(value)[0] ?? ''
|
||||
}
|
||||
|
||||
function defaultRouteRequirement(operation: RouteInspectOperation | ''): RouteInspectRequirement {
|
||||
return operation === 'responses_create' ||
|
||||
nativeRouteOperations.has(operation as RouteInspectOperation)
|
||||
? 'native'
|
||||
: 'any'
|
||||
}
|
||||
|
||||
function readRouteRequirement(raw: unknown): RouteInspectRequirement | undefined {
|
||||
return typeof raw === 'string' &&
|
||||
routeInspectRequirements.includes(raw as RouteInspectRequirement)
|
||||
? (raw as RouteInspectRequirement)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function readOperation(raw: unknown): RouteInspectOperation | undefined {
|
||||
return typeof raw === 'string' && responsesOperations.includes(raw as RouteInspectOperation)
|
||||
? (raw as RouteInspectOperation)
|
||||
@@ -248,21 +279,22 @@ watch(
|
||||
[
|
||||
routeState.value.protocol,
|
||||
routeState.value.operation,
|
||||
routeState.value.requiredFeatures.join(','),
|
||||
routeState.value.routeRequirement,
|
||||
routeState.value.externalModel,
|
||||
routeState.value.accessKeyID,
|
||||
routeState.value.run,
|
||||
] as const,
|
||||
([rawProtocol, rawOperation, rawFeatures, rawModel, rawAccessKeyID, run]) => {
|
||||
([rawProtocol, rawOperation, rawRouteRequirement, rawModel, rawAccessKeyID, run]) => {
|
||||
const protocol = readProtocol(rawProtocol)
|
||||
const operation = readOperation(rawOperation) ?? defaultOperation(protocol)
|
||||
const features = rawFeatures === '' ? [] : [...routeState.value.requiredFeatures]
|
||||
const routeRequirement =
|
||||
readRouteRequirement(rawRouteRequirement) ?? defaultRouteRequirement(operation)
|
||||
const model = readText(rawModel)
|
||||
const accessKeyID = readPositiveID(rawAccessKeyID)
|
||||
const fieldsChanged =
|
||||
protocol !== draftProtocol.value ||
|
||||
operation !== draftOperation.value ||
|
||||
features.join(',') !== draftFeatures.value.join(',') ||
|
||||
routeRequirement !== draftRouteRequirement.value ||
|
||||
model !== draftModel.value ||
|
||||
accessKeyID !== draftAccessKeyID.value
|
||||
if (!fieldsChanged && run) return
|
||||
@@ -272,7 +304,7 @@ watch(
|
||||
controller = undefined
|
||||
draftProtocol.value = protocol
|
||||
draftOperation.value = operation
|
||||
draftFeatures.value = features
|
||||
draftRouteRequirement.value = routeRequirement
|
||||
draftModel.value = model
|
||||
draftAccessKeyID.value = accessKeyID
|
||||
fieldErrors.value = {}
|
||||
@@ -315,7 +347,7 @@ function validatedRequest(): RouteInspectRequest | undefined {
|
||||
const request: RouteInspectRequest = {
|
||||
protocol: draftProtocol.value as AccessProtocol,
|
||||
operation: draftOperation.value as RouteInspectOperation,
|
||||
required_features: [...draftFeatures.value].sort(),
|
||||
route_requirement: draftRouteRequirement.value,
|
||||
access_key_id: accessKeyID,
|
||||
}
|
||||
if (draftModel.value !== '') request.external_model = draftModel.value
|
||||
@@ -331,11 +363,13 @@ function setDraftProtocol(value: string): void {
|
||||
) {
|
||||
draftOperation.value = defaultOperation(draftProtocol.value)
|
||||
}
|
||||
draftRouteRequirement.value = defaultRouteRequirement(draftOperation.value)
|
||||
if (!operationAllowsModel(draftOperation.value)) draftModel.value = ''
|
||||
}
|
||||
|
||||
function setDraftOperation(value: string): void {
|
||||
draftOperation.value = value as RouteInspectOperation | ''
|
||||
draftRouteRequirement.value = defaultRouteRequirement(draftOperation.value)
|
||||
if (!operationAllowsModel(draftOperation.value)) draftModel.value = ''
|
||||
}
|
||||
|
||||
@@ -346,7 +380,7 @@ async function inspect(): Promise<void> {
|
||||
const nextState: InspectorMonitorState = {
|
||||
protocol: request.protocol,
|
||||
operation: request.operation,
|
||||
requiredFeatures: request.required_features,
|
||||
routeRequirement: request.route_requirement,
|
||||
externalModel: request.external_model ?? undefined,
|
||||
accessKeyID: String(request.access_key_id),
|
||||
run: true,
|
||||
@@ -357,7 +391,7 @@ async function inspect(): Promise<void> {
|
||||
current.run &&
|
||||
current.protocol === nextState.protocol &&
|
||||
current.operation === nextState.operation &&
|
||||
current.requiredFeatures.join(',') === nextState.requiredFeatures.join(',') &&
|
||||
current.routeRequirement === nextState.routeRequirement &&
|
||||
current.externalModel === nextState.externalModel &&
|
||||
current.accessKeyID === nextState.accessKeyID
|
||||
) {
|
||||
@@ -408,7 +442,7 @@ watch(
|
||||
() => routeState.value.run,
|
||||
() => routeState.value.protocol,
|
||||
() => routeState.value.operation,
|
||||
() => routeState.value.requiredFeatures.join(','),
|
||||
() => routeState.value.routeRequirement,
|
||||
() => routeState.value.externalModel,
|
||||
() => routeState.value.accessKeyID,
|
||||
() => accessKeyOptionsQuery.data.value,
|
||||
@@ -421,7 +455,7 @@ watch(
|
||||
pending.value &&
|
||||
submitted.value?.protocol === request.protocol &&
|
||||
submitted.value.operation === request.operation &&
|
||||
submitted.value.required_features.join(',') === request.required_features.join(',') &&
|
||||
submitted.value.route_requirement === request.route_requirement &&
|
||||
submitted.value.access_key_id === request.access_key_id &&
|
||||
submitted.value.external_model === request.external_model
|
||||
) {
|
||||
@@ -431,7 +465,7 @@ watch(
|
||||
observation.value !== undefined &&
|
||||
submitted.value?.protocol === request.protocol &&
|
||||
submitted.value.operation === request.operation &&
|
||||
submitted.value.required_features.join(',') === request.required_features.join(',') &&
|
||||
submitted.value.route_requirement === request.route_requirement &&
|
||||
submitted.value.access_key_id === request.access_key_id &&
|
||||
submitted.value.external_model === request.external_model
|
||||
) {
|
||||
@@ -558,12 +592,12 @@ onBeforeUnmount(() => {
|
||||
<InspectorForm
|
||||
:protocol="draftProtocol"
|
||||
:operation="draftOperation"
|
||||
:features="draftFeatures"
|
||||
:route-requirement="draftRouteRequirement"
|
||||
:model="draftModel"
|
||||
:access-key-id="draftAccessKeyID"
|
||||
:protocol-options="protocolOptions"
|
||||
:operation-options="operationOptions"
|
||||
:feature-options="featureOptions"
|
||||
:route-requirement-options="routeRequirementOptions"
|
||||
:model-required="modelRequired"
|
||||
:model-allowed="modelAllowed"
|
||||
:access-key-options="accessKeyOptions"
|
||||
@@ -574,7 +608,7 @@ onBeforeUnmount(() => {
|
||||
:submit-pending="pending"
|
||||
@update:protocol="setDraftProtocol"
|
||||
@update:operation="setDraftOperation"
|
||||
@update:features="draftFeatures = $event as RouteInspectFeature[]"
|
||||
@update:route-requirement="draftRouteRequirement = $event as RouteInspectRequirement"
|
||||
@update:model="draftModel = $event"
|
||||
@update:access-key-id="draftAccessKeyID = $event"
|
||||
@submit="inspect"
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight } from '@lucide/vue'
|
||||
import { ArrowRight, ChevronRight } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { AccessProtocol } from '@/api/control/types'
|
||||
import type { RequestLogReasoningDto, RequestLogUpstreamAPI } from '@/app/resources/request-logs'
|
||||
import type {
|
||||
RequestConversionTraceDto,
|
||||
RequestLogReasoningDto,
|
||||
RequestLogUpstreamAPI,
|
||||
RequestParameterChangeDto,
|
||||
RequestParameterEntryDto,
|
||||
RequestParameterSnapshotDto,
|
||||
} from '@/app/resources/request-logs'
|
||||
import CopyButton from '@/components/ui/CopyButton.vue'
|
||||
|
||||
import { formatLogReasoningBudget, reasoningBudgetSemantic } from './log-format'
|
||||
|
||||
@@ -15,6 +23,8 @@ const props = defineProps<{
|
||||
upstreamModel: string | null
|
||||
clientReasoning: RequestLogReasoningDto | null
|
||||
upstreamReasoning: RequestLogReasoningDto | null
|
||||
clientParameters: RequestParameterSnapshotDto | null
|
||||
conversionTrace: RequestConversionTraceDto | null
|
||||
}>()
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
@@ -24,6 +34,38 @@ const upstreamAPILabel = computed(() =>
|
||||
)
|
||||
const clientReasoningLabel = computed(() => formatReasoning(props.clientReasoning, false))
|
||||
const upstreamReasoningLabel = computed(() => formatReasoning(props.upstreamReasoning, true))
|
||||
const targetParameters = computed(() => props.conversionTrace?.target ?? null)
|
||||
const parameterRows = computed(() => buildParameterRows())
|
||||
const parameterCopyValue = computed(() =>
|
||||
JSON.stringify(
|
||||
{
|
||||
client: props.clientParameters,
|
||||
converted_upstream: props.conversionTrace,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
const parameterHistoryUnavailable = computed(
|
||||
() => props.clientParameters === null && props.conversionTrace === null,
|
||||
)
|
||||
const parameterTruncated = computed(
|
||||
() => props.clientParameters?.truncated === true || targetParameters.value?.truncated === true,
|
||||
)
|
||||
const parameterRedacted = computed(() =>
|
||||
[...(props.clientParameters?.entries ?? []), ...(targetParameters.value?.entries ?? [])].some(
|
||||
({ value }) => value.includes('[REDACTED]') || value.includes('[redacted]'),
|
||||
),
|
||||
)
|
||||
|
||||
interface ParameterRow {
|
||||
key: string
|
||||
sourcePath: string | null
|
||||
targetPath: string | null
|
||||
source: RequestParameterEntryDto | null
|
||||
target: RequestParameterEntryDto | null
|
||||
change: RequestParameterChangeDto
|
||||
}
|
||||
|
||||
function formatReasoning(value: RequestLogReasoningDto | null, upstream: boolean): string {
|
||||
if (value === null) {
|
||||
@@ -52,6 +94,51 @@ function formatReasoning(value: RequestLogReasoningDto | null, upstream: boolean
|
||||
}
|
||||
return details.join(' · ') || t('monitor.logs.drawer.reasoningNotSpecified')
|
||||
}
|
||||
|
||||
function buildParameterRows(): ParameterRow[] {
|
||||
const source = new Map(
|
||||
(props.clientParameters?.entries ?? []).map((entry) => [entry.path, entry]),
|
||||
)
|
||||
const target = new Map(
|
||||
(targetParameters.value?.entries ?? []).map((entry) => [entry.path, entry]),
|
||||
)
|
||||
const changes = props.conversionTrace?.changes ?? []
|
||||
if (changes.length > 0) {
|
||||
return changes.map((change, index) => ({
|
||||
key: `${change.source_path ?? ''}:${change.target_path ?? ''}:${index}`,
|
||||
sourcePath: change.source_path,
|
||||
targetPath: change.target_path,
|
||||
source: change.source_path === null ? null : (source.get(change.source_path) ?? null),
|
||||
target: change.target_path === null ? null : (target.get(change.target_path) ?? null),
|
||||
change,
|
||||
}))
|
||||
}
|
||||
const paths = [...new Set([...source.keys(), ...target.keys()])].sort()
|
||||
return paths.map((path) => {
|
||||
const sourceEntry = source.get(path) ?? null
|
||||
const targetEntry = target.get(path) ?? null
|
||||
const disposition: RequestParameterChangeDto['disposition'] =
|
||||
sourceEntry && targetEntry ? 'preserved' : sourceEntry ? 'dropped' : 'added'
|
||||
return {
|
||||
key: path,
|
||||
sourcePath: sourceEntry ? path : null,
|
||||
targetPath: targetEntry ? path : null,
|
||||
source: sourceEntry,
|
||||
target: targetEntry,
|
||||
change: {
|
||||
disposition,
|
||||
source_path: sourceEntry ? path : null,
|
||||
target_path: targetEntry ? path : null,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function captureStateLabel(snapshot: RequestParameterSnapshotDto | null, trace = false): string {
|
||||
const state = trace ? props.conversionTrace?.state : snapshot?.state
|
||||
if (!state) return t('monitor.logs.protocolConversion.historyUnavailable')
|
||||
return t(`monitor.logs.protocolConversion.captureState.${state}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -93,6 +180,83 @@ function formatReasoning(value: RequestLogReasoningDto | null, upstream: boolean
|
||||
<dd>{{ upstreamReasoningLabel }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<details class="log-conversion-parameters">
|
||||
<summary>
|
||||
<ChevronRight class="log-conversion-parameters__chevron" :size="15" aria-hidden="true" />
|
||||
<span>{{ t('monitor.logs.protocolConversion.parameters') }}</span>
|
||||
<span
|
||||
v-if="parameterHistoryUnavailable"
|
||||
class="log-conversion-parameters__badge log-conversion-parameters__badge--muted"
|
||||
>
|
||||
{{ t('monitor.logs.protocolConversion.history') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="parameterTruncated"
|
||||
class="log-conversion-parameters__badge log-conversion-parameters__badge--warning"
|
||||
>
|
||||
{{ t('monitor.logs.protocolConversion.truncated') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="parameterRedacted"
|
||||
class="log-conversion-parameters__badge log-conversion-parameters__badge--muted"
|
||||
>
|
||||
{{ t('monitor.logs.protocolConversion.redacted') }}
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div class="log-conversion-parameters__toolbar">
|
||||
<div>
|
||||
<span>{{ captureStateLabel(clientParameters) }}</span>
|
||||
<ArrowRight :size="13" aria-hidden="true" />
|
||||
<span>{{ captureStateLabel(targetParameters, true) }}</span>
|
||||
</div>
|
||||
<CopyButton
|
||||
v-if="!parameterHistoryUnavailable"
|
||||
:value="parameterCopyValue"
|
||||
:label="t('monitor.logs.protocolConversion.copyParameters')"
|
||||
:success-label="t('common.copied')"
|
||||
:failure-label="t('common.copyFailed')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="parameterRows.length === 0" class="log-conversion-parameters__empty">
|
||||
{{
|
||||
parameterHistoryUnavailable
|
||||
? t('monitor.logs.protocolConversion.historyUnavailable')
|
||||
: t('monitor.logs.protocolConversion.noSafeParameters')
|
||||
}}
|
||||
</p>
|
||||
<div v-else class="log-conversion-parameters__scroll">
|
||||
<div class="log-conversion-parameters__columns" aria-hidden="true">
|
||||
<span>{{ t('monitor.logs.protocolConversion.parameter') }}</span>
|
||||
<span>{{ t('monitor.logs.protocolConversion.clientRequest') }}</span>
|
||||
<span>{{ t('monitor.logs.protocolConversion.convertedUpstream') }}</span>
|
||||
<span>{{ t('monitor.logs.protocolConversion.dispositionLabel') }}</span>
|
||||
</div>
|
||||
<div v-for="row in parameterRows" :key="row.key" class="log-conversion-parameters__row">
|
||||
<code class="log-conversion-parameters__path">
|
||||
{{
|
||||
row.sourcePath === row.targetPath
|
||||
? row.sourcePath
|
||||
: `${row.sourcePath ?? '—'} → ${row.targetPath ?? '—'}`
|
||||
}}
|
||||
</code>
|
||||
<code :data-label="t('monitor.logs.protocolConversion.clientRequest')">
|
||||
{{ row.source?.value ?? '—' }}
|
||||
</code>
|
||||
<code :data-label="t('monitor.logs.protocolConversion.convertedUpstream')">
|
||||
{{ row.target?.value ?? '—' }}
|
||||
</code>
|
||||
<span
|
||||
class="log-conversion-parameters__disposition"
|
||||
:class="`log-conversion-parameters__disposition--${row.change.disposition}`"
|
||||
>
|
||||
{{ t(`monitor.logs.protocolConversion.disposition.${row.change.disposition}`) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -150,6 +314,146 @@ function formatReasoning(value: RequestLogReasoningDto | null, upstream: boolean
|
||||
color: var(--color-warning) !important;
|
||||
}
|
||||
|
||||
.log-conversion-parameters {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-border-subtle) 82%, transparent);
|
||||
padding-top: 9px;
|
||||
}
|
||||
|
||||
.log-conversion-parameters summary {
|
||||
display: flex;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-label-xs);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.log-conversion-parameters summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.log-conversion-parameters__chevron {
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
.log-conversion-parameters[open] .log-conversion-parameters__chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.log-conversion-parameters__badge,
|
||||
.log-conversion-parameters__disposition {
|
||||
border-radius: 999px;
|
||||
padding: 2px 7px;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.log-conversion-parameters__badge--muted {
|
||||
background: color-mix(in srgb, var(--color-text-faint) 12%, transparent);
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
.log-conversion-parameters__badge--warning {
|
||||
background: color-mix(in srgb, var(--color-warning) 14%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.log-conversion-parameters__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 5px 0 8px;
|
||||
color: var(--color-text-faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.log-conversion-parameters__toolbar > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.log-conversion-parameters__scroll {
|
||||
max-height: 344px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.log-conversion-parameters__columns,
|
||||
.log-conversion-parameters__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 0.9fr) minmax(100px, 1fr) minmax(100px, 1fr) minmax(
|
||||
76px,
|
||||
auto
|
||||
);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.log-conversion-parameters__columns {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-faint);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.log-conversion-parameters__row {
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border-subtle) 65%, transparent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.log-conversion-parameters__row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.log-conversion-parameters__row code {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.log-conversion-parameters__path {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.log-conversion-parameters__disposition {
|
||||
justify-self: start;
|
||||
background: color-mix(in srgb, var(--color-text-faint) 10%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-conversion-parameters__disposition--mapped,
|
||||
.log-conversion-parameters__disposition--normalized {
|
||||
background: color-mix(in srgb, var(--color-warning) 13%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.log-conversion-parameters__disposition--dropped {
|
||||
background: color-mix(in srgb, var(--color-danger) 11%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.log-conversion-parameters__disposition--added {
|
||||
background: color-mix(in srgb, var(--color-success) 11%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.log-conversion-parameters__empty {
|
||||
margin: 6px 0 2px;
|
||||
color: var(--color-text-faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.log-conversion-trace__header {
|
||||
display: none;
|
||||
@@ -162,5 +466,34 @@ function formatReasoning(value: RequestLogReasoningDto | null, upstream: boolean
|
||||
.log-conversion-trace__row dt {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.log-conversion-parameters__columns {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.log-conversion-parameters__row {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(76px, auto);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.log-conversion-parameters__path {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.log-conversion-parameters__row code:nth-of-type(2)::before,
|
||||
.log-conversion-parameters__row code:nth-of-type(3)::before {
|
||||
display: block;
|
||||
color: var(--color-text-faint);
|
||||
content: attr(data-label);
|
||||
}
|
||||
|
||||
.log-conversion-parameters__disposition {
|
||||
grid-column: 2;
|
||||
grid-row: 2 / span 2;
|
||||
}
|
||||
|
||||
.log-conversion-parameters__toolbar {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -487,6 +487,8 @@ function toggleAttemptErrorMessage(sequence: number): void {
|
||||
:upstream-model="log.upstream_model"
|
||||
:client-reasoning="log.reasoning"
|
||||
:upstream-reasoning="finalAttempt?.reasoning ?? null"
|
||||
:client-parameters="log.client_parameters"
|
||||
:conversion-trace="finalAttempt?.conversion_trace ?? null"
|
||||
/>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -636,6 +638,8 @@ function toggleAttemptErrorMessage(sequence: number): void {
|
||||
:upstream-model="attempt.upstream_model"
|
||||
:client-reasoning="log.reasoning"
|
||||
:upstream-reasoning="attempt.reasoning"
|
||||
:client-parameters="log.client_parameters"
|
||||
:conversion-trace="attempt.conversion_trace"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showAttemptOperation(attempt)">
|
||||
|
||||
@@ -69,6 +69,7 @@ export const requestLogFailureCategories = [
|
||||
'invalid_key',
|
||||
'upstream_host_error',
|
||||
'client_error',
|
||||
'conversion_unsupported',
|
||||
'downstream_cancel',
|
||||
'ambiguous',
|
||||
] as const
|
||||
|
||||
@@ -4,10 +4,10 @@ import { enabledDataProtocols } from '@/api/control/protocols'
|
||||
import type { UsageFilters } from '@/app/resources/usage'
|
||||
import type { RequestLogFilters } from '@/app/resources/request-logs'
|
||||
import {
|
||||
routeInspectFeatures,
|
||||
routeInspectOperations,
|
||||
type RouteInspectFeature,
|
||||
routeInspectRequirements,
|
||||
type RouteInspectOperation,
|
||||
type RouteInspectRequirement,
|
||||
} from '@/app/resources/route-inspection'
|
||||
import { defaultTimeRange } from '@/lib/time'
|
||||
|
||||
@@ -50,7 +50,7 @@ export interface LogsMonitorState {
|
||||
export interface InspectorMonitorState {
|
||||
protocol?: string
|
||||
operation?: RouteInspectOperation
|
||||
requiredFeatures: RouteInspectFeature[]
|
||||
routeRequirement?: RouteInspectRequirement
|
||||
externalModel?: string
|
||||
accessKeyID?: string
|
||||
run: boolean
|
||||
@@ -210,20 +210,21 @@ export function sameMonitorQuery(left: LocationQueryRaw, right: LocationQueryRaw
|
||||
export function parseInspectorMonitorState(query: Record<string, unknown>): InspectorMonitorState {
|
||||
const protocol = scalarEnum(query.protocol, enabledDataProtocols)
|
||||
const operation = scalarEnum(query.operation, routeInspectOperations)
|
||||
const requiredFeatures = scalarEnumList(query.features, routeInspectFeatures)
|
||||
const routeRequirement = scalarEnum(query.route_requirement, routeInspectRequirements)
|
||||
const externalModel = scalarText(query.external_model)
|
||||
const accessKeyID = scalarPositiveID(query.access_key_id)
|
||||
|
||||
return {
|
||||
protocol,
|
||||
operation,
|
||||
requiredFeatures,
|
||||
routeRequirement,
|
||||
externalModel,
|
||||
accessKeyID,
|
||||
run:
|
||||
query.run === '1' &&
|
||||
protocol !== undefined &&
|
||||
operation !== undefined &&
|
||||
routeRequirement !== undefined &&
|
||||
accessKeyID !== undefined,
|
||||
expandedGroupIDs: parsePositiveIDList(query.expanded_groups),
|
||||
}
|
||||
@@ -234,13 +235,14 @@ export function inspectorMonitorQuery(state: InspectorMonitorState): LocationQue
|
||||
|
||||
if (state.protocol !== undefined) normalized.protocol = state.protocol
|
||||
if (state.operation !== undefined) normalized.operation = state.operation
|
||||
if (state.requiredFeatures.length > 0) normalized.features = state.requiredFeatures.join(',')
|
||||
if (state.routeRequirement !== undefined) normalized.route_requirement = state.routeRequirement
|
||||
if (state.externalModel !== undefined) normalized.external_model = state.externalModel
|
||||
if (state.accessKeyID !== undefined) normalized.access_key_id = state.accessKeyID
|
||||
if (
|
||||
state.run &&
|
||||
state.protocol !== undefined &&
|
||||
state.operation !== undefined &&
|
||||
state.routeRequirement !== undefined &&
|
||||
state.accessKeyID !== undefined
|
||||
) {
|
||||
normalized.run = '1'
|
||||
@@ -290,19 +292,6 @@ function scalarEnum<T extends string>(raw: unknown, values: readonly T[]): T | u
|
||||
return typeof raw === 'string' && values.includes(raw as T) ? (raw as T) : undefined
|
||||
}
|
||||
|
||||
function scalarEnumList<T extends string>(raw: unknown, values: readonly T[]): T[] {
|
||||
if (raw === undefined) return []
|
||||
if (typeof raw !== 'string' || raw === '') return []
|
||||
const entries = raw.split(',')
|
||||
if (
|
||||
entries.some((entry) => !values.includes(entry as T)) ||
|
||||
new Set(entries).size !== entries.length
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return entries.sort() as T[]
|
||||
}
|
||||
|
||||
function scalarUUIDv4(raw: unknown): string | undefined {
|
||||
return typeof raw === 'string' && requestIDPattern.test(raw) ? raw : undefined
|
||||
}
|
||||
|
||||
@@ -389,20 +389,16 @@ export default {
|
||||
responses_input_tokens: 'Count input tokens',
|
||||
responses_passthrough: 'Responses extension operation',
|
||||
},
|
||||
features: {
|
||||
streaming: 'Streaming',
|
||||
tools: 'Tools',
|
||||
reasoning: 'Reasoning',
|
||||
multimodal: 'Multimodal',
|
||||
structured_output: 'Structured output',
|
||||
native_resource_semantics: 'Native resource semantics',
|
||||
routeRequirements: {
|
||||
any: 'Allow protocol conversion (possibly lossy)',
|
||||
native: 'Require a native route',
|
||||
},
|
||||
form: {
|
||||
title: 'Simulated request',
|
||||
label: 'Route inspector inputs',
|
||||
protocol: 'Protocol',
|
||||
operation: 'Operation',
|
||||
features: 'Required capabilities',
|
||||
routeRequirement: 'Route requirement',
|
||||
model: 'Client model',
|
||||
optional: 'Optional',
|
||||
modelPlaceholder: 'Enter a client model',
|
||||
@@ -526,7 +522,8 @@ export default {
|
||||
protocol_filtered: 'AccessKey filters exclude this protocol',
|
||||
model_filtered: 'AccessKey filters exclude this model',
|
||||
model_required_by_filter: 'A model is required by the AccessKey model filter',
|
||||
capability_unsupported: 'The channel does not support the requested capability',
|
||||
operation_unsupported: 'The channel does not support this protocol operation',
|
||||
native_route_required: 'This request requires a native route',
|
||||
no_route_target: 'No route target matches this model and protocol',
|
||||
group_disabled: 'Group is disabled',
|
||||
group_filtered: 'AccessKey filters exclude this Group',
|
||||
@@ -705,6 +702,29 @@ export default {
|
||||
protocolAPI: 'Protocol / API',
|
||||
model: 'Model',
|
||||
reasoning: 'Reasoning',
|
||||
parameters: 'Parameter details',
|
||||
history: 'Historical log',
|
||||
truncated: 'Truncated',
|
||||
redacted: 'Redacted',
|
||||
historyUnavailable: 'Conversion parameters were not recorded in this historical log',
|
||||
noSafeParameters: 'No safe parameters are available to display',
|
||||
copyParameters: 'Copy safe parameters',
|
||||
parameter: 'Parameter',
|
||||
dispositionLabel: 'Disposition',
|
||||
captureState: {
|
||||
captured: 'Captured',
|
||||
partial: 'Partially captured',
|
||||
unavailable: 'Unavailable',
|
||||
skipped_oversize: 'Skipped because request was too large',
|
||||
preflight_blocked: 'Blocked before conversion',
|
||||
},
|
||||
disposition: {
|
||||
preserved: 'Preserved',
|
||||
mapped: 'Mapped',
|
||||
normalized: 'Normalized',
|
||||
dropped: 'Dropped',
|
||||
added: 'Added by conversion',
|
||||
},
|
||||
},
|
||||
routeIdentity: {
|
||||
group: 'Group: {name} (G{id})',
|
||||
@@ -719,6 +739,7 @@ export default {
|
||||
invalid_key: 'Invalid key',
|
||||
upstream_host_error: 'Upstream host error',
|
||||
client_error: 'Client error',
|
||||
conversion_unsupported: 'Protocol conversion unsupported',
|
||||
downstream_cancel: 'Downstream canceled',
|
||||
ambiguous: 'Ambiguous',
|
||||
unknown: 'Unknown category',
|
||||
|
||||
@@ -389,20 +389,16 @@ export default {
|
||||
responses_input_tokens: '入力 Token を計算',
|
||||
responses_passthrough: 'Responses 拡張操作',
|
||||
},
|
||||
features: {
|
||||
streaming: 'ストリーミング',
|
||||
tools: 'ツール呼び出し',
|
||||
reasoning: '推論',
|
||||
multimodal: 'マルチモーダル',
|
||||
structured_output: '構造化出力',
|
||||
native_resource_semantics: 'ネイティブリソースセマンティクス',
|
||||
routeRequirements: {
|
||||
any: 'プロトコル変換を許可(損失の可能性あり)',
|
||||
native: 'ネイティブルートを必須にする',
|
||||
},
|
||||
form: {
|
||||
title: '模擬リクエスト条件',
|
||||
label: 'ルート検査の入力',
|
||||
protocol: 'プロトコル',
|
||||
operation: '操作',
|
||||
features: '必要な機能(複数選択可)',
|
||||
routeRequirement: 'ルート要件',
|
||||
model: 'クライアントモデル',
|
||||
optional: '任意',
|
||||
modelPlaceholder: 'クライアントモデルを入力',
|
||||
@@ -524,7 +520,8 @@ export default {
|
||||
protocol_filtered: 'AccessKey フィルターがこのプロトコルを除外しました',
|
||||
model_filtered: 'AccessKey フィルターがこのモデルを除外しました',
|
||||
model_required_by_filter: 'AccessKey のモデルフィルターによりモデルの指定が必要です',
|
||||
capability_unsupported: 'チャネルはリクエストに必要な機能をサポートしていません',
|
||||
operation_unsupported: 'チャネルはこのプロトコル操作をサポートしていません',
|
||||
native_route_required: 'このリクエストにはネイティブルートが必要です',
|
||||
no_route_target: 'このモデルとプロトコルに一致するルート先がありません',
|
||||
group_disabled: 'Group は無効です',
|
||||
group_filtered: 'AccessKey フィルターがこの Group を除外しました',
|
||||
@@ -704,6 +701,29 @@ export default {
|
||||
protocolAPI: 'プロトコル / API',
|
||||
model: 'モデル',
|
||||
reasoning: '推論',
|
||||
parameters: 'パラメータ詳細',
|
||||
history: '過去のログ',
|
||||
truncated: '切り捨て済み',
|
||||
redacted: 'マスク済み',
|
||||
historyUnavailable: 'この過去のログには変換パラメータが記録されていません',
|
||||
noSafeParameters: '表示できる安全なパラメータはありません',
|
||||
copyParameters: '安全なパラメータをコピー',
|
||||
parameter: 'パラメータ',
|
||||
dispositionLabel: '処理',
|
||||
captureState: {
|
||||
captured: '記録済み',
|
||||
partial: '一部記録',
|
||||
unavailable: '取得不可',
|
||||
skipped_oversize: 'リクエストが大きすぎるためスキップ',
|
||||
preflight_blocked: '変換前にブロック',
|
||||
},
|
||||
disposition: {
|
||||
preserved: '保持',
|
||||
mapped: 'マッピング',
|
||||
normalized: '正規化',
|
||||
dropped: '削除',
|
||||
added: '変換時に追加',
|
||||
},
|
||||
},
|
||||
routeIdentity: {
|
||||
group: 'Group:{name}(G{id})',
|
||||
@@ -718,6 +738,7 @@ export default {
|
||||
invalid_key: '無効なキー',
|
||||
upstream_host_error: 'アップストリームホストエラー',
|
||||
client_error: 'クライアントエラー',
|
||||
conversion_unsupported: 'プロトコル変換は未対応',
|
||||
downstream_cancel: 'ダウンストリームによるキャンセル',
|
||||
ambiguous: '原因不明',
|
||||
unknown: '不明な分類',
|
||||
|
||||
@@ -371,20 +371,16 @@ export default {
|
||||
responses_input_tokens: '计算输入 Token',
|
||||
responses_passthrough: 'Responses 扩展操作',
|
||||
},
|
||||
features: {
|
||||
streaming: '流式',
|
||||
tools: '工具调用',
|
||||
reasoning: '思考',
|
||||
multimodal: '多模态',
|
||||
structured_output: '结构化输出',
|
||||
native_resource_semantics: '原生资源语义',
|
||||
routeRequirements: {
|
||||
any: '允许协议转换(可有损)',
|
||||
native: '必须使用原生路由',
|
||||
},
|
||||
form: {
|
||||
title: '模拟请求条件',
|
||||
label: '路由检查输入',
|
||||
protocol: '协议',
|
||||
operation: '操作',
|
||||
features: '所需能力(可多选)',
|
||||
routeRequirement: '路由要求',
|
||||
model: '客户端模型',
|
||||
optional: '可选',
|
||||
modelPlaceholder: '输入客户端模型',
|
||||
@@ -501,7 +497,8 @@ export default {
|
||||
protocol_filtered: 'AccessKey 筛选排除了该协议',
|
||||
model_filtered: 'AccessKey 筛选排除了该模型',
|
||||
model_required_by_filter: 'AccessKey 模型筛选要求请求提供模型',
|
||||
capability_unsupported: '渠道不支持该请求所需能力',
|
||||
operation_unsupported: '渠道不支持该协议操作',
|
||||
native_route_required: '该请求必须使用原生路由',
|
||||
no_route_target: '没有匹配该模型与协议的路由目标',
|
||||
group_disabled: 'Group 已停用',
|
||||
group_filtered: 'AccessKey 筛选排除了该 Group',
|
||||
@@ -682,6 +679,29 @@ export default {
|
||||
protocolAPI: '协议 / API',
|
||||
model: '模型',
|
||||
reasoning: '思考',
|
||||
parameters: '参数详情',
|
||||
history: '历史日志',
|
||||
truncated: '已截断',
|
||||
redacted: '已脱敏',
|
||||
historyUnavailable: '历史日志未记录转换参数',
|
||||
noSafeParameters: '没有可展示的安全参数',
|
||||
copyParameters: '复制安全参数',
|
||||
parameter: '参数',
|
||||
dispositionLabel: '处理方式',
|
||||
captureState: {
|
||||
captured: '已记录',
|
||||
partial: '部分记录',
|
||||
unavailable: '无法获取',
|
||||
skipped_oversize: '请求过大,已跳过',
|
||||
preflight_blocked: '转换前已拦截',
|
||||
},
|
||||
disposition: {
|
||||
preserved: '保留',
|
||||
mapped: '映射',
|
||||
normalized: '归一化',
|
||||
dropped: '已丢弃',
|
||||
added: '转换新增',
|
||||
},
|
||||
},
|
||||
routeIdentity: {
|
||||
group: 'Group:{name}(G{id})',
|
||||
@@ -696,6 +716,7 @@ export default {
|
||||
invalid_key: '密钥无效',
|
||||
upstream_host_error: '上游主机错误',
|
||||
client_error: '客户端错误',
|
||||
conversion_unsupported: '协议转换不支持',
|
||||
downstream_cancel: '下游取消',
|
||||
ambiguous: '原因不明确',
|
||||
unknown: '未知分类',
|
||||
|
||||
Reference in new issue
Block a user