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:
@@ -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
|
||||
@@ -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!',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -422,6 +422,7 @@ class AdminGetModelRoutingPreviewAdapter(AdminApiAdapter):
|
||||
preferred_order = [
|
||||
"openai:chat",
|
||||
"openai:cli",
|
||||
"openai:compact",
|
||||
"openai:video",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
# 所有格式(用于格式转换时的查询)
|
||||
|
||||
@@ -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 Token(API Key 或 JWT Token)
|
||||
"""
|
||||
adapter = OpenAICliAdapter(compact=True)
|
||||
adapter = OpenAICompactAdapter()
|
||||
return await pipeline.run(
|
||||
adapter=adapter,
|
||||
http_request=http_request,
|
||||
|
||||
@@ -1263,6 +1263,7 @@ class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
|
||||
all_formats = [
|
||||
"openai:chat",
|
||||
"openai:cli",
|
||||
"openai:compact",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
"gemini:chat",
|
||||
|
||||
@@ -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=false(Codex 强制要求,标准 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
|
||||
|
||||
# =========================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -36,6 +36,7 @@ class EndpointKind(str, Enum):
|
||||
|
||||
CHAT = "chat"
|
||||
CLI = "cli"
|
||||
COMPACT = "compact"
|
||||
VIDEO = "video"
|
||||
IMAGE = "image"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -481,6 +481,7 @@ class EndpointHealthService:
|
||||
kind_label = {
|
||||
"chat": "Chat",
|
||||
"cli": "CLI",
|
||||
"compact": "Compact",
|
||||
"video": "Video",
|
||||
"image": "Image",
|
||||
}.get(kind, kind)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user