mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +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',
|
CLAUDE_CLI: 'claude:cli',
|
||||||
OPENAI: 'openai:chat',
|
OPENAI: 'openai:chat',
|
||||||
OPENAI_CLI: 'openai:cli',
|
OPENAI_CLI: 'openai:cli',
|
||||||
|
OPENAI_COMPACT: 'openai:compact',
|
||||||
OPENAI_VIDEO: 'openai:video',
|
OPENAI_VIDEO: 'openai:video',
|
||||||
GEMINI: 'gemini:chat',
|
GEMINI: 'gemini:chat',
|
||||||
GEMINI_CLI: 'gemini:cli',
|
GEMINI_CLI: 'gemini:cli',
|
||||||
@@ -19,6 +20,7 @@ export const API_FORMAT_LABELS: Record<string, string> = {
|
|||||||
[API_FORMATS.CLAUDE_CLI]: 'Claude CLI',
|
[API_FORMATS.CLAUDE_CLI]: 'Claude CLI',
|
||||||
[API_FORMATS.OPENAI]: 'OpenAI Chat',
|
[API_FORMATS.OPENAI]: 'OpenAI Chat',
|
||||||
[API_FORMATS.OPENAI_CLI]: 'OpenAI CLI',
|
[API_FORMATS.OPENAI_CLI]: 'OpenAI CLI',
|
||||||
|
[API_FORMATS.OPENAI_COMPACT]: 'OpenAI Compact',
|
||||||
[API_FORMATS.OPENAI_VIDEO]: 'OpenAI Video',
|
[API_FORMATS.OPENAI_VIDEO]: 'OpenAI Video',
|
||||||
[API_FORMATS.GEMINI]: 'Gemini Chat',
|
[API_FORMATS.GEMINI]: 'Gemini Chat',
|
||||||
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
|
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
|
||||||
@@ -28,6 +30,7 @@ export const API_FORMAT_LABELS: Record<string, string> = {
|
|||||||
CLAUDE_CLI: 'Claude CLI',
|
CLAUDE_CLI: 'Claude CLI',
|
||||||
OPENAI: 'OpenAI Chat',
|
OPENAI: 'OpenAI Chat',
|
||||||
OPENAI_CLI: 'OpenAI CLI',
|
OPENAI_CLI: 'OpenAI CLI',
|
||||||
|
OPENAI_COMPACT: 'OpenAI Compact',
|
||||||
OPENAI_VIDEO: 'OpenAI Video',
|
OPENAI_VIDEO: 'OpenAI Video',
|
||||||
GEMINI: 'Gemini Chat',
|
GEMINI: 'Gemini Chat',
|
||||||
GEMINI_CLI: 'Gemini CLI',
|
GEMINI_CLI: 'Gemini CLI',
|
||||||
@@ -38,6 +41,7 @@ export const API_FORMAT_LABELS: Record<string, string> = {
|
|||||||
export const API_FORMAT_SHORT: Record<string, string> = {
|
export const API_FORMAT_SHORT: Record<string, string> = {
|
||||||
[API_FORMATS.OPENAI]: 'O',
|
[API_FORMATS.OPENAI]: 'O',
|
||||||
[API_FORMATS.OPENAI_CLI]: 'OC',
|
[API_FORMATS.OPENAI_CLI]: 'OC',
|
||||||
|
[API_FORMATS.OPENAI_COMPACT]: 'OCP',
|
||||||
[API_FORMATS.OPENAI_VIDEO]: 'OV',
|
[API_FORMATS.OPENAI_VIDEO]: 'OV',
|
||||||
[API_FORMATS.CLAUDE]: 'C',
|
[API_FORMATS.CLAUDE]: 'C',
|
||||||
[API_FORMATS.CLAUDE_CLI]: 'CC',
|
[API_FORMATS.CLAUDE_CLI]: 'CC',
|
||||||
@@ -47,6 +51,7 @@ export const API_FORMAT_SHORT: Record<string, string> = {
|
|||||||
// legacy 兼容(仅用于展示历史数据)
|
// legacy 兼容(仅用于展示历史数据)
|
||||||
OPENAI: 'O',
|
OPENAI: 'O',
|
||||||
OPENAI_CLI: 'OC',
|
OPENAI_CLI: 'OC',
|
||||||
|
OPENAI_COMPACT: 'OCP',
|
||||||
OPENAI_VIDEO: 'OV',
|
OPENAI_VIDEO: 'OV',
|
||||||
CLAUDE: 'C',
|
CLAUDE: 'C',
|
||||||
CLAUDE_CLI: 'CC',
|
CLAUDE_CLI: 'CC',
|
||||||
@@ -59,6 +64,7 @@ export const API_FORMAT_SHORT: Record<string, string> = {
|
|||||||
export const API_FORMAT_ORDER: string[] = [
|
export const API_FORMAT_ORDER: string[] = [
|
||||||
API_FORMATS.OPENAI,
|
API_FORMATS.OPENAI,
|
||||||
API_FORMATS.OPENAI_CLI,
|
API_FORMATS.OPENAI_CLI,
|
||||||
|
API_FORMATS.OPENAI_COMPACT,
|
||||||
API_FORMATS.OPENAI_VIDEO,
|
API_FORMATS.OPENAI_VIDEO,
|
||||||
API_FORMATS.CLAUDE,
|
API_FORMATS.CLAUDE,
|
||||||
API_FORMATS.CLAUDE_CLI,
|
API_FORMATS.CLAUDE_CLI,
|
||||||
|
|||||||
@@ -92,8 +92,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
|
import { DIALOG_CONTEXT_KEY } from './context'
|
||||||
|
|
||||||
// Props 定义
|
// Props 定义
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -116,6 +117,8 @@ const emit = defineEmits<{
|
|||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
provide(DIALOG_CONTEXT_KEY, true)
|
||||||
|
|
||||||
// 获取 slots 以便在模板中使用
|
// 获取 slots 以便在模板中使用
|
||||||
const slots = useSlots()
|
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>
|
<template>
|
||||||
<SelectPortal>
|
<SelectPortal :disabled="shouldDisablePortal">
|
||||||
<SelectContentPrimitive
|
<SelectContentPrimitive
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
:class="contentClass"
|
:class="contentClass"
|
||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
SelectViewport,
|
SelectViewport,
|
||||||
} from 'radix-vue'
|
} from 'radix-vue'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { computed } from 'vue'
|
import { computed, inject } from 'vue'
|
||||||
|
import { DIALOG_CONTEXT_KEY } from './dialog/context'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string
|
class?: string
|
||||||
@@ -32,6 +33,7 @@ interface Props {
|
|||||||
sideOffset?: number
|
sideOffset?: number
|
||||||
align?: 'start' | 'center' | 'end'
|
align?: 'start' | 'center' | 'end'
|
||||||
alignOffset?: number
|
alignOffset?: number
|
||||||
|
disablePortal?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -41,8 +43,12 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
sideOffset: 4,
|
sideOffset: 4,
|
||||||
align: undefined,
|
align: undefined,
|
||||||
alignOffset: undefined,
|
alignOffset: undefined,
|
||||||
|
disablePortal: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isInsideDialog = inject(DIALOG_CONTEXT_KEY, false)
|
||||||
|
const shouldDisablePortal = computed(() => props.disablePortal || isInsideDialog)
|
||||||
|
|
||||||
const contentClass = computed(() =>
|
const contentClass = computed(() =>
|
||||||
cn(
|
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',
|
'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"
|
size="icon"
|
||||||
:class="getUpstreamStreamButtonClass(endpoint)"
|
:class="getUpstreamStreamButtonClass(endpoint)"
|
||||||
:title="getUpstreamStreamTooltip(endpoint)"
|
:title="getUpstreamStreamTooltip(endpoint)"
|
||||||
:disabled="savingEndpointId === endpoint.id"
|
:disabled="savingEndpointId === endpoint.id || isUpstreamStreamPolicyLocked(endpoint)"
|
||||||
@click="handleCycleUpstreamStream(endpoint)"
|
@click="handleCycleUpstreamStream(endpoint)"
|
||||||
>
|
>
|
||||||
<Radio class="w-3.5 h-3.5" />
|
<Radio class="w-3.5 h-3.5" />
|
||||||
@@ -2029,12 +2029,21 @@ async function handleToggleFormatConversion(endpoint: ProviderEndpoint) {
|
|||||||
|
|
||||||
// 获取上游流式按钮的当前状态(优先使用编辑状态)
|
// 获取上游流式按钮的当前状态(优先使用编辑状态)
|
||||||
function getCurrentUpstreamStreamPolicy(endpoint: ProviderEndpoint): string {
|
function getCurrentUpstreamStreamPolicy(endpoint: ProviderEndpoint): string {
|
||||||
|
if (isUpstreamStreamPolicyLocked(endpoint)) return 'force_stream'
|
||||||
const state = endpointEditStates.value[endpoint.id]
|
const state = endpointEditStates.value[endpoint.id]
|
||||||
return state?.upstreamStreamPolicy ?? getEndpointUpstreamStreamPolicy(endpoint)
|
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 {
|
function getUpstreamStreamButtonClass(endpoint: ProviderEndpoint): string {
|
||||||
|
if (isUpstreamStreamPolicyLocked(endpoint)) {
|
||||||
|
return 'h-7 w-7 text-primary/70 cursor-not-allowed'
|
||||||
|
}
|
||||||
const policy = getCurrentUpstreamStreamPolicy(endpoint)
|
const policy = getCurrentUpstreamStreamPolicy(endpoint)
|
||||||
const base = 'h-7 w-7'
|
const base = 'h-7 w-7'
|
||||||
if (policy === 'force_stream') return `${base} text-primary`
|
if (policy === 'force_stream') return `${base} text-primary`
|
||||||
@@ -2044,6 +2053,7 @@ function getUpstreamStreamButtonClass(endpoint: ProviderEndpoint): string {
|
|||||||
|
|
||||||
// 获取上游流式按钮的提示文字
|
// 获取上游流式按钮的提示文字
|
||||||
function getUpstreamStreamTooltip(endpoint: ProviderEndpoint): string {
|
function getUpstreamStreamTooltip(endpoint: ProviderEndpoint): string {
|
||||||
|
if (isUpstreamStreamPolicyLocked(endpoint)) return '固定流式(Codex OpenAI CLI,已锁定)'
|
||||||
const policy = getCurrentUpstreamStreamPolicy(endpoint)
|
const policy = getCurrentUpstreamStreamPolicy(endpoint)
|
||||||
if (policy === 'force_stream') return '固定流式(点击切换为固定非流)'
|
if (policy === 'force_stream') return '固定流式(点击切换为固定非流)'
|
||||||
if (policy === 'force_non_stream') return '固定非流(点击切换为跟随请求)'
|
if (policy === 'force_non_stream') return '固定非流(点击切换为跟随请求)'
|
||||||
@@ -2052,6 +2062,8 @@ function getUpstreamStreamTooltip(endpoint: ProviderEndpoint): string {
|
|||||||
|
|
||||||
// 循环切换上游流式策略并直接保存
|
// 循环切换上游流式策略并直接保存
|
||||||
async function handleCycleUpstreamStream(endpoint: ProviderEndpoint) {
|
async function handleCycleUpstreamStream(endpoint: ProviderEndpoint) {
|
||||||
|
if (isUpstreamStreamPolicyLocked(endpoint)) return
|
||||||
|
|
||||||
const currentPolicy = getCurrentUpstreamStreamPolicy(endpoint)
|
const currentPolicy = getCurrentUpstreamStreamPolicy(endpoint)
|
||||||
let nextPolicy: string
|
let nextPolicy: string
|
||||||
let nextLabel: string
|
let nextLabel: string
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
<div class="max-h-96 overflow-y-auto">
|
<div class="max-h-96 overflow-y-auto">
|
||||||
<!-- 加载中 -->
|
<!-- 加载中 -->
|
||||||
<div
|
<div
|
||||||
v-if="loadingGlobalModels"
|
v-if="loadingProviderModels"
|
||||||
class="flex items-center justify-center py-12"
|
class="flex items-center justify-center py-12"
|
||||||
>
|
>
|
||||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 提供商模型 -->
|
<!-- 提供商模型 -->
|
||||||
<template v-if="filteredGlobalModels.length > 0">
|
<template v-if="filteredProviderModels.length > 0">
|
||||||
<!-- 标题 sticky top -->
|
<!-- 标题 sticky top -->
|
||||||
<div
|
<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"
|
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' : ''"
|
:class="collapsedGroups.has('global') ? '-rotate-90' : ''"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs font-medium">提供商模型</span>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-xs text-primary hover:underline"
|
class="text-xs text-primary hover:underline"
|
||||||
@click.stop="toggleAllGlobalModels"
|
@click.stop="toggleAllProviderModels"
|
||||||
>
|
>
|
||||||
{{ isAllGlobalModelsSelected ? '取消全选' : '全选' }}
|
{{ isAllProviderModelsSelected ? '取消全选' : '全选' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- 内容 -->
|
<!-- 内容 -->
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
class="space-y-1 p-2"
|
class="space-y-1 p-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="model in filteredGlobalModels"
|
v-for="model in filteredProviderModels"
|
||||||
:key="model.name"
|
:key="model.name"
|
||||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
||||||
@click="toggleModel(model.name)"
|
@click="toggleModel(model.name)"
|
||||||
@@ -196,11 +196,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm font-medium truncate">
|
<p class="text-sm font-medium truncate">
|
||||||
{{ model.display_name }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted-foreground truncate font-mono">
|
|
||||||
{{ model.name }}
|
{{ model.name }}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="selectedModels.includes(model.name)"
|
v-if="selectedModels.includes(model.name)"
|
||||||
@@ -366,17 +369,19 @@ import { useToast } from '@/composables/useToast'
|
|||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import {
|
import {
|
||||||
|
getProviderModels,
|
||||||
updateProviderKey,
|
updateProviderKey,
|
||||||
type EndpointAPIKey,
|
type EndpointAPIKey,
|
||||||
type AllowedModels,
|
type AllowedModels,
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
import { getGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
|
||||||
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
|
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
|
||||||
import { API_FORMAT_SHORT, type UpstreamModel } from '@/api/endpoints/types'
|
import { API_FORMAT_SHORT, type UpstreamModel } from '@/api/endpoints/types'
|
||||||
|
|
||||||
interface AvailableModel {
|
interface AvailableModel {
|
||||||
name: string
|
name: string
|
||||||
display_name: string
|
display_name: string
|
||||||
|
global_model_name?: string
|
||||||
|
global_model_display_name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -396,7 +401,7 @@ const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
|||||||
|
|
||||||
const isOpen = computed(() => props.open)
|
const isOpen = computed(() => props.open)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const loadingGlobalModels = ref(false)
|
const loadingProviderModels = ref(false)
|
||||||
const fetchingUpstreamModels = ref(false)
|
const fetchingUpstreamModels = ref(false)
|
||||||
const upstreamModelsLoaded = ref(false)
|
const upstreamModelsLoaded = ref(false)
|
||||||
|
|
||||||
@@ -406,8 +411,8 @@ let loadingCancelled = false
|
|||||||
// 搜索
|
// 搜索
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
// 可用模型列表(全局模型)
|
// 可用模型列表(该 Provider 已关联的模型)
|
||||||
const allGlobalModels = ref<AvailableModel[]>([])
|
const allProviderModels = ref<AvailableModel[]>([])
|
||||||
// 上游模型列表(从 API 查询获取)
|
// 上游模型列表(从 API 查询获取)
|
||||||
const upstreamModels = ref<UpstreamModel[]>([])
|
const upstreamModels = ref<UpstreamModel[]>([])
|
||||||
|
|
||||||
@@ -427,7 +432,7 @@ const isAutoFetchMode = computed(() => props.apiKey?.auto_fetch_models ?? false)
|
|||||||
|
|
||||||
// 空状态判断
|
// 空状态判断
|
||||||
const showEmptyState = computed(() => {
|
const showEmptyState = computed(() => {
|
||||||
return filteredGlobalModels.value.length === 0 &&
|
return filteredProviderModels.value.length === 0 &&
|
||||||
filteredUpstreamModels.value.length === 0 &&
|
filteredUpstreamModels.value.length === 0 &&
|
||||||
customModels.value.length === 0
|
customModels.value.length === 0
|
||||||
})
|
})
|
||||||
@@ -450,22 +455,22 @@ const hasChanges = computed(() => {
|
|||||||
return sortedLocked1.some((v, i) => v !== sortedLocked2[i])
|
return sortedLocked1.some((v, i) => v !== sortedLocked2[i])
|
||||||
})
|
})
|
||||||
|
|
||||||
// 所有已知模型的集合(全局 + 上游模型)
|
// 所有已知模型的集合(提供商模型 + 上游模型)
|
||||||
const allKnownModels = computed(() => {
|
const allKnownModels = computed(() => {
|
||||||
const set = new Set<string>()
|
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))
|
upstreamModels.value.forEach(m => set.add(m.id))
|
||||||
return set
|
return set
|
||||||
})
|
})
|
||||||
|
|
||||||
// 全局模型名称集合(用于判断模型是否为全局模型)
|
// 提供商模型名称集合(用于判断模型是否为“提供商模型”分组项)
|
||||||
const globalModelNamesSet = computed(() => {
|
const providerModelNamesSet = computed(() => {
|
||||||
return new Set(allGlobalModels.value.map(m => m.name))
|
return new Set(allProviderModels.value.map(m => m.name))
|
||||||
})
|
})
|
||||||
|
|
||||||
// 判断模型是否为全局模型(提供商模型)
|
// 判断模型是否为“提供商模型”分组项
|
||||||
function isGlobalModel(modelId: string): boolean {
|
function isProviderModel(modelId: string): boolean {
|
||||||
return globalModelNamesSet.value.has(modelId)
|
return providerModelNamesSet.value.has(modelId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上游模型列表(后端已按 id 聚合,包含 api_formats 数组)
|
// 上游模型列表(后端已按 id 聚合,包含 api_formats 数组)
|
||||||
@@ -541,27 +546,29 @@ const canAddAsCustom = computed(() => {
|
|||||||
if (selectedModels.value.includes(search)) return false
|
if (selectedModels.value.includes(search)) return false
|
||||||
// 已经在自定义模型列表中就不显示
|
// 已经在自定义模型列表中就不显示
|
||||||
if (allCustomModels.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
|
if (upstreamModelNames.value.includes(search)) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
// 搜索过滤后的全局模型
|
// 搜索过滤后的提供商模型
|
||||||
const filteredGlobalModels = computed(() => {
|
const filteredProviderModels = computed(() => {
|
||||||
if (!searchQuery.value.trim()) return allGlobalModels.value
|
if (!searchQuery.value.trim()) return allProviderModels.value
|
||||||
const query = searchQuery.value.toLowerCase()
|
const query = searchQuery.value.toLowerCase()
|
||||||
return allGlobalModels.value.filter(m =>
|
return allProviderModels.value.filter(m =>
|
||||||
m.name.toLowerCase().includes(query) ||
|
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(() => {
|
const isAllProviderModelsSelected = computed(() => {
|
||||||
if (filteredGlobalModels.value.length === 0) return false
|
if (filteredProviderModels.value.length === 0) return false
|
||||||
return filteredGlobalModels.value.every(m => selectedModels.value.includes(m.name))
|
return filteredProviderModels.value.every(m => selectedModels.value.includes(m.name))
|
||||||
})
|
})
|
||||||
|
|
||||||
// 切换模型选中状态
|
// 切换模型选中状态
|
||||||
@@ -569,9 +576,9 @@ function toggleModel(modelId: string) {
|
|||||||
const idx = selectedModels.value.indexOf(modelId)
|
const idx = selectedModels.value.indexOf(modelId)
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
selectedModels.value.push(modelId)
|
selectedModels.value.push(modelId)
|
||||||
// 自动获取模式下,勾选全局模型时自动锁定
|
// 自动获取模式下,勾选提供商模型时自动锁定
|
||||||
// 防止下次刷新时被覆盖(即使全局模型与上游模型同名)
|
// 防止下次刷新时被覆盖(即使全局模型与上游模型同名)
|
||||||
if (isAutoFetchMode.value && isGlobalModel(modelId)) {
|
if (isAutoFetchMode.value && isProviderModel(modelId)) {
|
||||||
if (!lockedModels.value.includes(modelId)) {
|
if (!lockedModels.value.includes(modelId)) {
|
||||||
lockedModels.value.push(modelId)
|
lockedModels.value.push(modelId)
|
||||||
}
|
}
|
||||||
@@ -618,10 +625,10 @@ function addCustomModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全选/取消全选全局模型
|
// 全选/取消全选提供商模型
|
||||||
function toggleAllGlobalModels() {
|
function toggleAllProviderModels() {
|
||||||
const allNames = filteredGlobalModels.value.map(m => m.name)
|
const allNames = filteredProviderModels.value.map(m => m.name)
|
||||||
if (isAllGlobalModelsSelected.value) {
|
if (isAllProviderModelsSelected.value) {
|
||||||
// 取消全选
|
// 取消全选
|
||||||
selectedModels.value = selectedModels.value.filter(id => !allNames.includes(id))
|
selectedModels.value = selectedModels.value.filter(id => !allNames.includes(id))
|
||||||
// 同时取消锁定
|
// 同时取消锁定
|
||||||
@@ -631,7 +638,7 @@ function toggleAllGlobalModels() {
|
|||||||
allNames.forEach(name => {
|
allNames.forEach(name => {
|
||||||
if (!selectedModels.value.includes(name)) {
|
if (!selectedModels.value.includes(name)) {
|
||||||
selectedModels.value.push(name)
|
selectedModels.value.push(name)
|
||||||
// 自动获取模式下,勾选全局模型时自动锁定
|
// 自动获取模式下,勾选提供商模型时自动锁定
|
||||||
if (isAutoFetchMode.value && !lockedModels.value.includes(name)) {
|
if (isAutoFetchMode.value && !lockedModels.value.includes(name)) {
|
||||||
lockedModels.value.push(name)
|
lockedModels.value.push(name)
|
||||||
}
|
}
|
||||||
@@ -650,21 +657,23 @@ function toggleGroupCollapse(group: string) {
|
|||||||
collapsedGroups.value = new Set(collapsedGroups.value)
|
collapsedGroups.value = new Set(collapsedGroups.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载全局模型
|
// 加载该 Provider 已关联的模型
|
||||||
async function loadGlobalModels() {
|
async function loadProviderModels() {
|
||||||
loadingGlobalModels.value = true
|
loadingProviderModels.value = true
|
||||||
try {
|
try {
|
||||||
const response = await getGlobalModels({ limit: 1000 })
|
const response = await getProviderModels(props.providerId, { limit: 1000 })
|
||||||
if (loadingCancelled) return
|
if (loadingCancelled) return
|
||||||
allGlobalModels.value = response.models.map((m: GlobalModelResponse) => ({
|
allProviderModels.value = response.map(m => ({
|
||||||
name: m.name,
|
name: m.provider_model_name,
|
||||||
display_name: m.display_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 {
|
} catch {
|
||||||
if (loadingCancelled) return
|
if (loadingCancelled) return
|
||||||
showError('加载全局模型失败', '错误')
|
showError('加载提供商模型失败', '错误')
|
||||||
} finally {
|
} finally {
|
||||||
loadingGlobalModels.value = false
|
loadingProviderModels.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,8 +743,8 @@ watch(() => props.open, async (open) => {
|
|||||||
collapsedGroups.value = new Set()
|
collapsedGroups.value = new Set()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载全局模型
|
// 加载该 Provider 已关联模型
|
||||||
await loadGlobalModels()
|
await loadProviderModels()
|
||||||
|
|
||||||
// 自动获取模式下,获取上游模型用于显示(但选中状态使用已保存的 allowed_models)
|
// 自动获取模式下,获取上游模型用于显示(但选中状态使用已保存的 allowed_models)
|
||||||
if (props.apiKey.auto_fetch_models) {
|
if (props.apiKey.auto_fetch_models) {
|
||||||
@@ -745,11 +754,11 @@ watch(() => props.open, async (open) => {
|
|||||||
// selectedModels 已在上面从 props.apiKey.allowed_models 初始化
|
// selectedModels 已在上面从 props.apiKey.allowed_models 初始化
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取自定义模型(不在全局模型和上游模型中的)
|
// 提取自定义模型(不在提供商模型和上游模型中的)
|
||||||
const upstreamModelIdsSet = new Set(upstreamModels.value.map(m => m.id))
|
const upstreamModelIdsSet = new Set(upstreamModels.value.map(m => m.id))
|
||||||
// 自定义模型是用户手动添加的、不在已知模型列表中的
|
// 自定义模型是用户手动添加的、不在已知模型列表中的
|
||||||
allCustomModels.value = selectedModels.value.filter(m =>
|
allCustomModels.value = selectedModels.value.filter(m =>
|
||||||
!globalModelNamesSet.value.has(m) && !upstreamModelIdsSet.has(m)
|
!providerModelNamesSet.value.has(m) && !upstreamModelIdsSet.has(m)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
loadingCancelled = true
|
loadingCancelled = true
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const ENDPOINT_SORT_ORDER = [
|
|||||||
'claude:cli',
|
'claude:cli',
|
||||||
'openai:chat',
|
'openai:chat',
|
||||||
'openai:cli',
|
'openai:cli',
|
||||||
|
'openai:compact',
|
||||||
'gemini:chat',
|
'gemini:chat',
|
||||||
'gemini:cli',
|
'gemini:cli',
|
||||||
'openai:video',
|
'openai:video',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function useProviderFilters(
|
|||||||
{ value: 'claude:cli', label: 'Claude CLI' },
|
{ value: 'claude:cli', label: 'Claude CLI' },
|
||||||
{ value: 'openai:chat', label: 'OpenAI Chat' },
|
{ value: 'openai:chat', label: 'OpenAI Chat' },
|
||||||
{ value: 'openai:cli', label: 'OpenAI CLI' },
|
{ value: 'openai:cli', label: 'OpenAI CLI' },
|
||||||
|
{ value: 'openai:compact', label: 'OpenAI Compact' },
|
||||||
{ value: 'gemini:chat', label: 'Gemini Chat' },
|
{ value: 'gemini:chat', label: 'Gemini Chat' },
|
||||||
{ value: 'gemini:cli', label: 'Gemini CLI' },
|
{ 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 = [
|
const AVAILABLE_API_FORMATS = [
|
||||||
{ value: 'openai:chat', label: 'OpenAI Chat' },
|
{ value: 'openai:chat', label: 'OpenAI Chat' },
|
||||||
{ value: 'openai:cli', label: 'OpenAI CLI' },
|
{ value: 'openai:cli', label: 'OpenAI CLI' },
|
||||||
|
{ value: 'openai:compact', label: 'OpenAI Compact' },
|
||||||
{ value: 'openai:video', label: 'OpenAI Video' },
|
{ value: 'openai:video', label: 'OpenAI Video' },
|
||||||
{ value: 'claude:chat', label: 'Claude Chat' },
|
{ value: 'claude:chat', label: 'Claude Chat' },
|
||||||
{ value: 'claude:cli', label: 'Claude CLI' },
|
{ 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 = [
|
preferred_order = [
|
||||||
"openai:chat",
|
"openai:chat",
|
||||||
"openai:cli",
|
"openai:cli",
|
||||||
|
"openai:compact",
|
||||||
"openai:video",
|
"openai:video",
|
||||||
"claude:chat",
|
"claude:chat",
|
||||||
"claude:cli",
|
"claude:cli",
|
||||||
|
|||||||
@@ -838,15 +838,20 @@ class AdminGetApiFormatsAdapter(AdminApiAdapter):
|
|||||||
def _label_for(sig: str) -> str:
|
def _label_for(sig: str) -> str:
|
||||||
fam, kind = (sig.split(":", 1) + [""])[:2]
|
fam, kind = (sig.split(":", 1) + [""])[:2]
|
||||||
fam_title = {"claude": "Claude", "openai": "OpenAI", "gemini": "Gemini"}.get(fam, fam)
|
fam_title = {"claude": "Claude", "openai": "OpenAI", "gemini": "Gemini"}.get(fam, fam)
|
||||||
kind_title = {"chat": "Chat", "cli": "CLI", "video": "Video", "image": "Image"}.get(
|
kind_title = {
|
||||||
kind, kind
|
"chat": "Chat",
|
||||||
)
|
"cli": "CLI",
|
||||||
|
"compact": "Compact",
|
||||||
|
"video": "Video",
|
||||||
|
"image": "Image",
|
||||||
|
}.get(kind, kind)
|
||||||
return f"{fam_title} {kind_title}".strip()
|
return f"{fam_title} {kind_title}".strip()
|
||||||
|
|
||||||
endpoint_defs = list_endpoint_definitions()
|
endpoint_defs = list_endpoint_definitions()
|
||||||
preferred_order = [
|
preferred_order = [
|
||||||
"openai:chat",
|
"openai:chat",
|
||||||
"openai:cli",
|
"openai:cli",
|
||||||
|
"openai:compact",
|
||||||
"openai:video",
|
"openai:video",
|
||||||
"claude:chat",
|
"claude:chat",
|
||||||
"claude:cli",
|
"claude:cli",
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
OpenAI CLI 透传处理器
|
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
|
from src.api.handlers.openai_cli.handler import OpenAICliMessageHandler
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"OpenAICliAdapter",
|
"OpenAICliAdapter",
|
||||||
|
"OpenAICompactAdapter",
|
||||||
"OpenAICliMessageHandler",
|
"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.base.cli_handler_base import CliMessageHandlerBase
|
||||||
from src.api.handlers.openai.adapter import OpenAIChatAdapter
|
from src.api.handlers.openai.adapter import OpenAIChatAdapter
|
||||||
from src.config.settings import config
|
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
|
from src.utils.url_utils import is_codex_url
|
||||||
|
|
||||||
|
|
||||||
@@ -166,3 +166,18 @@ class OpenAICliAdapter(CliAdapterBase):
|
|||||||
|
|
||||||
|
|
||||||
__all__ = ["OpenAICliAdapter"]
|
__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 格式)
|
# 各格式对应的 API 格式列表(包括对应的 CLI 格式)
|
||||||
_CLAUDE_FORMATS = ["claude:chat", "claude: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"]
|
_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.base.pipeline import ApiRequestPipeline
|
||||||
from src.api.handlers.openai import OpenAIChatAdapter
|
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
|
from src.database import get_db
|
||||||
|
|
||||||
router = APIRouter(tags=["OpenAI API"])
|
router = APIRouter(tags=["OpenAI API"])
|
||||||
@@ -68,7 +68,7 @@ async def create_responses_compact(
|
|||||||
|
|
||||||
**认证方式**: Bearer Token(API Key 或 JWT Token)
|
**认证方式**: Bearer Token(API Key 或 JWT Token)
|
||||||
"""
|
"""
|
||||||
adapter = OpenAICliAdapter(compact=True)
|
adapter = OpenAICompactAdapter()
|
||||||
return await pipeline.run(
|
return await pipeline.run(
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
http_request=http_request,
|
http_request=http_request,
|
||||||
|
|||||||
@@ -1263,6 +1263,7 @@ class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
|
|||||||
all_formats = [
|
all_formats = [
|
||||||
"openai:chat",
|
"openai:chat",
|
||||||
"openai:cli",
|
"openai:cli",
|
||||||
|
"openai:compact",
|
||||||
"claude:chat",
|
"claude:chat",
|
||||||
"claude:cli",
|
"claude:cli",
|
||||||
"gemini:chat",
|
"gemini:chat",
|
||||||
|
|||||||
@@ -199,24 +199,18 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
|
|
||||||
return internal
|
return internal
|
||||||
|
|
||||||
# Codex 需要的 include 项
|
|
||||||
_CODEX_REQUIRED_INCLUDE = "reasoning.encrypted_content"
|
|
||||||
|
|
||||||
def request_from_internal(
|
def request_from_internal(
|
||||||
self,
|
self,
|
||||||
internal: InternalRequest,
|
internal: InternalRequest,
|
||||||
*,
|
*,
|
||||||
target_variant: str | None = None,
|
target_variant: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
_ = target_variant
|
||||||
openai_cli_extra = internal.extra.get("openai_cli", {})
|
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] = {
|
result: dict[str, Any] = {
|
||||||
"model": internal.model,
|
"model": internal.model,
|
||||||
"input": self._internal_messages_to_input(
|
"input": self._internal_messages_to_input(internal.messages, system_to_developer=False),
|
||||||
internal.messages, system_to_developer=is_codex
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 合并 instructions,如果没有则使用 system
|
# 合并 instructions,如果没有则使用 system
|
||||||
@@ -229,20 +223,17 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
# 统一添加该字段以确保兼容性
|
# 统一添加该字段以确保兼容性
|
||||||
result["instructions"] = instructions_text or ""
|
result["instructions"] = instructions_text or ""
|
||||||
|
|
||||||
# max_output_tokens/temperature/top_p: Codex 不支持,标准 API 可选
|
if internal.max_tokens is not None:
|
||||||
if not is_codex:
|
# Responses API 使用 max_output_tokens
|
||||||
if internal.max_tokens is not None:
|
result["max_output_tokens"] = internal.max_tokens
|
||||||
# Responses API 使用 max_output_tokens
|
if internal.temperature is not None:
|
||||||
result["max_output_tokens"] = internal.max_tokens
|
result["temperature"] = internal.temperature
|
||||||
if internal.temperature is not None:
|
if internal.top_p is not None:
|
||||||
result["temperature"] = internal.temperature
|
result["top_p"] = internal.top_p
|
||||||
if internal.top_p is not None:
|
|
||||||
result["top_p"] = internal.top_p
|
|
||||||
|
|
||||||
if internal.stop_sequences:
|
if internal.stop_sequences:
|
||||||
result["stop"] = list(internal.stop_sequences)
|
result["stop"] = list(internal.stop_sequences)
|
||||||
# Codex 强制要求 stream=true;其他情况尊重客户端请求
|
result["stream"] = bool(internal.stream)
|
||||||
result["stream"] = True if is_codex else bool(internal.stream)
|
|
||||||
|
|
||||||
if internal.tools:
|
if internal.tools:
|
||||||
# Responses API 使用扁平结构: {type, name, description, parameters}
|
# Responses API 使用扁平结构: {type, name, description, parameters}
|
||||||
@@ -308,26 +299,10 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
if key not in handled_keys and key not in result:
|
if key not in handled_keys and key not in result:
|
||||||
result[key] = value
|
result[key] = value
|
||||||
|
|
||||||
# 统一设置 store=false(Codex 强制要求,标准 API 兼容)
|
# 标准 Responses API 默认设置 store=false
|
||||||
if "store" not in result:
|
if "store" not in result:
|
||||||
result["store"] = False
|
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
|
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.CLI)
|
||||||
return EndpointSignature(api_family=ApiFamily.CLAUDE, endpoint_kind=EndpointKind.CHAT)
|
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
|
# OpenAI CLI: /responses
|
||||||
if "/responses" in normalized:
|
if "/responses" in normalized:
|
||||||
return EndpointSignature(api_family=ApiFamily.OPENAI, endpoint_kind=EndpointKind.CLI)
|
return EndpointSignature(api_family=ApiFamily.OPENAI, endpoint_kind=EndpointKind.CLI)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class EndpointKind(str, Enum):
|
|||||||
|
|
||||||
CHAT = "chat"
|
CHAT = "chat"
|
||||||
CLI = "cli"
|
CLI = "cli"
|
||||||
|
COMPACT = "compact"
|
||||||
VIDEO = "video"
|
VIDEO = "video"
|
||||||
IMAGE = "image"
|
IMAGE = "image"
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,19 @@ _ENDPOINT_DEFINITIONS: dict[tuple[ApiFamily, EndpointKind], EndpointDefinition]
|
|||||||
protected_keys=frozenset({"authorization", "content-type"}),
|
protected_keys=frozenset({"authorization", "content-type"}),
|
||||||
data_format_id="openai_responses",
|
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(
|
(ApiFamily.OPENAI, EndpointKind.VIDEO): EndpointDefinition(
|
||||||
api_family=ApiFamily.OPENAI,
|
api_family=ApiFamily.OPENAI,
|
||||||
endpoint_kind=EndpointKind.VIDEO,
|
endpoint_kind=EndpointKind.VIDEO,
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ class EndpointHealthService:
|
|||||||
kind_label = {
|
kind_label = {
|
||||||
"chat": "Chat",
|
"chat": "Chat",
|
||||||
"cli": "CLI",
|
"cli": "CLI",
|
||||||
|
"compact": "Compact",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"image": "Image",
|
"image": "Image",
|
||||||
}.get(kind, kind)
|
}.get(kind, kind)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ MAX_CONCURRENT_REQUESTS = 5
|
|||||||
|
|
||||||
# 模型获取格式优先级:同族内优先使用 chat 端点,若无则回退到 cli 端点
|
# 模型获取格式优先级:同族内优先使用 chat 端点,若无则回退到 cli 端点
|
||||||
MODEL_FETCH_FORMAT_PRIORITY: list[tuple[str, ...]] = [
|
MODEL_FETCH_FORMAT_PRIORITY: list[tuple[str, ...]] = [
|
||||||
("openai:chat", "openai:cli"),
|
("openai:chat", "openai:cli", "openai:compact"),
|
||||||
("claude:chat", "claude:cli"),
|
("claude:chat", "claude:cli"),
|
||||||
("gemini:chat", "gemini: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
|
Codex requests are now treated as passthrough:
|
||||||
``patch_for_variant("codex")`` fast path. It applies minimal, non-destructive
|
- Do not mutate client payload fields.
|
||||||
patches directly on the original request dict -- no internal representation
|
- Only strip internal sentinel fields that must never reach upstream.
|
||||||
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``).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -24,20 +11,6 @@ from typing import Any
|
|||||||
|
|
||||||
from src.core.provider_types import ProviderType
|
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]:
|
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.
|
This function never mutates the input object.
|
||||||
"""
|
"""
|
||||||
out: dict[str, Any] = dict(request_body)
|
out: dict[str, Any] = dict(request_body)
|
||||||
|
# Internal routing marker; never send upstream.
|
||||||
for k in _REJECTED_PARAMS:
|
out.pop("_aether_compact", None)
|
||||||
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)
|
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -108,7 +40,7 @@ def maybe_patch_request_for_codex(
|
|||||||
"""
|
"""
|
||||||
if (provider_type or "").lower() != ProviderType.CODEX:
|
if (provider_type or "").lower() != ProviderType.CODEX:
|
||||||
return request_body
|
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
|
return request_body
|
||||||
if not isinstance(request_body, dict):
|
if not isinstance(request_body, dict):
|
||||||
return request_body
|
return request_body
|
||||||
|
|||||||
@@ -55,13 +55,15 @@ def get_upstream_stream_policy(
|
|||||||
|
|
||||||
Defaults:
|
Defaults:
|
||||||
- Codex + openai:cli: FORCE_STREAM (Codex upstream requires stream=true).
|
- 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)
|
provider_obj = getattr(endpoint, "provider", None)
|
||||||
pt = str(provider_type or getattr(provider_obj, "provider_type", "") or "").strip().lower()
|
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()
|
sig = str(endpoint_sig or getattr(endpoint, "api_format", "") or "").strip().lower()
|
||||||
is_codex_compact = False
|
is_codex_cli = pt == ProviderType.CODEX and sig == "openai:cli"
|
||||||
if pt == ProviderType.CODEX and sig == "openai:cli":
|
is_codex_compact = pt == ProviderType.CODEX and sig == "openai:compact"
|
||||||
|
if is_codex_cli:
|
||||||
try:
|
try:
|
||||||
from src.services.provider.adapters.codex.context import get_codex_request_context
|
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:
|
if parsed != UpstreamStreamPolicy.AUTO:
|
||||||
# Codex upstream requires streaming; do not allow forcing non-stream.
|
# Codex upstream requires streaming; do not allow forcing non-stream.
|
||||||
if (
|
if (
|
||||||
pt == ProviderType.CODEX
|
is_codex_cli
|
||||||
and sig == "openai:cli"
|
|
||||||
and parsed == UpstreamStreamPolicy.FORCE_NON_STREAM
|
and parsed == UpstreamStreamPolicy.FORCE_NON_STREAM
|
||||||
and not is_codex_compact
|
and not is_codex_compact
|
||||||
):
|
):
|
||||||
@@ -93,7 +94,7 @@ def get_upstream_stream_policy(
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
# Safe-by-default: Codex Responses OAuth behaves like SSE-only.
|
# Safe-by-default: Codex Responses OAuth behaves like SSE-only.
|
||||||
if pt == ProviderType.CODEX and sig == "openai:cli":
|
if is_codex_cli:
|
||||||
return (
|
return (
|
||||||
UpstreamStreamPolicy.FORCE_NON_STREAM
|
UpstreamStreamPolicy.FORCE_NON_STREAM
|
||||||
if is_codex_compact
|
if is_codex_compact
|
||||||
@@ -134,13 +135,30 @@ def enforce_stream_mode_for_upstream(
|
|||||||
meta = resolve_endpoint_definition(provider_api_format)
|
meta = resolve_endpoint_definition(provider_api_format)
|
||||||
provider_uses_stream = meta.stream_in_body if meta is not None else True
|
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:
|
if provider_uses_stream:
|
||||||
request_body["stream"] = bool(upstream_is_stream)
|
request_body["stream"] = bool(upstream_is_stream)
|
||||||
else:
|
else:
|
||||||
request_body.pop("stream", None)
|
request_body.pop("stream", None)
|
||||||
|
|
||||||
# OpenAI Chat Completions: request usage in streaming mode.
|
# 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":
|
if upstream_is_stream and provider_fmt == "openai:chat":
|
||||||
stream_options = request_body.get("stream_options")
|
stream_options = request_body.get("stream_options")
|
||||||
if not isinstance(stream_options, dict):
|
if not isinstance(stream_options, dict):
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ Provider Key 认证类型相关规则。
|
|||||||
def normalize_auth_type(raw: str) -> str:
|
def normalize_auth_type(raw: str) -> str:
|
||||||
"""将数据库中的 auth_type 归一化为逻辑类型。
|
"""将数据库中的 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"
|
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 的哈希值
|
- api_key: 比较 API Key 的哈希值
|
||||||
- vertex_ai: 比较 Service Account 的 client_email
|
- service_account: 比较 Service Account 的 client_email
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: 数据库会话
|
db: 数据库会话
|
||||||
provider_id: Provider ID
|
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_api_key: 新的 API Key(用于 api_key 类型)
|
||||||
new_auth_config: 新的认证配置(用于 vertex_ai 类型)
|
new_auth_config: 新的认证配置(用于 service_account 类型)
|
||||||
exclude_key_id: 要排除的 Key ID(用于更新场景)
|
exclude_key_id: 要排除的 Key ID(用于更新场景)
|
||||||
"""
|
"""
|
||||||
if auth_type == "api_key" and new_api_key:
|
if auth_type == "api_key" and new_api_key:
|
||||||
@@ -66,7 +66,7 @@ def check_duplicate_key(
|
|||||||
# 解密失败时跳过该 Key
|
# 解密失败时跳过该 Key
|
||||||
continue
|
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_client_email = (
|
||||||
new_auth_config.get("client_email") if isinstance(new_auth_config, dict) else None
|
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
|
# 仅查询同 auth_type 且有 auth_config 的 Keys
|
||||||
query = db.query(ProviderAPIKey).filter(
|
query = db.query(ProviderAPIKey).filter(
|
||||||
ProviderAPIKey.provider_id == provider_id,
|
ProviderAPIKey.provider_id == provider_id,
|
||||||
ProviderAPIKey.auth_type == "vertex_ai",
|
ProviderAPIKey.auth_type.in_(["service_account", "vertex_ai"]),
|
||||||
ProviderAPIKey.auth_config.isnot(None),
|
ProviderAPIKey.auth_config.isnot(None),
|
||||||
)
|
)
|
||||||
if exclude_key_id:
|
if exclude_key_id:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
|
|||||||
from src.core.crypto import crypto_service
|
from src.core.crypto import crypto_service
|
||||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
from src.core.provider_types import ProviderType
|
||||||
from src.models.database import Provider, ProviderAPIKey
|
from src.models.database import Provider, ProviderAPIKey
|
||||||
from src.models.endpoint_models import (
|
from src.models.endpoint_models import (
|
||||||
EndpointAPIKeyCreate,
|
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
|
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
|
@dataclass
|
||||||
class _UpdateKeyPreparation:
|
class _UpdateKeyPreparation:
|
||||||
"""更新 Key 前置准备结果。"""
|
"""更新 Key 前置准备结果。"""
|
||||||
@@ -154,14 +186,14 @@ def _prepare_update_key_payload(
|
|||||||
raise InvalidRequestException("API Key 认证模式下 api_key 不能为空")
|
raise InvalidRequestException("API Key 认证模式下 api_key 不能为空")
|
||||||
# 切换回 API Key:清理非本模式配置
|
# 切换回 API Key:清理非本模式配置
|
||||||
update_data["auth_config"] = None
|
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"):
|
if is_auth_type_switch and not update_data.get("auth_config"):
|
||||||
raise InvalidRequestException(
|
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__"}:
|
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:
|
if is_auth_type_switch or api_key_in_payload:
|
||||||
update_data["api_key"] = "__placeholder__"
|
update_data["api_key"] = "__placeholder__"
|
||||||
elif target_auth_type == "oauth":
|
elif target_auth_type == "oauth":
|
||||||
@@ -186,6 +218,15 @@ def _prepare_update_key_payload(
|
|||||||
exclude_key_id=key_id,
|
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:
|
if "api_key" in update_data:
|
||||||
api_key_raw = update_data["api_key"]
|
api_key_raw = update_data["api_key"]
|
||||||
if api_key_raw is None:
|
if api_key_raw is None:
|
||||||
@@ -261,7 +302,7 @@ def _prepare_create_key_payload(
|
|||||||
if auth_type == "api_key":
|
if auth_type == "api_key":
|
||||||
if not key_data.api_key:
|
if not key_data.api_key:
|
||||||
raise InvalidRequestException("API Key 认证模式下 api_key 为必填字段")
|
raise InvalidRequestException("API Key 认证模式下 api_key 为必填字段")
|
||||||
elif auth_type == "vertex_ai":
|
elif auth_type == "service_account":
|
||||||
if not key_data.auth_config:
|
if not key_data.auth_config:
|
||||||
raise InvalidRequestException("Service Account 认证模式下 auth_config 为必填字段")
|
raise InvalidRequestException("Service Account 认证模式下 auth_config 为必填字段")
|
||||||
elif auth_type == "oauth":
|
elif auth_type == "oauth":
|
||||||
@@ -384,12 +425,19 @@ async def create_provider_key_response(
|
|||||||
if not key_data.api_formats:
|
if not key_data.api_formats:
|
||||||
raise InvalidRequestException("api_formats 为必填字段")
|
raise InvalidRequestException("api_formats 为必填字段")
|
||||||
|
|
||||||
_, new_key = _prepare_create_key_payload(
|
auth_type, new_key = _prepare_create_key_payload(
|
||||||
db=db,
|
db=db,
|
||||||
provider_id=provider_id,
|
provider_id=provider_id,
|
||||||
key_data=key_data,
|
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.add(new_key)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_key)
|
db.refresh(new_key)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def get_keys_grouped_by_format(db: Session) -> dict:
|
|||||||
continue # 跳过没有 API 格式的 Key
|
continue # 跳过没有 API 格式的 Key
|
||||||
|
|
||||||
auth_type = normalize_auth_type(getattr(key, "auth_type", "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]"
|
masked_key = "[Service Account]"
|
||||||
elif auth_type == "oauth":
|
elif auth_type == "oauth":
|
||||||
masked_key = "[OAuth Token]"
|
masked_key = "[OAuth Token]"
|
||||||
@@ -166,15 +166,15 @@ def reveal_endpoint_key_payload(
|
|||||||
|
|
||||||
auth_type = normalize_auth_type(getattr(key, "auth_type", "api_key"))
|
auth_type = normalize_auth_type(getattr(key, "auth_type", "api_key"))
|
||||||
|
|
||||||
# Vertex AI 类型返回 auth_config(需要解密)
|
# Service Account 类型返回 auth_config(需要解密)
|
||||||
if auth_type == "vertex_ai":
|
if auth_type in ("service_account", "vertex_ai"):
|
||||||
encrypted_auth_config = getattr(key, "auth_config", None)
|
encrypted_auth_config = getattr(key, "auth_config", None)
|
||||||
if encrypted_auth_config:
|
if encrypted_auth_config:
|
||||||
try:
|
try:
|
||||||
decrypted_config = crypto_service.decrypt(encrypted_auth_config)
|
decrypted_config = crypto_service.decrypt(encrypted_auth_config)
|
||||||
auth_config = json.loads(decrypted_config)
|
auth_config = json.loads(decrypted_config)
|
||||||
logger.info(f"[REVEAL] 查看 Auth Config: ID={key_id}, Name={key.name}")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"解密 Auth Config 失败: ID={key_id}, Error={e}")
|
logger.error(f"解密 Auth Config 失败: ID={key_id}, Error={e}")
|
||||||
raise InvalidRequestException(
|
raise InvalidRequestException(
|
||||||
@@ -184,12 +184,11 @@ def reveal_endpoint_key_payload(
|
|||||||
# 兼容:auth_config 为空时尝试从 api_key 解密(仅对迁移前的旧数据有效)
|
# 兼容:auth_config 为空时尝试从 api_key 解密(仅对迁移前的旧数据有效)
|
||||||
try:
|
try:
|
||||||
decrypted_key = crypto_service.decrypt(key.api_key)
|
decrypted_key = crypto_service.decrypt(key.api_key)
|
||||||
# 检查是否是新格式的占位符(表示 auth_config 丢失)
|
|
||||||
if decrypted_key == "__placeholder__":
|
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("认证配置丢失,请重新添加该密钥。")
|
raise InvalidRequestException("认证配置丢失,请重新添加该密钥。")
|
||||||
logger.info(f"[REVEAL] 查看完整 Key (legacy vertex_ai): ID={key_id}, Name={key.name}")
|
logger.info(f"[REVEAL] 查看完整 Key (legacy SA): ID={key_id}, Name={key.name}")
|
||||||
return {"auth_type": "vertex_ai", "auth_config": decrypted_key}
|
return {"auth_type": auth_type, "auth_config": decrypted_key}
|
||||||
except InvalidRequestException:
|
except InvalidRequestException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ def build_key_response(
|
|||||||
"""构建 Key 响应对象。"""
|
"""构建 Key 响应对象。"""
|
||||||
auth_type = normalize_auth_type(getattr(key, "auth_type", "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"):
|
||||||
# Vertex AI 使用 Service Account,不显示占位符
|
# Service Account 不显示占位符
|
||||||
masked_key = "[Service Account]"
|
masked_key = "[Service Account]"
|
||||||
elif auth_type == "oauth":
|
elif auth_type == "oauth":
|
||||||
masked_key = "[OAuth Token]"
|
masked_key = "[OAuth Token]"
|
||||||
|
|||||||
@@ -415,9 +415,12 @@ class CandidateBuilder:
|
|||||||
if isinstance(raw, int) and raw > 0:
|
if isinstance(raw, int) and raw > 0:
|
||||||
output_limit = raw
|
output_limit = raw
|
||||||
|
|
||||||
# chat/cli 互相可回退(用于同协议族下的端点变体),video/image 等不跨类回退
|
# chat/cli 互相可回退(用于同协议族下的端点变体),compact 可回退到 cli。
|
||||||
|
# video/image 等不跨类回退。
|
||||||
if client_kind in {EndpointKind.CHAT, EndpointKind.CLI}:
|
if client_kind in {EndpointKind.CHAT, EndpointKind.CLI}:
|
||||||
allowed_kinds = {EndpointKind.CHAT, EndpointKind.CLI}
|
allowed_kinds = {EndpointKind.CHAT, EndpointKind.CLI}
|
||||||
|
elif client_kind == EndpointKind.COMPACT:
|
||||||
|
allowed_kinds = {EndpointKind.COMPACT, EndpointKind.CLI}
|
||||||
else:
|
else:
|
||||||
allowed_kinds = {client_kind}
|
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:
|
def test_patch_openai_cli_request_for_codex_is_passthrough_except_internal_sentinel() -> 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:
|
|
||||||
req = {
|
req = {
|
||||||
"model": "gpt-test",
|
"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": [
|
"input": [
|
||||||
{
|
{
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": [{"type": "input_text", "text": "You are a pirate."}],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"role": "user",
|
|
||||||
"content": [{"type": "input_text", "text": "Hello"}],
|
"content": [{"type": "input_text", "text": "Hello"}],
|
||||||
},
|
}
|
||||||
],
|
],
|
||||||
}
|
"store": True,
|
||||||
out = patch_openai_cli_request_for_codex(req)
|
"stream": False,
|
||||||
|
"instructions": "keep",
|
||||||
assert isinstance(out.get("input"), list)
|
"include": ["foo"],
|
||||||
assert out["input"][0]["role"] == "developer"
|
"parallel_tool_calls": False,
|
||||||
assert out["input"][1]["role"] == "user"
|
"temperature": 0.7,
|
||||||
|
"context_management": {"compaction": {"type": "summary"}},
|
||||||
|
"user": "u_123",
|
||||||
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": [],
|
|
||||||
"_aether_compact": True,
|
"_aether_compact": True,
|
||||||
"stream": True,
|
|
||||||
}
|
}
|
||||||
out = patch_openai_cli_request_for_codex(req)
|
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:
|
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:
|
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(
|
out = maybe_patch_request_for_codex(
|
||||||
provider_type="codex",
|
provider_type="codex",
|
||||||
provider_api_format="openai:cli",
|
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 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 out["store"] is False
|
||||||
assert "instructions" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_codex_envelope_extra_headers_includes_sse_accept_and_session() -> None:
|
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,
|
"model_exclude_patterns": None,
|
||||||
"provider_id": "provider-1",
|
"provider_id": "provider-1",
|
||||||
"auth_type": "api_key",
|
"auth_type": "api_key",
|
||||||
|
"api_formats": [],
|
||||||
|
"provider": None,
|
||||||
}
|
}
|
||||||
base.update(overrides)
|
base.update(overrides)
|
||||||
return SimpleNamespace(**base)
|
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"
|
assert url == "https://chatgpt.com/backend-api/codex/responses/compact"
|
||||||
set_codex_request_context(None)
|
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)
|
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:
|
def test_enforce_stream_mode_for_upstream_openai_chat_sets_stream_options_usage() -> None:
|
||||||
body = {"stream": False}
|
body = {"stream": False}
|
||||||
out = enforce_stream_mode_for_upstream(
|
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 "stream" not in out
|
||||||
assert out["foo"] == "bar"
|
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