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:
刘佳林
2026-08-31 19:21:31 +08:00
parent 52374af940
commit 7c01ec9be8
15 changed files with 996 additions and 103 deletions
@@ -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: "",
}),
],
}),
]);
});
});