From c69a0a850607ec91100faa67b853b45ba8d12268 Mon Sep 17 00:00:00 2001 From: fawney19 Date: Fri, 19 Dec 2025 13:06:36 +0800 Subject: [PATCH] refactor: remove stream smoothing config from system settings and improve base image caching - Remove stream_smoothing configuration from SystemConfigService (moved to handler default) - Remove stream smoothing UI controls from admin settings page - Add AdminClearSingleAffinityAdapter for targeted cache invalidation - Add clearSingleAffinity API endpoint to clear specific affinity cache entries - Include global_model_id in affinity list response for UI deletion support - Improve CI/CD workflow with hash-based base image change detection - Add hash label to base image for reliable cache invalidation detection - Use remote image inspection to determine if base image rebuild is needed - Include Dockerfile.base in hash calculation for proper dependency tracking --- .github/workflows/docker-publish.yml | 37 +++++++- frontend/src/api/cache.ts | 13 +++ frontend/src/views/admin/CacheMonitoring.vue | 27 +++--- frontend/src/views/admin/SystemSettings.vue | 99 -------------------- src/api/admin/monitoring/cache.py | 84 +++++++++++++++++ src/config/settings.py | 1 - src/services/system/config.py | 13 --- 7 files changed, 147 insertions(+), 127 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 11cc5b2..2997809 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -15,6 +15,8 @@ env: REGISTRY: ghcr.io BASE_IMAGE_NAME: fawney19/aether-base APP_IMAGE_NAME: fawney19/aether + # Files that affect base image - used for hash calculation + BASE_FILES: "Dockerfile.base pyproject.toml frontend/package.json frontend/package-lock.json" jobs: check-base-changes: @@ -23,8 +25,13 @@ jobs: base_changed: ${{ steps.check.outputs.base_changed }} steps: - uses: actions/checkout@v4 + + - name: Log in to Container Registry + uses: docker/login-action@v3 with: - fetch-depth: 2 + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Check if base image needs rebuild id: check @@ -34,10 +41,26 @@ jobs: exit 0 fi - # Check if base-related files changed - if git diff --name-only HEAD~1 HEAD | grep -qE '^(Dockerfile\.base|pyproject\.toml|frontend/package.*\.json)$'; then + # Calculate current hash of base-related files + CURRENT_HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1) + echo "Current base files hash: $CURRENT_HASH" + + # Try to get hash label from remote image config + # Pull the image config and extract labels + REMOTE_HASH="" + if docker pull ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest 2>/dev/null; then + REMOTE_HASH=$(docker inspect ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest --format '{{ index .Config.Labels "org.opencontainers.image.base.hash" }}' 2>/dev/null) || true + fi + + if [ -z "$REMOTE_HASH" ] || [ "$REMOTE_HASH" == "" ]; then + # No remote image or no hash label, need to rebuild + echo "No remote base image or hash label found, need rebuild" + echo "base_changed=true" >> $GITHUB_OUTPUT + elif [ "$CURRENT_HASH" != "$REMOTE_HASH" ]; then + echo "Hash mismatch: remote=$REMOTE_HASH, current=$CURRENT_HASH" echo "base_changed=true" >> $GITHUB_OUTPUT else + echo "Hash matches, no rebuild needed" echo "base_changed=false" >> $GITHUB_OUTPUT fi @@ -61,6 +84,12 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Calculate base files hash + id: hash + run: | + HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1) + echo "hash=$HASH" >> $GITHUB_OUTPUT + - name: Extract metadata for base image id: meta uses: docker/metadata-action@v5 @@ -69,6 +98,8 @@ jobs: tags: | type=raw,value=latest type=sha,prefix= + labels: | + org.opencontainers.image.base.hash=${{ steps.hash.outputs.hash }} - name: Build and push base image uses: docker/build-push-action@v5 diff --git a/frontend/src/api/cache.ts b/frontend/src/api/cache.ts index 9991457..1caa202 100644 --- a/frontend/src/api/cache.ts +++ b/frontend/src/api/cache.ts @@ -66,6 +66,7 @@ export interface UserAffinity { key_name: string | null key_prefix: string | null // Provider Key 脱敏显示(前4...后4) rate_multiplier: number + global_model_id: string | null // 原始的 global_model_id(用于删除) model_name: string | null // 模型名称(如 claude-haiku-4-5-20250514) model_display_name: string | null // 模型显示名称(如 Claude Haiku 4.5) api_format: string | null // API 格式 (claude/openai) @@ -119,6 +120,18 @@ export const cacheApi = { await api.delete(`/api/admin/monitoring/cache/users/${userIdentifier}`) }, + /** + * 清除单条缓存亲和性 + * + * @param affinityKey API Key ID + * @param endpointId Endpoint ID + * @param modelId GlobalModel ID + * @param apiFormat API 格式 (claude/openai) + */ + async clearSingleAffinity(affinityKey: string, endpointId: string, modelId: string, apiFormat: string): Promise { + await api.delete(`/api/admin/monitoring/cache/affinity/${affinityKey}/${endpointId}/${modelId}/${apiFormat}`) + }, + /** * 清除所有缓存 */ diff --git a/frontend/src/views/admin/CacheMonitoring.vue b/frontend/src/views/admin/CacheMonitoring.vue index 2cfc43d..4531963 100644 --- a/frontend/src/views/admin/CacheMonitoring.vue +++ b/frontend/src/views/admin/CacheMonitoring.vue @@ -142,32 +142,37 @@ async function resetAffinitySearch() { await fetchAffinityList() } -async function clearUserCache(identifier: string, displayName?: string) { - const target = identifier?.trim() - if (!target) { - showError('无法识别标识符') +async function clearSingleAffinity(item: UserAffinity) { + const affinityKey = item.affinity_key?.trim() + const endpointId = item.endpoint_id?.trim() + const modelId = item.global_model_id?.trim() + const apiFormat = item.api_format?.trim() + + if (!affinityKey || !endpointId || !modelId || !apiFormat) { + showError('缓存记录信息不完整,无法删除') return } - const label = displayName || target + const label = item.user_api_key_name || affinityKey + const modelLabel = item.model_display_name || item.model_name || modelId const confirmed = await showConfirm({ title: '确认清除', - message: `确定要清除 ${label} 的缓存吗?`, + message: `确定要清除 ${label} 在模型 ${modelLabel} 上的缓存亲和性吗?`, confirmText: '确认清除', variant: 'destructive' }) if (!confirmed) return - clearingRowAffinityKey.value = target + clearingRowAffinityKey.value = affinityKey try { - await cacheApi.clearUserCache(target) + await cacheApi.clearSingleAffinity(affinityKey, endpointId, modelId, apiFormat) showSuccess('清除成功') await fetchCacheStats() await fetchAffinityList(tableKeyword.value.trim() || undefined) } catch (error) { showError('清除失败') - log.error('清除用户缓存失败', error) + log.error('清除单条缓存失败', error) } finally { clearingRowAffinityKey.value = null } @@ -618,7 +623,7 @@ onBeforeUnmount(() => { class="h-7 w-7 text-muted-foreground/70 hover:text-destructive" :disabled="clearingRowAffinityKey === item.affinity_key" title="清除缓存" - @click="clearUserCache(item.affinity_key, item.user_api_key_name || item.affinity_key)" + @click="clearSingleAffinity(item)" > @@ -668,7 +673,7 @@ onBeforeUnmount(() => { variant="ghost" class="h-7 w-7 text-muted-foreground/70 hover:text-destructive shrink-0" :disabled="clearingRowAffinityKey === item.affinity_key" - @click="clearUserCache(item.affinity_key, item.user_api_key_name || item.affinity_key)" + @click="clearSingleAffinity(item)" > diff --git a/frontend/src/views/admin/SystemSettings.vue b/frontend/src/views/admin/SystemSettings.vue index a63425b..2b222a6 100644 --- a/frontend/src/views/admin/SystemSettings.vue +++ b/frontend/src/views/admin/SystemSettings.vue @@ -465,77 +465,6 @@ - - -
-
-
- -
- -

