Merge pull request #6726 from DuskLin/feat/openai-reasoning-effort-mapping-deny

feat(groups): 推理强度映射转发值支持拒绝
This commit is contained in:
Wesley Liddick
2026-09-07 18:50:41 +08:00
committed by GitHub
12 changed files with 274 additions and 29 deletions
+2 -1
View File
@@ -7,7 +7,8 @@ const (
)
// ReasoningEffortMapping rewrites one explicit OpenAI/Codex reasoning effort
// value to another before the group ceiling is applied.
// value to another before the group ceiling is applied. To may also be "deny"
// to reject the request when the matching source value is present.
//
// Model and MatchType optionally scope the rewrite to a request model:
// exact matches the full model id, prefix/suffix match a model-id affix.
@@ -152,6 +152,23 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
var overLimit *service.ReasoningEffortOverLimitError
require.ErrorAs(t, err, &overLimit)
mappingDenyGroup := *group
mappingDenyGroup.MaxReasoningEffort = ""
mappingDenyGroup.ReasoningEffortMappings = []service.ReasoningEffortMapping{
{From: "max", To: service.ReasoningEffortMappingDeny},
}
mappingDenyAPIKey := &service.APIKey{Group: &mappingDenyGroup}
mappingDenyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
mappingDenyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
mappingDenyCtx.Request = mappingDenyCtx.Request.WithContext(service.WithResolvedTargetPlatform(mappingDenyCtx.Request.Context(), service.PlatformOpenAI))
_, changed, err = applyOpenAIReasoningEffortPolicyForRequest(mappingDenyCtx, mappingDenyAPIKey, body)
require.Error(t, err)
require.False(t, changed)
var mappingDenied *service.ReasoningEffortMappingDeniedError
require.ErrorAs(t, err, &mappingDenied)
require.Equal(t, "max", mappingDenied.Requested)
require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy")
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))
@@ -272,10 +272,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
if account.Platform == PlatformOpenAI {
policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, responsesBody)
if policyErr != nil {
var overLimit *ReasoningEffortOverLimitError
if errors.As(policyErr, &overLimit) {
if IsReasoningEffortPolicyDenied(policyErr) {
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
writeAnthropicError(c, http.StatusForbidden, "forbidden_error", overLimit.Error())
writeAnthropicError(c, http.StatusForbidden, "forbidden_error", policyErr.Error())
}
return nil, policyErr
}
@@ -3,7 +3,6 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -83,10 +82,9 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions(
if account.Platform == PlatformOpenAI {
policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody)
if policyErr != nil {
var overLimit *ReasoningEffortOverLimitError
if errors.As(policyErr, &overLimit) {
if IsReasoningEffortPolicyDenied(policyErr) {
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
writeAnthropicError(c, http.StatusForbidden, "forbidden_error", overLimit.Error())
writeAnthropicError(c, http.StatusForbidden, "forbidden_error", policyErr.Error())
}
return nil, policyErr
}
@@ -2,6 +2,7 @@ package service
import (
"context"
"errors"
"fmt"
"strings"
@@ -19,6 +20,9 @@ const (
ReasoningEffortOverLimitDowngrade = "downgrade"
// ReasoningEffortOverLimitDeny rejects the request when the ceiling is exceeded.
ReasoningEffortOverLimitDeny = "deny"
// ReasoningEffortMappingDeny is a mapping target that rejects the request
// when the matching source reasoning effort is present.
ReasoningEffortMappingDeny = "deny"
)
var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"}
@@ -31,6 +35,17 @@ func normalizeReasoningEffortMappingSource(raw string) string {
return NormalizeMaxReasoningEffort(raw)
}
func normalizeReasoningEffortMappingTarget(raw string) string {
if isReasoningEffortMappingDeny(raw) {
return ReasoningEffortMappingDeny
}
return NormalizeMaxReasoningEffort(raw)
}
func isReasoningEffortMappingDeny(raw string) bool {
return strings.EqualFold(strings.TrimSpace(raw), ReasoningEffortMappingDeny)
}
type openAIReasoningEffortPolicyContextKey struct{}
type requestedReasoningEffortContextKey struct{}
@@ -65,6 +80,37 @@ func (e *ReasoningEffortOverLimitError) Error() string {
return fmt.Sprintf("reasoning effort %q exceeds this group's limit of %q", requested, max)
}
// ReasoningEffortMappingDeniedError is returned when a group mapping target is
// set to deny the explicit reasoning effort on the request.
type ReasoningEffortMappingDeniedError struct {
Requested string
}
func (e *ReasoningEffortMappingDeniedError) Error() string {
if e == nil {
return "reasoning effort is denied by this group's mapping policy"
}
requested := strings.TrimSpace(e.Requested)
if requested == "" {
return "reasoning effort is denied by this group's mapping policy"
}
return fmt.Sprintf("reasoning effort %q is denied by this group's mapping policy", requested)
}
// IsReasoningEffortPolicyDenied reports whether err is a local reasoning-effort
// policy rejection (ceiling deny or mapping deny).
func IsReasoningEffortPolicyDenied(err error) bool {
if err == nil {
return false
}
var overLimit *ReasoningEffortOverLimitError
if errors.As(err, &overLimit) {
return true
}
var mappingDenied *ReasoningEffortMappingDeniedError
return errors.As(err, &mappingDenied)
}
// NormalizeMaxReasoningEffort validates and canonicalizes a group policy value.
// Empty means that the group does not impose a ceiling.
func NormalizeMaxReasoningEffort(raw string) string {
@@ -298,7 +344,7 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp
seen := make(map[string]struct{}, len(raw))
for i, mapping := range raw {
from := normalizeReasoningEffortMappingSource(mapping.From)
to := NormalizeMaxReasoningEffort(mapping.To)
to := normalizeReasoningEffortMappingTarget(mapping.To)
if from == "" || to == "" {
return nil, fmt.Errorf("reasoning effort mapping %d contains an empty or unknown value", i+1)
}
@@ -315,8 +361,18 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp
i+1, PlatformOpenAI, PlatformComposite,
)
}
if _, err := normalizeMaxReasoningEffortForPlatform(platform, to); err != nil {
return nil, fmt.Errorf("reasoning effort mapping %d target: %w", i+1, err)
if to != ReasoningEffortMappingDeny {
if _, err := normalizeMaxReasoningEffortForPlatform(platform, to); err != nil {
return nil, fmt.Errorf("reasoning effort mapping %d target: %w", i+1, err)
}
} else if len(reasoningEffortValuesForPlatform(platform)) == 0 {
return nil, fmt.Errorf(
"reasoning effort mapping %d target: reasoning effort policy is only supported for platforms %q, %q, and %q",
i+1,
PlatformAnthropic,
PlatformOpenAI,
PlatformComposite,
)
}
model := strings.TrimSpace(mapping.Model)
if len(model) > maxReasoningEffortModelLen {
@@ -439,9 +495,10 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) {
// ApplyReasoningEffortPolicy applies one mapping (optionally scoped to
// the request model by exact name, prefix, or suffix) and then either caps
// known effort levels or rejects the request when the group is configured to
// deny values above the ceiling. Omitted values remain untouched so upstream
// defaults stay in control. It understands both OpenAI and Anthropic request
// field shapes.
// deny values above the ceiling. A mapping whose target is deny rejects the
// request when that source value is present. Omitted values remain untouched
// so upstream defaults stay in control. It understands both OpenAI and
// Anthropic request field shapes.
func ApplyReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) {
maxRank, hasMax := reasoningEffortRank(maxEffort)
if len(body) == 0 || (!hasMax && len(mappings) == 0) {
@@ -463,7 +520,14 @@ func ApplyReasoningEffortPolicy(body []byte, maxEffort string, mappings []Reason
continue
}
effective, _ := mapReasoningEffort(original, mappings, requestModel)
effective, mapped := mapReasoningEffort(original, mappings, requestModel)
if mapped && isReasoningEffortMappingDeny(effective) {
requested := normalizeReasoningEffortMappingSource(original)
if requested == "" {
requested = original
}
return body, false, &ReasoningEffortMappingDeniedError{Requested: requested}
}
if currentRank, recognized := reasoningEffortRank(effective); recognized {
effective = NormalizeMaxReasoningEffort(effective)
if hasMax && currentRank > maxRank {
@@ -167,6 +167,27 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) {
require.ErrorContains(t, err, "empty or unknown")
})
t.Run("allows deny as a mapping target", func(t *testing.T) {
for _, platform := range []string{PlatformOpenAI, PlatformComposite, PlatformAnthropic} {
got, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: " XHIGH ", To: " DENY "}})
require.NoError(t, err)
require.Equal(t, []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, got)
}
got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
{From: "none", To: "deny"},
{From: "max", To: "deny", MatchType: "prefix", Model: "gpt"},
})
require.NoError(t, err)
require.Equal(t, []ReasoningEffortMapping{
{From: "none", To: ReasoningEffortMappingDeny},
{From: "max", To: ReasoningEffortMappingDeny, MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"},
}, got)
_, err = NormalizeReasoningEffortMappings(PlatformGemini, []ReasoningEffortMapping{{From: "high", To: "deny"}})
require.ErrorContains(t, err, "only supported for platforms")
})
t.Run("supports Anthropic values except minimal", func(t *testing.T) {
got, err := NormalizeReasoningEffortMappings(PlatformAnthropic, []ReasoningEffortMapping{{From: " MAX ", To: " x-high "}})
require.NoError(t, err)
@@ -255,15 +276,16 @@ func TestOpenAIReasoningEffortPolicyContext(t *testing.T) {
func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) {
tests := []struct {
name string
body string
max string
overLimit string
mappings []ReasoningEffortMapping
path string
want string
changed bool
deny bool
name string
body string
max string
overLimit string
mappings []ReasoningEffortMapping
path string
want string
changed bool
deny bool
mappingDeny bool
}{
{name: "nested caps high", body: `{"reasoning":{"effort":"xhigh"}}`, max: "medium", path: "reasoning.effort", want: "medium", changed: true},
{name: "flat caps high", body: `{"reasoning_effort":"high"}`, max: "low", path: "reasoning_effort", want: "low", changed: true},
@@ -419,6 +441,41 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) {
{name: "deny after mapping still over ceiling", body: `{"reasoning":{"effort":"max"}}`, max: "medium", overLimit: ReasoningEffortOverLimitDeny, mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "reasoning.effort", want: "max", deny: true},
{name: "deny allows mapping under ceiling", body: `{"reasoning":{"effort":"max"}}`, max: "medium", overLimit: ReasoningEffortOverLimitDeny, mappings: []ReasoningEffortMapping{{From: "max", To: "low"}}, path: "reasoning.effort", want: "low", changed: true},
{name: "deny ignored without ceiling", body: `{"reasoning_effort":"high"}`, overLimit: ReasoningEffortOverLimitDeny, path: "reasoning_effort", want: "high"},
{name: "mapping deny rejects matching nested effort", body: `{"reasoning":{"effort":"xhigh"}}`, mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, path: "reasoning.effort", want: "xhigh", mappingDeny: true},
{name: "mapping deny rejects matching flat effort", body: `{"reasoning_effort":"X-HIGH"}`, mappings: []ReasoningEffortMapping{{From: "xhigh", To: " DENY "}}, path: "reasoning_effort", want: "X-HIGH", mappingDeny: true},
{name: "mapping deny rejects Anthropic output config", body: `{"output_config":{"effort":"max"}}`, mappings: []ReasoningEffortMapping{{From: "max", To: ReasoningEffortMappingDeny}}, path: "output_config.effort", want: "max", mappingDeny: true},
{name: "mapping deny rejects none source", body: `{"reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: ReasoningEffortMappingDeny}}, path: "reasoning_effort", want: "none", mappingDeny: true},
{
name: "mapping deny respects model scope",
body: `{"model":"gpt-5.6-sol","reasoning":{"effort":"xhigh"}}`,
mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny, MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt-5.6-sol"}},
path: "reasoning.effort",
want: "xhigh",
mappingDeny: true,
},
{
name: "mapping deny skips non matching model",
body: `{"model":"gpt-5.6-terra","reasoning":{"effort":"xhigh"}}`,
mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny, MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5.6-sol"}},
path: "reasoning.effort",
want: "xhigh",
},
{
name: "mapping deny leaves other efforts unchanged",
body: `{"reasoning_effort":"high"}`,
mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}},
path: "reasoning_effort",
want: "high",
},
{
name: "mapping deny takes precedence over ceiling rewrite",
body: `{"reasoning":{"effort":"xhigh"}}`,
max: "medium",
mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}},
path: "reasoning.effort",
want: "xhigh",
mappingDeny: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -427,6 +484,20 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) {
require.Error(t, err)
var overLimit *ReasoningEffortOverLimitError
require.ErrorAs(t, err, &overLimit)
require.True(t, IsReasoningEffortPolicyDenied(err))
require.False(t, changed)
require.Equal(t, tt.body, string(got))
return
}
if tt.mappingDeny {
require.Error(t, err)
var mappingDenied *ReasoningEffortMappingDeniedError
require.ErrorAs(t, err, &mappingDenied)
require.True(t, IsReasoningEffortPolicyDenied(err))
require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy")
if requested := normalizeReasoningEffortMappingSource(tt.want); requested != "" {
require.Equal(t, requested, mappingDenied.Requested)
}
require.False(t, changed)
require.Equal(t, tt.body, string(got))
return
@@ -442,3 +513,14 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) {
})
}
}
func TestReasoningEffortMappingDeniedError(t *testing.T) {
t.Parallel()
err := &ReasoningEffortMappingDeniedError{Requested: "xhigh"}
require.Equal(t, `reasoning effort "xhigh" is denied by this group's mapping policy`, err.Error())
require.True(t, IsReasoningEffortPolicyDenied(err))
require.True(t, IsReasoningEffortPolicyDenied(&ReasoningEffortOverLimitError{Requested: "max", Max: "low"}))
require.False(t, IsReasoningEffortPolicyDenied(nil))
require.False(t, IsReasoningEffortPolicyDenied(context.Canceled))
}
@@ -164,7 +164,7 @@
<Select
:id="`${idPrefix}-${pair.id}-to`"
:model-value="pair.to"
:options="reasoningEffortOptions"
:options="reasoningEffortTargetOptions"
:placeholder="t('admin.groups.form.reasoningEffortToPlaceholder')"
:error="showValidation && !!pairErrors(pair.id).to"
:aria-label="t('admin.groups.form.reasoningEffortTo')"
@@ -218,6 +218,8 @@ import {
normalizeReasoningEffortMatchType,
reasoningEffortOptionsForPlatform,
reasoningEffortSourceOptionsForPlatform,
reasoningEffortTargetOptionsForPlatform,
reasoningEffortMappingDeny,
reasoningEffortOverLimitDeny,
reasoningEffortOverLimitDowngrade,
validateReasoningEffortMappings,
@@ -247,6 +249,16 @@ const reasoningEffortOptions = computed(() =>
const reasoningEffortSourceOptions = computed(() =>
reasoningEffortSourceOptionsForPlatform(props.platform),
);
const reasoningEffortTargetOptions = computed(() =>
reasoningEffortTargetOptionsForPlatform(props.platform).map((option) =>
option.value === reasoningEffortMappingDeny
? {
...option,
label: t("admin.groups.form.reasoningEffortToDeny"),
}
: option,
),
);
const matchTypeOptions = computed(() => [
{
value: "exact",
@@ -47,12 +47,21 @@ describe("ReasoningEffortPolicyFields", () => {
expect(optionValues("edit-group-reasoning-max-effort")).not.toContain(
"none",
);
expect(optionValues("edit-group-reasoning-max-effort")).not.toContain(
"deny",
);
expect(
optionValues(`edit-group-reasoning-${mapping.pairs[0].id}-from`),
).toContain("none");
expect(
optionValues(`edit-group-reasoning-${mapping.pairs[0].id}-from`),
).not.toContain("deny");
expect(
optionValues(`edit-group-reasoning-${mapping.pairs[0].id}-to`),
).not.toContain("none");
expect(
optionValues(`edit-group-reasoning-${mapping.pairs[0].id}-to`),
).toContain("deny");
});
it("renders model scope fields for each mapping", () => {
@@ -866,7 +866,7 @@ export default {
maxReasoningEffortOverLimitDeny: 'Deny access',
maxReasoningEffortOverLimitHint: 'Applies after a ceiling is set. Downgrade rewrites values above the ceiling to the ceiling. Deny rejects the request.',
reasoningEffortMappings: 'Reasoning effort mappings',
reasoningEffortMappingsHint: 'Type and model can both be left empty to match every model. One type and model can hold multiple request mappings, for example prefix gpt mapping both high and xhigh to medium. Exact matches beat affixes, and longer affixes beat shorter ones.',
reasoningEffortMappingsHint: 'Type and model can both be left empty to match every model. One type and model can hold multiple request mappings, for example prefix gpt mapping both high and xhigh to medium. Choose Deny as the forwarded value to reject that request value. Exact matches beat affixes, and longer affixes beat shorter ones.',
addReasoningEffortMapping: 'Add mapping',
addReasoningEffortPair: 'Add request value',
removeReasoningEffortMapping: 'Remove mapping',
@@ -880,6 +880,7 @@ export default {
reasoningEffortModelPlaceholder: 'Empty = all / gpt / gpt-5.4',
reasoningEffortFrom: 'Request value',
reasoningEffortTo: 'Forwarded value',
reasoningEffortToDeny: 'Deny',
reasoningEffortFromPlaceholder: 'Select A',
reasoningEffortToPlaceholder: 'Select B',
fromRequired: 'Select request value A',
@@ -848,7 +848,7 @@ export default {
maxReasoningEffortOverLimitDeny: '拒绝访问',
maxReasoningEffortOverLimitHint: '设置上限后生效。自动降档会将超过上限的请求改写为上限值后转发;拒绝访问则直接返回错误。',
reasoningEffortMappings: '推理强度映射',
reasoningEffortMappingsHint: '类型和模型均可留空,表示匹配全部模型。同一类型和模型下可添加多条请求值映射,例如前缀 gpt 同时将 high、xhigh 转到 medium。精确优先于前后缀,更长前后缀优先。',
reasoningEffortMappingsHint: '类型和模型均可留空,表示匹配全部模型。同一类型和模型下可添加多条请求值映射,例如前缀 gpt 同时将 high、xhigh 转到 medium。转发值可选拒绝,命中对应请求值时直接返回错误。精确优先于前后缀,更长前后缀优先。',
addReasoningEffortMapping: '添加映射',
addReasoningEffortPair: '添加请求值',
removeReasoningEffortMapping: '删除映射',
@@ -862,6 +862,7 @@ export default {
reasoningEffortModelPlaceholder: '留空则全部 / gpt / gpt-5.4',
reasoningEffortFrom: '请求值',
reasoningEffortTo: '转发值',
reasoningEffortToDeny: '拒绝',
reasoningEffortFromPlaceholder: '请选择 A',
reasoningEffortToPlaceholder: '请选择 B',
fromRequired: '请选择请求值 A',
@@ -11,8 +11,11 @@ import {
reasoningEffortMappingsToRows,
reasoningEffortOptionsForPlatform,
reasoningEffortSourceOptionsForPlatform,
reasoningEffortTargetOptionsForPlatform,
reasoningEffortMappingDeny,
reasoningEffortOverLimitDeny,
reasoningEffortOverLimitDowngrade,
normalizeReasoningEffortTargetForPlatform,
supportsReasoningEffortPolicyPlatform,
validateReasoningEffortMappings,
} from "../groupsReasoningEffort";
@@ -38,6 +41,11 @@ describe("groupsReasoningEffort", () => {
(option) => option.value,
),
).toEqual(["none", ...expected]);
expect(
reasoningEffortTargetOptionsForPlatform(platform).map(
(option) => option.value,
),
).toEqual([...expected, reasoningEffortMappingDeny]);
expect(supportsReasoningEffortPolicyPlatform(platform)).toBe(true);
}
expect(
@@ -45,11 +53,17 @@ describe("groupsReasoningEffort", () => {
(option) => option.value,
),
).toEqual(["low", "medium", "high", "xhigh", "max"]);
expect(
reasoningEffortTargetOptionsForPlatform("anthropic").map(
(option) => option.value,
),
).toEqual(["low", "medium", "high", "xhigh", "max", reasoningEffortMappingDeny]);
expect(supportsReasoningEffortPolicyPlatform("anthropic")).toBe(true);
for (const platform of ["gemini", "antigravity", "grok"] as const) {
expect(reasoningEffortOptionsForPlatform(platform)).toEqual([]);
expect(reasoningEffortSourceOptionsForPlatform(platform)).toEqual([]);
expect(reasoningEffortTargetOptionsForPlatform(platform)).toEqual([]);
expect(supportsReasoningEffortPolicyPlatform(platform)).toBe(false);
}
});
@@ -90,6 +104,30 @@ describe("groupsReasoningEffort", () => {
expect(normalizeReasoningEffortForPlatform("openai", "none")).toBe("");
});
it("hydrates and validates deny only as a mapping target", () => {
const rows = reasoningEffortMappingsToRows(
[{ from: " xhigh ", to: " DENY " }],
"openai",
);
expect(reasoningEffortMappingsToAPI(rows)).toEqual([
{ from: "xhigh", to: reasoningEffortMappingDeny },
]);
expect(validateReasoningEffortMappings(rows, "openai")).toEqual({});
expect(normalizeReasoningEffortTargetForPlatform("openai", " Deny ")).toBe(
reasoningEffortMappingDeny,
);
expect(normalizeReasoningEffortForPlatform("openai", "deny")).toBe("");
expect(normalizeReasoningEffortSourceForPlatform("openai", "deny")).toBe("");
const invalidSource = createReasoningEffortMappingRow({
from: "deny",
to: "low",
});
expect(validateReasoningEffortMappings([invalidSource], "openai")).toEqual({
[invalidSource.pairs[0].id]: { from: "unsupportedFrom" },
});
});
it("hydrates model scoped mappings", () => {
const rows = reasoningEffortMappingsToRows(
[
@@ -33,6 +33,7 @@ const reasoningEffortMatchTypes: readonly ReasoningEffortMatchType[] = [
export const reasoningEffortOverLimitDowngrade = "downgrade";
export const reasoningEffortOverLimitDeny = "deny";
export const reasoningEffortMappingDeny = "deny";
const reasoningEffortValuesForPlatform = (
platform: GroupPlatform,
@@ -65,6 +66,17 @@ export function reasoningEffortSourceOptionsForPlatform(
).map((value) => ({ value, label: value }));
}
export function reasoningEffortTargetOptionsForPlatform(
platform: GroupPlatform,
) {
const options = reasoningEffortOptionsForPlatform(platform);
if (options.length === 0) return options;
return [
...options,
{ value: reasoningEffortMappingDeny, label: reasoningEffortMappingDeny },
];
}
export function normalizeReasoningEffortForPlatform(
platform: GroupPlatform,
value: string | null | undefined,
@@ -88,6 +100,17 @@ export function normalizeReasoningEffortSourceForPlatform(
: normalizeReasoningEffortForPlatform(platform, value);
}
export function normalizeReasoningEffortTargetForPlatform(
platform: GroupPlatform,
value: string | null | undefined,
): string {
const normalized = value?.trim().toLowerCase() ?? "";
return supportsReasoningEffortPolicyPlatform(platform) &&
normalized === reasoningEffortMappingDeny
? reasoningEffortMappingDeny
: normalizeReasoningEffortForPlatform(platform, value);
}
export function normalizeReasoningEffortMatchType(
value: string | null | undefined,
): ReasoningEffortMatchType | "" {
@@ -192,7 +215,7 @@ export function reasoningEffortMappingsToRows(
platform,
mapping.from,
);
const to = normalizeReasoningEffortForPlatform(platform, mapping.to);
const to = normalizeReasoningEffortTargetForPlatform(platform, mapping.to);
if (!from || !to) return;
const matchType = normalizeReasoningEffortMatchType(mapping.match_type);
@@ -270,7 +293,7 @@ export function validateReasoningEffortMappings(
}
if (!to) {
errors[pair.id] = { ...errors[pair.id], to: "toRequired" };
} else if (!normalizeReasoningEffortForPlatform(platform, to)) {
} else if (!normalizeReasoningEffortTargetForPlatform(platform, to)) {
errors[pair.id] = { ...errors[pair.id], to: "unsupportedTo" };
}
});