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

@@ -0,0 +1,199 @@
"""backfill_codex_compact_endpoint
Backfill Codex reverse-proxy endpoints:
- ensure `openai:cli` endpoint is pinned to force_stream
- ensure `openai:compact` endpoint exists
Revision ID: f0c3a7b9d1e2
Revises: 2a624af8dd3a
Create Date: 2026-03-01 17:00:00.000000+00:00
"""
from __future__ import annotations
import json
import uuid
from typing import Any
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f0c3a7b9d1e2"
down_revision = "2a624af8dd3a"
branch_labels = None
depends_on = None
_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
_COMPACT_FORMAT = "openai:compact"
_CLI_FORMAT = "openai:cli"
_FORCE_STREAM = "force_stream"
def _find_codex_provider_ids(conn: sa.Connection) -> list[str]:
"""Find Codex providers (by provider_type or legacy base_url pattern)."""
rows = conn.execute(sa.text("""
SELECT DISTINCT p.id
FROM providers p
LEFT JOIN provider_endpoints pe ON pe.provider_id = p.id
WHERE lower(COALESCE(p.provider_type, '')) = 'codex'
OR (
lower(COALESCE(pe.api_format, '')) = 'openai:cli'
AND lower(COALESCE(pe.base_url, '')) LIKE '%/backend-api/codex%'
)
"""))
return [str(r[0]) for r in rows if r[0]]
def _get_cli_endpoint(conn: sa.Connection, provider_id: str) -> dict[str, Any] | None:
"""Load existing openai:cli endpoint for the provider."""
row = (
conn.execute(
sa.text("""
SELECT base_url, header_rules, body_rules, max_retries, proxy, config
FROM provider_endpoints
WHERE provider_id = :pid AND api_format = :fmt
LIMIT 1
"""),
{"pid": provider_id, "fmt": _CLI_FORMAT},
)
.mappings()
.first()
)
return dict(row) if row else None
def _pin_cli_force_stream(conn: sa.Connection, provider_id: str, cli: dict[str, Any]) -> None:
"""Set upstream_stream_policy=force_stream on existing cli endpoint."""
cfg = dict(cli.get("config") or {}) if isinstance(cli.get("config"), dict) else {}
cfg.pop("upstreamStreamPolicy", None)
cfg.pop("upstream_stream", None)
cfg["upstream_stream_policy"] = _FORCE_STREAM
conn.execute(
sa.text("""
UPDATE provider_endpoints
SET api_family = 'openai',
endpoint_kind = 'cli',
config = CAST(:config AS json),
updated_at = CURRENT_TIMESTAMP
WHERE provider_id = :pid AND api_format = :fmt
"""),
{
"pid": provider_id,
"fmt": _CLI_FORMAT,
"config": json.dumps(cfg, ensure_ascii=False),
},
)
def _ensure_compact_endpoint(conn: sa.Connection, provider_id: str, cli: dict[str, Any]) -> None:
"""Create openai:compact endpoint if missing (clone from cli)."""
exists = conn.execute(
sa.text(
"SELECT 1 FROM provider_endpoints WHERE provider_id = :pid AND api_format = :fmt LIMIT 1"
),
{"pid": provider_id, "fmt": _COMPACT_FORMAT},
).first()
if exists:
# Already exists, just ensure api_family/endpoint_kind are set.
conn.execute(
sa.text("""
UPDATE provider_endpoints
SET api_family = 'openai', endpoint_kind = 'compact',
updated_at = CURRENT_TIMESTAMP
WHERE provider_id = :pid AND api_format = :fmt
"""),
{"pid": provider_id, "fmt": _COMPACT_FORMAT},
)
return
# Clone from cli endpoint, strip stream policy.
cfg = dict(cli.get("config") or {}) if isinstance(cli.get("config"), dict) else {}
for k in ("upstream_stream_policy", "upstreamStreamPolicy", "upstream_stream"):
cfg.pop(k, None)
def _json(val: Any) -> str | None:
return json.dumps(val, ensure_ascii=False) if val is not None else None
conn.execute(
sa.text("""
INSERT INTO provider_endpoints (
id, provider_id, api_format, api_family, endpoint_kind,
base_url, custom_path, header_rules, body_rules,
max_retries, is_active, config, format_acceptance_config,
proxy, created_at, updated_at
) VALUES (
:id, :pid, :fmt, 'openai', 'compact',
:base_url, NULL, CAST(:header_rules AS json), CAST(:body_rules AS json),
:max_retries, TRUE, CAST(:config AS json), NULL,
CAST(:proxy AS jsonb), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
"""),
{
"id": str(uuid.uuid4()),
"pid": provider_id,
"fmt": _COMPACT_FORMAT,
"base_url": cli.get("base_url") or _CODEX_BASE_URL,
"header_rules": _json(cli.get("header_rules")),
"body_rules": _json(cli.get("body_rules")),
"max_retries": cli.get("max_retries") or 2,
"config": _json(cfg or None),
"proxy": _json(cli.get("proxy")),
},
)
def _add_compact_to_key_formats(conn: sa.Connection, provider_id: str) -> None:
"""Ensure provider keys include openai:compact in api_formats."""
rows = (
conn.execute(
sa.text("SELECT id, api_formats FROM provider_api_keys WHERE provider_id = :pid"),
{"pid": provider_id},
)
.mappings()
.all()
)
for row in rows:
raw = row["api_formats"]
formats: list[str] = []
if isinstance(raw, list):
for item in raw:
v = str(item or "").strip().lower()
if v and v not in formats:
formats.append(v)
if _COMPACT_FORMAT in formats:
continue
# Insert compact right after cli, or at end.
if _CLI_FORMAT in formats:
idx = formats.index(_CLI_FORMAT) + 1
formats.insert(idx, _COMPACT_FORMAT)
else:
formats.append(_COMPACT_FORMAT)
conn.execute(
sa.text("""
UPDATE provider_api_keys
SET api_formats = CAST(:fmts AS json), updated_at = CURRENT_TIMESTAMP
WHERE id = :id
"""),
{"id": row["id"], "fmts": json.dumps(formats, ensure_ascii=False)},
)
def upgrade() -> None:
conn = op.get_bind()
for provider_id in _find_codex_provider_ids(conn):
cli = _get_cli_endpoint(conn, provider_id)
if not cli:
continue # No cli endpoint to clone from; skip.
_pin_cli_force_stream(conn, provider_id, cli)
_ensure_compact_endpoint(conn, provider_id, cli)
_add_compact_to_key_formats(conn, provider_id)
def downgrade() -> None:
# Data backfill: no-op to avoid deleting user-managed data.
return

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