- 将上游返回的大块内容拆分成小块,模拟打字效果 -

-
-
-
- -
- - -

- 每次输出的字符数量(1-100) -

-
- -
- - -

- 每块之间的延迟毫秒数(1-100) -

-
-
-
@@ -884,10 +813,6 @@ interface SystemConfig { log_retention_days: number cleanup_batch_size: number audit_log_retention_days: number - // 流式输出 - stream_smoothing_enabled: boolean - stream_smoothing_chunk_size: number - stream_smoothing_delay_ms: number } const loading = ref(false) @@ -937,10 +862,6 @@ const systemConfig = ref({ log_retention_days: 365, cleanup_batch_size: 1000, audit_log_retention_days: 30, - // 流式输出 - stream_smoothing_enabled: false, - stream_smoothing_chunk_size: 20, - stream_smoothing_delay_ms: 8, }) // 计算属性:KB 和 字节 之间的转换 @@ -997,10 +918,6 @@ async function loadSystemConfig() { 'log_retention_days', 'cleanup_batch_size', 'audit_log_retention_days', - // 流式输出 - 'stream_smoothing_enabled', - 'stream_smoothing_chunk_size', - 'stream_smoothing_delay_ms', ] for (const key of configs) { @@ -1108,22 +1025,6 @@ async function saveSystemConfig() { value: systemConfig.value.audit_log_retention_days, description: '审计日志保留天数' }, - // 流式输出 - { - key: 'stream_smoothing_enabled', - value: systemConfig.value.stream_smoothing_enabled, - description: '是否启用流式平滑输出' - }, - { - key: 'stream_smoothing_chunk_size', - value: systemConfig.value.stream_smoothing_chunk_size, - description: '流式平滑输出每个小块的字符数' - }, - { - key: 'stream_smoothing_delay_ms', - value: systemConfig.value.stream_smoothing_delay_ms, - description: '流式平滑输出每个小块之间的延迟毫秒数' - }, ] const promises = configItems.map(item => diff --git a/src/api/admin/monitoring/cache.py b/src/api/admin/monitoring/cache.py index 8f61714..05a9896 100644 --- a/src/api/admin/monitoring/cache.py +++ b/src/api/admin/monitoring/cache.py @@ -186,6 +186,30 @@ async def clear_user_cache( return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) +@router.delete("/affinity/{affinity_key}/{endpoint_id}/{model_id}/{api_format}") +async def clear_single_affinity( + affinity_key: str, + endpoint_id: str, + model_id: str, + api_format: str, + request: Request, + db: Session = Depends(get_db), +) -> Any: + """ + Clear a single cache affinity entry + + Parameters: + - affinity_key: API Key ID + - endpoint_id: Endpoint ID + - model_id: Model ID (GlobalModel ID) + - api_format: API format (claude/openai) + """ + adapter = AdminClearSingleAffinityAdapter( + affinity_key=affinity_key, endpoint_id=endpoint_id, model_id=model_id, api_format=api_format + ) + return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) + + @router.delete("") async def clear_all_cache( request: Request, @@ -655,6 +679,7 @@ class AdminListAffinitiesAdapter(AdminApiAdapter): "key_name": key.name if key else None, "key_prefix": provider_key_masked, "rate_multiplier": key.rate_multiplier if key else 1.0, + "global_model_id": affinity.get("model_name"), # 原始的 global_model_id "model_name": ( global_model_map.get(affinity.get("model_name")).name if affinity.get("model_name") and global_model_map.get(affinity.get("model_name")) @@ -817,6 +842,65 @@ class AdminClearUserCacheAdapter(AdminApiAdapter): raise HTTPException(status_code=500, detail=f"清除失败: {exc}") +@dataclass +class AdminClearSingleAffinityAdapter(AdminApiAdapter): + affinity_key: str + endpoint_id: str + model_id: str + api_format: str + + async def handle(self, context: ApiRequestContext) -> Dict[str, Any]: # type: ignore[override] + db = context.db + try: + redis_client = get_redis_client_sync() + affinity_mgr = await get_affinity_manager(redis_client) + + # 直接获取指定的亲和性记录(无需遍历全部) + existing_affinity = await affinity_mgr.get_affinity( + self.affinity_key, self.api_format, self.model_id + ) + + if not existing_affinity: + raise HTTPException(status_code=404, detail="未找到指定的缓存亲和性记录") + + # 验证 endpoint_id 是否匹配 + if existing_affinity.endpoint_id != self.endpoint_id: + raise HTTPException(status_code=404, detail="未找到指定的缓存亲和性记录") + + # 失效单条记录 + await affinity_mgr.invalidate_affinity( + self.affinity_key, self.api_format, self.model_id, endpoint_id=self.endpoint_id + ) + + # 获取用于日志的信息 + api_key = db.query(ApiKey).filter(ApiKey.id == self.affinity_key).first() + api_key_name = api_key.name if api_key else None + + logger.info( + f"已清除单条缓存亲和性: affinity_key={self.affinity_key[:8]}..., " + f"endpoint_id={self.endpoint_id[:8]}..., model_id={self.model_id[:8]}..." + ) + + context.add_audit_metadata( + action="cache_clear_single", + affinity_key=self.affinity_key, + endpoint_id=self.endpoint_id, + model_id=self.model_id, + ) + return { + "status": "ok", + "message": f"已清除缓存亲和性: {api_key_name or self.affinity_key[:8]}", + "affinity_key": self.affinity_key, + "endpoint_id": self.endpoint_id, + "model_id": self.model_id, + } + except HTTPException: + raise + except Exception as exc: + logger.exception(f"清除单条缓存亲和性失败: {exc}") + raise HTTPException(status_code=500, detail=f"清除失败: {exc}") + + class AdminClearAllCacheAdapter(AdminApiAdapter): async def handle(self, context: ApiRequestContext) -> Dict[str, Any]: # type: ignore[override] try: diff --git a/src/config/settings.py b/src/config/settings.py index 13ffa49..ab1b375 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -143,7 +143,6 @@ class Config: # STREAM_STATS_DELAY: 统计记录延迟(秒),等待流完全关闭 self.stream_prefetch_lines = int(os.getenv("STREAM_PREFETCH_LINES", "5")) self.stream_stats_delay = float(os.getenv("STREAM_STATS_DELAY", "0.1")) - # 注:流式平滑输出配置已移至数据库系统设置(stream_smoothing_*) # 验证连接池配置 self._validate_pool_config() diff --git a/src/services/system/config.py b/src/services/system/config.py index cfa9210..e6bb1e6 100644 --- a/src/services/system/config.py +++ b/src/services/system/config.py @@ -78,19 +78,6 @@ class SystemConfigService: "value": False, "description": "是否自动删除过期的API Key(True=物理删除,False=仅禁用),仅管理员可配置", }, - # 流式平滑输出配置 - "stream_smoothing_enabled": { - "value": False, - "description": "是否启用流式平滑输出,自动根据文本长度调整输出速度", - }, - "stream_smoothing_chunk_size": { - "value": 20, - "description": "流式平滑输出每个小块的字符数", - }, - "stream_smoothing_delay_ms": { - "value": 8, - "description": "流式平滑输出每个小块之间的延迟毫秒数", - }, } @classmethod