mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 16:18:29 +08:00
feat(composite): enforce reasoning effort policy
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func ensureCompositeTargetPlatform(c *gin.Context, apiKey *service.APIKey, model string) {
|
||||
@@ -55,3 +58,42 @@ func effectiveAPIKeyPlatform(c *gin.Context, apiKey *service.APIKey) string {
|
||||
}
|
||||
return apiKey.Group.Platform
|
||||
}
|
||||
|
||||
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, bool) {
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return "", nil, false
|
||||
}
|
||||
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
return "", nil, false
|
||||
}
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI {
|
||||
return "", nil, false
|
||||
}
|
||||
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, true
|
||||
}
|
||||
|
||||
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool) {
|
||||
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return body, false
|
||||
}
|
||||
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings)
|
||||
}
|
||||
|
||||
func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) {
|
||||
if c == nil || c.Request == nil {
|
||||
return
|
||||
}
|
||||
// The Messages bridge synthesizes a default OpenAI effort when
|
||||
// output_config.effort is omitted. Bind the group policy only for an
|
||||
// explicit client value so the ceiling does not alter that default.
|
||||
effort := gjson.GetBytes(body, "output_config.effort")
|
||||
if !effort.Exists() || effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" {
|
||||
return
|
||||
}
|
||||
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings))
|
||||
}
|
||||
|
||||
@@ -77,6 +77,46 @@ func TestCompositeTargetPlatformResolvedAllowsConcreteGroupWithoutResolution(t *
|
||||
require.True(t, compositeTargetPlatformResolved(c, apiKey, "llama-4-maverick"))
|
||||
}
|
||||
|
||||
func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
group := &service.Group{
|
||||
Platform: service.PlatformComposite,
|
||||
MaxReasoningEffort: "medium",
|
||||
ReasoningEffortMappings: []service.ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
},
|
||||
}
|
||||
apiKey := &service.APIKey{Group: group}
|
||||
body := []byte(`{"reasoning":{"effort":"max"}}`)
|
||||
|
||||
openAICtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
openAICtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
openAICtx.Request = openAICtx.Request.WithContext(service.WithResolvedTargetPlatform(openAICtx.Request.Context(), service.PlatformOpenAI))
|
||||
got, changed := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got))
|
||||
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`))
|
||||
bound, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound))
|
||||
|
||||
omittedCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI))
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, apiKey, []byte(`{"model":"gpt-5"}`))
|
||||
omitted, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, omitted)
|
||||
|
||||
grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok))
|
||||
got, changed = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
|
||||
func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
@@ -80,10 +80,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI {
|
||||
if cappedBody, changed := service.ApplyOpenAIReasoningEffortPolicy(body, apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings); changed {
|
||||
body = cappedBody
|
||||
}
|
||||
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
|
||||
body = cappedBody
|
||||
}
|
||||
reqStream, ok := parseOpenAICompatibleStream(body)
|
||||
if !ok {
|
||||
|
||||
@@ -312,10 +312,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI {
|
||||
if cappedBody, changed := service.ApplyOpenAIReasoningEffortPolicy(body, apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings); changed {
|
||||
body = cappedBody
|
||||
}
|
||||
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
|
||||
body = cappedBody
|
||||
}
|
||||
|
||||
reqStream, ok := parseOpenAICompatibleStream(body)
|
||||
@@ -932,6 +930,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(c, apiKey, body)
|
||||
routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel)
|
||||
preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel)
|
||||
reqStream := gjson.GetBytes(body, "stream").Bool()
|
||||
@@ -1813,12 +1812,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
zap.Int("candidate_count", scheduleDecision.CandidateCount),
|
||||
)
|
||||
|
||||
maxReasoningEffort := ""
|
||||
var reasoningEffortMappings []service.ReasoningEffortMapping
|
||||
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI {
|
||||
maxReasoningEffort = apiKey.Group.MaxReasoningEffort
|
||||
reasoningEffortMappings = apiKey.Group.ReasoningEffortMappings
|
||||
}
|
||||
maxReasoningEffort, reasoningEffortMappings, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
var requestPayloadHash string
|
||||
// Passthrough rejects overlapping response.create frames, so one immutable
|
||||
// turn-tagged slot preserves the exact mapping used for the in-flight request.
|
||||
|
||||
@@ -41,14 +41,20 @@ func TestAdminService_CreateCompositeGroupCopiesAccountsFromConcreteGroups(t *te
|
||||
svc := &adminServiceImpl{groupRepo: groupRepo}
|
||||
|
||||
group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{
|
||||
Name: "Composite",
|
||||
Platform: PlatformComposite,
|
||||
RateMultiplier: 1,
|
||||
Name: "Composite",
|
||||
Platform: PlatformComposite,
|
||||
RateMultiplier: 1,
|
||||
MaxReasoningEffort: "medium",
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
},
|
||||
CopyAccountsFromGroupIDs: []int64{10, 20, 10},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PlatformComposite, groupRepo.created.Platform)
|
||||
require.Equal(t, "medium", groupRepo.created.MaxReasoningEffort)
|
||||
require.Equal(t, []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, groupRepo.created.ReasoningEffortMappings)
|
||||
require.Equal(t, int64(99), group.ID)
|
||||
require.Equal(t, int64(2), group.AccountCount)
|
||||
require.ElementsMatch(t, []int64{10, 20}, copiedFrom)
|
||||
@@ -82,13 +88,19 @@ func TestAdminService_UpdateCompositeGroupCopiesAccountsFromConcreteGroups(t *te
|
||||
},
|
||||
}
|
||||
svc := &adminServiceImpl{groupRepo: groupRepo}
|
||||
maxReasoningEffort := "low"
|
||||
reasoningEffortMappings := []ReasoningEffortMapping{{From: "max", To: "high"}}
|
||||
|
||||
group, err := svc.UpdateGroup(context.Background(), 99, &UpdateGroupInput{
|
||||
MaxReasoningEffort: &maxReasoningEffort,
|
||||
ReasoningEffortMappings: &reasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: []int64{10, 20},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PlatformComposite, group.Platform)
|
||||
require.Equal(t, "low", group.MaxReasoningEffort)
|
||||
require.Equal(t, reasoningEffortMappings, group.ReasoningEffortMappings)
|
||||
require.Equal(t, int64(99), clearedGroupID)
|
||||
require.ElementsMatch(t, []int64{10, 20}, copiedFrom)
|
||||
require.Equal(t, int64(99), boundGroupID)
|
||||
|
||||
@@ -296,8 +296,8 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi
|
||||
},
|
||||
Group: &Group{
|
||||
ID: groupID,
|
||||
Name: "openai",
|
||||
Platform: PlatformOpenAI,
|
||||
Name: "composite",
|
||||
Platform: PlatformComposite,
|
||||
Status: StatusActive,
|
||||
SubscriptionType: SubscriptionTypeStandard,
|
||||
RateMultiplier: 1,
|
||||
@@ -313,6 +313,7 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi
|
||||
|
||||
require.NotNil(t, roundTrip)
|
||||
require.NotNil(t, roundTrip.Group)
|
||||
require.Equal(t, PlatformComposite, roundTrip.Group.Platform)
|
||||
require.Equal(t, "medium", roundTrip.Group.MaxReasoningEffort)
|
||||
require.Equal(t, apiKey.Group.ReasoningEffortMappings, roundTrip.Group.ReasoningEffortMappings)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -246,6 +247,14 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
}
|
||||
}
|
||||
}
|
||||
if account.Platform == PlatformOpenAI {
|
||||
if policyBody, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, responsesBody); changed {
|
||||
responsesBody = policyBody
|
||||
if responsesReq.Reasoning != nil {
|
||||
responsesReq.Reasoning.Effort = gjson.GetBytes(responsesBody, "reasoning.effort").String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4c. Apply OpenAI fast policy (may filter service_tier or block the request).
|
||||
// Mirrors the Claude anthropic-beta "fast-mode-2026-02-01" filter, but keyed
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -78,6 +79,14 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions(
|
||||
if normalizedBody, normalized := NormalizeGLMOpenAIReasoningEffort(chatBody, upstreamModel); normalized {
|
||||
chatBody = normalizedBody
|
||||
}
|
||||
if account.Platform == PlatformOpenAI {
|
||||
if policyBody, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody); changed {
|
||||
chatBody = policyBody
|
||||
if effectiveEffort := strings.TrimSpace(gjson.GetBytes(chatBody, "reasoning_effort").String()); effectiveEffort != "" {
|
||||
reasoningEffort = &effectiveEffort
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unlike forwardResponsesViaRawChatCompletions, applyOpenAIFastPolicyToBody
|
||||
// is intentionally skipped: Anthropic Messages bodies carry no service_tier,
|
||||
// so the converted Chat Completions body never contains one and the policy
|
||||
|
||||
@@ -54,7 +54,16 @@ func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffo
|
||||
mapped string
|
||||
effortJSON string
|
||||
wantEffort string
|
||||
maxPolicy string
|
||||
}{
|
||||
{
|
||||
name: "policy caps converted effort",
|
||||
model: "gpt-5.6-luna",
|
||||
mapped: "gpt-5.6-luna",
|
||||
effortJSON: `,"output_config":{"effort":"max"}`,
|
||||
wantEffort: "medium",
|
||||
maxPolicy: "medium",
|
||||
},
|
||||
{
|
||||
name: "GPT56 max",
|
||||
model: "luna",
|
||||
@@ -103,7 +112,11 @@ func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffo
|
||||
account.Credentials["model_mapping"] = map[string]any{tt.model: tt.mapped}
|
||||
|
||||
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
|
||||
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, []byte(body), "", "")
|
||||
ctx := context.Background()
|
||||
if tt.maxPolicy != "" {
|
||||
ctx = WithOpenAIReasoningEffortPolicy(ctx, tt.maxPolicy, nil)
|
||||
}
|
||||
result, err := svc.ForwardAsAnthropic(ctx, c, account, []byte(body), "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, tt.mapped, gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
@@ -420,7 +433,7 @@ func TestForwardAsAnthropic_ForceChatCompletionsStreamReadErrorSkipsFinalize(t *
|
||||
func TestForwardAsAnthropic_ResponsesSupportedAccountStillUsesResponsesEndpoint(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
|
||||
body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"output_config":{"effort":"high"},"stream":false}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
@@ -449,12 +462,16 @@ func TestForwardAsAnthropic_ResponsesSupportedAccountStillUsesResponsesEndpoint(
|
||||
openai_compat.ExtraKeyResponsesSupported: true,
|
||||
}
|
||||
|
||||
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
|
||||
ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", nil)
|
||||
result, err := svc.ForwardAsAnthropic(ctx, c, account, body, "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, strings.HasSuffix(upstream.lastReq.URL.Path, "/responses"),
|
||||
"responses-capable account must stay on /v1/responses, got %s", upstream.lastReq.URL.String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
|
||||
require.Equal(t, "medium", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
|
||||
require.NotNil(t, result.ReasoningEffort)
|
||||
require.Equal(t, "medium", *result.ReasoningEffort)
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
|
||||
require.Equal(t, "third-party-client/1.0.0", upstream.lastReq.Header.Get("User-Agent"))
|
||||
require.Equal(t, "opencode", upstream.lastReq.Header.Get("originator"))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -15,6 +16,13 @@ const (
|
||||
|
||||
var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"}
|
||||
|
||||
type openAIReasoningEffortPolicyContextKey struct{}
|
||||
|
||||
type openAIReasoningEffortPolicy struct {
|
||||
maxEffort string
|
||||
mappings []ReasoningEffortMapping
|
||||
}
|
||||
|
||||
// NormalizeMaxReasoningEffort validates and canonicalizes a group policy value.
|
||||
// Empty means that the group does not impose a ceiling.
|
||||
func NormalizeMaxReasoningEffort(raw string) string {
|
||||
@@ -41,7 +49,7 @@ func NormalizeMaxReasoningEffort(raw string) string {
|
||||
}
|
||||
|
||||
func reasoningEffortValuesForPlatform(platform string) []string {
|
||||
if platform != PlatformOpenAI {
|
||||
if platform != PlatformOpenAI && platform != PlatformComposite {
|
||||
return nil
|
||||
}
|
||||
return openAIReasoningEffortValues
|
||||
@@ -54,7 +62,11 @@ func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error
|
||||
|
||||
allowedValues := reasoningEffortValuesForPlatform(platform)
|
||||
if len(allowedValues) == 0 {
|
||||
return "", fmt.Errorf("reasoning effort policy is only supported for platform %q", PlatformOpenAI)
|
||||
return "", fmt.Errorf(
|
||||
"reasoning effort policy is only supported for platforms %q and %q",
|
||||
PlatformOpenAI,
|
||||
PlatformComposite,
|
||||
)
|
||||
}
|
||||
|
||||
value := NormalizeMaxReasoningEffort(raw)
|
||||
@@ -91,7 +103,7 @@ func reasoningEffortRank(raw string) (int, bool) {
|
||||
}
|
||||
|
||||
// NormalizeReasoningEffortMappings validates group mapping rules against the
|
||||
// fixed effort values supported by OpenAI groups.
|
||||
// fixed effort values supported by OpenAI routes.
|
||||
func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapping) ([]ReasoningEffortMapping, error) {
|
||||
if len(raw) > maxReasoningEffortMappings {
|
||||
return nil, fmt.Errorf("reasoning effort mappings cannot exceed %d entries", maxReasoningEffortMappings)
|
||||
@@ -124,6 +136,33 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// WithOpenAIReasoningEffortPolicy binds a group policy to a request after its
|
||||
// concrete target platform has been resolved to OpenAI. The policy is copied so
|
||||
// retries and asynchronous forwarding cannot observe later slice mutations.
|
||||
func WithOpenAIReasoningEffortPolicy(ctx context.Context, maxEffort string, mappings []ReasoningEffortMapping) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
policy := openAIReasoningEffortPolicy{
|
||||
maxEffort: maxEffort,
|
||||
mappings: append([]ReasoningEffortMapping(nil), mappings...),
|
||||
}
|
||||
return context.WithValue(ctx, openAIReasoningEffortPolicyContextKey{}, policy)
|
||||
}
|
||||
|
||||
// ApplyOpenAIReasoningEffortPolicyFromContext applies a policy previously bound
|
||||
// to the request. An unbound request is returned byte-for-byte unchanged.
|
||||
func ApplyOpenAIReasoningEffortPolicyFromContext(ctx context.Context, body []byte) ([]byte, bool) {
|
||||
if ctx == nil {
|
||||
return body, false
|
||||
}
|
||||
policy, ok := ctx.Value(openAIReasoningEffortPolicyContextKey{}).(openAIReasoningEffortPolicy)
|
||||
if !ok {
|
||||
return body, false
|
||||
}
|
||||
return ApplyOpenAIReasoningEffortPolicy(body, policy.maxEffort, policy.mappings)
|
||||
}
|
||||
|
||||
func mapReasoningEffort(raw string, mappings []ReasoningEffortMapping) (string, bool) {
|
||||
value := strings.TrimSpace(raw)
|
||||
canonical := NormalizeMaxReasoningEffort(value)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -28,15 +29,17 @@ func TestNormalizeMaxReasoningEffort(t *testing.T) {
|
||||
|
||||
func TestNormalizeReasoningEffortMappings(t *testing.T) {
|
||||
t.Run("canonicalizes fixed OpenAI values", func(t *testing.T) {
|
||||
got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
|
||||
{From: " MAX ", To: " x-high "},
|
||||
{From: "minimal", To: "high"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
{From: "minimal", To: "high"},
|
||||
}, got)
|
||||
for _, platform := range []string{PlatformOpenAI, PlatformComposite} {
|
||||
got, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{
|
||||
{From: " MAX ", To: " x-high "},
|
||||
{From: "minimal", To: "high"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
{From: "minimal", To: "high"},
|
||||
}, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects empty values", func(t *testing.T) {
|
||||
@@ -55,7 +58,7 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) {
|
||||
t.Run("rejects mappings for non OpenAI platforms", func(t *testing.T) {
|
||||
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} {
|
||||
_, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: "low", To: "high"}})
|
||||
require.ErrorContains(t, err, "only supported for platform \"openai\"")
|
||||
require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"")
|
||||
}
|
||||
|
||||
_, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "none", To: "low"}})
|
||||
@@ -70,16 +73,34 @@ func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) {
|
||||
value, err := normalizeMaxReasoningEffortForPlatform(PlatformOpenAI, "max")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "max", value)
|
||||
value, err = normalizeMaxReasoningEffortForPlatform(PlatformComposite, "max")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "max", value)
|
||||
|
||||
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} {
|
||||
_, err = normalizeMaxReasoningEffortForPlatform(platform, "low")
|
||||
require.ErrorContains(t, err, "only supported for platform \"openai\"")
|
||||
require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"")
|
||||
}
|
||||
|
||||
_, err = normalizeMaxReasoningEffortForPlatform(PlatformOpenAI, "none")
|
||||
require.ErrorContains(t, err, "not supported")
|
||||
}
|
||||
|
||||
func TestOpenAIReasoningEffortPolicyContext(t *testing.T) {
|
||||
body := []byte(`{"reasoning":{"effort":"max"}}`)
|
||||
|
||||
unbound, changed := ApplyOpenAIReasoningEffortPolicyFromContext(context.Background(), body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, unbound)
|
||||
|
||||
mappings := []ReasoningEffortMapping{{From: "max", To: "xhigh"}}
|
||||
ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", mappings)
|
||||
mappings[0].To = "low"
|
||||
got, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "medium", gjson.GetBytes(got, "reasoning.effort").String())
|
||||
}
|
||||
|
||||
func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
Reference in New Issue
Block a user