feat: OAuth Token 自动刷新调度、OAuth 对话框优化及 OpenAI CLI normalizer 重构

- 系统设置新增 OAuth Token 自动刷新任务开关,支持动态调度
- OAuth 授权对话框简化步骤布局,移除编号圆圈样式
- 重构 OpenAI CLI normalizer:将 if-else 链替换为事件处理器映射表,
  将 stream_event_from_internal 拆分为独立方法
- 维护调度器在禁用刷新时移除已调度的 job
- .gitignore 新增 CLIProxyAPI/ 和 sub2api/ 排除项
- 补充 noop/unknown 事件处理器的测试覆盖
This commit is contained in:
fawney19
2026-02-05 01:07:47 +08:00
parent ba4d88e8ce
commit 9a8f25d1a9
7 changed files with 609 additions and 513 deletions

3
.gitignore vendored
View File

@@ -1,6 +1,9 @@
# Created by https://www.toptal.com/developers/gitignore/api/python # Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python # Edit at https://www.toptal.com/developers/gitignore?templates=python
CLIProxyAPI/
sub2api/
# AI Assistant Configuration # AI Assistant Configuration
.claude/ .claude/
.serena/ .serena/

View File

@@ -6,7 +6,7 @@
size="md" size="md"
@update:model-value="handleDialogUpdate" @update:model-value="handleDialogUpdate"
> >
<div class="space-y-6"> <div class="space-y-5">
<!-- 加载中 --> <!-- 加载中 -->
<div <div
v-if="oauth.starting && !oauth.authorization_url" v-if="oauth.starting && !oauth.authorization_url"
@@ -21,22 +21,14 @@
<!-- 授权流程 --> <!-- 授权流程 -->
<template v-else-if="oauth.authorization_url"> <template v-else-if="oauth.authorization_url">
<!-- 步骤 1: 打开授权链接 --> <!-- 步骤 1: 打开授权链接 -->
<div class="space-y-3"> <div class="space-y-2">
<div class="flex items-center gap-2"> <p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
<div class="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0"> 第一步 · 前往授权
1 </p>
</div> <p class="text-xs text-muted-foreground">
<span class="text-sm font-medium">打开授权链接</span>
</div>
<p class="text-xs text-muted-foreground pl-7">
点击下方按钮在浏览器中完成登录授权 点击下方按钮在浏览器中完成登录授权
</p> </p>
<div class="ml-7 p-2.5 rounded-md bg-muted/50 border border-border/50"> <div class="flex gap-2 pt-1">
<p class="text-xs font-mono text-muted-foreground break-all line-clamp-3 leading-relaxed">
{{ oauth.authorization_url }}
</p>
</div>
<div class="flex gap-2 pl-7">
<Button <Button
size="sm" size="sm"
:disabled="oauthBusy" :disabled="oauthBusy"
@@ -57,28 +49,26 @@
</div> </div>
</div> </div>
<Separator />
<!-- 步骤 2: 粘贴回调地址 --> <!-- 步骤 2: 粘贴回调地址 -->
<div class="space-y-3"> <div class="space-y-2">
<div class="flex items-center gap-2"> <p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
<div class="w-5 h-5 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-xs font-medium shrink-0"> 第二步 · 粘贴回调
2 </p>
</div> <p class="text-xs text-muted-foreground">
<span class="text-sm font-medium">粘贴回调地址</span>
</div>
<p class="text-xs text-muted-foreground pl-7">
授权完成后复制浏览器地址栏的完整 URL 并粘贴到下方 授权完成后复制浏览器地址栏的完整 URL 并粘贴到下方
</p> </p>
<div class="pl-7"> <div class="pt-1">
<Textarea <Textarea
v-model="oauth.callback_url" v-model="oauth.callback_url"
:disabled="oauthBusy" :disabled="oauthBusy"
placeholder="http://localhost:xxx/callback?code=..." placeholder="http://localhost:xxx/callback?code=..."
class="min-h-[80px] text-xs font-mono resize-none" class="min-h-[80px] text-xs font-mono break-all !rounded-xl"
spellcheck="false" spellcheck="false"
/> />
</div> </div>
</div> </div>
</template> </template>
</div> </div>
@@ -93,7 +83,7 @@
:disabled="!canCompleteOAuth" :disabled="!canCompleteOAuth"
@click="handleCompleteOAuth" @click="handleCompleteOAuth"
> >
{{ oauth.completing ? '验证中...' : '完成授权' }} {{ oauth.completing ? '验证中...' : '验证' }}
</Button> </Button>
</template> </template>
</Dialog> </Dialog>
@@ -101,7 +91,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { Dialog, Button, Textarea } from '@/components/ui' import { Dialog, Button, Textarea, Separator } from '@/components/ui'
import { UserPlus, Copy, ExternalLink } from 'lucide-vue-next' import { UserPlus, Copy, ExternalLink } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'

View File

