mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 00:24:38 +00:00
Merge pull request #4674 from AdrianZhaoDev/main
feat(groups): add OpenAI reasoning policy
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label :for="`${idPrefix}-max-effort`" class="input-label">
|
||||
{{ t("admin.groups.form.maxReasoningEffort") }}
|
||||
</label>
|
||||
<Select
|
||||
:id="`${idPrefix}-max-effort`"
|
||||
:model-value="maxEffort"
|
||||
:options="reasoningEffortOptions"
|
||||
:placeholder="t('admin.groups.form.maxReasoningEffortUnlimited')"
|
||||
:aria-label="t('admin.groups.form.maxReasoningEffort')"
|
||||
:searchable="false"
|
||||
clearable
|
||||
@update:model-value="updateMaxEffort"
|
||||
/>
|
||||
<p class="input-hint">{{ t("admin.groups.form.maxReasoningEffortHint") }}</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
<Icon name="plus" size="sm" />
|
||||
{{ t("admin.groups.form.addReasoningEffortMapping") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)_auto] md:items-start">
|
||||
<div>
|
||||
<label :for="`${idPrefix}-${row.id}-from`" class="input-label">
|
||||
{{ t("admin.groups.form.reasoningEffortFrom") }}
|
||||
</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"
|
||||
:searchable="false"
|
||||
clearable
|
||||
@update:model-value="updateMapping(row.id, 'from', $event)"
|
||||
/>
|
||||
<p
|
||||
v-if="showValidation && validationErrors[row.id]?.from"
|
||||
:id="`${idPrefix}-${row.id}-from-error`"
|
||||
class="mt-1 text-xs text-red-600 dark:text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
{{ mappingErrorText(validationErrors[row.id]?.from) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hidden pt-8 text-gray-400 md:block dark:text-dark-400">
|
||||
<Icon name="arrowRight" size="sm" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label :for="`${idPrefix}-${row.id}-to`" class="input-label">
|
||||
{{ t("admin.groups.form.reasoningEffortTo") }}
|
||||
</label>
|
||||
<Select
|
||||
:id="`${idPrefix}-${row.id}-to`"
|
||||
:model-value="row.to"
|
||||
:options="reasoningEffortOptions"
|
||||
:placeholder="t('admin.groups.form.reasoningEffortToPlaceholder')"
|
||||
:error="showValidation && !!validationErrors[row.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)"
|
||||
/>
|
||||
<p
|
||||
v-if="showValidation && validationErrors[row.id]?.to"
|
||||
:id="`${idPrefix}-${row.id}-to-error`"
|
||||
class="mt-1 text-xs text-red-600 dark:text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
{{ mappingErrorText(validationErrors[row.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)"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { GroupPlatform } from "@/types";
|
||||
import Icon from "@/components/icons/Icon.vue";
|
||||
import Select from "@/components/common/Select.vue";
|
||||
import {
|
||||
createReasoningEffortMappingRow,
|
||||
reasoningEffortOptionsForPlatform,
|
||||
validateReasoningEffortMappings,
|
||||
type ReasoningEffortMappingErrorCode,
|
||||
type ReasoningEffortMappingRow,
|
||||
} from "@/views/admin/groupsReasoningEffort";
|
||||
|
||||
const props = defineProps<{
|
||||
idPrefix: string;
|
||||
platform: GroupPlatform;
|
||||
maxEffort: string;
|
||||
mappings: ReasoningEffortMappingRow[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:maxEffort", value: string): void;
|
||||
(event: "update:mappings", value: ReasoningEffortMappingRow[]): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const showValidation = ref(false);
|
||||
const reasoningEffortOptions = computed(() =>
|
||||
reasoningEffortOptionsForPlatform(props.platform),
|
||||
);
|
||||
const validationErrors = computed(() =>
|
||||
validateReasoningEffortMappings(props.mappings, props.platform),
|
||||
);
|
||||
|
||||
const asString = (value: string | number | boolean | null): string =>
|
||||
value == null ? "" : String(value);
|
||||
|
||||
const updateMaxEffort = (value: string | number | boolean | null) => {
|
||||
emit("update:maxEffort", asString(value));
|
||||
};
|
||||
|
||||
const updateMapping = (
|
||||
id: string,
|
||||
field: "from" | "to",
|
||||
value: string | number | boolean | null,
|
||||
) => {
|
||||
emit(
|
||||
"update:mappings",
|
||||
props.mappings.map((row) =>
|
||||
row.id === id ? { ...row, [field]: asString(value) } : row,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const addMapping = () => {
|
||||
emit("update:mappings", [
|
||||
...props.mappings,
|
||||
createReasoningEffortMappingRow(),
|
||||
]);
|
||||
};
|
||||
|
||||
const removeMapping = (id: string) => {
|
||||
emit(
|
||||
"update:mappings",
|
||||
props.mappings.filter((row) => row.id !== id),
|
||||
);
|
||||
};
|
||||
|
||||
const mappingErrorText = (
|
||||
code: ReasoningEffortMappingErrorCode | undefined,
|
||||
): string => (code ? t(`admin.groups.form.${code}`) : "");
|
||||
|
||||
const validate = (): boolean => {
|
||||
showValidation.value = true;
|
||||
return Object.keys(validationErrors.value).length === 0;
|
||||
};
|
||||
|
||||
const resetValidation = () => {
|
||||
showValidation.value = false;
|
||||
};
|
||||
|
||||
defineExpose({ validate, resetValidation });
|
||||
</script>
|
||||
@@ -7,7 +7,9 @@
|
||||
:disabled="disabled"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-haspopup="true"
|
||||
aria-label="Select option"
|
||||
:id="id"
|
||||
:aria-label="ariaLabel ?? 'Select option'"
|
||||
:aria-describedby="ariaDescribedby"
|
||||
:class="[
|
||||
'select-trigger',
|
||||
isOpen && 'select-trigger-open',
|
||||
@@ -65,6 +67,7 @@
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="searchPlaceholderText"
|
||||
:aria-label="searchPlaceholderText"
|
||||
class="select-search-input"
|
||||
@click.stop
|
||||
/>
|
||||
@@ -148,6 +151,9 @@ interface Props {
|
||||
creatable?: boolean
|
||||
creatablePrefix?: string
|
||||
clearable?: boolean
|
||||
id?: string
|
||||
ariaLabel?: string
|
||||
ariaDescribedby?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
|
||||
@@ -848,6 +848,21 @@ export default {
|
||||
rpmLimit: 'Requests Per Minute (RPM)',
|
||||
rpmLimitPlaceholder: '0 = unlimited',
|
||||
rpmLimitHint: 'Max requests per minute for each user in this group; 0 = unlimited. Once set, it takes over per-user rate limiting in this group (overrides the user-level rpm_limit fallback).',
|
||||
maxReasoningEffort: 'Max reasoning effort',
|
||||
maxReasoningEffortUnlimited: 'Unlimited (follow request)',
|
||||
maxReasoningEffortHint: 'Limits explicit OpenAI reasoning effort requests only. Higher values are capped; omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.',
|
||||
reasoningEffortMappings: 'Reasoning effort mappings',
|
||||
addReasoningEffortMapping: 'Add mapping',
|
||||
removeReasoningEffortMapping: 'Remove mapping',
|
||||
reasoningEffortFrom: 'Request value',
|
||||
reasoningEffortTo: 'Forwarded value',
|
||||
reasoningEffortFromPlaceholder: 'Select A',
|
||||
reasoningEffortToPlaceholder: 'Select B',
|
||||
fromRequired: 'Select request value A',
|
||||
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',
|
||||
exclusiveLabel: 'Exclusive Group',
|
||||
exclusiveHint: 'Exclusive group, can be manually assigned to users',
|
||||
platformLabel: 'Platform Restriction',
|
||||
|
||||
@@ -830,6 +830,21 @@ export default {
|
||||
rpmLimit: '每分钟请求数 (RPM)',
|
||||
rpmLimitPlaceholder: '0 表示不限制',
|
||||
rpmLimitHint: '每用户在本分组每分钟最大请求数,0 = 不限制;一旦设置即接管该用户的限流(覆盖用户级 rpm_limit)',
|
||||
maxReasoningEffort: '推理强度上限',
|
||||
maxReasoningEffortUnlimited: '不限制(跟随请求)',
|
||||
maxReasoningEffortHint: '仅限制客户端主动请求的 OpenAI reasoning effort;超过上限时自动降档,不会为缺省请求主动开启推理。上限优先级高于推理强度映射。',
|
||||
reasoningEffortMappings: '推理强度映射',
|
||||
addReasoningEffortMapping: '添加映射',
|
||||
removeReasoningEffortMapping: '删除映射',
|
||||
reasoningEffortFrom: '请求值',
|
||||
reasoningEffortTo: '转发值',
|
||||
reasoningEffortFromPlaceholder: '请选择 A',
|
||||
reasoningEffortToPlaceholder: '请选择 B',
|
||||
fromRequired: '请选择请求值 A',
|
||||
toRequired: '请选择转发值 B',
|
||||
unsupportedFrom: '请求值不受当前平台支持',
|
||||
unsupportedTo: '转发值不受当前平台支持',
|
||||
duplicateFrom: '请求值 A 不能重复',
|
||||
exclusiveLabel: '专属分组',
|
||||
exclusiveHint: '专属分组,可以手动指定给用户',
|
||||
platformLabel: '平台限制',
|
||||
|
||||
@@ -503,6 +503,11 @@ export interface OpenAIMessagesDispatchModelConfig {
|
||||
exact_model_mappings?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ReasoningEffortMapping {
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: number
|
||||
name: string
|
||||
@@ -510,6 +515,8 @@ export interface Group {
|
||||
platform: GroupPlatform
|
||||
rate_multiplier: number
|
||||
rpm_limit?: number // Group-level RPM cap (0 = unlimited); overrides user-level rpm_limit when set
|
||||
max_reasoning_effort?: string // OpenAI/Codex reasoning ceiling; empty means unlimited
|
||||
reasoning_effort_mappings?: ReasoningEffortMapping[]
|
||||
is_exclusive: boolean
|
||||
status: 'active' | 'inactive'
|
||||
subscription_type: SubscriptionType
|
||||
@@ -683,6 +690,8 @@ export interface CreateGroupRequest {
|
||||
model_routing?: Record<string, number[]> | null
|
||||
model_routing_enabled?: boolean
|
||||
rpm_limit?: number
|
||||
max_reasoning_effort?: string
|
||||
reasoning_effort_mappings?: ReasoningEffortMapping[]
|
||||
require_oauth_only?: boolean
|
||||
require_privacy_set?: boolean
|
||||
// 从指定分组复制账号
|
||||
@@ -731,6 +740,8 @@ export interface UpdateGroupRequest {
|
||||
model_routing?: Record<string, number[]> | null
|
||||
model_routing_enabled?: boolean
|
||||
rpm_limit?: number
|
||||
max_reasoning_effort?: string
|
||||
reasoning_effort_mappings?: ReasoningEffortMapping[]
|
||||
require_oauth_only?: boolean
|
||||
require_privacy_set?: boolean
|
||||
copy_accounts_from_group_ids?: number[]
|
||||
|
||||
@@ -599,6 +599,14 @@
|
||||
/>
|
||||
<p class="input-hint">{{ t("admin.groups.form.rpmLimitHint") }}</p>
|
||||
</div>
|
||||
<ReasoningEffortPolicyFields
|
||||
v-if="createForm.platform === 'openai'"
|
||||
ref="createReasoningEffortPolicyRef"
|
||||
id-prefix="create-group-reasoning"
|
||||
:platform="createForm.platform"
|
||||
v-model:max-effort="createForm.max_reasoning_effort"
|
||||
v-model:mappings="createForm.reasoning_effort_mappings"
|
||||
/>
|
||||
<div
|
||||
v-if="createForm.subscription_type !== 'subscription'"
|
||||
data-tour="group-form-exclusive"
|
||||
@@ -2111,6 +2119,14 @@
|
||||
/>
|
||||
<p class="input-hint">{{ t("admin.groups.form.rpmLimitHint") }}</p>
|
||||
</div>
|
||||
<ReasoningEffortPolicyFields
|
||||
v-if="editForm.platform === 'openai'"
|
||||
ref="editReasoningEffortPolicyRef"
|
||||
id-prefix="edit-group-reasoning"
|
||||
:platform="editForm.platform"
|
||||
v-model:max-effort="editForm.max_reasoning_effort"
|
||||
v-model:mappings="editForm.reasoning_effort_mappings"
|
||||
/>
|
||||
<div v-if="editForm.subscription_type !== 'subscription'">
|
||||
<div class="mb-1.5 flex items-center gap-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
@@ -3601,6 +3617,7 @@ import Icon from "@/components/icons/Icon.vue";
|
||||
import GroupRateMultipliersModal from "@/components/admin/group/GroupRateMultipliersModal.vue";
|
||||
import GroupRPMOverridesModal from "@/components/admin/group/GroupRPMOverridesModal.vue";
|
||||
import GroupCapacityBadge from "@/components/common/GroupCapacityBadge.vue";
|
||||
import ReasoningEffortPolicyFields from "@/components/admin/group/ReasoningEffortPolicyFields.vue";
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import { createStableObjectKeyResolver } from "@/utils/stableObjectKey";
|
||||
import { extractApiErrorMessage } from "@/utils/apiError";
|
||||
@@ -3623,6 +3640,12 @@ import {
|
||||
} from "./groupsModelsList";
|
||||
import { createModelsListCandidatesTracker } from "./groupsModelsListCandidates";
|
||||
import { normalizeSupportedModelScopesForPlatform } from "./groupsSupportedModelScopes";
|
||||
import {
|
||||
normalizeReasoningEffortForPlatform,
|
||||
reasoningEffortMappingsToAPI,
|
||||
reasoningEffortMappingsToRows,
|
||||
type ReasoningEffortMappingRow,
|
||||
} from "./groupsReasoningEffort";
|
||||
import {
|
||||
getDefaultImagePreviewPrice,
|
||||
getDefaultVideoPreviewPrice,
|
||||
@@ -4000,6 +4023,12 @@ const createModelsListState = reactive(createInitialModelsListState());
|
||||
const editModelsListState = reactive(createInitialModelsListState());
|
||||
const createModelsListLoading = ref(false);
|
||||
const editModelsListLoading = ref(false);
|
||||
type ReasoningEffortPolicyFieldsExpose = {
|
||||
validate: () => boolean;
|
||||
resetValidation: () => void;
|
||||
};
|
||||
const createReasoningEffortPolicyRef = ref<ReasoningEffortPolicyFieldsExpose | null>(null);
|
||||
const editReasoningEffortPolicyRef = ref<ReasoningEffortPolicyFieldsExpose | null>(null);
|
||||
const modelsListCandidatesTracker = createModelsListCandidatesTracker();
|
||||
const createModelsListSelectedCount = computed(
|
||||
() => createModelsListState.items.filter((item) => item.selected).length,
|
||||
@@ -4064,6 +4093,8 @@ const createForm = reactive({
|
||||
copy_accounts_from_group_ids: [] as number[],
|
||||
// 分组级 RPM 限制(每用户每分钟最大请求数;0 = 不限制)
|
||||
rpm_limit: 0 as number,
|
||||
max_reasoning_effort: "",
|
||||
reasoning_effort_mappings: [] as ReasoningEffortMappingRow[],
|
||||
});
|
||||
|
||||
// 简单账号类型(用于模型路由选择)
|
||||
@@ -4412,6 +4443,8 @@ const editForm = reactive({
|
||||
copy_accounts_from_group_ids: [] as number[],
|
||||
// 分组级 RPM 限制(每用户每分钟最大请求数;0 = 不限制)
|
||||
rpm_limit: 0 as number,
|
||||
max_reasoning_effort: "",
|
||||
reasoning_effort_mappings: [] as ReasoningEffortMappingRow[],
|
||||
});
|
||||
|
||||
type ImagePricingFormState = {
|
||||
@@ -4801,6 +4834,9 @@ const closeCreateModal = () => {
|
||||
createForm.mcp_xml_inject = true;
|
||||
createForm.copy_accounts_from_group_ids = [];
|
||||
createForm.rpm_limit = 0;
|
||||
createForm.max_reasoning_effort = "";
|
||||
createForm.reasoning_effort_mappings = [];
|
||||
createReasoningEffortPolicyRef.value?.resetValidation();
|
||||
resetModelsListState(createModelsListState);
|
||||
createModelRoutingRules.value = [];
|
||||
};
|
||||
@@ -4839,6 +4875,13 @@ const handleCreateGroup = async () => {
|
||||
appStore.showError(t("admin.groups.nameRequired"));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
createForm.platform === "openai" &&
|
||||
createReasoningEffortPolicyRef.value &&
|
||||
!createReasoningEffortPolicyRef.value.validate()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
// 构建请求数据,包含模型路由配置
|
||||
@@ -4871,6 +4914,9 @@ const handleCreateGroup = async () => {
|
||||
exact_model_mappings: createForm.exact_model_mappings,
|
||||
})
|
||||
: undefined,
|
||||
reasoning_effort_mappings: reasoningEffortMappingsToAPI(
|
||||
createForm.reasoning_effort_mappings,
|
||||
),
|
||||
};
|
||||
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
|
||||
const emptyToNull = (v: any) => (v === "" ? null : v);
|
||||
@@ -4985,6 +5031,14 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
editForm.mcp_xml_inject = group.mcp_xml_inject ?? true;
|
||||
editForm.copy_accounts_from_group_ids = []; // 复制账号字段每次编辑时重置为空
|
||||
editForm.rpm_limit = group.rpm_limit ?? 0;
|
||||
editForm.max_reasoning_effort = normalizeReasoningEffortForPlatform(
|
||||
group.platform,
|
||||
group.max_reasoning_effort,
|
||||
);
|
||||
editForm.reasoning_effort_mappings = reasoningEffortMappingsToRows(
|
||||
group.reasoning_effort_mappings,
|
||||
group.platform,
|
||||
);
|
||||
resetModelsListState(editModelsListState, group.models_list_config);
|
||||
// 加载模型路由规则(异步加载账号名称)
|
||||
editModelRoutingRules.value = await convertApiFormatToRoutingRules(
|
||||
@@ -5001,6 +5055,9 @@ const closeEditModal = () => {
|
||||
clearAllAccountSearchState();
|
||||
showEditModal.value = false;
|
||||
editingGroup.value = null;
|
||||
editForm.max_reasoning_effort = "";
|
||||
editForm.reasoning_effort_mappings = [];
|
||||
editReasoningEffortPolicyRef.value?.resetValidation();
|
||||
editModelRoutingRules.value = [];
|
||||
editForm.copy_accounts_from_group_ids = [];
|
||||
editForm.peak_rate_enabled = false;
|
||||
@@ -5023,6 +5080,13 @@ const handleUpdateGroup = async () => {
|
||||
appStore.showError(t("admin.groups.nameRequired"));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
editForm.platform === "openai" &&
|
||||
editReasoningEffortPolicyRef.value &&
|
||||
!editReasoningEffortPolicyRef.value.validate()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
@@ -5062,6 +5126,9 @@ const handleUpdateGroup = async () => {
|
||||
exact_model_mappings: editForm.exact_model_mappings,
|
||||
})
|
||||
: undefined,
|
||||
reasoning_effort_mappings: reasoningEffortMappingsToAPI(
|
||||
editForm.reasoning_effort_mappings,
|
||||
),
|
||||
};
|
||||
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
|
||||
const emptyToNull = (v: any) => (v === "" ? null : v);
|
||||
@@ -5227,6 +5294,15 @@ watch(
|
||||
if (newVal !== "openai") {
|
||||
resetMessagesDispatchFormState(createForm);
|
||||
}
|
||||
createForm.max_reasoning_effort = normalizeReasoningEffortForPlatform(
|
||||
newVal,
|
||||
createForm.max_reasoning_effort,
|
||||
);
|
||||
createForm.reasoning_effort_mappings = reasoningEffortMappingsToRows(
|
||||
reasoningEffortMappingsToAPI(createForm.reasoning_effort_mappings),
|
||||
newVal,
|
||||
);
|
||||
createReasoningEffortPolicyRef.value?.resetValidation();
|
||||
if (!["openai", "antigravity", "anthropic", "gemini"].includes(newVal)) {
|
||||
createForm.require_oauth_only = false;
|
||||
createForm.require_privacy_set = false;
|
||||
@@ -5260,6 +5336,15 @@ watch(
|
||||
if (newVal !== "openai") {
|
||||
resetMessagesDispatchFormState(editForm);
|
||||
}
|
||||
editForm.max_reasoning_effort = normalizeReasoningEffortForPlatform(
|
||||
newVal,
|
||||
editForm.max_reasoning_effort,
|
||||
);
|
||||
editForm.reasoning_effort_mappings = reasoningEffortMappingsToRows(
|
||||
reasoningEffortMappingsToAPI(editForm.reasoning_effort_mappings),
|
||||
newVal,
|
||||
);
|
||||
editReasoningEffortPolicyRef.value?.resetValidation();
|
||||
if (!["openai", "antigravity", "anthropic", "gemini"].includes(newVal)) {
|
||||
editForm.require_oauth_only = false;
|
||||
editForm.require_privacy_set = false;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createReasoningEffortMappingRow,
|
||||
normalizeReasoningEffortForPlatform,
|
||||
reasoningEffortMappingsToAPI,
|
||||
reasoningEffortMappingsToRows,
|
||||
reasoningEffortOptionsForPlatform,
|
||||
validateReasoningEffortMappings,
|
||||
} from "../groupsReasoningEffort";
|
||||
|
||||
describe("groupsReasoningEffort", () => {
|
||||
it("provides fixed OpenAI choices without none", () => {
|
||||
expect(
|
||||
reasoningEffortOptionsForPlatform("openai").map((option) => option.value),
|
||||
).toEqual([
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
]);
|
||||
for (const platform of [
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"antigravity",
|
||||
"grok",
|
||||
] as const) {
|
||||
expect(reasoningEffortOptionsForPlatform(platform)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("hydrates supported rows and drops stale custom values", () => {
|
||||
const rows = reasoningEffortMappingsToRows(
|
||||
[
|
||||
{ from: " max ", to: " xhigh " },
|
||||
{ from: "ultra", to: "high" },
|
||||
],
|
||||
"openai",
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(reasoningEffortMappingsToAPI(rows)).toEqual([
|
||||
{ from: "max", to: "xhigh" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears values unsupported by OpenAI or used on another platform", () => {
|
||||
expect(normalizeReasoningEffortForPlatform("openai", " MAX ")).toBe("max");
|
||||
expect(normalizeReasoningEffortForPlatform("grok", "max")).toBe("");
|
||||
expect(normalizeReasoningEffortForPlatform("openai", "none")).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" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects duplicate source values case insensitively", () => {
|
||||
const first = createReasoningEffortMappingRow({ from: "MAX", to: "xhigh" });
|
||||
const second = createReasoningEffortMappingRow({ from: " max ", to: "high" });
|
||||
|
||||
expect(validateReasoningEffortMappings([first, second])).toEqual({
|
||||
[first.id]: { from: "duplicateFrom" },
|
||||
[second.id]: { from: "duplicateFrom" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects custom mappings", () => {
|
||||
const row = createReasoningEffortMappingRow({ from: "ultra", to: "high" });
|
||||
expect(validateReasoningEffortMappings([row], "openai")).toEqual({
|
||||
[row.id]: { from: "unsupportedFrom" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { GroupPlatform, ReasoningEffortMapping } from "@/types";
|
||||
|
||||
const openAIReasoningEffortValues = [
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
] as const;
|
||||
|
||||
const reasoningEffortValuesForPlatform = (
|
||||
platform: GroupPlatform,
|
||||
): readonly string[] =>
|
||||
platform === "openai" ? openAIReasoningEffortValues : [];
|
||||
|
||||
export function reasoningEffortOptionsForPlatform(platform: GroupPlatform) {
|
||||
return reasoningEffortValuesForPlatform(platform).map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeReasoningEffortForPlatform(
|
||||
platform: GroupPlatform,
|
||||
value: string | null | undefined,
|
||||
): string {
|
||||
const normalized = value?.trim().toLowerCase() ?? "";
|
||||
return reasoningEffortValuesForPlatform(platform).some(
|
||||
(allowed) => allowed === normalized,
|
||||
)
|
||||
? normalized
|
||||
: "";
|
||||
}
|
||||
|
||||
export interface ReasoningEffortMappingRow extends ReasoningEffortMapping {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type ReasoningEffortMappingErrorCode =
|
||||
| "fromRequired"
|
||||
| "toRequired"
|
||||
| "duplicateFrom"
|
||||
| "unsupportedFrom"
|
||||
| "unsupportedTo";
|
||||
|
||||
export type ReasoningEffortMappingErrors = Record<
|
||||
string,
|
||||
Partial<Record<"from" | "to", ReasoningEffortMappingErrorCode>>
|
||||
>;
|
||||
|
||||
let nextMappingRowID = 0;
|
||||
|
||||
export function createReasoningEffortMappingRow(
|
||||
mapping: Partial<ReasoningEffortMapping> = {},
|
||||
): ReasoningEffortMappingRow {
|
||||
nextMappingRowID += 1;
|
||||
return {
|
||||
id: `reasoning-effort-mapping-${nextMappingRowID}`,
|
||||
from: mapping.from ?? "",
|
||||
to: mapping.to ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function reasoningEffortMappingsToRows(
|
||||
mappings?: ReasoningEffortMapping[] | null,
|
||||
platform: GroupPlatform = "openai",
|
||||
): ReasoningEffortMappingRow[] {
|
||||
return (mappings ?? []).flatMap((mapping) => {
|
||||
const from = normalizeReasoningEffortForPlatform(platform, mapping.from);
|
||||
const to = normalizeReasoningEffortForPlatform(platform, mapping.to);
|
||||
return from && to
|
||||
? [createReasoningEffortMappingRow({ from, to })]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
export function reasoningEffortMappingsToAPI(
|
||||
rows: ReasoningEffortMappingRow[],
|
||||
): ReasoningEffortMapping[] {
|
||||
return rows.map((row) => ({
|
||||
from: row.from.trim(),
|
||||
to: row.to.trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
export function validateReasoningEffortMappings(
|
||||
rows: ReasoningEffortMappingRow[],
|
||||
platform: GroupPlatform = "openai",
|
||||
): ReasoningEffortMappingErrors {
|
||||
const errors: ReasoningEffortMappingErrors = {};
|
||||
const sourceRows = new Map<string, ReasoningEffortMappingRow[]>();
|
||||
|
||||
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" };
|
||||
}
|
||||
});
|
||||
|
||||
sourceRows.forEach((duplicateRows) => {
|
||||
if (duplicateRows.length < 2) return;
|
||||
duplicateRows.forEach((row) => {
|
||||
errors[row.id] = { ...errors[row.id], from: "duplicateFrom" };
|
||||
});
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
Reference in New Issue
Block a user