mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-28 16:09:34 +00:00
feat(groups): scope OpenAI reasoning effort mappings by model
Allow OpenAI/Composite group mappings to match by exact model name, prefix, or suffix. Empty type and model still apply to every model. The admin form groups multiple request-to-forward pairs under one type+model scope.
This commit is contained in:
@@ -19,13 +19,18 @@
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<label class="input-label mb-0">
|
||||
{{ t("admin.groups.form.reasoningEffortMappings") }}
|
||||
</label>
|
||||
<div>
|
||||
<label class="input-label mb-0">
|
||||
{{ t("admin.groups.form.reasoningEffortMappings") }}
|
||||
</label>
|
||||
<p class="input-hint mb-0">
|
||||
{{ t("admin.groups.form.reasoningEffortMappingsHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-11 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-primary-600 transition-colors hover:bg-primary-50 hover:text-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500/30 dark:text-primary-400 dark:hover:bg-primary-900/20 dark:hover:text-primary-300"
|
||||
@click="addMapping"
|
||||
class="inline-flex min-h-11 shrink-0 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-primary-600 transition-colors hover:bg-primary-50 hover:text-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500/30 dark:text-primary-400 dark:hover:bg-primary-900/20 dark:hover:text-primary-300"
|
||||
@click="addGroup"
|
||||
>
|
||||
<Icon name="plus" size="sm" />
|
||||
{{ t("admin.groups.form.addReasoningEffortMapping") }}
|
||||
@@ -34,77 +39,151 @@
|
||||
|
||||
<div v-if="mappings.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="row in mappings"
|
||||
:key="row.id"
|
||||
class="rounded-lg border border-gray-200 bg-gray-50/40 p-3 dark:border-dark-600 dark:bg-dark-800/40"
|
||||
v-for="group in mappings"
|
||||
:key="group.id"
|
||||
class="space-y-3 rounded-lg border border-gray-200 bg-gray-50/40 p-3 dark:border-dark-600 dark:bg-dark-800/40"
|
||||
>
|
||||
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)_auto] md:items-start">
|
||||
<div
|
||||
class="grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_1.25rem_minmax(0,1fr)_2.75rem]"
|
||||
>
|
||||
<div>
|
||||
<label :for="`${idPrefix}-${row.id}-from`" class="input-label">
|
||||
{{ t("admin.groups.form.reasoningEffortFrom") }}
|
||||
<label :for="`${idPrefix}-${group.id}-match-type`" class="input-label">
|
||||
{{ t("admin.groups.form.reasoningEffortMatchType") }}
|
||||
</label>
|
||||
<Select
|
||||
:id="`${idPrefix}-${row.id}-from`"
|
||||
:model-value="row.from"
|
||||
:options="reasoningEffortOptions"
|
||||
:placeholder="t('admin.groups.form.reasoningEffortFromPlaceholder')"
|
||||
:error="showValidation && !!validationErrors[row.id]?.from"
|
||||
:aria-label="t('admin.groups.form.reasoningEffortFrom')"
|
||||
:aria-describedby="showValidation && validationErrors[row.id]?.from ? `${idPrefix}-${row.id}-from-error` : undefined"
|
||||
:id="`${idPrefix}-${group.id}-match-type`"
|
||||
:model-value="group.match_type"
|
||||
:options="matchTypeOptions"
|
||||
:placeholder="t('admin.groups.form.reasoningEffortMatchTypePlaceholder')"
|
||||
:error="showValidation && !!groupErrors(group.id).match_type"
|
||||
:aria-label="t('admin.groups.form.reasoningEffortMatchType')"
|
||||
:searchable="false"
|
||||
clearable
|
||||
@update:model-value="updateMapping(row.id, 'from', $event)"
|
||||
@update:model-value="updateGroup(group.id, 'match_type', $event)"
|
||||
/>
|
||||
<p
|
||||
v-if="showValidation && validationErrors[row.id]?.from"
|
||||
:id="`${idPrefix}-${row.id}-from-error`"
|
||||
v-if="showValidation && groupErrors(group.id).match_type"
|
||||
class="mt-1 text-xs text-red-600 dark:text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
{{ mappingErrorText(validationErrors[row.id]?.from) }}
|
||||
{{ mappingErrorText(groupErrors(group.id).match_type) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hidden pt-8 text-gray-400 md:block dark:text-dark-400">
|
||||
<div class="hidden md:block" aria-hidden="true" />
|
||||
|
||||
<div>
|
||||
<label :for="`${idPrefix}-${group.id}-model`" class="input-label">
|
||||
{{ t("admin.groups.form.reasoningEffortModel") }}
|
||||
</label>
|
||||
<input
|
||||
:id="`${idPrefix}-${group.id}-model`"
|
||||
:value="group.model"
|
||||
type="text"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.form.reasoningEffortModelPlaceholder')"
|
||||
:aria-label="t('admin.groups.form.reasoningEffortModel')"
|
||||
@input="onModelInput(group.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-11 items-center justify-center self-end rounded-lg text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/30 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
:title="t('admin.groups.form.removeReasoningEffortMapping')"
|
||||
:aria-label="t('admin.groups.form.removeReasoningEffortMapping')"
|
||||
@click="removeGroup(group.id)"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="showValidation && groupErrors(group.id).duplicateScope"
|
||||
class="text-xs text-red-600 dark:text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
{{ mappingErrorText(groupErrors(group.id).duplicateScope) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="pair in group.pairs"
|
||||
:key="pair.id"
|
||||
class="grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_1.25rem_minmax(0,1fr)_2.75rem]"
|
||||
>
|
||||
<div>
|
||||
<label :for="`${idPrefix}-${pair.id}-from`" class="input-label">
|
||||
{{ t("admin.groups.form.reasoningEffortFrom") }}
|
||||
</label>
|
||||
<Select
|
||||
:id="`${idPrefix}-${pair.id}-from`"
|
||||
:model-value="pair.from"
|
||||
:options="reasoningEffortOptions"
|
||||
:placeholder="t('admin.groups.form.reasoningEffortFromPlaceholder')"
|
||||
:error="showValidation && !!pairErrors(pair.id).from"
|
||||
:aria-label="t('admin.groups.form.reasoningEffortFrom')"
|
||||
:searchable="false"
|
||||
clearable
|
||||
@update:model-value="updatePair(group.id, pair.id, 'from', $event)"
|
||||
/>
|
||||
<p
|
||||
v-if="showValidation && pairErrors(pair.id).from"
|
||||
class="mt-1 text-xs text-red-600 dark:text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
{{ mappingErrorText(pairErrors(pair.id).from) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hidden h-11 items-center justify-center self-end text-gray-400 md:flex dark:text-dark-400">
|
||||
<Icon name="arrowRight" size="sm" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label :for="`${idPrefix}-${row.id}-to`" class="input-label">
|
||||
<label :for="`${idPrefix}-${pair.id}-to`" class="input-label">
|
||||
{{ t("admin.groups.form.reasoningEffortTo") }}
|
||||
</label>
|
||||
<Select
|
||||
:id="`${idPrefix}-${row.id}-to`"
|
||||
:model-value="row.to"
|
||||
:id="`${idPrefix}-${pair.id}-to`"
|
||||
:model-value="pair.to"
|
||||
:options="reasoningEffortOptions"
|
||||
:placeholder="t('admin.groups.form.reasoningEffortToPlaceholder')"
|
||||
:error="showValidation && !!validationErrors[row.id]?.to"
|
||||
:error="showValidation && !!pairErrors(pair.id).to"
|
||||
:aria-label="t('admin.groups.form.reasoningEffortTo')"
|
||||
:aria-describedby="showValidation && validationErrors[row.id]?.to ? `${idPrefix}-${row.id}-to-error` : undefined"
|
||||
:searchable="false"
|
||||
clearable
|
||||
@update:model-value="updateMapping(row.id, 'to', $event)"
|
||||
@update:model-value="updatePair(group.id, pair.id, 'to', $event)"
|
||||
/>
|
||||
<p
|
||||
v-if="showValidation && validationErrors[row.id]?.to"
|
||||
:id="`${idPrefix}-${row.id}-to-error`"
|
||||
v-if="showValidation && pairErrors(pair.id).to"
|
||||
class="mt-1 text-xs text-red-600 dark:text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
{{ mappingErrorText(validationErrors[row.id]?.to) }}
|
||||
{{ mappingErrorText(pairErrors(pair.id).to) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/30 md:mt-6 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
:title="t('admin.groups.form.removeReasoningEffortMapping')"
|
||||
:aria-label="t('admin.groups.form.removeReasoningEffortMapping')"
|
||||
@click="removeMapping(row.id)"
|
||||
class="flex h-11 w-11 items-center justify-center self-end rounded-lg text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/30 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
:title="t('admin.groups.form.removeReasoningEffortPair')"
|
||||
:aria-label="t('admin.groups.form.removeReasoningEffortPair')"
|
||||
@click="removePair(group.id, pair.id)"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-9 items-center gap-1.5 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500/30 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
@click="addPair(group.id)"
|
||||
>
|
||||
<Icon name="plus" size="sm" />
|
||||
{{ t("admin.groups.form.addReasoningEffortPair") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,7 +197,9 @@ import type { GroupPlatform } from "@/types";
|
||||
import Icon from "@/components/icons/Icon.vue";
|
||||
import Select from "@/components/common/Select.vue";
|
||||
import {
|
||||
createReasoningEffortMappingPair,
|
||||
createReasoningEffortMappingRow,
|
||||
normalizeReasoningEffortMatchType,
|
||||
reasoningEffortOptionsForPlatform,
|
||||
validateReasoningEffortMappings,
|
||||
type ReasoningEffortMappingErrorCode,
|
||||
@@ -142,6 +223,20 @@ const showValidation = ref(false);
|
||||
const reasoningEffortOptions = computed(() =>
|
||||
reasoningEffortOptionsForPlatform(props.platform),
|
||||
);
|
||||
const matchTypeOptions = computed(() => [
|
||||
{
|
||||
value: "exact",
|
||||
label: t("admin.groups.form.reasoningEffortMatchExact"),
|
||||
},
|
||||
{
|
||||
value: "prefix",
|
||||
label: t("admin.groups.form.reasoningEffortMatchPrefix"),
|
||||
},
|
||||
{
|
||||
value: "suffix",
|
||||
label: t("admin.groups.form.reasoningEffortMatchSuffix"),
|
||||
},
|
||||
]);
|
||||
const validationErrors = computed(() =>
|
||||
validateReasoningEffortMappings(props.mappings, props.platform),
|
||||
);
|
||||
@@ -149,34 +244,89 @@ const validationErrors = computed(() =>
|
||||
const asString = (value: string | number | boolean | null): string =>
|
||||
value == null ? "" : String(value);
|
||||
|
||||
const groupErrors = (id: string) => validationErrors.value[id] ?? {};
|
||||
const pairErrors = (id: string) => validationErrors.value[id] ?? {};
|
||||
|
||||
const updateMaxEffort = (value: string | number | boolean | null) => {
|
||||
emit("update:maxEffort", asString(value));
|
||||
};
|
||||
|
||||
const updateMapping = (
|
||||
const updateGroup = (
|
||||
id: string,
|
||||
field: "match_type" | "model",
|
||||
value: string | number | boolean | null,
|
||||
) => {
|
||||
const nextValue =
|
||||
field === "match_type"
|
||||
? normalizeReasoningEffortMatchType(asString(value))
|
||||
: asString(value);
|
||||
emit(
|
||||
"update:mappings",
|
||||
props.mappings.map((group) =>
|
||||
group.id === id ? { ...group, [field]: nextValue } : group,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const onModelInput = (id: string, event: Event) => {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
updateGroup(id, "model", target?.value ?? "");
|
||||
};
|
||||
|
||||
const updatePair = (
|
||||
groupId: string,
|
||||
pairId: string,
|
||||
field: "from" | "to",
|
||||
value: string | number | boolean | null,
|
||||
) => {
|
||||
emit(
|
||||
"update:mappings",
|
||||
props.mappings.map((row) =>
|
||||
row.id === id ? { ...row, [field]: asString(value) } : row,
|
||||
props.mappings.map((group) =>
|
||||
group.id === groupId
|
||||
? {
|
||||
...group,
|
||||
pairs: group.pairs.map((pair) =>
|
||||
pair.id === pairId ? { ...pair, [field]: asString(value) } : pair,
|
||||
),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const addMapping = () => {
|
||||
const addGroup = () => {
|
||||
emit("update:mappings", [
|
||||
...props.mappings,
|
||||
createReasoningEffortMappingRow(),
|
||||
]);
|
||||
};
|
||||
|
||||
const removeMapping = (id: string) => {
|
||||
const removeGroup = (id: string) => {
|
||||
emit(
|
||||
"update:mappings",
|
||||
props.mappings.filter((row) => row.id !== id),
|
||||
props.mappings.filter((group) => group.id !== id),
|
||||
);
|
||||
};
|
||||
|
||||
const addPair = (groupId: string) => {
|
||||
emit(
|
||||
"update:mappings",
|
||||
props.mappings.map((group) =>
|
||||
group.id === groupId
|
||||
? { ...group, pairs: [...group.pairs, createReasoningEffortMappingPair()] }
|
||||
: group,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const removePair = (groupId: string, pairId: string) => {
|
||||
emit(
|
||||
"update:mappings",
|
||||
props.mappings.flatMap((group) => {
|
||||
if (group.id !== groupId) return [group];
|
||||
const pairs = group.pairs.filter((pair) => pair.id !== pairId);
|
||||
return pairs.length > 0 ? [{ ...group, pairs }] : [];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ReasoningEffortPolicyFields from "../ReasoningEffortPolicyFields.vue";
|
||||
import { createReasoningEffortMappingRow } from "@/views/admin/groupsReasoningEffort";
|
||||
|
||||
vi.mock("vue-i18n", async () => {
|
||||
const actual = await vi.importActual<typeof import("vue-i18n")>("vue-i18n");
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
describe("ReasoningEffortPolicyFields", () => {
|
||||
it("renders model scope fields for each mapping", () => {
|
||||
const mapping = createReasoningEffortMappingRow({
|
||||
from: "max",
|
||||
to: "low",
|
||||
match_type: "prefix",
|
||||
model: "gpt",
|
||||
});
|
||||
const wrapper = mount(ReasoningEffortPolicyFields, {
|
||||
props: {
|
||||
idPrefix: "create-group-reasoning",
|
||||
platform: "openai",
|
||||
maxEffort: "",
|
||||
mappings: [mapping],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: { props: ["name"], template: '<i :data-icon="name" />' },
|
||||
Select: {
|
||||
props: ["id", "modelValue", "placeholder"],
|
||||
template:
|
||||
'<input :id="id" :value="modelValue ?? \'\'" :placeholder="placeholder" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("admin.groups.form.reasoningEffortMatchType");
|
||||
expect(wrapper.text()).toContain("admin.groups.form.reasoningEffortModel");
|
||||
expect(wrapper.text()).toContain("admin.groups.form.reasoningEffortFrom");
|
||||
expect(wrapper.text()).toContain("admin.groups.form.reasoningEffortTo");
|
||||
expect(wrapper.text()).toContain("admin.groups.form.addReasoningEffortPair");
|
||||
|
||||
const modelInput = wrapper.get(
|
||||
`#create-group-reasoning-${mapping.id}-model`,
|
||||
);
|
||||
expect(modelInput.element).toMatchObject({
|
||||
value: "gpt",
|
||||
placeholder: "admin.groups.form.reasoningEffortModelPlaceholder",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a new mapping group with one empty pair", async () => {
|
||||
const wrapper = mount(ReasoningEffortPolicyFields, {
|
||||
props: {
|
||||
idPrefix: "edit-group-reasoning",
|
||||
platform: "openai",
|
||||
maxEffort: "",
|
||||
mappings: [],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: { props: ["name"], template: '<i :data-icon="name" />' },
|
||||
Select: { template: "<div />" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.get("button").trigger("click");
|
||||
|
||||
const emitted = wrapper.emitted("update:mappings");
|
||||
expect(emitted).toHaveLength(1);
|
||||
expect(emitted?.[0]?.[0]).toEqual([
|
||||
expect.objectContaining({
|
||||
match_type: "",
|
||||
model: "",
|
||||
pairs: [
|
||||
expect.objectContaining({
|
||||
from: "",
|
||||
to: "",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -861,8 +861,18 @@ export default {
|
||||
maxReasoningEffortUnlimited: 'Unlimited (follow request)',
|
||||
maxReasoningEffortHint: 'Limits explicit OpenAI reasoning effort requests only. For Composite groups, it applies only to requests resolved to OpenAI. Higher values are capped; omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.',
|
||||
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.',
|
||||
addReasoningEffortMapping: 'Add mapping',
|
||||
addReasoningEffortPair: 'Add request value',
|
||||
removeReasoningEffortMapping: 'Remove mapping',
|
||||
removeReasoningEffortPair: 'Remove request value',
|
||||
reasoningEffortMatchType: 'Type',
|
||||
reasoningEffortModel: 'Model',
|
||||
reasoningEffortMatchExact: 'Exact',
|
||||
reasoningEffortMatchPrefix: 'Prefix',
|
||||
reasoningEffortMatchSuffix: 'Suffix',
|
||||
reasoningEffortMatchTypePlaceholder: 'All models',
|
||||
reasoningEffortModelPlaceholder: 'Empty = all / gpt / gpt-5.4',
|
||||
reasoningEffortFrom: 'Request value',
|
||||
reasoningEffortTo: 'Forwarded value',
|
||||
reasoningEffortFromPlaceholder: 'Select A',
|
||||
@@ -871,7 +881,9 @@ export default {
|
||||
toRequired: 'Select forwarded value B',
|
||||
unsupportedFrom: 'Request value is not supported by this platform',
|
||||
unsupportedTo: 'Forwarded value is not supported by this platform',
|
||||
duplicateFrom: 'Request value A must be unique',
|
||||
unsupportedMatchType: 'Match type must be exact, prefix, or suffix',
|
||||
duplicateFrom: 'Request value A must be unique within the same model scope',
|
||||
duplicateScope: 'Type and model combination must be unique',
|
||||
exclusiveLabel: 'Exclusive Group',
|
||||
exclusiveHint: 'Exclusive group, can be manually assigned to users',
|
||||
platformLabel: 'Platform Restriction',
|
||||
|
||||
@@ -843,8 +843,18 @@ export default {
|
||||
maxReasoningEffortUnlimited: '不限制(跟随请求)',
|
||||
maxReasoningEffortHint: '仅限制客户端主动请求的 OpenAI reasoning effort;Composite 分组仅对解析到 OpenAI 的请求生效。超过上限时自动降档,不会为缺省请求主动开启推理。上限优先级高于推理强度映射。',
|
||||
reasoningEffortMappings: '推理强度映射',
|
||||
reasoningEffortMappingsHint: '类型和模型均可留空,表示匹配全部模型。同一类型和模型下可添加多条请求值映射,例如前缀 gpt 同时将 high、xhigh 转到 medium。精确优先于前后缀,更长前后缀优先。',
|
||||
addReasoningEffortMapping: '添加映射',
|
||||
addReasoningEffortPair: '添加请求值',
|
||||
removeReasoningEffortMapping: '删除映射',
|
||||
removeReasoningEffortPair: '删除请求值',
|
||||
reasoningEffortMatchType: '类型',
|
||||
reasoningEffortModel: '模型',
|
||||
reasoningEffortMatchExact: '精确',
|
||||
reasoningEffortMatchPrefix: '前缀',
|
||||
reasoningEffortMatchSuffix: '后缀',
|
||||
reasoningEffortMatchTypePlaceholder: '全匹配',
|
||||
reasoningEffortModelPlaceholder: '留空则全部 / gpt / gpt-5.4',
|
||||
reasoningEffortFrom: '请求值',
|
||||
reasoningEffortTo: '转发值',
|
||||
reasoningEffortFromPlaceholder: '请选择 A',
|
||||
@@ -853,7 +863,9 @@ export default {
|
||||
toRequired: '请选择转发值 B',
|
||||
unsupportedFrom: '请求值不受当前平台支持',
|
||||
unsupportedTo: '转发值不受当前平台支持',
|
||||
duplicateFrom: '请求值 A 不能重复',
|
||||
unsupportedMatchType: '匹配类型仅支持精确、前缀或后缀',
|
||||
duplicateFrom: '同一模型范围内请求值不能重复',
|
||||
duplicateScope: '类型和模型组合不能重复',
|
||||
exclusiveLabel: '专属分组',
|
||||
exclusiveHint: '专属分组,可以手动指定给用户',
|
||||
platformLabel: '平台限制',
|
||||
|
||||
@@ -544,9 +544,13 @@ export interface OpenAIMessagesDispatchModelConfig {
|
||||
exact_model_mappings?: Record<string, string>
|
||||
}
|
||||
|
||||
export type ReasoningEffortMatchType = 'exact' | 'prefix' | 'suffix'
|
||||
|
||||
export interface ReasoningEffortMapping {
|
||||
from: string
|
||||
to: string
|
||||
match_type?: ReasoningEffortMatchType
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createReasoningEffortMappingPair,
|
||||
createReasoningEffortMappingRow,
|
||||
normalizeReasoningEffortForPlatform,
|
||||
normalizeReasoningEffortMatchType,
|
||||
reasoningEffortMappingsToAPI,
|
||||
reasoningEffortMappingsToRows,
|
||||
reasoningEffortOptionsForPlatform,
|
||||
@@ -54,6 +56,71 @@ describe("groupsReasoningEffort", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates model scoped mappings", () => {
|
||||
const rows = reasoningEffortMappingsToRows(
|
||||
[
|
||||
{
|
||||
from: "max",
|
||||
to: "low",
|
||||
match_type: "prefix",
|
||||
model: " gpt ",
|
||||
},
|
||||
{
|
||||
from: "max",
|
||||
to: "medium",
|
||||
match_type: "exact",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
],
|
||||
"openai",
|
||||
);
|
||||
|
||||
expect(reasoningEffortMappingsToAPI(rows)).toEqual([
|
||||
{ from: "max", to: "low", match_type: "prefix", model: "gpt" },
|
||||
{ from: "max", to: "medium", match_type: "exact", model: "gpt-5.4" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups multiple request mappings under the same type and model", () => {
|
||||
const rows = reasoningEffortMappingsToRows(
|
||||
[
|
||||
{ from: "high", to: "medium", match_type: "prefix", model: "gpt" },
|
||||
{ from: "xhigh", to: "medium", match_type: "prefix", model: "GPT" },
|
||||
],
|
||||
"openai",
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].match_type).toBe("prefix");
|
||||
expect(rows[0].model).toBe("gpt");
|
||||
expect(rows[0].pairs.map((pair) => pair.from)).toEqual(["high", "xhigh"]);
|
||||
expect(reasoningEffortMappingsToAPI(rows)).toEqual([
|
||||
{ from: "high", to: "medium", match_type: "prefix", model: "gpt" },
|
||||
{ from: "xhigh", to: "medium", match_type: "prefix", model: "gpt" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits empty model scopes from the API payload", () => {
|
||||
const row = createReasoningEffortMappingRow({
|
||||
from: "max",
|
||||
to: "low",
|
||||
match_type: "prefix",
|
||||
});
|
||||
expect(reasoningEffortMappingsToAPI([row])).toEqual([
|
||||
{ from: "max", to: "low" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates suffix mappings", () => {
|
||||
const rows = reasoningEffortMappingsToRows(
|
||||
[{ from: "max", to: "low", match_type: "suffix", model: " mini " }],
|
||||
"openai",
|
||||
);
|
||||
expect(reasoningEffortMappingsToAPI(rows)).toEqual([
|
||||
{ from: "max", to: "low", match_type: "suffix", model: "mini" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears values unsupported by OpenAI or used on another platform", () => {
|
||||
expect(normalizeReasoningEffortForPlatform("openai", " MAX ")).toBe("max");
|
||||
expect(normalizeReasoningEffortForPlatform("composite", " MAX ")).toBe(
|
||||
@@ -63,13 +130,23 @@ describe("groupsReasoningEffort", () => {
|
||||
expect(normalizeReasoningEffortForPlatform("openai", "none")).toBe("");
|
||||
});
|
||||
|
||||
it("normalizes match type to exact, prefix, suffix, or empty", () => {
|
||||
expect(normalizeReasoningEffortMatchType("PREFIX")).toBe("prefix");
|
||||
expect(normalizeReasoningEffortMatchType("suffix")).toBe("suffix");
|
||||
expect(normalizeReasoningEffortMatchType("exact")).toBe("exact");
|
||||
expect(normalizeReasoningEffortMatchType("")).toBe("");
|
||||
expect(normalizeReasoningEffortMatchType("wildcard")).toBe("");
|
||||
});
|
||||
|
||||
it("requires both sides of every mapping", () => {
|
||||
const first = createReasoningEffortMappingRow({ to: "low" });
|
||||
const second = createReasoningEffortMappingRow({ from: "max" });
|
||||
|
||||
expect(validateReasoningEffortMappings([first, second])).toEqual({
|
||||
[first.id]: { from: "fromRequired" },
|
||||
[second.id]: { to: "toRequired" },
|
||||
[first.id]: { duplicateScope: "duplicateScope" },
|
||||
[second.id]: { duplicateScope: "duplicateScope" },
|
||||
[first.pairs[0].id]: { from: "fromRequired" },
|
||||
[second.pairs[0].id]: { to: "toRequired" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,15 +155,97 @@ describe("groupsReasoningEffort", () => {
|
||||
const second = createReasoningEffortMappingRow({ from: " max ", to: "high" });
|
||||
|
||||
expect(validateReasoningEffortMappings([first, second])).toEqual({
|
||||
[first.id]: { from: "duplicateFrom" },
|
||||
[second.id]: { from: "duplicateFrom" },
|
||||
[first.id]: { duplicateScope: "duplicateScope" },
|
||||
[second.id]: { duplicateScope: "duplicateScope" },
|
||||
[first.pairs[0].id]: { from: "duplicateFrom" },
|
||||
[second.pairs[0].id]: { from: "duplicateFrom" },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows the same source across different model scopes", () => {
|
||||
const prefix = createReasoningEffortMappingRow({
|
||||
from: "max",
|
||||
to: "low",
|
||||
match_type: "prefix",
|
||||
model: "gpt",
|
||||
});
|
||||
const exact = createReasoningEffortMappingRow({
|
||||
from: "max",
|
||||
to: "medium",
|
||||
match_type: "exact",
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
const global = createReasoningEffortMappingRow({ from: "max", to: "high" });
|
||||
|
||||
expect(validateReasoningEffortMappings([prefix, exact, global])).toEqual({});
|
||||
});
|
||||
|
||||
it("allows multiple request values in one model scope", () => {
|
||||
const row = createReasoningEffortMappingRow({
|
||||
from: "high",
|
||||
to: "medium",
|
||||
match_type: "prefix",
|
||||
model: "gpt",
|
||||
});
|
||||
row.pairs.push(
|
||||
createReasoningEffortMappingPair({ from: "xhigh", to: "medium" }),
|
||||
);
|
||||
|
||||
expect(validateReasoningEffortMappings([row])).toEqual({});
|
||||
expect(reasoningEffortMappingsToAPI([row])).toEqual([
|
||||
{ from: "high", to: "medium", match_type: "prefix", model: "gpt" },
|
||||
{ from: "xhigh", to: "medium", match_type: "prefix", model: "gpt" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects duplicate source values within the same model scope", () => {
|
||||
const first = createReasoningEffortMappingRow({
|
||||
from: "max",
|
||||
to: "low",
|
||||
match_type: "prefix",
|
||||
model: "GPT",
|
||||
});
|
||||
const second = createReasoningEffortMappingRow({
|
||||
from: "MAX",
|
||||
to: "high",
|
||||
match_type: "prefix",
|
||||
model: " gpt ",
|
||||
});
|
||||
|
||||
expect(validateReasoningEffortMappings([first, second])).toEqual({
|
||||
[first.id]: { duplicateScope: "duplicateScope" },
|
||||
[second.id]: { duplicateScope: "duplicateScope" },
|
||||
[first.pairs[0].id]: { from: "duplicateFrom" },
|
||||
[second.pairs[0].id]: { from: "duplicateFrom" },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows empty type and model as a global mapping", () => {
|
||||
const row = createReasoningEffortMappingRow({
|
||||
from: "max",
|
||||
to: "low",
|
||||
});
|
||||
expect(row.match_type).toBe("");
|
||||
expect(row.model).toBe("");
|
||||
expect(validateReasoningEffortMappings([row], "openai")).toEqual({});
|
||||
});
|
||||
|
||||
it("treats prefix without a model as a global mapping", () => {
|
||||
const row = createReasoningEffortMappingRow({
|
||||
from: "max",
|
||||
to: "low",
|
||||
match_type: "prefix",
|
||||
});
|
||||
expect(validateReasoningEffortMappings([row], "openai")).toEqual({});
|
||||
expect(reasoningEffortMappingsToAPI([row])).toEqual([
|
||||
{ from: "max", to: "low" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects custom mappings", () => {
|
||||
const row = createReasoningEffortMappingRow({ from: "ultra", to: "high" });
|
||||
expect(validateReasoningEffortMappings([row], "openai")).toEqual({
|
||||
[row.id]: { from: "unsupportedFrom" },
|
||||
[row.pairs[0].id]: { from: "unsupportedFrom" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { GroupPlatform, ReasoningEffortMapping } from "@/types";
|
||||
import type {
|
||||
GroupPlatform,
|
||||
ReasoningEffortMapping,
|
||||
ReasoningEffortMatchType,
|
||||
} from "@/types";
|
||||
|
||||
const openAIReasoningEffortValues = [
|
||||
"minimal",
|
||||
@@ -9,6 +13,12 @@ const openAIReasoningEffortValues = [
|
||||
"max",
|
||||
] as const;
|
||||
|
||||
const reasoningEffortMatchTypes: readonly ReasoningEffortMatchType[] = [
|
||||
"exact",
|
||||
"prefix",
|
||||
"suffix",
|
||||
];
|
||||
|
||||
const reasoningEffortValuesForPlatform = (
|
||||
platform: GroupPlatform,
|
||||
): readonly string[] =>
|
||||
@@ -41,55 +51,144 @@ export function normalizeReasoningEffortForPlatform(
|
||||
: "";
|
||||
}
|
||||
|
||||
export interface ReasoningEffortMappingRow extends ReasoningEffortMapping {
|
||||
export function normalizeReasoningEffortMatchType(
|
||||
value: string | null | undefined,
|
||||
): ReasoningEffortMatchType | "" {
|
||||
const normalized = value?.trim().toLowerCase() ?? "";
|
||||
return reasoningEffortMatchTypes.some((allowed) => allowed === normalized)
|
||||
? (normalized as ReasoningEffortMatchType)
|
||||
: "";
|
||||
}
|
||||
|
||||
export interface ReasoningEffortMappingPair {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface ReasoningEffortMappingRow {
|
||||
id: string;
|
||||
match_type: ReasoningEffortMatchType | "";
|
||||
model: string;
|
||||
pairs: ReasoningEffortMappingPair[];
|
||||
}
|
||||
|
||||
export type ReasoningEffortMappingErrorCode =
|
||||
| "fromRequired"
|
||||
| "toRequired"
|
||||
| "duplicateFrom"
|
||||
| "duplicateScope"
|
||||
| "unsupportedFrom"
|
||||
| "unsupportedTo";
|
||||
| "unsupportedTo"
|
||||
| "unsupportedMatchType";
|
||||
|
||||
export type ReasoningEffortMappingErrors = Record<
|
||||
string,
|
||||
Partial<Record<"from" | "to", ReasoningEffortMappingErrorCode>>
|
||||
Partial<
|
||||
Record<
|
||||
"from" | "to" | "match_type" | "duplicateScope",
|
||||
ReasoningEffortMappingErrorCode
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
let nextMappingRowID = 0;
|
||||
|
||||
export function createReasoningEffortMappingPair(
|
||||
pair: Partial<Pick<ReasoningEffortMapping, "from" | "to">> = {},
|
||||
): ReasoningEffortMappingPair {
|
||||
nextMappingRowID += 1;
|
||||
return {
|
||||
id: `reasoning-effort-pair-${nextMappingRowID}`,
|
||||
from: pair.from ?? "",
|
||||
to: pair.to ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function createReasoningEffortMappingRow(
|
||||
mapping: Partial<ReasoningEffortMapping> = {},
|
||||
mapping: Partial<Omit<ReasoningEffortMapping, "match_type">> & {
|
||||
match_type?: ReasoningEffortMatchType | "";
|
||||
} = {},
|
||||
): ReasoningEffortMappingRow {
|
||||
nextMappingRowID += 1;
|
||||
return {
|
||||
id: `reasoning-effort-mapping-${nextMappingRowID}`,
|
||||
from: mapping.from ?? "",
|
||||
to: mapping.to ?? "",
|
||||
id: `reasoning-effort-group-${nextMappingRowID}`,
|
||||
match_type: normalizeReasoningEffortMatchType(mapping.match_type),
|
||||
model: mapping.model?.trim() ?? "",
|
||||
pairs: [
|
||||
createReasoningEffortMappingPair({
|
||||
from: mapping.from,
|
||||
to: mapping.to,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function mappingScopeKey(
|
||||
matchType: string | null | undefined,
|
||||
model: string | null | undefined,
|
||||
): string {
|
||||
const trimmedModel = model?.trim().toLowerCase() ?? "";
|
||||
const normalizedType = trimmedModel
|
||||
? normalizeReasoningEffortMatchType(matchType) || "exact"
|
||||
: "";
|
||||
return `${normalizedType}\0${trimmedModel}`;
|
||||
}
|
||||
|
||||
export function reasoningEffortMappingsToRows(
|
||||
mappings?: ReasoningEffortMapping[] | null,
|
||||
platform: GroupPlatform = "openai",
|
||||
): ReasoningEffortMappingRow[] {
|
||||
return (mappings ?? []).flatMap((mapping) => {
|
||||
const groups: ReasoningEffortMappingRow[] = [];
|
||||
const indexByScope = new Map<string, number>();
|
||||
|
||||
(mappings ?? []).forEach((mapping) => {
|
||||
const from = normalizeReasoningEffortForPlatform(platform, mapping.from);
|
||||
const to = normalizeReasoningEffortForPlatform(platform, mapping.to);
|
||||
return from && to
|
||||
? [createReasoningEffortMappingRow({ from, to })]
|
||||
: [];
|
||||
if (!from || !to) return;
|
||||
|
||||
const matchType = normalizeReasoningEffortMatchType(mapping.match_type);
|
||||
const model = mapping.model?.trim() ?? "";
|
||||
const scope = mappingScopeKey(matchType, model);
|
||||
const existingIndex = indexByScope.get(scope);
|
||||
if (existingIndex !== undefined) {
|
||||
groups[existingIndex].pairs.push(
|
||||
createReasoningEffortMappingPair({ from, to }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const group = createReasoningEffortMappingRow({
|
||||
from,
|
||||
to,
|
||||
match_type: matchType,
|
||||
model,
|
||||
});
|
||||
indexByScope.set(scope, groups.length);
|
||||
groups.push(group);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function reasoningEffortMappingsToAPI(
|
||||
rows: ReasoningEffortMappingRow[],
|
||||
): ReasoningEffortMapping[] {
|
||||
return rows.map((row) => ({
|
||||
from: row.from.trim(),
|
||||
to: row.to.trim(),
|
||||
}));
|
||||
return rows.flatMap((row) => {
|
||||
const model = row.model.trim();
|
||||
return row.pairs.map((pair) => {
|
||||
const mapping: ReasoningEffortMapping = {
|
||||
from: pair.from.trim(),
|
||||
to: pair.to.trim(),
|
||||
};
|
||||
if (model) {
|
||||
mapping.match_type =
|
||||
normalizeReasoningEffortMatchType(row.match_type) || "exact";
|
||||
mapping.model = model;
|
||||
}
|
||||
return mapping;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function validateReasoningEffortMappings(
|
||||
@@ -97,30 +196,49 @@ export function validateReasoningEffortMappings(
|
||||
platform: GroupPlatform = "openai",
|
||||
): ReasoningEffortMappingErrors {
|
||||
const errors: ReasoningEffortMappingErrors = {};
|
||||
const sourceRows = new Map<string, ReasoningEffortMappingRow[]>();
|
||||
const scopeGroups = new Map<string, ReasoningEffortMappingRow[]>();
|
||||
const sourcePairs = new Map<string, ReasoningEffortMappingPair[]>();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const from = row.from.trim();
|
||||
const to = row.to.trim();
|
||||
if (!from) {
|
||||
errors[row.id] = { ...errors[row.id], from: "fromRequired" };
|
||||
} else if (!normalizeReasoningEffortForPlatform(platform, from)) {
|
||||
errors[row.id] = { ...errors[row.id], from: "unsupportedFrom" };
|
||||
} else {
|
||||
const key = from.toLowerCase();
|
||||
sourceRows.set(key, [...(sourceRows.get(key) ?? []), row]);
|
||||
}
|
||||
if (!to) {
|
||||
errors[row.id] = { ...errors[row.id], to: "toRequired" };
|
||||
} else if (!normalizeReasoningEffortForPlatform(platform, to)) {
|
||||
errors[row.id] = { ...errors[row.id], to: "unsupportedTo" };
|
||||
const rawMatchType = row.match_type.trim().toLowerCase();
|
||||
const matchType = normalizeReasoningEffortMatchType(row.match_type);
|
||||
if (rawMatchType && !matchType) {
|
||||
errors[row.id] = { ...errors[row.id], match_type: "unsupportedMatchType" };
|
||||
}
|
||||
|
||||
const scope = mappingScopeKey(row.match_type, row.model);
|
||||
scopeGroups.set(scope, [...(scopeGroups.get(scope) ?? []), row]);
|
||||
|
||||
row.pairs.forEach((pair) => {
|
||||
const from = pair.from.trim();
|
||||
const to = pair.to.trim();
|
||||
if (!from) {
|
||||
errors[pair.id] = { ...errors[pair.id], from: "fromRequired" };
|
||||
} else if (!normalizeReasoningEffortForPlatform(platform, from)) {
|
||||
errors[pair.id] = { ...errors[pair.id], from: "unsupportedFrom" };
|
||||
} else {
|
||||
const key = `${scope}\0${from.toLowerCase()}`;
|
||||
sourcePairs.set(key, [...(sourcePairs.get(key) ?? []), pair]);
|
||||
}
|
||||
if (!to) {
|
||||
errors[pair.id] = { ...errors[pair.id], to: "toRequired" };
|
||||
} else if (!normalizeReasoningEffortForPlatform(platform, to)) {
|
||||
errors[pair.id] = { ...errors[pair.id], to: "unsupportedTo" };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
sourceRows.forEach((duplicateRows) => {
|
||||
if (duplicateRows.length < 2) return;
|
||||
duplicateRows.forEach((row) => {
|
||||
errors[row.id] = { ...errors[row.id], from: "duplicateFrom" };
|
||||
scopeGroups.forEach((duplicateGroups) => {
|
||||
if (duplicateGroups.length < 2) return;
|
||||
duplicateGroups.forEach((row) => {
|
||||
errors[row.id] = { ...errors[row.id], duplicateScope: "duplicateScope" };
|
||||
});
|
||||
});
|
||||
|
||||
sourcePairs.forEach((duplicatePairs) => {
|
||||
if (duplicatePairs.length < 2) return;
|
||||
duplicatePairs.forEach((pair) => {
|
||||
errors[pair.id] = { ...errors[pair.id], from: "duplicateFrom" };
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user