This commit is contained in:
MMEXA
2026-07-19 16:12:37 +08:00
committed by GitHub
25 changed files with 1200 additions and 99 deletions
@@ -369,6 +369,13 @@ mod tests {
),
vec!["openai:search".to_string()]
);
assert_eq!(
intersect_api_format_allowed_lists(
&["openai:responses".to_string()],
&["openai:responses:compact".to_string()],
),
vec!["openai:responses:compact".to_string()]
);
assert_eq!(
intersect_api_format_allowed_lists(
&["openai:search".to_string()],
@@ -3965,6 +3965,93 @@ mod tests {
panic!("pending lifecycle usage event was not enqueued");
}
#[tokio::test]
async fn stale_lifecycle_generation_keeps_the_current_generation_registered() {
let coalescer = super::LifecycleEventCoalescer::default();
let request_id = "req-lifecycle-generation";
let pending_generation = coalescer
.register(request_id.to_string())
.await
.expect("pending generation should register");
let streaming_generation = coalescer
.register(request_id.to_string())
.await
.expect("streaming generation should register");
assert!(!coalescer.should_emit(request_id, pending_generation).await);
assert!(
coalescer
.should_emit(request_id, streaming_generation)
.await
);
}
#[tokio::test]
async fn streaming_lifecycle_event_survives_a_superseded_pending_event() {
let config = UsageRuntimeConfig {
enabled: true,
queue_lifecycle_events: true,
lifecycle_enqueue_delay_ms: 40,
stream_key: "usage:events:test:pending-streaming-coalescing".to_string(),
consumer_group: "usage_consumers_test_pending_streaming_coalescing".to_string(),
consumer_block_ms: 1,
..UsageRuntimeConfig::default()
};
let queue_runner: Arc<dyn RuntimeQueueStore> =
Arc::new(RuntimeState::memory(MemoryRuntimeStateConfig::default()));
let queue = UsageQueue::new(Arc::clone(&queue_runner), config.clone())
.expect("usage queue should build");
queue
.ensure_consumer_group()
.await
.expect("consumer group should initialize");
let store = CloneQueueConfiguredUsageStore {
records: Arc::new(Mutex::new(Vec::new())),
queue: queue_runner,
};
let runtime = UsageRuntime::new(config).expect("usage runtime should build");
runtime
.enqueue_or_write_lifecycle(
&store,
UsageEvent::new(
UsageEventType::Pending,
"req-pending-streaming-coalescing",
UsageEventData {
provider_name: "openai".to_string(),
model: "gpt-5.6-sol".to_string(),
..UsageEventData::default()
},
),
)
.await;
sleep(Duration::from_millis(10)).await;
runtime
.enqueue_or_write_lifecycle(
&store,
UsageEvent::new(
UsageEventType::Streaming,
"req-pending-streaming-coalescing",
UsageEventData {
provider_name: "openai".to_string(),
model: "gpt-5.6-sol".to_string(),
..UsageEventData::default()
},
),
)
.await;
sleep(Duration::from_millis(90)).await;
let entries = queue
.read_group("usage-test-consumer-pending-streaming-coalescing")
.await
.expect("queue read should succeed");
assert_eq!(entries.len(), 1);
let event = UsageEvent::from_stream_fields(&entries[0].fields)
.expect("queued usage event should parse");
assert_eq!(event.event_type, UsageEventType::Streaming);
}
#[tokio::test]
async fn pending_lifecycle_enqueue_can_be_delayed() {
let config = UsageRuntimeConfig {
+22 -2
View File
@@ -2036,6 +2036,22 @@ fn build_runtime_request_metadata_seed(
provider_request_body_ref.as_deref(),
plan.body.body_bytes_b64.as_deref(),
);
let provider_request_body = plan.body.json_body.as_ref().or_else(|| {
context_value_ref(context, "provider_request_body").filter(|value| !value.is_null())
});
let provider_api_format = context_string(context, "provider_api_format")
.or_else(|| non_empty_str(Some(plan.provider_api_format.as_str())));
let provider_model = context_string(context, "mapped_model")
.or_else(|| non_empty_str(plan.model_name.as_deref()));
let source_model =
context_string(context, "model").or_else(|| non_empty_str(plan.model_name.as_deref()));
metadata = attach_provider_request_body_metadata(
metadata,
provider_api_format.as_deref(),
provider_model.as_deref(),
source_model.as_deref(),
provider_request_body,
);
if let Some(proxy) = plan.proxy.as_ref() {
if let Some(node_id) = proxy
.node_id
@@ -3635,7 +3651,8 @@ mod tests {
content_encoding: None,
body: RequestBody::from_json(json!({
"model": "gpt-5.4",
"messages": [{"role": "user", "content": "hello"}]
"messages": [{"role": "user", "content": "hello"}],
"reasoning": {"effort": "max"}
})),
stream: false,
client_api_format: "claude:messages".to_string(),
@@ -3694,7 +3711,10 @@ mod tests {
.as_ref()
.and_then(Value::as_object)
.expect("pending usage should only keep lightweight request metadata");
assert_eq!(metadata.len(), 1);
assert_eq!(
metadata.get("provider_reasoning_effort"),
Some(&json!("max"))
);
let body_size = metadata
.get("body_size")
.and_then(Value::as_object)
@@ -50,9 +50,11 @@ describe('api format display helpers', () => {
])
})
it('applies Responses to Search permissions in one direction', () => {
it('applies Responses companion permissions in one direction', () => {
expect(apiFormatPermissionCovers('OPENAI_RESPONSES', 'openai:search')).toBe(true)
expect(apiFormatPermissionCovers('OPENAI_RESPONSES', 'openai:responses:compact')).toBe(true)
expect(apiFormatPermissionCovers('openai:search', 'openai:responses')).toBe(false)
expect(apiFormatPermissionCovers('openai:responses:compact', 'openai:responses')).toBe(false)
})
it('formats embedding api format ids distinctly from chat formats', () => {
@@ -235,7 +235,9 @@ export function apiFormatPermissionCovers(
return Boolean(allowed)
&& Boolean(requested)
&& (allowed === requested
|| (allowed === API_FORMATS.OPENAI_RESPONSES && requested === API_FORMATS.OPENAI_SEARCH))
|| (allowed === API_FORMATS.OPENAI_RESPONSES
&& (requested === API_FORMATS.OPENAI_RESPONSES_COMPACT
|| requested === API_FORMATS.OPENAI_SEARCH)))
}
// 工具函数:按 family 分组并排序 API 格式数组
@@ -940,6 +940,7 @@ export interface ProviderModelMapping {
priority: number // 优先级(数字越小优先级越高)
api_formats?: string[] // 作用域(适用的 API 格式),为空表示对所有格式生效
endpoint_ids?: string[] // 作用域(适用的端点 ID),为空表示对所有端点生效
operations?: string[] // 作用域(适用的请求操作),为空表示对该格式的全部操作生效
}
// 保留别名以保持向后兼容
+2
View File
@@ -54,6 +54,7 @@ export interface UsageRecordDetail {
id: string
provider?: string // 仅管理员可见
model: string
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
@@ -369,6 +370,7 @@ export const meApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
+2
View File
@@ -14,6 +14,7 @@ export interface UsageRecord {
provider_id?: string // UUID
provider_name?: string
model: string
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
@@ -569,6 +570,7 @@ export const usageApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
@@ -37,27 +37,6 @@
</p>
</div>
<!-- 端点限制 -->
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs">限制端点</Label>
<span class="text-xs text-muted-foreground">{{ endpointScopeSummary }}</span>
</div>
<MultiSelect
v-model="selectedEndpointIds"
:options="endpointOptions"
placeholder="全部端点"
empty-text="暂无端点"
no-results-text="未找到端点"
trigger-class="h-9 rounded-md"
dropdown-min-width="24rem"
:search-threshold="4"
/>
<p class="text-xs text-muted-foreground">
默认对全部端点生效选择端点后此映射只在选中的端点上生效
</p>
</div>
<!-- 映射名称选择面板 -->
<div class="space-y-1.5">
<Label class="text-xs">提供商模型</Label>
@@ -244,6 +223,67 @@
</div>
</div>
</div>
<div class="space-y-3 border-t border-border/60 pt-4">
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div class="min-w-0 space-y-0.5">
<h3 class="text-sm font-medium text-foreground">
适用范围
</h3>
<p class="text-xs text-muted-foreground">
{{ t('providers.modelMapping.scope.matchHelp') }}
</p>
</div>
<span class="max-w-full break-words text-left text-xs text-muted-foreground sm:max-w-[55%] sm:text-right">
{{ mappingScopeSummary }}
</span>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="min-w-0 space-y-1.5">
<Label class="text-xs">适用端点</Label>
<MultiSelect
v-model="selectedEndpointIds"
:options="endpointOptions"
:placeholder="t('providers.modelMapping.scope.allEndpoints')"
empty-text="暂无端点"
no-results-text="未找到端点"
trigger-class="h-9 rounded-md"
:search-threshold="4"
/>
<p class="text-xs leading-5 text-muted-foreground">
{{ t('providers.modelMapping.scope.endpointHelp') }}
</p>
</div>
<div class="min-w-0 space-y-1.5">
<Label class="text-xs">适用请求</Label>
<div
class="flex min-h-9 w-full flex-wrap gap-1 rounded-md bg-muted/50 p-1"
role="radiogroup"
aria-label="适用请求"
>
<Button
v-for="option in requestScopeOptions"
:key="option.value"
type="button"
size="sm"
:variant="requestScopeValue === option.value ? 'secondary' : 'ghost'"
class="h-7 min-w-0 flex-1 basis-[9rem] px-2.5"
role="radio"
:aria-checked="requestScopeValue === option.value"
:title="option.label"
@click="handleRequestScopeChange(option.value)"
>
<span class="truncate">{{ option.label }}</span>
</Button>
</div>
<p class="text-xs leading-5 text-muted-foreground">
{{ requestScopeDescription }}
</p>
</div>
</div>
</div>
</div>
<template #footer>
@@ -261,7 +301,7 @@
v-if="submitting"
class="w-4 h-4 mr-2 animate-spin"
/>
{{ editingGroup ? '保存' : '添加' }}
{{ editingGroup ? '保存映射' : '添加映射' }}
</Button>
</template>
</Dialog>
@@ -283,6 +323,7 @@ import {
} from '@/components/ui'
import MultiSelect from '@/components/common/MultiSelect.vue'
import { useToast } from '@/composables/useToast'
import { useI18n } from '@/i18n'
import { parseApiError } from '@/utils/errorParser'
import {
type Model,
@@ -291,8 +332,19 @@ import {
type UpstreamModel,
} from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
import {
ALL_REQUESTS_SCOPE_VALUE,
COMPACT_REQUEST_SCOPE_VALUE,
formatModelMappingEndpointLabel,
formatModelMappingRequestScope,
modelMappingEndpointScopeSupportsSessionCompaction,
modelMappingOperationsKey,
modelMappingOperationsFromScopeValue,
modelMappingRequestScopeOptions,
modelMappingRequestScopeValue,
normalizeModelMappingOperations,
} from '../utils/modelMappingScope'
export interface AliasGroup {
model: Model
@@ -302,6 +354,8 @@ export interface AliasGroup {
apiFormats: string[]
endpointIdsKey: string
endpointIds: string[]
operationsKey: string
operations: string[]
aliases: ProviderModelAlias[]
}
@@ -323,6 +377,7 @@ const emit = defineEmits<{
}>()
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
const { t } = useI18n()
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
type EndpointOption = {
@@ -358,39 +413,100 @@ const selectedNames = ref<string[]>([])
// 选中的端点 ID;空数组表示全部端点
const selectedEndpointIds = ref<string[]>([])
const selectedOperations = ref<string[]>([])
// 自定义名称列表(手动添加的)
const allCustomNames = ref<string[]>([])
const endpointOptions = computed<EndpointOption[]>(() => {
return (props.endpoints ?? []).map((endpoint) => {
const status = endpoint.is_active ? '' : '(停用)'
return {
value: endpoint.id,
label: `${formatApiFormat(endpoint.api_format)}${status}`,
}
})
const endpoints = props.endpoints ?? []
return endpoints.map(endpoint => ({
value: endpoint.id,
label: formatModelMappingEndpointLabel(endpoint, endpoints),
}))
})
const normalizedSelectedEndpointIds = computed(() => {
const validIds = new Set(endpointOptions.value.map(option => option.value))
const selected = normalizeStringList(selectedEndpointIds.value)
if (selected.length === 0) {
return undefined
}
const invalidSelected = selected.filter(endpointId => !validIds.has(endpointId))
const selectedValidCount = selected.filter(endpointId => validIds.has(endpointId)).length
if (validIds.size > 0 && invalidSelected.length === 0 && selectedValidCount === validIds.size) {
return undefined
}
return selected
return selected.length > 0 ? selected : undefined
})
const sessionCompactionScopeAvailable = computed(() => {
return modelMappingEndpointScopeSupportsSessionCompaction(
normalizedSelectedEndpointIds.value,
props.endpoints ?? [],
)
})
const endpointScopeSummary = computed(() => {
const selected = normalizedSelectedEndpointIds.value
if (!selected || selected.length === 0) return '全部端点'
return `${selected.length} 个端点`
if (!selected || selected.length === 0) {
return t('providers.modelMapping.scope.allEndpoints')
}
if (selected.length === 1) {
return endpointOptions.value.find(option => option.value === selected[0])?.label
?? t('providers.modelMapping.scope.endpointCount', { count: 1 })
}
return t('providers.modelMapping.scope.endpointCount', { count: selected.length })
})
const requestScopeLabels = computed(() => ({
allRequests: t('providers.modelMapping.scope.allRequests'),
sessionCompactionOnly: t('providers.modelMapping.scope.sessionCompactionOnly'),
customOperations: (operations: string[]) => t(
'providers.modelMapping.scope.customOperations',
{ operations: operations.join(', ') },
),
}))
const normalizedSelectedOperations = computed(() => {
const selected = normalizeModelMappingOperations(selectedOperations.value)
return selected.length > 0 ? selected : undefined
})
const operationScopeSummary = computed(() => {
return formatModelMappingRequestScope(
normalizedSelectedOperations.value,
requestScopeLabels.value,
)
})
const mappingScopeSummary = computed(() => {
return `${endpointScopeSummary.value} · ${operationScopeSummary.value}`
})
const requestScopeValue = computed(() => {
return modelMappingRequestScopeValue(selectedOperations.value)
})
const requestScopeOptions = computed(() => {
return modelMappingRequestScopeOptions(
selectedOperations.value,
{ sessionCompaction: sessionCompactionScopeAvailable.value },
requestScopeLabels.value,
)
})
const requestScopeDescription = computed(() => {
if (requestScopeValue.value === ALL_REQUESTS_SCOPE_VALUE) {
return sessionCompactionScopeAvailable.value
? t('providers.modelMapping.scope.allRequestsDescription')
: t('providers.modelMapping.scope.allRequestsDefaultDescription')
}
if (requestScopeValue.value === COMPACT_REQUEST_SCOPE_VALUE) {
return t('providers.modelMapping.scope.sessionCompactionDescription')
}
return t('providers.modelMapping.scope.customOperationsDescription', {
operations: normalizeModelMappingOperations(selectedOperations.value).join(', '),
})
})
watch(
[() => props.endpoints, () => selectedEndpointIds.value],
() => normalizeUnavailableSessionCompactionScope(),
{ deep: true },
)
// 所有已知名称集合
const allKnownNames = computed(() => {
const set = new Set<string>()
@@ -518,11 +634,23 @@ function scopesOverlap(left: string[] | undefined, right: string[] | undefined):
return leftValues.some(value => rightSet.has(value))
}
function operationScopesOverlap(
left: string[] | undefined,
right: string[] | undefined,
): boolean {
const leftValues = normalizeModelMappingOperations(left)
const rightValues = normalizeModelMappingOperations(right)
if (leftValues.length === 0 || rightValues.length === 0) return true
const rightSet = new Set(rightValues)
return leftValues.some(value => rightSet.has(value))
}
function findDuplicateNames(
existingAliases: ProviderModelAlias[],
names: string[],
endpointIds: string[] | undefined,
apiFormats: string[] | undefined = undefined,
operations: string[] | undefined = undefined,
): string[] {
const duplicates = new Set<string>()
for (const rawName of names) {
@@ -532,6 +660,7 @@ function findDuplicateNames(
return alias.name === name
&& scopesOverlap(alias.endpoint_ids, endpointIds)
&& scopesOverlap(alias.api_formats, apiFormats)
&& operationScopesOverlap(alias.operations, operations)
})
if (duplicate) duplicates.add(name)
}
@@ -597,6 +726,7 @@ function initForm() {
const existingNames = props.editingGroup.aliases.map(a => a.name)
selectedNames.value = [...existingNames]
selectedEndpointIds.value = normalizeStringList(props.editingGroup.endpointIds)
selectedOperations.value = normalizeModelMappingOperations(props.editingGroup.operations)
allCustomNames.value = [...existingNames]
} else {
formData.value = {
@@ -604,12 +734,14 @@ function initForm() {
}
selectedNames.value = []
selectedEndpointIds.value = []
selectedOperations.value = []
allCustomNames.value = []
}
searchQuery.value = ''
upstreamModels.value = []
upstreamModelsLoaded.value = false
collapsedGroups.value = new Set()
normalizeUnavailableSessionCompactionScope()
}
// 处理模型选择变更
@@ -617,6 +749,24 @@ function handleModelChange(value: string) {
formData.value.modelId = value
}
function handleRequestScopeChange(value: string) {
if (
value === COMPACT_REQUEST_SCOPE_VALUE
&& !sessionCompactionScopeAvailable.value
) return
selectedOperations.value = modelMappingOperationsFromScopeValue(value) ?? []
}
function normalizeUnavailableSessionCompactionScope() {
if (props.endpoints === undefined) return
if (
requestScopeValue.value === COMPACT_REQUEST_SCOPE_VALUE
&& !sessionCompactionScopeAvailable.value
) {
selectedOperations.value = []
}
}
// 生成作用域唯一键
function getApiFormatsKey(formats: string[] | undefined): string {
return getScopeKey(formats)
@@ -626,6 +776,10 @@ function getEndpointIdsKey(endpointIds: string[] | undefined): string {
return getScopeKey(endpointIds)
}
function getOperationsKey(operations: string[] | undefined): string {
return modelMappingOperationsKey(operations)
}
// 提交表单
async function handleSubmit() {
if (submitting.value) return
@@ -642,6 +796,7 @@ async function handleSubmit() {
const currentAliases = targetModel.provider_model_mappings || []
let newAliases: ProviderModelAlias[]
const nextEndpointIds = normalizedSelectedEndpointIds.value
const nextOperations = normalizedSelectedOperations.value
const buildAliases = (names: string[]): ProviderModelAlias[] => {
return names.map((name) => {
@@ -652,6 +807,9 @@ async function handleSubmit() {
if (nextEndpointIds && nextEndpointIds.length > 0) {
alias.endpoint_ids = nextEndpointIds
}
if (nextOperations && nextOperations.length > 0) {
alias.operations = nextOperations
}
return alias
})
}
@@ -659,15 +817,26 @@ async function handleSubmit() {
if (props.editingGroup) {
const oldApiFormatsKey = props.editingGroup.apiFormatsKey
const oldEndpointIdsKey = props.editingGroup.endpointIdsKey
const oldOperationsKey = modelMappingOperationsKey(props.editingGroup.operations)
const oldAliasNames = new Set(props.editingGroup.aliases.map(a => a.name))
const filteredAliases = currentAliases.filter((a: ProviderModelAlias) => {
const currentKey = getApiFormatsKey(a.api_formats)
const currentEndpointIdsKey = getEndpointIdsKey(a.endpoint_ids)
return !(currentKey === oldApiFormatsKey && currentEndpointIdsKey === oldEndpointIdsKey && oldAliasNames.has(a.name))
const currentOperationsKey = getOperationsKey(a.operations)
return !(currentKey === oldApiFormatsKey
&& currentEndpointIdsKey === oldEndpointIdsKey
&& currentOperationsKey === oldOperationsKey
&& oldAliasNames.has(a.name))
})
const duplicates = findDuplicateNames(filteredAliases, selectedNames.value, nextEndpointIds)
const duplicates = findDuplicateNames(
filteredAliases,
selectedNames.value,
nextEndpointIds,
undefined,
nextOperations,
)
if (duplicates.length > 0) {
showError(`以下映射名称已存在:${duplicates.join(', ')}`, '错误')
return
@@ -678,7 +847,13 @@ async function handleSubmit() {
...buildAliases(selectedNames.value)
]
} else {
const duplicates = findDuplicateNames(currentAliases, selectedNames.value, nextEndpointIds)
const duplicates = findDuplicateNames(
currentAliases,
selectedNames.value,
nextEndpointIds,
undefined,
nextOperations,
)
if (duplicates.length > 0) {
showError(`以下映射名称已存在:${duplicates.join(', ')}`, '错误')
return
@@ -320,7 +320,7 @@
:title="item.title"
class="tabular-nums"
>
{{ item.displayKey }} {{ formatCodexResetCreditDays(item.remainingSeconds) }}
{{ item.displayKey }} {{ formatCodexResetCreditExpiresAt(item.expiresAt) }}
</span>
<span
v-if="itemIndex < getVisibleCodexResetCreditItems(key).length - 1"
@@ -1038,7 +1038,7 @@ import { getGeminiCliAccountCreditsText } from '@/utils/providerKeyQuota'
import {
createCodexResetCreditIdempotencyKey,
formatCodexResetCreditCount as formatCodexResetCreditCountLabel,
formatCodexResetCreditDays,
formatCodexResetCreditExpiresAt,
getCodexResetCreditAvailableCount as getCodexResetCreditAvailableCountFromSnapshot,
getVisibleCodexResetCreditItems as getVisibleCodexResetCreditItemsFromSnapshot,
mergeCodexQuotaDisplays,
@@ -1722,7 +1722,7 @@ async function handleConsumeCodexResetCredit(key: EndpointAPIKey) {
const earliest = getVisibleCodexResetCreditItems(key)[0]
const detailMessage = earliest
? `\n当前最早过期项:${earliest.displayKey}${formatCodexResetCreditDays(earliest.remainingSeconds)} 过期。`
? `\n当前最早过期项:${earliest.displayKey}${formatCodexResetCreditExpiresAt(earliest.expiresAt)} 过期。`
: ''
const confirmed = await confirm({
title: legacyT('确认使用 Codex 重置机会'),
@@ -0,0 +1,325 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
import type { Model, ProviderEndpoint } from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = (name: string, tag = 'div') => defineComponent({
name,
setup(_, { slots }) {
return () => h(tag, [slots.default?.(), slots.footer?.()])
},
})
return {
Button: defineComponent({
name: 'ButtonStub',
setup(_, { attrs, slots }) {
return () => h('button', { ...attrs, type: 'button' }, slots.default?.())
},
}),
Dialog: passthrough('DialogStub'),
Input: defineComponent({
name: 'InputStub',
props: { modelValue: String },
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
value: props.modelValue ?? '',
onInput: (event: Event) => emit(
'update:modelValue',
(event.target as HTMLInputElement).value,
),
})
},
}),
Label: passthrough('LabelStub', 'label'),
Select: passthrough('SelectStub'),
SelectContent: passthrough('SelectContentStub'),
SelectItem: passthrough('SelectItemStub'),
SelectTrigger: passthrough('SelectTriggerStub'),
SelectValue: passthrough('SelectValueStub', 'span'),
}
})
vi.mock('@/components/common/MultiSelect.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'MultiSelectStub',
props: {
modelValue: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
},
emits: ['update:modelValue'],
setup(props, { emit }) {
return () => h('div', (props.options as Array<{ value: string, label: string }>).map(option => h(
'button',
{
type: 'button',
'data-endpoint-id': option.value,
onClick: () => emit('update:modelValue', [option.value]),
},
option.label,
)))
},
}),
}
})
vi.mock('lucide-vue-next', async () => {
const { defineComponent, h } = await import('vue')
const Icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span')
},
})
return {
Check: Icon,
ChevronDown: Icon,
Loader2: Icon,
Plus: Icon,
RefreshCw: Icon,
Search: Icon,
Tag: Icon,
Zap: Icon,
}
})
vi.mock('@/api/endpoints/models', () => ({
updateModel: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
}),
}))
vi.mock('../../composables/useUpstreamModelsCache', () => ({
useUpstreamModelsCache: () => ({
fetchModels: vi.fn(),
}),
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
vi.mocked(updateModel).mockClear()
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('ModelMappingDialog', () => {
it('offers session compaction only for an explicitly selected Responses endpoint', async () => {
const chatEndpoint = {
id: 'endpoint-chat',
api_format: 'openai:chat',
base_url: 'https://api.example.com/v1',
is_active: true,
} as ProviderEndpoint
const responsesEndpoint = {
id: 'endpoint-responses',
api_format: 'openai:responses',
base_url: 'https://api.example.com/v1',
is_active: true,
} as ProviderEndpoint
const model = {
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
provider_model_mappings: [],
} as Model
const open = ref(false)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(ModelMappingDialog, {
open: open.value,
providerId: 'provider-1',
endpoints: [chatEndpoint, responsesEndpoint],
models: [model],
preselectedModelId: model.id,
'onUpdate:open': (value: boolean) => { open.value = value },
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
open.value = true
await nextTick()
await nextTick()
expect(root.textContent).toContain('所有请求')
expect(root.textContent).not.toContain('仅会话压缩')
root.querySelector<HTMLButtonElement>('[data-endpoint-id="endpoint-chat"]')?.click()
await nextTick()
expect(root.textContent).not.toContain('仅会话压缩')
root.querySelector<HTMLButtonElement>('[data-endpoint-id="endpoint-responses"]')?.click()
await nextTick()
expect(root.textContent).toContain('仅会话压缩')
})
it('returns to all requests when a compact mapping switches away from Responses', async () => {
const responsesEndpoint = {
id: 'endpoint-responses',
api_format: 'openai:responses',
base_url: 'https://api.example.com/v1',
is_active: true,
} as ProviderEndpoint
const chatEndpoint = {
id: 'endpoint-chat',
api_format: 'openai:chat',
base_url: 'https://api.example.com/v1',
is_active: true,
} as ProviderEndpoint
const model = {
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
provider_model_mappings: [{
name: 'gpt-5.6-luna',
priority: 1,
endpoint_ids: [responsesEndpoint.id],
operations: ['compact'],
}],
} as Model
const editingGroup: AliasGroup = {
model,
apiFormatsKey: '',
apiFormats: [],
endpointIdsKey: responsesEndpoint.id,
endpointIds: [responsesEndpoint.id],
operationsKey: 'compact',
operations: ['compact'],
aliases: model.provider_model_mappings ?? [],
}
const open = ref(false)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(ModelMappingDialog, {
open: open.value,
providerId: 'provider-1',
endpoints: [responsesEndpoint, chatEndpoint],
models: [model],
editingGroup,
'onUpdate:open': (value: boolean) => { open.value = value },
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
open.value = true
await nextTick()
await nextTick()
expect(root.textContent).toContain('仅会话压缩')
root.querySelector<HTMLButtonElement>('[data-endpoint-id="endpoint-chat"]')?.click()
await nextTick()
expect(root.textContent).not.toContain('仅会话压缩')
expect(root.querySelector<HTMLButtonElement>('[role="radio"][aria-checked="true"]')?.textContent)
.toContain('所有请求')
const saveButton = [...root.querySelectorAll('button')]
.find(button => button.textContent?.includes('保存映射'))
saveButton?.click()
await vi.waitFor(() => expect(updateModel).toHaveBeenCalledTimes(1))
expect(updateModel).toHaveBeenCalledWith('provider-1', 'model-sol', {
provider_model_mappings: [{
name: 'gpt-5.6-luna',
priority: 1,
endpoint_ids: [chatEndpoint.id],
}],
})
})
it('normalizes and replaces an edited compact operation scope', async () => {
const endpoint = {
id: 'endpoint-responses',
api_format: 'openai:responses',
base_url: 'https://api.example.com/v1',
is_active: true,
} as ProviderEndpoint
const model = {
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
provider_model_mappings: [{
name: 'gpt-5.6-luna',
priority: 1,
endpoint_ids: [endpoint.id],
operations: ['Compact'],
}],
} as Model
const editingGroup: AliasGroup = {
model,
apiFormatsKey: '',
apiFormats: [],
endpointIdsKey: endpoint.id,
endpointIds: [endpoint.id],
operationsKey: 'Compact',
operations: ['Compact'],
aliases: model.provider_model_mappings ?? [],
}
const open = ref(false)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(ModelMappingDialog, {
open: open.value,
providerId: 'provider-1',
endpoints: [endpoint],
models: [model],
editingGroup,
'onUpdate:open': (value: boolean) => { open.value = value },
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
open.value = true
await nextTick()
await nextTick()
expect(root.textContent).toContain('仅会话压缩')
const scopeButtons = [...root.querySelectorAll('button')]
scopeButtons.find(button => button.textContent?.includes('所有请求'))?.click()
await nextTick()
scopeButtons.find(button => button.textContent?.includes('仅会话压缩'))?.click()
await nextTick()
const saveButton = [...root.querySelectorAll('button')]
.find(button => button.textContent?.includes('保存映射'))
expect(saveButton).toBeDefined()
saveButton?.click()
await vi.waitFor(() => expect(updateModel).toHaveBeenCalledTimes(1))
expect(updateModel).toHaveBeenCalledWith('provider-1', 'model-sol', {
provider_model_mappings: [{
name: 'gpt-5.6-luna',
priority: 1,
endpoint_ids: [endpoint.id],
operations: ['compact'],
}],
})
})
})
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
createCodexResetCreditIdempotencyKey,
formatCodexResetCreditCount,
formatCodexResetCreditDays,
formatCodexResetCreditExpiresAt,
getCodexResetCreditAvailableCount,
getVisibleCodexResetCreditItems,
mergeCodexQuotaDisplays,
@@ -105,9 +105,21 @@ describe('codex reset credit display helpers', () => {
])
})
it('formats reset credit remaining days with a one-day minimum', () => {
expect(formatCodexResetCreditDays(1)).toBe('1天')
expect(formatCodexResetCreditDays(86_401)).toBe('2天')
it('formats reset credit expiry as a precise local timestamp', () => {
const expiresAt = new Date(2026, 6, 12, 22, 4, 41).getTime() / 1000
expect(formatCodexResetCreditExpiresAt(expiresAt)).toBe('07-12 22:04:41')
expect(formatCodexResetCreditExpiresAt(null)).toBe('-')
})
it('derives a stable expiry timestamp from remaining seconds', () => {
const snapshot: QuotaResetCreditsSnapshot = {
available_count: 1,
updated_at: 1_700_000_000,
credits: [{ status: 'available', remaining_seconds: 600 }],
}
expect(getVisibleCodexResetCreditItems(snapshot, 1_700_000_300)[0]?.expiresAt)
.toBe(1_700_000_600)
})
it('generates a UUID v4 with secure random bytes when randomUUID is unavailable', () => {
@@ -120,7 +120,7 @@ export function getVisibleCodexResetCreditItems(
if (remainingSeconds === null || remainingSeconds <= 0) return null
return {
id: item.id,
expiresAt: item.expires_at,
expiresAt: nowUnixSecs + remainingSeconds,
remainingSeconds,
} satisfies CodexResetCreditDisplayCandidate
})
@@ -137,7 +137,11 @@ export function getVisibleCodexResetCreditItems(
})
}
export function formatCodexResetCreditDays(remainingSeconds: number): string {
const days = Math.max(1, Math.ceil(remainingSeconds / 86_400))
return `${days}`
export function formatCodexResetCreditExpiresAt(expiresAt: number | null | undefined): string {
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return '-'
const date = new Date(expiresAt * 1000)
if (Number.isNaN(date.getTime())) return '-'
const pad = (value: number) => String(value).padStart(2, '0')
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
@@ -38,21 +38,21 @@
>
<!-- 分组头部可点击展开 -->
<div
class="flex items-center justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer"
class="flex items-start justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer"
@click="toggleAliasGroupExpand(getAliasGroupKey(group))"
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1.5">
<!-- 展开/收起图标 -->
<ChevronRight
class="w-4 h-4 text-muted-foreground shrink-0 transition-transform"
:class="{ 'rotate-90': expandedAliasGroups.has(getAliasGroupKey(group)) }"
/>
<!-- 模型名称 -->
<span class="font-semibold text-sm truncate">
<span class="min-w-0 flex-[1_1_12rem] truncate text-sm font-semibold">
{{ group.model.global_model_display_name || group.model.provider_model_name }}
</span>
<!-- 作用域标签 -->
<div class="flex items-center gap-1 shrink-0">
<div class="flex min-w-0 max-w-full flex-wrap items-center gap-1">
<Badge
v-if="group.apiFormats.length === 0"
variant="outline"
@@ -75,6 +75,12 @@
>
{{ getEndpointScopeLabel(group) }}
</Badge>
<Badge
variant="outline"
class="min-w-0 max-w-full text-xs"
>
<span class="truncate">{{ getOperationScopeLabel(group) }}</span>
</Badge>
</div>
<!-- 映射数量 -->
<span class="text-xs text-muted-foreground shrink-0">
@@ -130,6 +136,7 @@
</div>
<!-- 测试按钮 -->
<Button
v-if="group.operations.length === 0"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
@@ -206,10 +213,16 @@ import {
type ProviderModelAlias
} from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
import { useI18n } from '@/i18n'
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { buildExactModelMappingTestRequest } from './model-test-request'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import {
formatModelMappingRequestScope,
modelMappingOperationsKey,
normalizeModelMappingOperations,
} from '../../utils/modelMappingScope'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
@@ -220,6 +233,7 @@ const emit = defineEmits<{
}>()
const { error: showError, success: showSuccess } = useToast()
const { t } = useI18n()
// 状态
const loading = ref(false)
@@ -269,13 +283,32 @@ function getEndpointIdsKey(endpointIds: string[] | undefined): string {
return getScopeKey(endpointIds)
}
function getOperationsKey(operations: string[] | undefined): string {
return modelMappingOperationsKey(operations)
}
const requestScopeLabels = computed(() => ({
allRequests: t('providers.modelMapping.scope.allRequests'),
sessionCompactionOnly: t('providers.modelMapping.scope.sessionCompactionOnly'),
customOperations: (operations: string[]) => t(
'providers.modelMapping.scope.customOperations',
{ operations: operations.join(', ') },
),
}))
function getAliasGroupKey(group: AliasGroup): string {
return `${group.model.id}-${group.apiFormatsKey}-${group.endpointIdsKey}`
return `${group.model.id}-${group.apiFormatsKey}-${group.endpointIdsKey}-${group.operationsKey}`
}
function getEndpointScopeLabel(group: AliasGroup): string {
if (!group.endpointIds || group.endpointIds.length === 0) return '全部端点'
return `${group.endpointIds.length} 端点`
if (!group.endpointIds || group.endpointIds.length === 0) {
return t('providers.modelMapping.scope.allEndpoints')
}
return t('providers.modelMapping.scope.endpointCount', { count: group.endpointIds.length })
}
function getOperationScopeLabel(group: AliasGroup): string {
return formatModelMappingRequestScope(group.operations, requestScopeLabels.value)
}
// 按"模型+作用域"分组的映射列表
@@ -289,7 +322,8 @@ const aliasGroups = computed<AliasGroup[]>(() => {
for (const alias of model.provider_model_mappings) {
const apiFormatsKey = getApiFormatsKey(alias.api_formats)
const endpointIdsKey = getEndpointIdsKey(alias.endpoint_ids)
const groupKey = `${model.id}|${apiFormatsKey}|${endpointIdsKey}`
const operationsKey = getOperationsKey(alias.operations)
const groupKey = `${model.id}|${apiFormatsKey}|${endpointIdsKey}|${operationsKey}`
if (!groupMap.has(groupKey)) {
const group: AliasGroup = {
@@ -298,6 +332,8 @@ const aliasGroups = computed<AliasGroup[]>(() => {
apiFormats: alias.api_formats || [],
endpointIdsKey,
endpointIds: normalizeStringList(alias.endpoint_ids),
operationsKey,
operations: normalizeModelMappingOperations(alias.operations),
aliases: []
}
groupMap.set(groupKey, group)
@@ -317,6 +353,7 @@ const aliasGroups = computed<AliasGroup[]>(() => {
if (nameA !== nameB) return nameA.localeCompare(nameB)
return a.apiFormatsKey.localeCompare(b.apiFormatsKey)
|| a.endpointIdsKey.localeCompare(b.endpointIdsKey)
|| a.operationsKey.localeCompare(b.operationsKey)
})
})
@@ -339,8 +376,9 @@ const deleteConfirmDescription = computed(() => {
const modelName = model.global_model_display_name || model.provider_model_name
const scopeText = apiFormats.length === 0 ? '全部' : apiFormats.map(f => formatApiFormat(f)).join(', ')
const endpointScope = getEndpointScopeLabel(deletingGroup.value)
const operationScope = getOperationScopeLabel(deletingGroup.value)
const aliasNames = aliases.map(a => a.name).join(', ')
return `确定要删除模型「${modelName}」在作用域「${scopeText} / ${endpointScope}」下的 ${aliases.length} 个映射吗?\n\n映射名称:${aliasNames}`
return `确定要删除模型「${modelName}」在作用域「${scopeText} / ${endpointScope} / ${operationScope}」下的 ${aliases.length} 个映射吗?\n\n映射名称:${aliasNames}`
})
// 切换映射组展开状态
@@ -383,7 +421,7 @@ function deleteGroup(group: AliasGroup) {
async function confirmDelete() {
if (!deletingGroup.value) return
const { model, aliases, apiFormatsKey, endpointIdsKey } = deletingGroup.value
const { model, aliases, apiFormatsKey, endpointIdsKey, operationsKey } = deletingGroup.value
try {
const currentAliases = model.provider_model_mappings || []
@@ -391,7 +429,11 @@ async function confirmDelete() {
const newAliases = currentAliases.filter((a: ProviderModelAlias) => {
const currentKey = getApiFormatsKey(a.api_formats)
const currentEndpointIdsKey = getEndpointIdsKey(a.endpoint_ids)
return !(currentKey === apiFormatsKey && currentEndpointIdsKey === endpointIdsKey && aliasNamesToRemove.has(a.name))
const currentOperationsKey = getOperationsKey(a.operations)
return !(currentKey === apiFormatsKey
&& currentEndpointIdsKey === endpointIdsKey
&& currentOperationsKey === operationsKey
&& aliasNamesToRemove.has(a.name))
})
await updateModel(props.provider.id, model.id, {
@@ -39,10 +39,10 @@
>
<!-- 行头部可点击展开 -->
<div
class="flex items-center justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer"
class="flex items-start justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer"
@click="toggleExpand(item.key)"
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1.5">
<!-- 展开/收起图标 -->
<ChevronRight
class="w-4 h-4 text-muted-foreground shrink-0 transition-transform self-start mt-0.5"
@@ -50,7 +50,7 @@
/>
<!-- 精确映射 -->
<template v-if="item.type === 'exact'">
<div class="flex flex-col min-w-0">
<div class="flex min-w-0 flex-[1_1_12rem] flex-col">
<span class="font-semibold text-sm truncate">
{{ item.targetModelName }}
</span>
@@ -75,14 +75,23 @@
<Badge
v-if="item.group"
variant="outline"
class="text-xs shrink-0"
class="min-w-0 max-w-full text-xs"
:title="getGroupEndpointScopeTitle(item.group)"
>
{{ getGroupEndpointScopeLabel(item.group) }}
<span class="truncate">{{ getGroupEndpointScopeLabel(item.group) }}</span>
</Badge>
<Badge
v-if="item.group"
variant="outline"
class="min-w-0 max-w-full text-xs"
:title="getGroupOperationScopeLabel(item.group)"
>
<span class="truncate">{{ getGroupOperationScopeLabel(item.group) }}</span>
</Badge>
</template>
<!-- 正则映射 -->
<template v-else>
<div class="flex flex-col min-w-0">
<div class="flex min-w-0 flex-[1_1_12rem] flex-col">
<span class="font-semibold text-sm truncate">
{{ item.targetModelName }}
</span>
@@ -160,6 +169,7 @@
</span>
<!-- 测试按钮直连测试 -->
<Button
v-if="!item.group || item.group.operations.length === 0"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
@@ -364,8 +374,15 @@ import {
} from '@/api/endpoints'
import { type EndpointAPIKey } from '@/api/endpoints/keys'
import { updateModel } from '@/api/endpoints/models'
import { useI18n } from '@/i18n'
import { parseApiError } from '@/utils/errorParser'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import {
formatModelMappingEndpointLabel,
formatModelMappingRequestScope,
modelMappingOperationsKey,
normalizeModelMappingOperations,
} from '../../utils/modelMappingScope'
import {
buildDefaultModelTestRequestHeaders,
buildDefaultModelTestRequestBody,
@@ -416,6 +433,7 @@ const emit = defineEmits<{
}>()
const { error: showError, success: showSuccess } = useToast()
const { t } = useI18n()
// 模型测试 composable
const modelTest = useModelTest({ providerId: () => props.provider.id })
@@ -492,9 +510,47 @@ function getEndpointIdsKey(endpointIds: string[] | undefined): string {
return getScopeKey(endpointIds)
}
function getOperationsKey(operations: string[] | undefined): string {
return modelMappingOperationsKey(operations)
}
const requestScopeLabels = computed(() => ({
allRequests: t('providers.modelMapping.scope.allRequests'),
sessionCompactionOnly: t('providers.modelMapping.scope.sessionCompactionOnly'),
customOperations: (operations: string[]) => t(
'providers.modelMapping.scope.customOperations',
{ operations: operations.join(', ') },
),
}))
function getGroupEndpointScopeLabel(group: AliasGroup): string {
if (!group.endpointIds || group.endpointIds.length === 0) return '全部端点'
return `${group.endpointIds.length} 端点`
if (!group.endpointIds || group.endpointIds.length === 0) {
return t('providers.modelMapping.scope.allEndpoints')
}
const labels = getGroupEndpointScopeLabels(group)
return labels.length === 1
? labels[0]
: t('providers.modelMapping.scope.endpointCount', { count: labels.length })
}
function getGroupEndpointScopeTitle(group: AliasGroup): string {
if (!group.endpointIds || group.endpointIds.length === 0) {
return t('providers.modelMapping.scope.allEndpoints')
}
return getGroupEndpointScopeLabels(group).join('、')
}
function getGroupEndpointScopeLabels(group: AliasGroup): string[] {
const endpoints = props.endpoints ?? []
return group.endpointIds.map((endpointId) => {
const endpoint = endpoints.find(item => item.id === endpointId)
if (!endpoint) return endpointId
return formatModelMappingEndpointLabel(endpoint, endpoints)
})
}
function getGroupOperationScopeLabel(group: AliasGroup): string {
return formatModelMappingRequestScope(group.operations, requestScopeLabels.value)
}
// 精确映射分组(来自 provider_model_mappings
@@ -508,7 +564,8 @@ const exactMappingGroups = computed<AliasGroup[]>(() => {
for (const alias of model.provider_model_mappings) {
const apiFormatsKey = getApiFormatsKey(alias.api_formats)
const endpointIdsKey = getEndpointIdsKey(alias.endpoint_ids)
const groupKey = `${model.id}|${apiFormatsKey}|${endpointIdsKey}`
const operationsKey = getOperationsKey(alias.operations)
const groupKey = `${model.id}|${apiFormatsKey}|${endpointIdsKey}|${operationsKey}`
if (!groupMap.has(groupKey)) {
const group: AliasGroup = {
@@ -517,6 +574,8 @@ const exactMappingGroups = computed<AliasGroup[]>(() => {
apiFormats: alias.api_formats || [],
endpointIdsKey,
endpointIds: normalizeStringList(alias.endpoint_ids),
operationsKey,
operations: normalizeModelMappingOperations(alias.operations),
aliases: []
}
groupMap.set(groupKey, group)
@@ -600,7 +659,7 @@ const combinedMappings = computed<CombinedMapping[]>(() => {
// 添加精确映射
for (const group of exactMappingGroups.value) {
result.push({
key: `exact-${group.model.id}-${group.apiFormatsKey}`,
key: `exact-${group.model.id}-${group.apiFormatsKey}-${group.endpointIdsKey}-${group.operationsKey}`,
type: 'exact',
targetModelName: group.model.global_model_display_name || group.model.provider_model_name,
targetModelId: group.model.id,
@@ -645,7 +704,8 @@ const deleteConfirmDescription = computed(() => {
const modelName = model.global_model_display_name || model.provider_model_name
const aliasNames = aliases.map(a => a.name).join(', ')
const endpointScope = getGroupEndpointScopeLabel(deletingGroup.value)
return `确定要删除模型「${modelName}」在「${endpointScope}」下的 ${aliases.length} 个映射吗?\n\n映射名称:${aliasNames}`
const operationScope = getGroupOperationScopeLabel(deletingGroup.value)
return `确定要删除模型「${modelName}」在「${endpointScope} / ${operationScope}」下的 ${aliases.length} 个映射吗?\n\n映射名称:${aliasNames}`
})
// 切换展开状态
@@ -692,7 +752,7 @@ function deleteGroup(group: AliasGroup) {
async function confirmDelete() {
if (!deletingGroup.value) return
const { model, aliases, apiFormatsKey, endpointIdsKey } = deletingGroup.value
const { model, aliases, apiFormatsKey, endpointIdsKey, operationsKey } = deletingGroup.value
try {
const currentAliases = model.provider_model_mappings || []
@@ -700,7 +760,11 @@ async function confirmDelete() {
const newAliases = currentAliases.filter((a: ProviderModelAlias) => {
const currentKey = getApiFormatsKey(a.api_formats)
const currentEndpointIdsKey = getEndpointIdsKey(a.endpoint_ids)
return !(currentKey === apiFormatsKey && currentEndpointIdsKey === endpointIdsKey && aliasNamesToRemove.has(a.name))
const currentOperationsKey = getOperationsKey(a.operations)
return !(currentKey === apiFormatsKey
&& currentEndpointIdsKey === endpointIdsKey
&& currentOperationsKey === operationsKey
&& aliasNamesToRemove.has(a.name))
})
await updateModel(props.provider.id, model.id, {
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import {
ALL_REQUESTS_SCOPE_VALUE,
COMPACT_REQUEST_SCOPE_VALUE,
formatModelMappingEndpointLabel,
formatModelMappingRequestScope,
modelMappingEndpointScopeSupportsSessionCompaction,
modelMappingOperationsKey,
modelMappingOperationsFromScopeValue,
modelMappingRequestScopeOptions,
modelMappingRequestScopeValue,
normalizeModelMappingOperations,
} from '../modelMappingScope'
describe('model mapping request scope', () => {
it('represents an omitted operation filter as all requests', () => {
expect(modelMappingRequestScopeValue(undefined)).toBe(ALL_REQUESTS_SCOPE_VALUE)
expect(modelMappingOperationsFromScopeValue(ALL_REQUESTS_SCOPE_VALUE)).toBeUndefined()
expect(formatModelMappingRequestScope(undefined)).toBe('所有请求')
})
it('round-trips the compact operation as a dedicated request scope', () => {
expect(modelMappingRequestScopeValue(['compact'])).toBe(COMPACT_REQUEST_SCOPE_VALUE)
expect(modelMappingOperationsFromScopeValue(COMPACT_REQUEST_SCOPE_VALUE)).toEqual(['compact'])
expect(formatModelMappingRequestScope(['compact'])).toBe('仅会话压缩')
})
it('normalizes operation values using the backend matching semantics', () => {
expect(normalizeModelMappingOperations([' Compact ', 'compact', '', 'SEARCH'])).toEqual([
'compact',
'search',
])
expect(modelMappingOperationsKey(['SEARCH', ' compact ', 'Compact'])).toBe('compact,search')
})
it('preserves an unknown operation scope while editing', () => {
const operations = ['future_operation', 'compact']
const value = modelMappingRequestScopeValue(operations)
const options = modelMappingRequestScopeOptions(
operations,
{ sessionCompaction: true },
)
expect(modelMappingOperationsFromScopeValue(value)).toEqual(operations)
expect(options).toContainEqual({ value, label: '仅匹配:future_operation, compact' })
})
it('offers compact scope only when the selected endpoint scope supports it', () => {
expect(modelMappingRequestScopeOptions(undefined, { sessionCompaction: false }))
.toEqual([{ value: ALL_REQUESTS_SCOPE_VALUE, label: '所有请求' }])
expect(modelMappingRequestScopeOptions(undefined, { sessionCompaction: true }))
.toContainEqual({ value: COMPACT_REQUEST_SCOPE_VALUE, label: '仅会话压缩' })
})
it('requires every explicitly selected endpoint to use OpenAI Responses', () => {
const responsesEndpoint = {
id: 'responses',
api_format: 'OPENAI_RESPONSES',
base_url: 'https://api.example.com/v1',
is_active: true,
}
const chatEndpoint = {
id: 'chat',
api_format: 'openai:chat',
base_url: 'https://api.example.com/v1',
is_active: true,
}
expect(modelMappingEndpointScopeSupportsSessionCompaction(
undefined,
[responsesEndpoint, chatEndpoint],
)).toBe(false)
expect(modelMappingEndpointScopeSupportsSessionCompaction(
[responsesEndpoint.id],
[responsesEndpoint, chatEndpoint],
)).toBe(true)
expect(modelMappingEndpointScopeSupportsSessionCompaction(
[responsesEndpoint.id, chatEndpoint.id],
[responsesEndpoint, chatEndpoint],
)).toBe(false)
})
it('rejects malformed scope values without constructing operations', () => {
expect(modelMappingOperationsFromScopeValue('compact')).toBeUndefined()
expect(modelMappingOperationsFromScopeValue('{"compact":true}')).toBeUndefined()
})
it('disambiguates endpoints that share an API format', () => {
const endpoints = [
{
id: 'endpoint-1',
api_format: 'openai:responses',
base_url: 'https://api.example.com/v1',
is_active: true,
},
{
id: 'endpoint-2',
api_format: 'openai:responses',
base_url: 'https://backup.example.com/v1',
custom_path: '/backend-api/codex/responses',
is_active: false,
},
]
expect(formatModelMappingEndpointLabel(endpoints[0], endpoints)).toBe(
'OpenAI Responses · api.example.com/v1',
)
expect(formatModelMappingEndpointLabel(endpoints[1], endpoints)).toBe(
'OpenAI Responses · backup.example.com/backend-api/codex/responses(停用)',
)
})
})
@@ -0,0 +1,165 @@
import {
API_FORMATS,
formatApiFormat,
normalizeApiFormatAlias,
} from '@/api/endpoints/types/api-format'
export const MODEL_MAPPING_OPERATION_COMPACT = 'compact'
export const ALL_REQUESTS_SCOPE_VALUE = '[]'
export const COMPACT_REQUEST_SCOPE_VALUE = JSON.stringify([
MODEL_MAPPING_OPERATION_COMPACT,
])
export interface ModelMappingRequestScopeOption {
value: string
label: string
}
export interface ModelMappingRequestScopeCapabilities {
sessionCompaction: boolean
}
export interface ModelMappingRequestScopeLabels {
allRequests: string
sessionCompactionOnly: string
customOperations: (operations: string[]) => string
}
export interface ModelMappingEndpoint {
id: string
api_format: string
base_url: string
custom_path?: string
is_active: boolean
}
const DEFAULT_REQUEST_SCOPE_LABELS: ModelMappingRequestScopeLabels = {
allRequests: '所有请求',
sessionCompactionOnly: '仅会话压缩',
customOperations: operations => `仅匹配:${operations.join(', ')}`,
}
export function normalizeModelMappingOperations(
operations: string[] | undefined,
): string[] {
const seen = new Set<string>()
const normalized: string[] = []
for (const operation of operations ?? []) {
const value = operation.trim().toLowerCase()
if (!value || seen.has(value)) continue
seen.add(value)
normalized.push(value)
}
return normalized
}
export function modelMappingRequestScopeValue(
operations: string[] | undefined,
): string {
return JSON.stringify(normalizeModelMappingOperations(operations))
}
export function modelMappingOperationsKey(
operations: string[] | undefined,
): string {
return normalizeModelMappingOperations(operations).sort().join(',')
}
export function modelMappingOperationsFromScopeValue(
value: string,
): string[] | undefined {
try {
const parsed = JSON.parse(value)
if (!Array.isArray(parsed) || parsed.some(item => typeof item !== 'string')) {
return undefined
}
const operations = normalizeModelMappingOperations(parsed)
return operations.length > 0 ? operations : undefined
} catch {
return undefined
}
}
export function formatModelMappingRequestScope(
operations: string[] | undefined,
labels: ModelMappingRequestScopeLabels = DEFAULT_REQUEST_SCOPE_LABELS,
): string {
const normalized = normalizeModelMappingOperations(operations)
if (normalized.length === 0) return labels.allRequests
if (
normalized.length === 1
&& normalized[0] === MODEL_MAPPING_OPERATION_COMPACT
) {
return labels.sessionCompactionOnly
}
return labels.customOperations(normalized)
}
export function modelMappingRequestScopeOptions(
operations: string[] | undefined,
capabilities: ModelMappingRequestScopeCapabilities,
labels: ModelMappingRequestScopeLabels = DEFAULT_REQUEST_SCOPE_LABELS,
): ModelMappingRequestScopeOption[] {
const options: ModelMappingRequestScopeOption[] = [
{ value: ALL_REQUESTS_SCOPE_VALUE, label: labels.allRequests },
]
if (capabilities.sessionCompaction) {
options.push({
value: COMPACT_REQUEST_SCOPE_VALUE,
label: labels.sessionCompactionOnly,
})
}
const currentValue = modelMappingRequestScopeValue(operations)
const compactScopeUnavailable = currentValue === COMPACT_REQUEST_SCOPE_VALUE
&& !capabilities.sessionCompaction
if (!compactScopeUnavailable && !options.some(option => option.value === currentValue)) {
options.push({
value: currentValue,
label: formatModelMappingRequestScope(operations, labels),
})
}
return options
}
export function modelMappingEndpointScopeSupportsSessionCompaction(
endpointIds: string[] | undefined,
endpoints: ModelMappingEndpoint[],
): boolean {
const selectedIds = [...new Set(
(endpointIds ?? []).map(id => id.trim()).filter(Boolean),
)]
if (selectedIds.length === 0) return false
const endpointsById = new Map(endpoints.map(endpoint => [endpoint.id, endpoint]))
return selectedIds.every((endpointId) => {
const endpoint = endpointsById.get(endpointId)
return endpoint !== undefined
&& normalizeApiFormatAlias(endpoint.api_format) === API_FORMATS.OPENAI_RESPONSES
})
}
export function formatModelMappingEndpointLabel(
endpoint: ModelMappingEndpoint,
endpoints: ModelMappingEndpoint[],
): string {
const sameFormatCount = endpoints.filter(item => item.api_format === endpoint.api_format).length
const format = formatApiFormat(endpoint.api_format)
const discriminator = sameFormatCount > 1
? formatModelMappingEndpointDiscriminator(endpoint)
: ''
const status = endpoint.is_active ? '' : '(停用)'
return `${format}${discriminator ? ` · ${discriminator}` : ''}${status}`
}
function formatModelMappingEndpointDiscriminator(endpoint: ModelMappingEndpoint): string {
const baseUrl = endpoint.base_url.trim()
try {
const parsed = new URL(baseUrl)
const customPath = endpoint.custom_path?.trim()
const path = (customPath || parsed.pathname).replace(/\/$/, '')
return `${parsed.host}${path && path !== '/' ? path : ''}`
} catch {
return baseUrl || endpoint.id.slice(0, 8)
}
}
@@ -69,7 +69,7 @@ import { Badge } from '@/components/ui'
import { isCyberPolicyError } from '../utils/cyberError'
import { formatServiceTierFact } from '../utils/service-tier'
type ModelBadgeKey = 'reasoning' | 'fast' | 'cyber' | 'reasoning_tokens'
type ModelBadgeKey = 'compact' | 'reasoning' | 'fast' | 'cyber' | 'reasoning_tokens'
interface ModelBadgePresentation {
key: ModelBadgeKey
@@ -84,6 +84,7 @@ interface UsageModelDisplayRecord {
model: string
target_model?: string | null
model_version?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
@@ -132,6 +133,16 @@ const reasoningLabel = computed(() => {
const modelBadges = computed<ModelBadgePresentation[]>(() => {
const badges: ModelBadgePresentation[] = []
if (normalizeText(props.record.request_type)?.toLowerCase() === 'compact') {
badges.push({
key: 'compact',
label: '会话压缩',
variant: 'outline',
className: 'border-sky-500/30 bg-sky-500/5 text-sky-700 dark:text-sky-300',
title: '会话压缩',
ariaLabel: '会话压缩',
})
}
if (props.showReasoningBadge && reasoningLabel.value) {
badges.push({
key: 'reasoning',
@@ -165,19 +165,21 @@ describe('RequestDetailDrawer settlement pricing', () => {
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
.toContain('Cyber')
const modelLayout = document.body.querySelector(
'[data-request-detail-model-layout="stacked"]',
'[data-request-detail-model-layout="inline"]',
)
expect(modelLayout?.firstElementChild?.textContent).toContain('gpt-5')
expect(modelLayout?.firstElementChild?.textContent).toContain('->')
expect(modelLayout?.firstElementChild?.textContent).toContain('gpt-5.1')
expect(modelLayout?.firstElementChild?.querySelector('[data-request-detail-model-badge]'))
.toBeNull()
const modelBadgesRow = modelLayout?.querySelector(
'[data-request-detail-model-badges-row]',
)
expect(modelBadgesRow?.textContent).toContain('xhigh -> max')
expect(modelBadgesRow?.textContent).toContain('Fast')
expect(modelBadgesRow?.textContent).toContain('Cyber')
const modelRow = modelLayout?.firstElementChild
expect(modelRow?.textContent).toContain('gpt-5')
expect(modelRow?.textContent).toContain('->')
expect(modelRow?.textContent).toContain('gpt-5.1')
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('basis-full'))
.toBe(true)
expect(modelRow?.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
.toContain('xhigh -> max')
expect(modelRow?.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
.toContain('Fast')
expect(modelRow?.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
.toContain('Cyber')
expect(modelLayout?.querySelector('[data-request-detail-model-badges-row]')).toBeNull()
const serviceTierFacts = document.body.querySelector('[data-testid="service-tier-facts"]')
expect([...serviceTierFacts?.querySelectorAll('dt') ?? []].map(node => node.textContent?.trim()))
.toEqual(['上游请求层级', '计费层级'])
@@ -359,7 +361,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
await vi.waitFor(() => {
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
.toBe('gpt-5.1')
.toBe('->gpt-5.1')
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
.toBe('xhigh -> max')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
@@ -414,7 +416,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
await vi.waitFor(() => {
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
.toBe('gpt-5.1-2026-07-17')
.toBe('->gpt-5.1-2026-07-17')
})
})
@@ -306,6 +306,27 @@ describe('UsageRecordsTable', () => {
.toBe('Fast')
})
it('shows request reasoning effort while the record is pending', () => {
const root = mountUsageRecordsTable([buildRecord({
status: 'pending',
requested_reasoning_effort: 'max',
reasoning_effort: null,
})])
expect(root.querySelector('[data-usage-model-badge="reasoning"]')?.textContent?.trim())
.toBe('max')
})
it('marks Responses compaction while the record is pending', () => {
const root = mountUsageRecordsTable([buildRecord({
status: 'pending',
request_type: 'compact',
})])
expect(root.querySelector('[data-usage-model-badge="compact"]')?.textContent?.trim())
.toBe('会话压缩')
})
it('shows mapping, reasoning, Fast, and Cyber in the model area', () => {
const root = mountUsageRecordsTable([buildRecord({
model: 'gpt-5',
@@ -355,6 +355,7 @@ describe('useUsageData', () => {
has_format_conversion: false,
has_retry: true,
target_model: 'gpt-5.5',
request_type: 'compact',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
service_tier: 'priority',
@@ -385,6 +386,7 @@ describe('useUsageData', () => {
has_format_conversion: undefined,
has_retry: false,
target_model: undefined,
request_type: null,
requested_reasoning_effort: null,
reasoning_effort: undefined,
service_tier: undefined,
@@ -420,6 +422,7 @@ describe('useUsageData', () => {
has_format_conversion: false,
has_retry: true,
target_model: null,
request_type: 'compact',
requested_reasoning_effort: 'xhigh',
reasoning_effort: null,
service_tier: null,
@@ -669,6 +669,12 @@ export function useUsageData(options: UseUsageDataOptions) {
? record.target_model
: null)
: existing.target_model,
// Request type is client-request identity, not a provider-candidate fact. Preserve a
// known compact operation when a later sparse snapshot omits it.
request_type:
typeof record.request_type === 'string' && record.request_type.trim()
? record.request_type
: existing.request_type,
requested_reasoning_effort:
typeof record.requested_reasoning_effort === 'string'
&& record.requested_reasoning_effort.trim()
+1
View File
@@ -96,6 +96,7 @@ export interface UsageRecord {
model: string
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
request_type?: string | null // 由请求语义识别出的操作类型
requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
service_tier?: string | null // 从发送给 Provider 的请求体提取的服务层级
+31
View File
@@ -295,6 +295,17 @@ export const messages = {
'nav.cacheMonitoring': '缓存监控',
'nav.moduleManagement': '模块管理',
'nav.systemSettings': '系统设置',
'providers.modelMapping.scope.allRequests': '所有请求',
'providers.modelMapping.scope.sessionCompactionOnly': '仅会话压缩',
'providers.modelMapping.scope.customOperations': '仅匹配:{operations}',
'providers.modelMapping.scope.allRequestsDescription': '普通请求和会话压缩都可使用此映射。',
'providers.modelMapping.scope.allRequestsDefaultDescription': '所选端点的所有请求都可使用此映射。仅当适用端点已明确选择且全部为 OpenAI Responses 时,可限定为会话压缩。',
'providers.modelMapping.scope.sessionCompactionDescription': '仅匹配所选 OpenAI Responses 端点中的会话压缩请求。',
'providers.modelMapping.scope.customOperationsDescription': '只匹配请求操作:{operations}。',
'providers.modelMapping.scope.allEndpoints': '全部端点',
'providers.modelMapping.scope.endpointCount': '{count} 个端点',
'providers.modelMapping.scope.endpointHelp': '留空时不限制端点;选择后仅匹配指定端点。',
'providers.modelMapping.scope.matchHelp': '端点范围和请求范围必须同时匹配。',
'breadcrumb.personalSettings': '个人设置',
'breadcrumb.routingCreate': '新建调度策略',
'breadcrumb.routingConfig': '调度策略配置',
@@ -595,6 +606,17 @@ export const messages = {
'nav.cacheMonitoring': 'Cache monitoring',
'nav.moduleManagement': 'Modules',
'nav.systemSettings': 'System settings',
'providers.modelMapping.scope.allRequests': 'All requests',
'providers.modelMapping.scope.sessionCompactionOnly': 'Session compaction only',
'providers.modelMapping.scope.customOperations': 'Match only: {operations}',
'providers.modelMapping.scope.allRequestsDescription': 'Regular requests and session compaction can both use this mapping.',
'providers.modelMapping.scope.allRequestsDefaultDescription': 'This mapping applies to all requests on the selected endpoints. Session compaction can be selected only when every explicitly selected endpoint uses OpenAI Responses.',
'providers.modelMapping.scope.sessionCompactionDescription': 'Match only session compaction requests on the selected OpenAI Responses endpoints.',
'providers.modelMapping.scope.customOperationsDescription': 'Match only these request operations: {operations}.',
'providers.modelMapping.scope.allEndpoints': 'All endpoints',
'providers.modelMapping.scope.endpointCount': '{count} endpoints',
'providers.modelMapping.scope.endpointHelp': 'Leave empty for no endpoint restriction. Selections match only those endpoints.',
'providers.modelMapping.scope.matchHelp': 'Both the endpoint scope and request scope must match.',
'breadcrumb.personalSettings': 'Profile settings',
'breadcrumb.routingCreate': 'Create routing profile',
'breadcrumb.routingConfig': 'Routing profile',
@@ -673,6 +695,15 @@ const legacyExactEnglishMessages: Record<string, string> = {
'全部分组': 'All groups',
'全部归属': 'All ownership',
'全部方式': 'All methods',
'适用范围': 'Applies to',
'适用端点': 'Applicable endpoints',
'适用请求': 'Applicable requests',
'全部端点': 'All endpoints',
'所有请求': 'All requests',
'仅会话压缩': 'Session compaction only',
'会话压缩': 'Session compaction',
'保存映射': 'Save mapping',
'添加映射': 'Add mapping',
'未设置': 'Not set',
'已设置': 'Configured',
'默认': 'Default',
+3
View File
@@ -596,6 +596,9 @@ async function pollActiveRequests() {
record.reasoning_effort = typeof update.reasoning_effort === 'string' && update.reasoning_effort.trim()
? update.reasoning_effort
: null
if (typeof update.request_type === 'string' && update.request_type.trim()) {
record.request_type = update.request_type
}
if (typeof update.requested_reasoning_effort === 'string' && update.requested_reasoning_effort.trim()) {
record.requested_reasoning_effort = update.requested_reasoning_effort
}