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:
fawney19
2026-03-01 23:55:26 +08:00
parent 4bf3a453e7
commit 97d42703da
37 changed files with 650 additions and 286 deletions

View File

@@ -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,

View File

@@ -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()

View File

@@ -0,0 +1,3 @@
import type { InjectionKey } from 'vue'
export const DIALOG_CONTEXT_KEY: InjectionKey<boolean> = Symbol('dialog-context')

View File

@@ -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',

View File

@@ -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

View File

@@ -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

View File

@@ -8,6 +8,7 @@ const ENDPOINT_SORT_ORDER = [
'claude:cli',
'openai:chat',
'openai:cli',
'openai:compact',
'gemini:chat',
'gemini:cli',
'openai:video',

View File

@@ -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' },
]

View 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)

View File

@@ -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' },

View File

@@ -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!',
})
})
})