@@ -534,7 +534,7 @@
<!-- 右侧时间选择器 + 保存按钮 --> <!-- 右侧时间选择器 + 保存按钮 -->
<div <div
v-if="task.enabled" v-if="task.enabled && task.hasTimeConfig"
class="flex items-center gap-2 shrink-0" class="flex items-center gap-2 shrink-0"
> >
<Clock class="w-4 h-4 text-muted-foreground" /> <Clock class="w-4 h-4 text-muted-foreground" />
@@ -995,7 +995,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { Download, Upload, CalendarCheck, RotateCcw, Clock, Check, Loader2 } from 'lucide-vue-next' import { Download, Upload, CalendarCheck, RotateCcw, RefreshCw, Clock, Check, Loader2 } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue' import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue' import Label from '@/components/ui/label.vue'
@@ -1044,6 +1044,7 @@ interface SystemConfig {
enable_user_quota_reset: boolean enable_user_quota_reset: boolean
user_quota_reset_time: string user_quota_reset_time: string
user_quota_reset_interval_days: number user_quota_reset_interval_days: number
enable_oauth_token_refresh: boolean
} }
const basicConfigLoading = ref(false) const basicConfigLoading = ref(false)
@@ -1104,6 +1105,7 @@ const systemConfig = ref<SystemConfig>({
enable_user_quota_reset: false, enable_user_quota_reset: false,
user_quota_reset_time: '05:00', user_quota_reset_time: '05:00',
user_quota_reset_interval_days: 1, user_quota_reset_interval_days: 1,
enable_oauth_token_refresh: true,
}) })
// 原始配置值(用于检测变动) // 原始配置值(用于检测变动)
@@ -1215,6 +1217,7 @@ async function loadSystemConfig() {
'enable_user_quota_reset', 'enable_user_quota_reset',
'user_quota_reset_time', 'user_quota_reset_time',
'user_quota_reset_interval_days', 'user_quota_reset_interval_days',
'enable_oauth_token_refresh',
] ]
for (const key of configs) { for (const key of configs) {
@@ -1423,6 +1426,28 @@ async function handleUserQuotaResetToggle(enabled: boolean) {
} }
} }
async function handleOAuthTokenRefreshToggle(enabled: boolean) {
const previousValue = systemConfig.value.enable_oauth_token_refresh
systemConfig.value.enable_oauth_token_refresh = enabled
try {
await adminApi.updateSystemConfig(
'enable_oauth_token_refresh',
enabled,
'是否启用 OAuth Token 自动刷新任务'
)
success(enabled ? '已启用 OAuth Token 自动刷新' : '已禁用 OAuth Token 自动刷新')
} catch (err) {
error('保存配置失败')
log.error('保存 OAuth Token 自动刷新配置失败:', err)
// 回滚状态
systemConfig.value.enable_oauth_token_refresh = previousValue
}
}
// OAuth Token 刷新(无时间配置,占位 ref
const oauthRefreshHourSelectOpen = ref(false)
const oauthRefreshMinuteSelectOpen = ref(false)
// 用户配额重置时间相关 // 用户配额重置时间相关
const previousUserQuotaResetTime = ref('') const previousUserQuotaResetTime = ref('')
const userQuotaResetHourSelectOpen = ref(false) const userQuotaResetHourSelectOpen = ref(false)
@@ -1465,6 +1490,7 @@ const scheduledTasks = computed(() => [
title: 'Provider 自动签到', title: 'Provider 自动签到',
description: '自动执行已配置 Provider 的签到任务', description: '自动执行已配置 Provider 的签到任务',
enabled: systemConfig.value.enable_provider_checkin, enabled: systemConfig.value.enable_provider_checkin,
hasTimeConfig: true,
hour: checkinHour.value, hour: checkinHour.value,
minute: checkinMinute.value, minute: checkinMinute.value,
hourSelectOpen: checkinHourSelectOpen, hourSelectOpen: checkinHourSelectOpen,
@@ -1481,6 +1507,7 @@ const scheduledTasks = computed(() => [
title: '用户配额自动重置', title: '用户配额自动重置',
description: '定时将用户已使用配额重置为零', description: '定时将用户已使用配额重置为零',
enabled: systemConfig.value.enable_user_quota_reset, enabled: systemConfig.value.enable_user_quota_reset,
hasTimeConfig: true,
hour: userQuotaResetHour.value, hour: userQuotaResetHour.value,
minute: userQuotaResetMinute.value, minute: userQuotaResetMinute.value,
hourSelectOpen: userQuotaResetHourSelectOpen, hourSelectOpen: userQuotaResetHourSelectOpen,
@@ -1491,6 +1518,23 @@ const scheduledTasks = computed(() => [
onToggle: handleUserQuotaResetToggle, onToggle: handleUserQuotaResetToggle,
onSave: handleQuotaResetConfigSave, onSave: handleQuotaResetConfigSave,
}, },
{
id: 'oauth-token-refresh',
icon: RefreshCw,
title: 'OAuth Token 自动刷新',
description: '主动刷新即将过期的 OAuth Token动态调度',
enabled: systemConfig.value.enable_oauth_token_refresh,
hasTimeConfig: false,
hour: '',
minute: '',
hourSelectOpen: oauthRefreshHourSelectOpen,
minuteSelectOpen: oauthRefreshMinuteSelectOpen,
updateTime: () => {},
hasChanges: false,
loading: false,
onToggle: handleOAuthTokenRefreshToggle,
onSave: () => {},
},
]) ])
// Provider 签到时间保存 // Provider 签到时间保存

View File

