mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(codex): 拆分openai:compact为独立端点,简化Codex请求为透传模式
- 新增openai:compact端点类型(EndpointKind.COMPACT),独立于openai:cli - OpenAICompactAdapter继承OpenAICliAdapter,自动标记compact模式 - Codex请求补丁改为纯透传:仅清理内部标记,不再修改客户端payload - stream_policy支持openai:compact独立策略,compact端点移除stream字段 - candidate_builder支持compact回退到cli端点 - auth_type: vertex_ai重命名为service_account,保持向后兼容 - Vertex Provider新增api_formats与auth_type组合校验 - KeyAllowedModels对话框改为从Provider获取模型,展示provider_model_name - Dialog内Select组件自动禁用Portal,修复层级遮挡问题 - 新增Codex compact端点回填迁移脚本
This commit is contained in:
@@ -5,6 +5,7 @@ export const API_FORMATS = {
|
||||
CLAUDE_CLI: 'claude:cli',
|
||||
OPENAI: 'openai:chat',
|
||||
OPENAI_CLI: 'openai:cli',
|
||||
OPENAI_COMPACT: 'openai:compact',
|
||||
OPENAI_VIDEO: 'openai:video',
|
||||
GEMINI: 'gemini:chat',
|
||||
GEMINI_CLI: 'gemini:cli',
|
||||
@@ -19,6 +20,7 @@ export const API_FORMAT_LABELS: Record<string, string> = {
|
||||
[API_FORMATS.CLAUDE_CLI]: 'Claude CLI',
|
||||
[API_FORMATS.OPENAI]: 'OpenAI Chat',
|
||||
[API_FORMATS.OPENAI_CLI]: 'OpenAI CLI',
|
||||
[API_FORMATS.OPENAI_COMPACT]: 'OpenAI Compact',
|
||||
[API_FORMATS.OPENAI_VIDEO]: 'OpenAI Video',
|
||||
[API_FORMATS.GEMINI]: 'Gemini Chat',
|
||||
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
|
||||
@@ -28,6 +30,7 @@ export const API_FORMAT_LABELS: Record<string, string> = {
|
||||
CLAUDE_CLI: 'Claude CLI',
|
||||
OPENAI: 'OpenAI Chat',
|
||||
OPENAI_CLI: 'OpenAI CLI',
|
||||
OPENAI_COMPACT: 'OpenAI Compact',
|
||||
OPENAI_VIDEO: 'OpenAI Video',
|
||||
GEMINI: 'Gemini Chat',
|
||||
GEMINI_CLI: 'Gemini CLI',
|
||||
@@ -38,6 +41,7 @@ export const API_FORMAT_LABELS: Record<string, string> = {
|
||||
export const API_FORMAT_SHORT: Record<string, string> = {
|
||||
[API_FORMATS.OPENAI]: 'O',
|
||||
[API_FORMATS.OPENAI_CLI]: 'OC',
|
||||
[API_FORMATS.OPENAI_COMPACT]: 'OCP',
|
||||
[API_FORMATS.OPENAI_VIDEO]: 'OV',
|
||||
[API_FORMATS.CLAUDE]: 'C',
|
||||
[API_FORMATS.CLAUDE_CLI]: 'CC',
|
||||
@@ -47,6 +51,7 @@ export const API_FORMAT_SHORT: Record<string, string> = {
|
||||
// legacy 兼容(仅用于展示历史数据)
|
||||
OPENAI: 'O',
|
||||
OPENAI_CLI: 'OC',
|
||||
OPENAI_COMPACT: 'OCP',
|
||||
OPENAI_VIDEO: 'OV',
|
||||
CLAUDE: 'C',
|
||||
CLAUDE_CLI: 'CC',
|
||||
@@ -59,6 +64,7 @@ export const API_FORMAT_SHORT: Record<string, string> = {
|
||||
export const API_FORMAT_ORDER: string[] = [
|
||||
API_FORMATS.OPENAI,
|
||||
API_FORMATS.OPENAI_CLI,
|
||||
API_FORMATS.OPENAI_COMPACT,
|
||||
API_FORMATS.OPENAI_VIDEO,
|
||||
API_FORMATS.CLAUDE,
|
||||
API_FORMATS.CLAUDE_CLI,
|
||||
|
||||
@@ -92,8 +92,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots, type Component } from 'vue'
|
||||
import { computed, provide, useSlots, type Component } from 'vue'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { DIALOG_CONTEXT_KEY } from './context'
|
||||
|
||||
// Props 定义
|
||||
const props = defineProps<{
|
||||
@@ -116,6 +117,8 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
provide(DIALOG_CONTEXT_KEY, true)
|
||||
|
||||
// 获取 slots 以便在模板中使用
|
||||
const slots = useSlots()
|
||||
|
||||
|
||||
3
frontend/src/components/ui/dialog/context.ts
Normal file
3
frontend/src/components/ui/dialog/context.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
export const DIALOG_CONTEXT_KEY: InjectionKey<boolean> = Symbol('dialog-context')
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<SelectPortal>
|
||||
<SelectPortal :disabled="shouldDisablePortal">
|
||||
<SelectContentPrimitive
|
||||
v-bind="$attrs"
|
||||
:class="contentClass"
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
SelectViewport,
|
||||
} from 'radix-vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { computed } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
import { DIALOG_CONTEXT_KEY } from './dialog/context'
|
||||
|
||||
interface Props {
|
||||
class?: string
|
||||
@@ -32,6 +33,7 @@ interface Props {
|
||||
sideOffset?: number
|
||||
align?: 'start' | 'center' | 'end'
|
||||
alignOffset?: number
|
||||
disablePortal?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -41,8 +43,12 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
sideOffset: 4,
|
||||
align: undefined,
|
||||
alignOffset: undefined,
|
||||
disablePortal: false,
|
||||
})
|
||||
|
||||
const isInsideDialog = inject(DIALOG_CONTEXT_KEY, false)
|
||||
const shouldDisablePortal = computed(() => props.disablePortal || isInsideDialog)
|
||||
|
||||
const contentClass = computed(() =>
|
||||
cn(
|
||||
'z-[200] max-h-96 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
size="icon"
|
||||
:class="getUpstreamStreamButtonClass(endpoint)"
|
||||
:title="getUpstreamStreamTooltip(endpoint)"
|
||||
:disabled="savingEndpointId === endpoint.id"
|
||||
:disabled="savingEndpointId === endpoint.id || isUpstreamStreamPolicyLocked(endpoint)"
|
||||
@click="handleCycleUpstreamStream(endpoint)"
|
||||
>
|
||||
<Radio class="w-3.5 h-3.5" />
|
||||
@@ -2029,12 +2029,21 @@ async function handleToggleFormatConversion(endpoint: ProviderEndpoint) {
|
||||
|
||||
// 获取上游流式按钮的当前状态(优先使用编辑状态)
|
||||
function getCurrentUpstreamStreamPolicy(endpoint: ProviderEndpoint): string {
|
||||
if (isUpstreamStreamPolicyLocked(endpoint)) return 'force_stream'
|
||||
const state = endpointEditStates.value[endpoint.id]
|
||||
return state?.upstreamStreamPolicy ?? getEndpointUpstreamStreamPolicy(endpoint)
|
||||
}
|
||||
|
||||
function isUpstreamStreamPolicyLocked(endpoint: ProviderEndpoint): boolean {
|
||||
return (props.provider?.provider_type || '').toLowerCase() === 'codex'
|
||||
&& endpoint.api_format === 'openai:cli'
|
||||
}
|
||||
|
||||
// 获取上游流式按钮的样式类
|
||||
function getUpstreamStreamButtonClass(endpoint: ProviderEndpoint): string {
|
||||
if (isUpstreamStreamPolicyLocked(endpoint)) {
|
||||
return 'h-7 w-7 text-primary/70 cursor-not-allowed'
|
||||
}
|
||||
const policy = getCurrentUpstreamStreamPolicy(endpoint)
|
||||
const base = 'h-7 w-7'
|
||||
if (policy === 'force_stream') return `${base} text-primary`
|
||||
@@ -2044,6 +2053,7 @@ function getUpstreamStreamButtonClass(endpoint: ProviderEndpoint): string {
|
||||
|
||||
// 获取上游流式按钮的提示文字
|
||||
function getUpstreamStreamTooltip(endpoint: ProviderEndpoint): string {
|
||||
if (isUpstreamStreamPolicyLocked(endpoint)) return '固定流式(Codex OpenAI CLI,已锁定)'
|
||||
const policy = getCurrentUpstreamStreamPolicy(endpoint)
|
||||
if (policy === 'force_stream') return '固定流式(点击切换为固定非流)'
|
||||
if (policy === 'force_non_stream') return '固定非流(点击切换为跟随请求)'
|
||||
@@ -2052,6 +2062,8 @@ function getUpstreamStreamTooltip(endpoint: ProviderEndpoint): string {
|
||||
|
||||
// 循环切换上游流式策略并直接保存
|
||||
async function handleCycleUpstreamStream(endpoint: ProviderEndpoint) {
|
||||
if (isUpstreamStreamPolicyLocked(endpoint)) return
|
||||
|
||||
const currentPolicy = getCurrentUpstreamStreamPolicy(endpoint)
|
||||
let nextPolicy: string
|
||||
let nextLabel: string
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<!-- 加载中 -->
|
||||
<div
|
||||
v-if="loadingGlobalModels"
|
||||
v-if="loadingProviderModels"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||
@@ -152,7 +152,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 提供商模型 -->
|
||||
<template v-if="filteredGlobalModels.length > 0">
|
||||
<template v-if="filteredProviderModels.length > 0">
|
||||
<!-- 标题 sticky top -->
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
@@ -164,14 +164,14 @@
|
||||
:class="collapsedGroups.has('global') ? '-rotate-90' : ''"
|
||||
/>
|
||||
<span class="text-xs font-medium">提供商模型</span>
|
||||
<span class="text-xs text-muted-foreground">({{ filteredGlobalModels.length }})</span>
|
||||
<span class="text-xs text-muted-foreground">({{ filteredProviderModels.length }})</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-primary hover:underline"
|
||||
@click.stop="toggleAllGlobalModels"
|
||||
@click.stop="toggleAllProviderModels"
|
||||
>
|
||||
{{ isAllGlobalModelsSelected ? '取消全选' : '全选' }}
|
||||
{{ isAllProviderModelsSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 内容 -->
|
||||
@@ -180,7 +180,7 @@
|
||||
class="space-y-1 p-2"
|
||||
>
|
||||
<div
|
||||
v-for="model in filteredGlobalModels"
|
||||
v-for="model in filteredProviderModels"
|
||||
:key="model.name"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
||||
@click="toggleModel(model.name)"
|
||||
@@ -196,11 +196,14 @@
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">
|
||||
{{ model.display_name }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground truncate font-mono">
|
||||
{{ model.name }}
|
||||
</p>
|
||||
<p
|
||||
v-if="model.global_model_display_name || model.global_model_name"
|
||||
class="text-xs text-muted-foreground truncate font-mono"
|
||||
>
|
||||
{{ model.global_model_display_name || model.global_model_name }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="selectedModels.includes(model.name)"
|
||||
@@ -366,17 +369,19 @@ import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
getProviderModels,
|
||||
updateProviderKey,
|
||||
type EndpointAPIKey,
|
||||
type AllowedModels,
|
||||
} from '@/api/endpoints'
|
||||
import { getGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
||||
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
|
||||
import { API_FORMAT_SHORT, type UpstreamModel } from '@/api/endpoints/types'
|
||||
|
||||
interface AvailableModel {
|
||||
name: string
|
||||
display_name: string
|
||||
global_model_name?: string
|
||||
global_model_display_name?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -396,7 +401,7 @@ const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
const saving = ref(false)
|
||||
const loadingGlobalModels = ref(false)
|
||||
const loadingProviderModels = ref(false)
|
||||
const fetchingUpstreamModels = ref(false)
|
||||
const upstreamModelsLoaded = ref(false)
|
||||
|
||||
@@ -406,8 +411,8 @@ let loadingCancelled = false
|
||||
// 搜索
|
||||
const searchQuery = ref('')
|
||||
|
||||
// 可用模型列表(全局模型)
|
||||
const allGlobalModels = ref<AvailableModel[]>([])
|
||||
// 可用模型列表(该 Provider 已关联的模型)
|
||||
const allProviderModels = ref<AvailableModel[]>([])
|
||||
// 上游模型列表(从 API 查询获取)
|
||||
const upstreamModels = ref<UpstreamModel[]>([])
|
||||
|
||||
@@ -427,7 +432,7 @@ const isAutoFetchMode = computed(() => props.apiKey?.auto_fetch_models ?? false)
|
||||
|
||||
// 空状态判断
|
||||
const showEmptyState = computed(() => {
|
||||
return filteredGlobalModels.value.length === 0 &&
|
||||
return filteredProviderModels.value.length === 0 &&
|
||||
filteredUpstreamModels.value.length === 0 &&
|
||||
customModels.value.length === 0
|
||||
})
|
||||
@@ -450,22 +455,22 @@ const hasChanges = computed(() => {
|
||||
return sortedLocked1.some((v, i) => v !== sortedLocked2[i])
|
||||
})
|
||||
|
||||
// 所有已知模型的集合(全局 + 上游模型)
|
||||
// 所有已知模型的集合(提供商模型 + 上游模型)
|
||||
const allKnownModels = computed(() => {
|
||||
const set = new Set<string>()
|
||||
allGlobalModels.value.forEach(m => set.add(m.name))
|
||||
allProviderModels.value.forEach(m => set.add(m.name))
|
||||
upstreamModels.value.forEach(m => set.add(m.id))
|
||||
return set
|
||||
})
|
||||
|
||||
// 全局模型名称集合(用于判断模型是否为全局模型)
|
||||
const globalModelNamesSet = computed(() => {
|
||||
return new Set(allGlobalModels.value.map(m => m.name))
|
||||
// 提供商模型名称集合(用于判断模型是否为“提供商模型”分组项)
|
||||
const providerModelNamesSet = computed(() => {
|
||||
return new Set(allProviderModels.value.map(m => m.name))
|
||||
})
|
||||
|
||||
// 判断模型是否为全局模型(提供商模型)
|
||||
function isGlobalModel(modelId: string): boolean {
|
||||
return globalModelNamesSet.value.has(modelId)
|
||||
// 判断模型是否为“提供商模型”分组项
|
||||
function isProviderModel(modelId: string): boolean {
|
||||
return providerModelNamesSet.value.has(modelId)
|
||||
}
|
||||
|
||||
// 上游模型列表(后端已按 id 聚合,包含 api_formats 数组)
|
||||
@@ -541,27 +546,29 @@ const canAddAsCustom = computed(() => {
|
||||
if (selectedModels.value.includes(search)) return false
|
||||
// 已经在自定义模型列表中就不显示
|
||||
if (allCustomModels.value.includes(search)) return false
|
||||
// 精确匹配全局模型就不显示
|
||||
if (allGlobalModels.value.some(m => m.name === search)) return false
|
||||
// 精确匹配提供商模型就不显示
|
||||
if (allProviderModels.value.some(m => m.name === search)) return false
|
||||
// 精确匹配上游模型就不显示
|
||||
if (upstreamModelNames.value.includes(search)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// 搜索过滤后的全局模型
|
||||
const filteredGlobalModels = computed(() => {
|
||||
if (!searchQuery.value.trim()) return allGlobalModels.value
|
||||
// 搜索过滤后的提供商模型
|
||||
const filteredProviderModels = computed(() => {
|
||||
if (!searchQuery.value.trim()) return allProviderModels.value
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return allGlobalModels.value.filter(m =>
|
||||
return allProviderModels.value.filter(m =>
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
m.display_name.toLowerCase().includes(query)
|
||||
m.display_name.toLowerCase().includes(query) ||
|
||||
(m.global_model_name || '').toLowerCase().includes(query) ||
|
||||
(m.global_model_display_name || '').toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
// 全局模型是否全选
|
||||
const isAllGlobalModelsSelected = computed(() => {
|
||||
if (filteredGlobalModels.value.length === 0) return false
|
||||
return filteredGlobalModels.value.every(m => selectedModels.value.includes(m.name))
|
||||
// 提供商模型是否全选
|
||||
const isAllProviderModelsSelected = computed(() => {
|
||||
if (filteredProviderModels.value.length === 0) return false
|
||||
return filteredProviderModels.value.every(m => selectedModels.value.includes(m.name))
|
||||
})
|
||||
|
||||
// 切换模型选中状态
|
||||
@@ -569,9 +576,9 @@ function toggleModel(modelId: string) {
|
||||
const idx = selectedModels.value.indexOf(modelId)
|
||||
if (idx === -1) {
|
||||
selectedModels.value.push(modelId)
|
||||
// 自动获取模式下,勾选全局模型时自动锁定
|
||||
// 自动获取模式下,勾选提供商模型时自动锁定
|
||||
// 防止下次刷新时被覆盖(即使全局模型与上游模型同名)
|
||||
if (isAutoFetchMode.value && isGlobalModel(modelId)) {
|
||||
if (isAutoFetchMode.value && isProviderModel(modelId)) {
|
||||
if (!lockedModels.value.includes(modelId)) {
|
||||
lockedModels.value.push(modelId)
|
||||
}
|
||||
@@ -618,10 +625,10 @@ function addCustomModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选全局模型
|
||||
function toggleAllGlobalModels() {
|
||||
const allNames = filteredGlobalModels.value.map(m => m.name)
|
||||
if (isAllGlobalModelsSelected.value) {
|
||||
// 全选/取消全选提供商模型
|
||||
function toggleAllProviderModels() {
|
||||
const allNames = filteredProviderModels.value.map(m => m.name)
|
||||
if (isAllProviderModelsSelected.value) {
|
||||
// 取消全选
|
||||
selectedModels.value = selectedModels.value.filter(id => !allNames.includes(id))
|
||||
// 同时取消锁定
|
||||
@@ -631,7 +638,7 @@ function toggleAllGlobalModels() {
|
||||
allNames.forEach(name => {
|
||||
if (!selectedModels.value.includes(name)) {
|
||||
selectedModels.value.push(name)
|
||||
// 自动获取模式下,勾选全局模型时自动锁定
|
||||
// 自动获取模式下,勾选提供商模型时自动锁定
|
||||
if (isAutoFetchMode.value && !lockedModels.value.includes(name)) {
|
||||
lockedModels.value.push(name)
|
||||
}
|
||||
@@ -650,21 +657,23 @@ function toggleGroupCollapse(group: string) {
|
||||
collapsedGroups.value = new Set(collapsedGroups.value)
|
||||
}
|
||||
|
||||
// 加载全局模型
|
||||
async function loadGlobalModels() {
|
||||
loadingGlobalModels.value = true
|
||||
// 加载该 Provider 已关联的模型
|
||||
async function loadProviderModels() {
|
||||
loadingProviderModels.value = true
|
||||
try {
|
||||
const response = await getGlobalModels({ limit: 1000 })
|
||||
const response = await getProviderModels(props.providerId, { limit: 1000 })
|
||||
if (loadingCancelled) return
|
||||
allGlobalModels.value = response.models.map((m: GlobalModelResponse) => ({
|
||||
name: m.name,
|
||||
display_name: m.display_name
|
||||
allProviderModels.value = response.map(m => ({
|
||||
name: m.provider_model_name,
|
||||
display_name: m.global_model_display_name || m.global_model_name || m.provider_model_name,
|
||||
global_model_name: m.global_model_name,
|
||||
global_model_display_name: m.global_model_display_name
|
||||
}))
|
||||
} catch {
|
||||
if (loadingCancelled) return
|
||||
showError('加载全局模型失败', '错误')
|
||||
showError('加载提供商模型失败', '错误')
|
||||
} finally {
|
||||
loadingGlobalModels.value = false
|
||||
loadingProviderModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,8 +743,8 @@ watch(() => props.open, async (open) => {
|
||||
collapsedGroups.value = new Set()
|
||||
}
|
||||
|
||||
// 加载全局模型
|
||||
await loadGlobalModels()
|
||||
// 加载该 Provider 已关联模型
|
||||
await loadProviderModels()
|
||||
|
||||
// 自动获取模式下,获取上游模型用于显示(但选中状态使用已保存的 allowed_models)
|
||||
if (props.apiKey.auto_fetch_models) {
|
||||
@@ -745,11 +754,11 @@ watch(() => props.open, async (open) => {
|
||||
// selectedModels 已在上面从 props.apiKey.allowed_models 初始化
|
||||
}
|
||||
|
||||
// 提取自定义模型(不在全局模型和上游模型中的)
|
||||
// 提取自定义模型(不在提供商模型和上游模型中的)
|
||||
const upstreamModelIdsSet = new Set(upstreamModels.value.map(m => m.id))
|
||||
// 自定义模型是用户手动添加的、不在已知模型列表中的
|
||||
allCustomModels.value = selectedModels.value.filter(m =>
|
||||
!globalModelNamesSet.value.has(m) && !upstreamModelIdsSet.has(m)
|
||||
!providerModelNamesSet.value.has(m) && !upstreamModelIdsSet.has(m)
|
||||
)
|
||||
} else {
|
||||
loadingCancelled = true
|
||||
|
||||
@@ -8,6 +8,7 @@ const ENDPOINT_SORT_ORDER = [
|
||||
'claude:cli',
|
||||
'openai:chat',
|
||||
'openai:cli',
|
||||
'openai:compact',
|
||||
'gemini:chat',
|
||||
'gemini:cli',
|
||||
'openai:video',
|
||||
|
||||
@@ -28,6 +28,7 @@ export function useProviderFilters(
|
||||
{ value: 'claude:cli', label: 'Claude CLI' },
|
||||
{ value: 'openai:chat', label: 'OpenAI Chat' },
|
||||
{ value: 'openai:cli', label: 'OpenAI CLI' },
|
||||
{ value: 'openai:compact', label: 'OpenAI Compact' },
|
||||
{ value: 'gemini:chat', label: 'Gemini Chat' },
|
||||
{ value: 'gemini:cli', label: 'Gemini CLI' },
|
||||
]
|
||||
|
||||
19
frontend/src/features/providers/utils/providerTypeUtils.ts
Normal file
19
frontend/src/features/providers/utils/providerTypeUtils.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Provider 类型判断工具函数。
|
||||
*
|
||||
* 区分"密钥型"和"OAuth 账号型"两类 Provider,影响前端显示标签和操作入口。
|
||||
*/
|
||||
|
||||
const oauthAccountProviderTypes = new Set([
|
||||
'claude_code',
|
||||
'codex',
|
||||
'gemini_cli',
|
||||
'antigravity',
|
||||
'kiro',
|
||||
])
|
||||
|
||||
export const isOAuthAccountProviderType = (providerType?: string | null): boolean =>
|
||||
oauthAccountProviderTypes.has((providerType || '').toLowerCase())
|
||||
|
||||
export const isKeyManagedProviderType = (providerType?: string | null): boolean =>
|
||||
!isOAuthAccountProviderType(providerType)
|
||||
@@ -723,6 +723,7 @@ const emit = defineEmits<{
|
||||
const AVAILABLE_API_FORMATS = [
|
||||
{ value: 'openai:chat', label: 'OpenAI Chat' },
|
||||
{ value: 'openai:cli', label: 'OpenAI CLI' },
|
||||
{ value: 'openai:compact', label: 'OpenAI Compact' },
|
||||
{ value: 'openai:video', label: 'OpenAI Video' },
|
||||
{ value: 'claude:chat', label: 'Claude Chat' },
|
||||
{ value: 'claude:cli', label: 'Claude CLI' },
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseResponse, renderResponse } from '../registry'
|
||||
|
||||
describe('Gemini conversation parser', () => {
|
||||
const requestBody = { model: 'gemini-3-pro-preview' }
|
||||
const normalizedResponse = {
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'Hello!',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('parses normalized output payload when hint is gemini', () => {
|
||||
const parsed = parseResponse(normalizedResponse, requestBody, 'gemini:chat')
|
||||
expect(parsed.apiFormat).toBe('gemini')
|
||||
expect(parsed.messages).toHaveLength(1)
|
||||
expect(parsed.messages[0]?.role).toBe('assistant')
|
||||
expect(parsed.messages[0]?.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'Hello!',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders normalized output payload when hint is gemini', () => {
|
||||
const rendered = renderResponse(normalizedResponse, requestBody, 'gemini:chat')
|
||||
expect(rendered.error).toBeUndefined()
|
||||
expect(rendered.blocks).toHaveLength(1)
|
||||
expect(rendered.blocks[0]).toMatchObject({
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
})
|
||||
|
||||
const firstBlock = rendered.blocks[0]
|
||||
if (!firstBlock || firstBlock.type !== 'message') {
|
||||
throw new Error('expected first render block to be message')
|
||||
}
|
||||
|
||||
expect(firstBlock.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
content: 'Hello!',
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user