mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 00:24:38 +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: "",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user