View File

@@ -422,6 +422,7 @@ class AdminGetModelRoutingPreviewAdapter(AdminApiAdapter):
preferred_order = [
"openai:chat",
"openai:cli",
"openai:compact",
"openai:video",
"claude:chat",
"claude:cli",

View File

@@ -838,15 +838,20 @@ class AdminGetApiFormatsAdapter(AdminApiAdapter):
def _label_for(sig: str) -> str:
fam, kind = (sig.split(":", 1) + [""])[:2]
fam_title = {"claude": "Claude", "openai": "OpenAI", "gemini": "Gemini"}.get(fam, fam)
kind_title = {"chat": "Chat", "cli": "CLI", "video": "Video", "image": "Image"}.get(
kind, kind
)
kind_title = {
"chat": "Chat",
"cli": "CLI",
"compact": "Compact",
"video": "Video",
"image": "Image",
}.get(kind, kind)
return f"{fam_title} {kind_title}".strip()
endpoint_defs = list_endpoint_definitions()
preferred_order = [
"openai:chat",
"openai:cli",
"openai:compact",
"openai:video",
"claude:chat",
"claude:cli",

View File

@@ -2,10 +2,11 @@
OpenAI CLI 透传处理器
"""
from src.api.handlers.openai_cli.adapter import OpenAICliAdapter
from src.api.handlers.openai_cli.adapter import OpenAICliAdapter, OpenAICompactAdapter
from src.api.handlers.openai_cli.handler import OpenAICliMessageHandler
__all__ = [
"OpenAICliAdapter",
"OpenAICompactAdapter",
"OpenAICliMessageHandler",
]

View File

@@ -15,7 +15,7 @@ from src.api.handlers.base.cli_adapter_base import CliAdapterBase, register_cli_
from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
from src.api.handlers.openai.adapter import OpenAIChatAdapter
from src.config.settings import config
from src.core.api_format import ApiFamily
from src.core.api_format import ApiFamily, EndpointKind
from src.utils.url_utils import is_codex_url
@@ -166,3 +166,18 @@ class OpenAICliAdapter(CliAdapterBase):
__all__ = ["OpenAICliAdapter"]
@register_cli_adapter
class OpenAICompactAdapter(OpenAICliAdapter):
"""OpenAI Compact Responses adapter (/v1/responses/compact)."""
FORMAT_ID = "openai:compact"
ENDPOINT_KIND = EndpointKind.COMPACT
name = "openai.compact"
def __init__(self, allowed_api_formats: list[str] | None = None):
super().__init__(allowed_api_formats=allowed_api_formats, compact=True)
__all__.append("OpenAICompactAdapter")

View File

@@ -36,7 +36,7 @@ router = APIRouter(tags=["System Catalog"])
# 各格式对应的 API 格式列表(包括对应的 CLI 格式)
_CLAUDE_FORMATS = ["claude:chat", "claude:cli"]
_OPENAI_FORMATS = ["openai:chat", "openai:cli"]
_OPENAI_FORMATS = ["openai:chat", "openai:cli", "openai:compact"]
_GEMINI_FORMATS = ["gemini:chat", "gemini:cli"]
# 所有格式(用于格式转换时的查询)

View File

@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
from src.api.base.pipeline import ApiRequestPipeline
from src.api.handlers.openai import OpenAIChatAdapter
from src.api.handlers.openai_cli import OpenAICliAdapter
from src.api.handlers.openai_cli import OpenAICliAdapter, OpenAICompactAdapter
from src.database import get_db
router = APIRouter(tags=["OpenAI API"])
@@ -68,7 +68,7 @@ async def create_responses_compact(
**认证方式**: Bearer TokenAPI Key 或 JWT Token
"""
adapter = OpenAICliAdapter(compact=True)
adapter = OpenAICompactAdapter()
return await pipeline.run(
adapter=adapter,
http_request=http_request,

View File

@@ -1263,6 +1263,7 @@ class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
all_formats = [
"openai:chat",
"openai:cli",
"openai:compact",
"claude:chat",
"claude:cli",
"gemini:chat",

View File

@@ -199,24 +199,18 @@ class OpenAICliNormalizer(FormatNormalizer):
return internal
# Codex 需要的 include 项
_CODEX_REQUIRED_INCLUDE = "reasoning.encrypted_content"
def request_from_internal(
self,
internal: InternalRequest,
*,
target_variant: str | None = None,
) -> dict[str, Any]:
_ = target_variant
openai_cli_extra = internal.extra.get("openai_cli", {})
is_compact = bool(openai_cli_extra.get("_aether_compact"))
is_codex = str(target_variant or "").lower() == "codex" and not is_compact
result: dict[str, Any] = {
"model": internal.model,
"input": self._internal_messages_to_input(
internal.messages, system_to_developer=is_codex
),
"input": self._internal_messages_to_input(internal.messages, system_to_developer=False),
}
# 合并 instructions如果没有则使用 system
@@ -229,20 +223,17 @@ class OpenAICliNormalizer(FormatNormalizer):
# 统一添加该字段以确保兼容性
result["instructions"] = instructions_text or ""
# max_output_tokens/temperature/top_p: Codex 不支持,标准 API 可选
if not is_codex:
if internal.max_tokens is not None:
# Responses API 使用 max_output_tokens
result["max_output_tokens"] = internal.max_tokens
if internal.temperature is not None:
result["temperature"] = internal.temperature
if internal.top_p is not None:
result["top_p"] = internal.top_p
if internal.max_tokens is not None:
# Responses API 使用 max_output_tokens
result["max_output_tokens"] = internal.max_tokens
if internal.temperature is not None:
result["temperature"] = internal.temperature
if internal.top_p is not None:
result["top_p"] = internal.top_p
if internal.stop_sequences:
result["stop"] = list(internal.stop_sequences)
# Codex 强制要求 stream=true其他情况尊重客户端请求
result["stream"] = True if is_codex else bool(internal.stream)
result["stream"] = bool(internal.stream)
if internal.tools:
# Responses API 使用扁平结构: {type, name, description, parameters}
@@ -308,26 +299,10 @@ class OpenAICliNormalizer(FormatNormalizer):
if key not in handled_keys and key not in result:
result[key] = value
# 统一设置 store=falseCodex 强制要求,标准 API 兼容)
# 标准 Responses API 默认设置 store=false
if "store" not in result:
result["store"] = False
# Codex 特定设置(覆盖/删除不支持的字段)
if is_codex:
result["parallel_tool_calls"] = True
# 和 codex passthrough patch 保持一致:固定 include 列表
result["include"] = [self._CODEX_REQUIRED_INCLUDE]
# 删除 Codex 不支持的字段
for key in (
"previous_response_id",
"service_tier",
"max_completion_tokens",
"truncation",
"context_management",
"user",
):
result.pop(key, None)
return result
# =========================

View File

@@ -62,6 +62,10 @@ def _detect_data_format(
return EndpointSignature(api_family=ApiFamily.CLAUDE, endpoint_kind=EndpointKind.CLI)
return EndpointSignature(api_family=ApiFamily.CLAUDE, endpoint_kind=EndpointKind.CHAT)
# OpenAI compact: /responses/compact
if "/responses/compact" in normalized:
return EndpointSignature(api_family=ApiFamily.OPENAI, endpoint_kind=EndpointKind.COMPACT)
# OpenAI CLI: /responses
if "/responses" in normalized:
return EndpointSignature(api_family=ApiFamily.OPENAI, endpoint_kind=EndpointKind.CLI)

View File

@@ -36,6 +36,7 @@ class EndpointKind(str, Enum):
CHAT = "chat"
CLI = "cli"
COMPACT = "compact"
VIDEO = "video"
IMAGE = "image"

View File

@@ -123,6 +123,19 @@ _ENDPOINT_DEFINITIONS: dict[tuple[ApiFamily, EndpointKind], EndpointDefinition]
protected_keys=frozenset({"authorization", "content-type"}),
data_format_id="openai_responses",
),
(ApiFamily.OPENAI, EndpointKind.COMPACT): EndpointDefinition(
api_family=ApiFamily.OPENAI,
endpoint_kind=EndpointKind.COMPACT,
aliases=("openai_compact", "responses_compact"),
default_path="/v1/responses/compact",
auth_method=AuthMethod.BEARER,
auth_header="Authorization",
auth_type="bearer",
protected_keys=frozenset({"authorization", "content-type"}),
# compact endpoint is non-streaming by design.
stream_in_body=False,
data_format_id="openai_responses",
),
(ApiFamily.OPENAI, EndpointKind.VIDEO): EndpointDefinition(
api_family=ApiFamily.OPENAI,
endpoint_kind=EndpointKind.VIDEO,

View File

@@ -481,6 +481,7 @@ class EndpointHealthService:
kind_label = {
"chat": "Chat",
"cli": "CLI",
"compact": "Compact",
"video": "Video",
"image": "Image",
}.get(kind, kind)

View File

@@ -21,7 +21,7 @@ MAX_CONCURRENT_REQUESTS = 5
# 模型获取格式优先级:同族内优先使用 chat 端点,若无则回退到 cli 端点
MODEL_FETCH_FORMAT_PRIORITY: list[tuple[str, ...]] = [
("openai:chat", "openai:cli"),
("openai:chat", "openai:cli", "openai:compact"),
("claude:chat", "claude:cli"),
("gemini:chat", "gemini:cli"),
]

View File

@@ -1,21 +1,8 @@
"""
Codex provider request patching helpers (passthrough path).
"""Codex provider request patching helpers (passthrough path).
This is the **primary** Codex request transformation used by the normalizer's
``patch_for_variant("codex")`` fast path. It applies minimal, non-destructive
patches directly on the original request dict -- no internal representation
round-trip, so every field the client sent is preserved as-is unless explicitly
modified here.
Transformations applied:
- Force ``store=false``.
- Force ``stream=true`` (except compact requests).
- Force ``parallel_tool_calls=true``.
- Ensure ``instructions`` exists (empty string when absent).
- Convert ``role=system`` messages to ``role=developer``.
- Drop request parameters known to be rejected by Codex gateways.
- Force ``include`` to ``["reasoning.encrypted_content"]``.
- Drop compatibility-problematic fields (``context_management`` / ``user``).
Codex requests are now treated as passthrough:
- Do not mutate client payload fields.
- Only strip internal sentinel fields that must never reach upstream.
"""
from __future__ import annotations
@@ -24,20 +11,6 @@ from typing import Any
from src.core.provider_types import ProviderType
_REJECTED_PARAMS: frozenset[str] = frozenset(
{
"max_output_tokens",
"max_completion_tokens",
"temperature",
"top_p",
"service_tier",
"previous_response_id",
"truncation",
}
)
_REQUIRED_INCLUDE_ITEM = "reasoning.encrypted_content"
def patch_openai_cli_request_for_codex(request_body: dict[str, Any]) -> dict[str, Any]:
"""
@@ -46,49 +19,8 @@ def patch_openai_cli_request_for_codex(request_body: dict[str, Any]) -> dict[str
This function never mutates the input object.
"""
out: dict[str, Any] = dict(request_body)
for k in _REJECTED_PARAMS:
out.pop(k, None)
# Codex gateways often reject/ignore persistence; be explicit.
out["store"] = False
# Codex compact endpoint is non-streaming; normal responses requires stream=true.
is_compact = bool(out.pop("_aether_compact", False))
if is_compact:
out.pop("stream", None)
else:
out["stream"] = True
# Codex expects parallel tool calls enabled.
out["parallel_tool_calls"] = True
# Ensure instructions exists (some gateways require it even if empty).
instructions = out.get("instructions")
if not isinstance(instructions, str):
out["instructions"] = ""
# Convert "system" role to "developer" (Codex behavior).
input_items = out.get("input")
if isinstance(input_items, list):
patched_items: list[Any] = []
for item in input_items:
if isinstance(item, dict):
patched = dict(item)
if patched.get("role") == "system":
patched["role"] = "developer"
patched_items.append(patched)
else:
patched_items.append(item)
out["input"] = patched_items
# Keep codex behavior deterministic: force the exact include list.
out["include"] = [_REQUIRED_INCLUDE_ITEM]
# Codex upstream currently rejects these fields.
out.pop("context_management", None)
out.pop("user", None)
# Internal routing marker; never send upstream.
out.pop("_aether_compact", None)
return out
@@ -108,7 +40,7 @@ def maybe_patch_request_for_codex(
"""
if (provider_type or "").lower() != ProviderType.CODEX:
return request_body
if (provider_api_format or "").lower() != "openai:cli":
if (provider_api_format or "").lower() not in {"openai:cli", "openai:compact"}:
return request_body
if not isinstance(request_body, dict):
return request_body

View File

@@ -55,13 +55,15 @@ def get_upstream_stream_policy(
Defaults:
- Codex + openai:cli: FORCE_STREAM (Codex upstream requires stream=true).
- Codex + openai:compact: follow endpoint/client policy (no hard force).
"""
provider_obj = getattr(endpoint, "provider", None)
pt = str(provider_type or getattr(provider_obj, "provider_type", "") or "").strip().lower()
sig = str(endpoint_sig or getattr(endpoint, "api_format", "") or "").strip().lower()
is_codex_compact = False
if pt == ProviderType.CODEX and sig == "openai:cli":
is_codex_cli = pt == ProviderType.CODEX and sig == "openai:cli"
is_codex_compact = pt == ProviderType.CODEX and sig == "openai:compact"
if is_codex_cli:
try:
from src.services.provider.adapters.codex.context import get_codex_request_context
@@ -82,8 +84,7 @@ def get_upstream_stream_policy(
if parsed != UpstreamStreamPolicy.AUTO:
# Codex upstream requires streaming; do not allow forcing non-stream.
if (
pt == ProviderType.CODEX
and sig == "openai:cli"
is_codex_cli
and parsed == UpstreamStreamPolicy.FORCE_NON_STREAM
and not is_codex_compact
):
@@ -93,7 +94,7 @@ def get_upstream_stream_policy(
return parsed
# Safe-by-default: Codex Responses OAuth behaves like SSE-only.
if pt == ProviderType.CODEX and sig == "openai:cli":
if is_codex_cli:
return (
UpstreamStreamPolicy.FORCE_NON_STREAM
if is_codex_compact
@@ -134,13 +135,30 @@ def enforce_stream_mode_for_upstream(
meta = resolve_endpoint_definition(provider_api_format)
provider_uses_stream = meta.stream_in_body if meta is not None else True
provider_fmt = str(provider_api_format or "").strip().lower()
# OpenAI compact endpoint: keep request body stream field absent.
if provider_fmt == "openai:compact":
request_body.pop("stream", None)
return request_body
# Backward compatibility: Codex compact routed through openai:cli + context marker.
if provider_fmt == "openai:cli":
try:
from src.services.provider.adapters.codex.context import get_codex_request_context
ctx = get_codex_request_context()
if ctx and ctx.is_compact:
request_body.pop("stream", None)
return request_body
except Exception:
pass
if provider_uses_stream:
request_body["stream"] = bool(upstream_is_stream)
else:
request_body.pop("stream", None)
# OpenAI Chat Completions: request usage in streaming mode.
provider_fmt = str(provider_api_format or "").strip().lower()
if upstream_is_stream and provider_fmt == "openai:chat":
stream_options = request_body.get("stream_options")
if not isinstance(stream_options, dict):

View File

@@ -6,7 +6,12 @@ Provider Key 认证类型相关规则。
def normalize_auth_type(raw: str) -> str:
"""将数据库中的 auth_type 归一化为逻辑类型。
Kiro 在数据库中存储为 ``"kiro"`` ``"oauth"``,统一映射为 ``"oauth"``。
- ``"kiro"`` -> ``"oauth"`` (Kiro 使用 OAuth 流程)
- ``"vertex_ai"`` -> ``"service_account"`` (旧的 Vertex AI auth_type 已重命名)
"""
t = str(raw or "api_key").strip() or "api_key"
return "oauth" if t == "kiro" else t
if t == "kiro": # TODO: 迁移稳定后清理,同步清理各处 in ("...", "kiro") 兼容检查
return "oauth"
if t == "vertex_ai": # TODO: 迁移稳定后清理,同步清理各处 in ("...", "vertex_ai") 兼容检查
return "service_account"
return t

View File

@@ -26,14 +26,14 @@ def check_duplicate_key(
对于不同的认证类型,使用不同的比较方式:
- api_key: 比较 API Key 的哈希值
- vertex_ai: 比较 Service Account 的 client_email
- service_account: 比较 Service Account 的 client_email
Args:
db: 数据库会话
provider_id: Provider ID
auth_type: 认证类型 (api_key, vertex_ai, oauth)
auth_type: 认证类型 (api_key, service_account, oauth)
new_api_key: 新的 API Key用于 api_key 类型)
new_auth_config: 新的认证配置(用于 vertex_ai 类型)
new_auth_config: 新的认证配置(用于 service_account 类型)
exclude_key_id: 要排除的 Key ID用于更新场景
"""
if auth_type == "api_key" and new_api_key:
@@ -66,7 +66,7 @@ def check_duplicate_key(
# 解密失败时跳过该 Key
continue
elif auth_type == "vertex_ai" and new_auth_config:
elif auth_type in ("service_account", "vertex_ai") and new_auth_config:
new_client_email = (
new_auth_config.get("client_email") if isinstance(new_auth_config, dict) else None
)
@@ -76,7 +76,7 @@ def check_duplicate_key(
# 仅查询同 auth_type 且有 auth_config 的 Keys
query = db.query(ProviderAPIKey).filter(
ProviderAPIKey.provider_id == provider_id,
ProviderAPIKey.auth_type == "vertex_ai",
ProviderAPIKey.auth_type.in_(["service_account", "vertex_ai"]),
ProviderAPIKey.auth_config.isnot(None),
)
if exclude_key_id:

View File

@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
from src.core.crypto import crypto_service
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger
from src.core.provider_types import ProviderType
from src.models.database import Provider, ProviderAPIKey
from src.models.endpoint_models import (
EndpointAPIKeyCreate,
@@ -32,6 +33,37 @@ from src.services.provider_keys.key_side_effects import (
from src.services.provider_keys.response_builder import build_key_response
def _validate_vertex_api_formats(
provider_type: str | None,
auth_type: str,
api_formats: list[str] | None,
) -> None:
"""校验 Vertex Provider 的 key.api_formats 与 auth_type 是否匹配。"""
if str(provider_type or "").strip().lower() != ProviderType.VERTEX_AI.value:
return
formats = [
str(fmt or "").strip().lower() for fmt in (api_formats or []) if str(fmt or "").strip()
]
if not formats:
return
if auth_type == "api_key":
allowed = {"gemini:chat"}
elif auth_type in {"service_account", "vertex_ai"}:
allowed = {"gemini:chat", "claude:chat"}
else:
return
invalid = sorted({fmt for fmt in formats if fmt not in allowed})
if invalid:
allowed_text = ", ".join(sorted(allowed))
invalid_text = ", ".join(invalid)
raise InvalidRequestException(
f"Vertex {auth_type} 不支持以下 API 格式: {invalid_text};允许: {allowed_text}"
)
@dataclass
class _UpdateKeyPreparation:
"""更新 Key 前置准备结果。"""
@@ -154,14 +186,14 @@ def _prepare_update_key_payload(
raise InvalidRequestException("API Key 认证模式下 api_key 不能为空")
# 切换回 API Key清理非本模式配置
update_data["auth_config"] = None
elif target_auth_type == "vertex_ai":
elif target_auth_type == "service_account":
if is_auth_type_switch and not update_data.get("auth_config"):
raise InvalidRequestException(
"从 API Key 切换到 Vertex AI 认证模式时,必须提供 Service Account JSON"
"切换到 Service Account 认证模式时,必须提供 Service Account JSON"
)
# Vertex AI 不允许手工写入 api_key仅保留占位符
# Service Account 不允许手工写入 api_key仅保留占位符
if api_key_in_payload and api_key_value not in {None, "__placeholder__"}:
raise InvalidRequestException("Vertex AI 认证模式下不允许直接填写 api_key")
raise InvalidRequestException("Service Account 认证模式下不允许直接填写 api_key")
if is_auth_type_switch or api_key_in_payload:
update_data["api_key"] = "__placeholder__"
elif target_auth_type == "oauth":
@@ -186,6 +218,15 @@ def _prepare_update_key_payload(
exclude_key_id=key_id,
)
# Vertex Provider: auth_type 与 api_formats 的组合必须合法
provider = getattr(key, "provider", None)
effective_api_formats = update_data.get("api_formats", key.api_formats)
_validate_vertex_api_formats(
getattr(provider, "provider_type", None),
target_auth_type,
effective_api_formats,
)
if "api_key" in update_data:
api_key_raw = update_data["api_key"]
if api_key_raw is None:
@@ -261,7 +302,7 @@ def _prepare_create_key_payload(
if auth_type == "api_key":
if not key_data.api_key:
raise InvalidRequestException("API Key 认证模式下 api_key 为必填字段")
elif auth_type == "vertex_ai":
elif auth_type == "service_account":
if not key_data.auth_config:
raise InvalidRequestException("Service Account 认证模式下 auth_config 为必填字段")
elif auth_type == "oauth":
@@ -384,12 +425,19 @@ async def create_provider_key_response(
if not key_data.api_formats:
raise InvalidRequestException("api_formats 为必填字段")
_, new_key = _prepare_create_key_payload(
auth_type, new_key = _prepare_create_key_payload(
db=db,
provider_id=provider_id,
key_data=key_data,
)
# Vertex Provider: auth_type 与 api_formats 的组合必须合法
_validate_vertex_api_formats(
getattr(provider, "provider_type", None),
auth_type,
key_data.api_formats,
)
db.add(new_key)
db.commit()
db.refresh(new_key)

View File

@@ -59,7 +59,7 @@ def get_keys_grouped_by_format(db: Session) -> dict:
continue # 跳过没有 API 格式的 Key
auth_type = normalize_auth_type(getattr(key, "auth_type", "api_key"))
if auth_type == "vertex_ai":
if auth_type in ("service_account", "vertex_ai"):
masked_key = "[Service Account]"
elif auth_type == "oauth":
masked_key = "[OAuth Token]"
@@ -166,15 +166,15 @@ def reveal_endpoint_key_payload(
auth_type = normalize_auth_type(getattr(key, "auth_type", "api_key"))
# Vertex AI 类型返回 auth_config需要解密
if auth_type == "vertex_ai":
# Service Account 类型返回 auth_config需要解密
if auth_type in ("service_account", "vertex_ai"):
encrypted_auth_config = getattr(key, "auth_config", None)
if encrypted_auth_config:
try:
decrypted_config = crypto_service.decrypt(encrypted_auth_config)
auth_config = json.loads(decrypted_config)
logger.info(f"[REVEAL] 查看 Auth Config: ID={key_id}, Name={key.name}")
return {"auth_type": "vertex_ai", "auth_config": auth_config}
return {"auth_type": auth_type, "auth_config": auth_config}
except Exception as e:
logger.error(f"解密 Auth Config 失败: ID={key_id}, Error={e}")
raise InvalidRequestException(
@@ -184,12 +184,11 @@ def reveal_endpoint_key_payload(
# 兼容auth_config 为空时尝试从 api_key 解密(仅对迁移前的旧数据有效)
try:
decrypted_key = crypto_service.decrypt(key.api_key)
# 检查是否是新格式的占位符(表示 auth_config 丢失)
if decrypted_key == "__placeholder__":
logger.error(f"Vertex AI Key 缺少 auth_config: ID={key_id}")
logger.error(f"Service Account Key 缺少 auth_config: ID={key_id}")
raise InvalidRequestException("认证配置丢失,请重新添加该密钥。")
logger.info(f"[REVEAL] 查看完整 Key (legacy vertex_ai): ID={key_id}, Name={key.name}")
return {"auth_type": "vertex_ai", "auth_config": decrypted_key}
logger.info(f"[REVEAL] 查看完整 Key (legacy SA): ID={key_id}, Name={key.name}")
return {"auth_type": auth_type, "auth_config": decrypted_key}
except InvalidRequestException:
raise
except Exception as e:

View File

@@ -19,8 +19,8 @@ def build_key_response(
"""构建 Key 响应对象。"""
auth_type = normalize_auth_type(getattr(key, "auth_type", "api_key"))
if auth_type == "vertex_ai":
# Vertex AI 使用 Service Account不显示占位符
if auth_type in ("service_account", "vertex_ai"):
# Service Account 不显示占位符
masked_key = "[Service Account]"
elif auth_type == "oauth":
masked_key = "[OAuth Token]"

View File

@@ -415,9 +415,12 @@ class CandidateBuilder:
if isinstance(raw, int) and raw > 0:
output_limit = raw
# chat/cli 互相可回退(用于同协议族下的端点变体),video/image 等不跨类回退
# chat/cli 互相可回退(用于同协议族下的端点变体),compact 可回退到 cli。
# video/image 等不跨类回退。
if client_kind in {EndpointKind.CHAT, EndpointKind.CLI}:
allowed_kinds = {EndpointKind.CHAT, EndpointKind.CLI}
elif client_kind == EndpointKind.COMPACT:
allowed_kinds = {EndpointKind.COMPACT, EndpointKind.CLI}
else:
allowed_kinds = {client_kind}

View File

@@ -6,94 +6,39 @@ from src.services.provider.adapters.codex.request_patching import (
)
def test_patch_openai_cli_request_for_codex_sets_store_and_instructions() -> None:
req = {"model": "gpt-test", "input": []}
out = patch_openai_cli_request_for_codex(req)
assert out is not req
assert out["store"] is False
assert out["stream"] is True
assert out["instructions"] == ""
def test_patch_openai_cli_request_for_codex_strips_rejected_params() -> None:
def test_patch_openai_cli_request_for_codex_is_passthrough_except_internal_sentinel() -> None:
req = {
"model": "gpt-test",
"input": [],
"max_output_tokens": 123,
"max_completion_tokens": 456,
"temperature": 0.5,
"top_p": 0.9,
"service_tier": "default",
"truncation": "auto",
"context_management": {"compaction": {"type": "summary"}},
"user": "u_123",
}
out = patch_openai_cli_request_for_codex(req)
for key in (
"max_output_tokens",
"max_completion_tokens",
"temperature",
"top_p",
"service_tier",
"truncation",
"context_management",
"user",
):
assert key not in out
def test_patch_openai_cli_request_for_codex_converts_system_role_to_developer() -> None:
req = {
"model": "gpt-test",
"instructions": "ignored",
"input": [
{
"type": "message",
"role": "system",
"content": [{"type": "input_text", "text": "You are a pirate."}],
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Hello"}],
},
}
],
}
out = patch_openai_cli_request_for_codex(req)
assert isinstance(out.get("input"), list)
assert out["input"][0]["role"] == "developer"
assert out["input"][1]["role"] == "user"
def test_patch_openai_cli_request_for_codex_adds_required_include_item() -> None:
req = {"model": "gpt-test", "input": []}
out = patch_openai_cli_request_for_codex(req)
assert out["include"] == ["reasoning.encrypted_content"]
def test_patch_openai_cli_request_for_codex_overrides_include() -> None:
req = {
"model": "gpt-test",
"input": [],
"include": ["foo", "bar"],
}
out = patch_openai_cli_request_for_codex(req)
assert out["include"] == ["reasoning.encrypted_content"]
def test_patch_openai_cli_request_for_codex_compact_drops_stream() -> None:
req = {
"model": "gpt-test",
"input": [],
"store": True,
"stream": False,
"instructions": "keep",
"include": ["foo"],
"parallel_tool_calls": False,
"temperature": 0.7,
"context_management": {"compaction": {"type": "summary"}},
"user": "u_123",
"_aether_compact": True,
"stream": True,
}
out = patch_openai_cli_request_for_codex(req)
assert "stream" not in out
assert out is not req
assert "_aether_compact" not in out
assert out["store"] is True
assert out["stream"] is False
assert out["instructions"] == "keep"
assert out["include"] == ["foo"]
assert out["parallel_tool_calls"] is False
assert out["temperature"] == 0.7
assert out["context_management"] == {"compaction": {"type": "summary"}}
assert out["user"] == "u_123"
assert out["input"][0]["role"] == "system"
def test_maybe_patch_request_for_codex_is_noop_for_non_codex() -> None:
@@ -117,7 +62,7 @@ def test_maybe_patch_request_for_codex_is_noop_for_non_openai_cli() -> None:
def test_maybe_patch_request_for_codex_patches_for_codex_openai_cli() -> None:
req = {"model": "gpt-test", "input": []}
req = {"model": "gpt-test", "input": [], "_aether_compact": True, "store": True}
out = maybe_patch_request_for_codex(
provider_type="codex",
provider_api_format="openai:cli",
@@ -125,8 +70,41 @@ def test_maybe_patch_request_for_codex_patches_for_codex_openai_cli() -> None:
)
assert out is not req
assert out["store"] is True
assert "_aether_compact" not in out
def test_maybe_patch_request_for_codex_patches_for_codex_openai_compact() -> None:
req = {"model": "gpt-test", "input": [], "_aether_compact": True, "store": True}
out = maybe_patch_request_for_codex(
provider_type="codex",
provider_api_format="openai:compact",
request_body=req,
)
assert out is not req
assert out["store"] is True
assert "_aether_compact" not in out
def test_openai_cli_normalizer_request_from_internal_codex_variant_preserves_store() -> None:
from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer
normalizer = OpenAICliNormalizer()
internal = normalizer.request_to_internal({"model": "gpt-test", "input": [], "store": True})
out = normalizer.request_from_internal(internal, target_variant="codex")
assert out["store"] is True
def test_openai_cli_normalizer_request_from_internal_codex_variant_defaults_store_false() -> None:
from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer
normalizer = OpenAICliNormalizer()
internal = normalizer.request_to_internal({"model": "gpt-test", "input": []})
out = normalizer.request_from_internal(internal, target_variant="codex")
assert out["store"] is False
assert "instructions" in out
def test_codex_envelope_extra_headers_includes_sse_accept_and_session() -> None:

View File

@@ -65,6 +65,8 @@ def _build_key(**overrides: Any) -> SimpleNamespace:
"model_exclude_patterns": None,
"provider_id": "provider-1",
"auth_type": "api_key",
"api_formats": [],
"provider": None,
}
base.update(overrides)
return SimpleNamespace(**base)

View File

@@ -64,3 +64,17 @@ def test_codex_openai_cli_uses_compact_suffix_when_context_marked_compact() -> N
)
assert url == "https://chatgpt.com/backend-api/codex/responses/compact"
set_codex_request_context(None)
def test_codex_openai_compact_uses_compact_path_without_v1_prefix() -> None:
endpoint = _DummyEndpoint(
base_url="https://chatgpt.com/backend-api/codex",
api_format="openai:compact",
provider=SimpleNamespace(provider_type="codex"),
)
url = build_provider_url(
endpoint, # type: ignore[arg-type]
path_params={"model": "ignored"},
is_stream=False,
)
assert url == "https://chatgpt.com/backend-api/codex/responses/compact"

View File

@@ -59,6 +59,15 @@ def test_get_upstream_stream_policy_codex_compact_forces_non_stream() -> None:
set_codex_request_context(None)
def test_get_upstream_stream_policy_codex_openai_compact_defaults_to_auto() -> None:
ep = _DummyEndpoint(
api_format="openai:compact",
config=None,
provider=SimpleNamespace(provider_type="codex"),
)
assert get_upstream_stream_policy(ep) == UpstreamStreamPolicy.AUTO
def test_enforce_stream_mode_for_upstream_openai_chat_sets_stream_options_usage() -> None:
body = {"stream": False}
out = enforce_stream_mode_for_upstream(
@@ -79,3 +88,30 @@ def test_enforce_stream_mode_for_upstream_gemini_drops_stream_field() -> None:
)
assert "stream" not in out
assert out["foo"] == "bar"
def test_enforce_stream_mode_for_upstream_openai_compact_drops_stream_field() -> None:
body = {"stream": True, "foo": "bar"}
out = enforce_stream_mode_for_upstream(
body,
provider_api_format="openai:compact",
upstream_is_stream=True,
)
assert "stream" not in out
assert out["foo"] == "bar"
def test_enforce_stream_mode_for_upstream_codex_compact_keeps_stream_absent() -> None:
body = {"stream": True, "foo": "bar"}
try:
set_codex_request_context(CodexRequestContext(is_compact=True))
out = enforce_stream_mode_for_upstream(
body,
provider_api_format="openai:cli",
upstream_is_stream=False,
)
finally:
set_codex_request_context(None)
assert "stream" not in out
assert out["foo"] == "bar"