@@ -634,6 +634,15 @@ class AdminSetSystemConfigAdapter(AdminApiAdapter):
except Exception as e: except Exception as e:
logger.warning(f"更新用户配额重置任务时间失败: {e}") logger.warning(f"更新用户配额重置任务时间失败: {e}")
# 如果更新的是 OAuth Token 自动刷新开关,触发调度器重新计算
if self.key == "enable_oauth_token_refresh":
try:
from src.services.system.maintenance_scheduler import get_maintenance_scheduler
get_maintenance_scheduler().trigger_oauth_refresh_check()
except Exception as e:
logger.warning("触发 OAuth Token 刷新调度失败: {}", e)
# 返回时不暴露加密后的值 # 返回时不暴露加密后的值
display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value

View File

@@ -12,6 +12,7 @@ OpenAI CLI / Responses Normalizer (OPENAI_CLI)
import json import json
import time import time
from collections.abc import Callable
from typing import Any from typing import Any
from src.core.api_format.conversion.field_mappings import ( from src.core.api_format.conversion.field_mappings import (
@@ -318,12 +319,26 @@ class OpenAICliNormalizer(FormatNormalizer):
ss.setdefault("text_block_stopped", False) ss.setdefault("text_block_stopped", False)
events.append(MessageStartEvent(message_id=msg_id, model=model)) events.append(MessageStartEvent(message_id=msg_id, model=model))
# response.created响应创建事件message_start 已在上面处理 handler = self._CHUNK_HANDLERS.get(etype)
if etype == "response.created": if handler is not None:
events.extend(handler(self, chunk, state, ss))
return events return events
# 文本增量response.output_text.delta # 未匹配的事件类型
if etype in ("response.output_text.delta", "response.outtext.delta"): if etype:
return events + [UnknownStreamEvent(raw_type=etype, payload=chunk)]
return events + [UnknownStreamEvent(raw_type="unknown", payload=chunk)]
def _handle_response_created(
self, chunk: dict[str, Any], state: StreamState, ss: dict[str, Any]
) -> list[InternalStreamEvent]:
# message_start 已在主方法中处理
return []
def _handle_output_text_delta(
self, chunk: dict[str, Any], state: StreamState, ss: dict[str, Any]
) -> list[InternalStreamEvent]:
events: list[InternalStreamEvent] = []
delta = chunk.get("delta") delta = chunk.get("delta")
delta_text = "" delta_text = ""
if isinstance(delta, str): if isinstance(delta, str):
@@ -334,21 +349,23 @@ class OpenAICliNormalizer(FormatNormalizer):
if delta_text: if delta_text:
if not ss.get("text_block_started"): if not ss.get("text_block_started"):
ss["text_block_started"] = True ss["text_block_started"] = True
events.append( events.append(ContentBlockStartEvent(block_index=0, block_type=ContentType.TEXT))
ContentBlockStartEvent(block_index=0, block_type=ContentType.TEXT)
)
events.append(ContentDeltaEvent(block_index=0, text_delta=delta_text)) events.append(ContentDeltaEvent(block_index=0, text_delta=delta_text))
return events return events
# 文本完成response.output_text.done(可选) def _handle_output_text_done(
if etype == "response.output_text.done": self, chunk: dict[str, Any], state: StreamState, ss: dict[str, Any]
) -> list[InternalStreamEvent]:
events: list[InternalStreamEvent] = []
if ss.get("text_block_started") and not ss.get("text_block_stopped"): if ss.get("text_block_started") and not ss.get("text_block_stopped"):
ss["text_block_stopped"] = True ss["text_block_stopped"] = True
events.append(ContentBlockStopEvent(block_index=0)) events.append(ContentBlockStopEvent(block_index=0))
return events return events
# 完成:response.completed(包含 usage def _handle_response_completed(
if etype == "response.completed": self, chunk: dict[str, Any], state: StreamState, ss: dict[str, Any]
) -> list[InternalStreamEvent]:
events: list[InternalStreamEvent] = []
resp_obj = chunk.get("response") resp_obj = chunk.get("response")
resp_obj = resp_obj if isinstance(resp_obj, dict) else {} resp_obj = resp_obj if isinstance(resp_obj, dict) else {}
usage = self._usage_to_internal(resp_obj.get("usage") or chunk.get("usage")) usage = self._usage_to_internal(resp_obj.get("usage") or chunk.get("usage"))
@@ -360,16 +377,19 @@ class OpenAICliNormalizer(FormatNormalizer):
events.append(MessageStopEvent(stop_reason=StopReason.END_TURN, usage=usage)) events.append(MessageStopEvent(stop_reason=StopReason.END_TURN, usage=usage))
return events return events
# 失败:response.failed(最佳努力) def _handle_response_failed(
if etype == "response.failed": self, chunk: dict[str, Any], state: StreamState, ss: dict[str, Any]
) -> list[InternalStreamEvent]:
events: list[InternalStreamEvent] = []
try: try:
events.append(ErrorEvent(error=self.error_to_internal(chunk))) events.append(ErrorEvent(error=self.error_to_internal(chunk)))
except Exception: except Exception:
pass pass
return events return events
# response.in_progress:状态更新,不产生内容事件 def _handle_response_in_progress(
if etype == "response.in_progress": self, chunk: dict[str, Any], state: StreamState, ss: dict[str, Any]
) -> list[InternalStreamEvent]:
# 更新 state 中的元数据(如果有) # 更新 state 中的元数据(如果有)
# 注意model 保持初始值(客户端请求的模型),不被上游覆盖 # 注意model 保持初始值(客户端请求的模型),不被上游覆盖
resp_obj = chunk.get("response") resp_obj = chunk.get("response")
@@ -381,12 +401,13 @@ class OpenAICliNormalizer(FormatNormalizer):
state.model = str(resp_obj.get("model")) state.model = str(resp_obj.get("model"))
return [] return []
# response.output_item.added:新输出项添加(如 message、function_call 等) def _handle_output_item_added(
if etype == "response.output_item.added": self, chunk: dict[str, Any], state: StreamState, ss: dict[str, Any]
) -> list[InternalStreamEvent]:
events: list[InternalStreamEvent] = []
item = chunk.get("item") item = chunk.get("item")
if isinstance(item, dict): if isinstance(item, dict):
item_type = item.get("type") item_type = item.get("type")
# function_call 输出项
if item_type == "function_call": if item_type == "function_call":
if not ss.get("tool_block_started"): if not ss.get("tool_block_started"):
ss["tool_block_started"] = True ss["tool_block_started"] = True
@@ -403,14 +424,12 @@ class OpenAICliNormalizer(FormatNormalizer):
) )
) )
ss["block_index"] = ss.get("block_index", 0) + 1 ss["block_index"] = ss.get("block_index", 0) + 1
# message 输出项
elif item_type == "message":
# 通常在 response.created 时已处理,这里可以忽略或更新状态
pass
return events return events
# response.output_item.done:输出项完成 def _handle_output_item_done(
if etype == "response.output_item.done": self, chunk: dict[str, Any], state: StreamState, ss: dict[str, Any]
) -> list[InternalStreamEvent]:
events: list[InternalStreamEvent] = []
item = chunk.get("item") item = chunk.get("item")
if isinstance(item, dict): if isinstance(item, dict):
item_type = item.get("type") item_type = item.get("type")
@@ -419,8 +438,10 @@ class OpenAICliNormalizer(FormatNormalizer):
events.append(ContentBlockStopEvent(block_index=ss.get("block_index", 1) - 1)) events.append(ContentBlockStopEvent(block_index=ss.get("block_index", 1) - 1))
return events return events
# response.function_call_arguments.delta工具调用参数增量 def _handle_function_call_delta(
if etype == "response.function_call_arguments.delta": self, chunk: dict[str, Any], state: StreamState, ss: dict[str, Any]
) -> list[InternalStreamEvent]:
events: list[InternalStreamEvent] = []
delta = chunk.get("delta") or "" delta = chunk.get("delta") or ""
if delta: if delta:
events.append( events.append(
@@ -432,28 +453,35 @@ class OpenAICliNormalizer(FormatNormalizer):
) )
return events return events
# response.function_call_arguments.done工具调用参数完成 def _handle_noop(
if etype == "response.function_call_arguments.done": self, chunk: dict[str, Any], state: StreamState, ss: dict[str, Any]
# 参数已完整,不产生额外事件 ) -> list[InternalStreamEvent]:
return [] return []
# response.content_part.added / response.content_part.done内容部分事件 def _handle_as_unknown(
if etype in ("response.content_part.added", "response.content_part.done"): self, chunk: dict[str, Any], state: StreamState, ss: dict[str, Any]
# 通常伴随 output_text 事件,这里可以忽略 ) -> list[InternalStreamEvent]:
return [] etype = str(chunk.get("type") or "unknown")
# response.reasoning_summary_text.delta推理摘要增量
if etype == "response.reasoning_summary_text.delta":
# 保留为 UnknownStreamEvent让下游决定是否使用
return [UnknownStreamEvent(raw_type=etype, payload=chunk)] return [UnknownStreamEvent(raw_type=etype, payload=chunk)]
# response.reasoning_summary_text.done推理摘要完成 # 事件类型 -> 处理器映射表
if etype == "response.reasoning_summary_text.done": _CHUNK_HANDLERS: dict[str, Callable[..., list[InternalStreamEvent]]] = {
return [UnknownStreamEvent(raw_type=etype, payload=chunk)] "response.created": _handle_response_created,
"response.output_text.delta": _handle_output_text_delta,
if etype: "response.outtext.delta": _handle_output_text_delta,
return [UnknownStreamEvent(raw_type=etype, payload=chunk)] "response.output_text.done": _handle_output_text_done,
return [UnknownStreamEvent(raw_type="unknown", payload=chunk)] "response.completed": _handle_response_completed,
"response.failed": _handle_response_failed,
"response.in_progress": _handle_response_in_progress,
"response.output_item.added": _handle_output_item_added,
"response.output_item.done": _handle_output_item_done,
"response.function_call_arguments.delta": _handle_function_call_delta,
"response.function_call_arguments.done": _handle_noop,
"response.content_part.added": _handle_noop,
"response.content_part.done": _handle_noop,
"response.reasoning_summary_text.delta": _handle_as_unknown,
"response.reasoning_summary_text.done": _handle_as_unknown,
}
def stream_event_from_internal( def stream_event_from_internal(
self, self,
@@ -461,13 +489,28 @@ class OpenAICliNormalizer(FormatNormalizer):
state: StreamState, state: StreamState,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
ss = state.substate(self.FORMAT_ID) ss = state.substate(self.FORMAT_ID)
out: list[dict[str, Any]] = []
def event_block(payload: dict[str, Any]) -> dict[str, Any]:
# OpenAI Responses SSE 的 payload 通常自带 type 字段;这里强制保证
return payload
if isinstance(event, MessageStartEvent): if isinstance(event, MessageStartEvent):
return self._emit_message_start(event, state, ss)
if isinstance(event, ContentBlockStartEvent):
return self._emit_content_block_start(event, state, ss)
if isinstance(event, ToolCallDeltaEvent):
return self._emit_tool_call_delta(event, state, ss)
if isinstance(event, ContentBlockStopEvent):
return self._emit_content_block_stop(event, state, ss)
if isinstance(event, ContentDeltaEvent):
return self._emit_content_delta(event, state, ss)
if isinstance(event, MessageStopEvent):
return self._emit_message_stop(event, state, ss)
if isinstance(event, ErrorEvent):
return self._emit_error(event, state, ss)
# 其他事件Responses SSE 无直接对应,跳过
return []
def _emit_message_start(
self, event: MessageStartEvent, state: StreamState, ss: dict[str, Any]
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
state.message_id = event.message_id or state.message_id or "resp_stream" state.message_id = event.message_id or state.message_id or "resp_stream"
# 保留初始化时设置的 model客户端请求的模型仅在空时用事件值 # 保留初始化时设置的 model客户端请求的模型仅在空时用事件值
if not state.model: if not state.model:
@@ -490,21 +533,17 @@ class OpenAICliNormalizer(FormatNormalizer):
"status": "in_progress", "status": "in_progress",
"output": [], "output": [],
} }
out.append( out.append({"type": "response.created", "response": response_obj})
event_block(
{
"type": "response.created",
"response": response_obj,
}
)
)
# OpenAI Responses API 常见的 in_progress 事件(可选,最佳努力) # OpenAI Responses API 常见的 in_progress 事件(可选,最佳努力)
if not ss.get("sent_in_progress"): if not ss.get("sent_in_progress"):
ss["sent_in_progress"] = True ss["sent_in_progress"] = True
out.append(event_block({"type": "response.in_progress", "response": response_obj})) out.append({"type": "response.in_progress", "response": response_obj})
return out return out
if isinstance(event, ContentBlockStartEvent): def _emit_content_block_start(
self, event: ContentBlockStartEvent, state: StreamState, ss: dict[str, Any]
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
# 工具调用块:输出 function_call 添加事件 # 工具调用块:输出 function_call 添加事件
if event.block_type == ContentType.TOOL_USE: if event.block_type == ContentType.TOOL_USE:
tool_id = event.tool_id or "" tool_id = event.tool_id or ""
@@ -515,13 +554,10 @@ class OpenAICliNormalizer(FormatNormalizer):
tool_calls = ss.setdefault("tool_calls", {}) tool_calls = ss.setdefault("tool_calls", {})
tool_calls.setdefault(tool_id, {"name": tool_name, "args": ""}) tool_calls.setdefault(tool_id, {"name": tool_name, "args": ""})
output_order = ss.setdefault("output_order", []) output_order = ss.setdefault("output_order", [])
output_order.append( output_order.append({"kind": "tool", "id": tool_id, "output_index": output_index})
{"kind": "tool", "id": tool_id, "output_index": output_index}
)
ss.setdefault("tool_blocks", {})[event.block_index] = tool_id ss.setdefault("tool_blocks", {})[event.block_index] = tool_id
ss.setdefault("tool_output_index", {})[tool_id] = output_index ss.setdefault("tool_output_index", {})[tool_id] = output_index
out.append( out.append(
event_block(
{ {
"type": "response.output_item.added", "type": "response.output_item.added",
"output_index": output_index, "output_index": output_index,
@@ -535,10 +571,12 @@ class OpenAICliNormalizer(FormatNormalizer):
}, },
} }
) )
)
return out return out
if isinstance(event, ToolCallDeltaEvent): def _emit_tool_call_delta(
self, event: ToolCallDeltaEvent, state: StreamState, ss: dict[str, Any]
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
tool_id = event.tool_id or ss.get("tool_blocks", {}).get(event.block_index, "") tool_id = event.tool_id or ss.get("tool_blocks", {}).get(event.block_index, "")
if tool_id: if tool_id:
tool_calls = ss.setdefault("tool_calls", {}) tool_calls = ss.setdefault("tool_calls", {})
@@ -546,7 +584,6 @@ class OpenAICliNormalizer(FormatNormalizer):
entry["args"] = str(entry.get("args") or "") + (event.input_delta or "") entry["args"] = str(entry.get("args") or "") + (event.input_delta or "")
output_index = ss.get("tool_output_index", {}).get(tool_id, event.block_index) output_index = ss.get("tool_output_index", {}).get(tool_id, event.block_index)
out.append( out.append(
event_block(
{ {
"type": "response.function_call_arguments.delta", "type": "response.function_call_arguments.delta",
"delta": event.input_delta, "delta": event.input_delta,
@@ -554,10 +591,12 @@ class OpenAICliNormalizer(FormatNormalizer):
"output_index": output_index, "output_index": output_index,
} }
) )
)
return out return out
if isinstance(event, ContentBlockStopEvent): def _emit_content_block_stop(
self, event: ContentBlockStopEvent, state: StreamState, ss: dict[str, Any]
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
tool_blocks = ss.get("tool_blocks", {}) tool_blocks = ss.get("tool_blocks", {})
tool_id = ( tool_id = (
tool_blocks.pop(event.block_index, None) if isinstance(tool_blocks, dict) else None tool_blocks.pop(event.block_index, None) if isinstance(tool_blocks, dict) else None
@@ -567,7 +606,6 @@ class OpenAICliNormalizer(FormatNormalizer):
entry = tool_calls.get(tool_id, {}) entry = tool_calls.get(tool_id, {})
output_index = ss.get("tool_output_index", {}).get(tool_id, event.block_index) output_index = ss.get("tool_output_index", {}).get(tool_id, event.block_index)
out.append( out.append(
event_block(
{ {
"type": "response.output_item.done", "type": "response.output_item.done",
"output_index": output_index, "output_index": output_index,
@@ -581,10 +619,12 @@ class OpenAICliNormalizer(FormatNormalizer):
}, },
} }
) )
)
return out return out
if isinstance(event, ContentDeltaEvent): def _emit_content_delta(
self, event: ContentDeltaEvent, state: StreamState, ss: dict[str, Any]
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
if event.text_delta: if event.text_delta:
if not ss.get("message_output_started"): if not ss.get("message_output_started"):
output_index = int(ss.get("next_output_index") or 0) output_index = int(ss.get("next_output_index") or 0)
@@ -596,7 +636,6 @@ class OpenAICliNormalizer(FormatNormalizer):
{"kind": "message", "id": message_id, "output_index": output_index} {"kind": "message", "id": message_id, "output_index": output_index}
) )
out.append( out.append(
event_block(
{ {
"type": "response.output_item.added", "type": "response.output_item.added",
"output_index": output_index, "output_index": output_index,
@@ -609,20 +648,15 @@ class OpenAICliNormalizer(FormatNormalizer):
}, },
} }
) )
)
ss["text_started"] = True ss["text_started"] = True
ss["collected_text"] = str(ss.get("collected_text") or "") + event.text_delta ss["collected_text"] = str(ss.get("collected_text") or "") + event.text_delta
out.append( out.append({"type": "response.output_text.delta", "delta": event.text_delta})
event_block(
{
"type": "response.output_text.delta",
"delta": event.text_delta,
}
)
)
return out return out
if isinstance(event, MessageStopEvent): def _emit_message_stop(
self, event: MessageStopEvent, state: StreamState, ss: dict[str, Any]
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
final_text = str(ss.get("collected_text") or "") final_text = str(ss.get("collected_text") or "")
message_id = f"msg_{state.message_id or 'stream'}" message_id = f"msg_{state.message_id or 'stream'}"
message_item = { message_item = {
@@ -633,25 +667,16 @@ class OpenAICliNormalizer(FormatNormalizer):
"content": ([{"type": "output_text", "text": final_text}] if final_text else []), "content": ([{"type": "output_text", "text": final_text}] if final_text else []),
} }
if ss.get("text_started"): if ss.get("text_started"):
out.append( out.append({"type": "response.output_text.done", "text": final_text})
event_block(
{
"type": "response.output_text.done",
"text": final_text,
}
)
)
if ss.get("message_output_started"): if ss.get("message_output_started"):
output_index = ss.get("message_output_index") or 0 output_index = ss.get("message_output_index") or 0
out.append( out.append(
event_block(
{ {
"type": "response.output_item.done", "type": "response.output_item.done",
"output_index": output_index, "output_index": output_index,
"item": message_item, "item": message_item,
} }
) )
)
response_obj = self.response_from_internal( response_obj = self.response_from_internal(
InternalResponse( InternalResponse(
id=state.message_id or "resp", id=state.message_id or "resp",
@@ -662,6 +687,15 @@ class OpenAICliNormalizer(FormatNormalizer):
) )
) )
# 将工具调用添加到 output最佳努力 # 将工具调用添加到 output最佳努力
output_items = self._build_final_output_items(message_item, ss)
if output_items:
response_obj["output"] = output_items
out.append({"type": "response.completed", "response": response_obj})
return out
def _build_final_output_items(
self, message_item: dict[str, Any], ss: dict[str, Any]
) -> list[dict[str, Any]]:
tool_calls = ss.get("tool_calls", {}) tool_calls = ss.get("tool_calls", {})
output_order = ss.get("output_order", []) output_order = ss.get("output_order", [])
output_items: list[dict[str, Any]] = [] output_items: list[dict[str, Any]] = []
@@ -678,47 +712,33 @@ class OpenAICliNormalizer(FormatNormalizer):
if not isinstance(tool_id, str) or not tool_id: if not isinstance(tool_id, str) or not tool_id:
continue continue
used_tool_ids.add(tool_id) used_tool_ids.add(tool_id)
tool_entry = ( tool_entry = tool_calls.get(tool_id) if isinstance(tool_calls, dict) else None
tool_calls.get(tool_id) if isinstance(tool_calls, dict) else None
)
if isinstance(tool_entry, dict): if isinstance(tool_entry, dict):
output_items.append( output_items.append(self._tool_call_item(tool_id, tool_entry))
{
"type": "function_call",
"call_id": tool_id,
"id": tool_id,
"name": tool_entry.get("name") or "",
"arguments": tool_entry.get("args") or "",
"status": "completed",
}
)
if isinstance(tool_calls, dict): if isinstance(tool_calls, dict):
for tool_id, tool_entry in tool_calls.items(): for tool_id, tool_entry in tool_calls.items():
if tool_id in used_tool_ids or not isinstance(tool_entry, dict): if tool_id in used_tool_ids or not isinstance(tool_entry, dict):
continue continue
output_items.append( output_items.append(self._tool_call_item(tool_id, tool_entry))
{ return output_items
@staticmethod
def _tool_call_item(tool_id: str, entry: dict[str, Any]) -> dict[str, Any]:
return {
"type": "function_call", "type": "function_call",
"call_id": tool_id, "call_id": tool_id,
"id": tool_id, "id": tool_id,
"name": tool_entry.get("name") or "", "name": entry.get("name") or "",
"arguments": tool_entry.get("args") or "", "arguments": entry.get("args") or "",
"status": "completed", "status": "completed",
} }
)
if output_items:
response_obj["output"] = output_items
out.append(event_block({"type": "response.completed", "response": response_obj}))
return out
if isinstance(event, ErrorEvent): def _emit_error(
self, event: ErrorEvent, state: StreamState, ss: dict[str, Any]
) -> list[dict[str, Any]]:
err_payload = self.error_from_internal(event.error) err_payload = self.error_from_internal(event.error)
err_payload["type"] = "response.failed" err_payload["type"] = "response.failed"
out.append(event_block(err_payload)) return [err_payload]
return out
# 其他事件Responses SSE 无直接对应,跳过
return out
# ========================= # =========================
# Error conversion # Error conversion
@@ -851,24 +871,40 @@ class OpenAICliNormalizer(FormatNormalizer):
for item in input_data: for item in input_data:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
msg = self._parse_input_item(item)
if msg is not None:
messages.append(msg)
return messages
def _parse_input_item(self, item: dict[str, Any]) -> InternalMessage | None:
item_type = str(item.get("type") or "") item_type = str(item.get("type") or "")
# 标准 message有 role 字段) # 标准 message有 role 字段)
if item_type == "message" or item.get("role"): if item_type == "message" or item.get("role"):
return self._parse_message_item(item)
if item_type == "function_call":
return self._parse_function_call_item(item)
if item_type == "function_call_output":
return self._parse_function_call_output_item(item)
if item_type == "reasoning":
return self._parse_reasoning_item(item)
# 其他未知类型 -> 保留为 UnknownBlock
return InternalMessage(
role=Role.UNKNOWN,
content=[UnknownBlock(raw_type=item_type or "unknown", payload=item)],
)
def _parse_message_item(self, item: dict[str, Any]) -> InternalMessage:
role = self._role_from_value(item.get("role")) role = self._role_from_value(item.get("role"))
blocks = self._responses_content_to_blocks(item.get("content")) blocks = self._responses_content_to_blocks(item.get("content"))
messages.append( return InternalMessage(
InternalMessage(
role=role, role=role,
content=blocks, content=blocks,
extra=self._extract_extra(item, {"type", "role", "content"}), extra=self._extract_extra(item, {"type", "role", "content"}),
) )
)
continue
# function_call -> assistant 消息 + ToolUseBlock def _parse_function_call_item(self, item: dict[str, Any]) -> InternalMessage:
if item_type == "function_call":
tool_id = str(item.get("call_id") or item.get("id") or "") tool_id = str(item.get("call_id") or item.get("id") or "")
tool_name = str(item.get("name") or "") tool_name = str(item.get("name") or "")
args_raw = item.get("arguments") or "{}" args_raw = item.get("arguments") or "{}"
@@ -890,28 +926,21 @@ class OpenAICliNormalizer(FormatNormalizer):
) )
}, },
) )
messages.append(InternalMessage(role=Role.ASSISTANT, content=[tool_block])) return InternalMessage(role=Role.ASSISTANT, content=[tool_block])
continue
# function_call_output -> tool 消息 + ToolResultBlock def _parse_function_call_output_item(self, item: dict[str, Any]) -> InternalMessage:
if item_type == "function_call_output":
tool_use_id = str(item.get("call_id") or item.get("id") or "") tool_use_id = str(item.get("call_id") or item.get("id") or "")
output = item.get("output") output = item.get("output")
# output 可能是字符串或结构化数据
content_text = output if isinstance(output, str) else None content_text = output if isinstance(output, str) else None
result_block = ToolResultBlock( result_block = ToolResultBlock(
tool_use_id=tool_use_id, tool_use_id=tool_use_id,
output=output, output=output,
content_text=content_text, content_text=content_text,
extra={ extra={"openai_cli": self._extract_extra(item, {"type", "call_id", "id", "output"})},
"openai_cli": self._extract_extra(item, {"type", "call_id", "id", "output"})
},
) )
messages.append(InternalMessage(role=Role.TOOL, content=[result_block])) return InternalMessage(role=Role.TOOL, content=[result_block])
continue
# reasoning -> assistant 消息,提取 summary 作为文本 def _parse_reasoning_item(self, item: dict[str, Any]) -> InternalMessage:
if item_type == "reasoning":
summary_parts: list[str] = [] summary_parts: list[str] = []
summary = item.get("summary") summary = item.get("summary")
if isinstance(summary, list): if isinstance(summary, list):
@@ -925,10 +954,8 @@ class OpenAICliNormalizer(FormatNormalizer):
elif isinstance(summary, str) and summary: elif isinstance(summary, str) and summary:
summary_parts.append(summary) summary_parts.append(summary)
# 如果有 summary 文本,创建一个 UnknownBlock 保留原始结构
reasoning_blocks: list[ContentBlock] = [] reasoning_blocks: list[ContentBlock] = []
if summary_parts: if summary_parts:
# 保留 reasoning 的 summary 作为 UnknownBlock便于输出时决策
reasoning_blocks.append( reasoning_blocks.append(
UnknownBlock( UnknownBlock(
raw_type="reasoning", raw_type="reasoning",
@@ -937,24 +964,11 @@ class OpenAICliNormalizer(FormatNormalizer):
) )
else: else:
reasoning_blocks.append(UnknownBlock(raw_type="reasoning", payload=item)) reasoning_blocks.append(UnknownBlock(raw_type="reasoning", payload=item))
messages.append( return InternalMessage(
InternalMessage(
role=Role.ASSISTANT, role=Role.ASSISTANT,
content=reasoning_blocks, content=reasoning_blocks,
extra={"openai_cli": {"type": "reasoning"}}, extra={"openai_cli": {"type": "reasoning"}},
) )
)
continue
# 其他未知类型 -> 保留为 UnknownBlock
messages.append(
InternalMessage(
role=Role.UNKNOWN,
content=[UnknownBlock(raw_type=item_type or "unknown", payload=item)],
)
)
return messages
def _responses_content_to_blocks(self, content: Any) -> list[ContentBlock]: def _responses_content_to_blocks(self, content: Any) -> list[ContentBlock]:
if content is None: if content is None:

View File

@@ -396,6 +396,11 @@ class MaintenanceScheduler:
# 检查配置开关 # 检查配置开关
if not SystemConfigService.get_config(db, "enable_oauth_token_refresh", True): if not SystemConfigService.get_config(db, "enable_oauth_token_refresh", True):
logger.info("OAuth Token 自动刷新已禁用,不调度任务") logger.info("OAuth Token 自动刷新已禁用,不调度任务")
# 移除已调度的 job如果存在
try:
scheduler.remove_job(job_id)
except Exception:
pass
return return
# 查找所有活跃的 OAuth 类型 Key # 查找所有活跃的 OAuth 类型 Key
oauth_keys = ( oauth_keys = (
@@ -966,9 +971,7 @@ class MaintenanceScheduler:
days_since_reset = (now_local.date() - last_local_date).days days_since_reset = (now_local.date() - last_local_date).days
if days_since_reset < 0: if days_since_reset < 0:
logger.warning( logger.warning("user_quota_last_reset_at 在未来,跳过本次用户配额自动重置")
"user_quota_last_reset_at 在未来,跳过本次用户配额自动重置"
)
should_run = False should_run = False
elif days_since_reset < interval_days: elif days_since_reset < interval_days:
logger.info( logger.info(
@@ -1006,7 +1009,9 @@ class MaintenanceScheduler:
"用户配额自动重置的上次执行时间UTC内部使用", "用户配额自动重置的上次执行时间UTC内部使用",
) )
logger.info(f"用户配额自动重置完成: interval_days={interval_days}, 重置用户数={reset_count}") logger.info(
f"用户配额自动重置完成: interval_days={interval_days}, 重置用户数={reset_count}"
)
except Exception as e: except Exception as e:
logger.exception(f"用户配额自动重置任务执行失败: {e}") logger.exception(f"用户配额自动重置任务执行失败: {e}")

View File

@@ -17,6 +17,7 @@ from src.core.api_format.conversion.normalizers.gemini_cli import GeminiCliNorma
from src.core.api_format.conversion.normalizers.openai import OpenAINormalizer from src.core.api_format.conversion.normalizers.openai import OpenAINormalizer
from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer
from src.core.api_format.conversion.registry import FormatConversionRegistry from src.core.api_format.conversion.registry import FormatConversionRegistry
from src.core.api_format.conversion.stream_events import UnknownStreamEvent
from src.core.api_format.conversion.stream_state import StreamState from src.core.api_format.conversion.stream_state import StreamState
@@ -309,6 +310,36 @@ def test_stream_openai_cli_in_progress_event() -> None:
assert events2 == [] assert events2 == []
def test_stream_openai_cli_noop_and_unknown_events() -> None:
"""覆盖 OpenAI CLI noop/unknown 事件处理器"""
normalizer = OpenAICliNormalizer()
state = StreamState()
# 先触发 message_start 初始化
normalizer.stream_chunk_to_internal(
{"type": "response.created", "response": {"id": "resp_789", "model": "gpt-5"}}, state
)
# noop 事件不应产生内部事件
assert (
normalizer.stream_chunk_to_internal({"type": "response.function_call_arguments.done"}, state)
== []
)
assert (
normalizer.stream_chunk_to_internal({"type": "response.content_part.added"}, state) == []
)
assert (
normalizer.stream_chunk_to_internal({"type": "response.content_part.done"}, state) == []
)
# unknown 事件应返回 UnknownStreamEvent
events = normalizer.stream_chunk_to_internal(
{"type": "response.reasoning_summary_text.delta"}, state
)
assert len(events) == 1
assert isinstance(events[0], UnknownStreamEvent)
def test_stream_openai_cli_function_call_events() -> None: def test_stream_openai_cli_function_call_events() -> None:
"""测试 OpenAI CLI 流式 function_call 相关事件""" """测试 OpenAI CLI 流式 function_call 相关事件"""
reg = _make_registry_with_cli() reg = _make_registry_with_cli()