mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-04 01:40:45 +08:00
chore(release): v6.3.19 - Dense Reranker, CLI Tools & Issue Workflow
## Documentation Updates - Update all version references to v6.3.19 - Add Dense + Reranker search documentation - Add OpenCode AI CLI tool integration docs - Add Issue workflow (plan → queue → execute) with Codex recommendation - Update CHANGELOG with complete v6.3.19 release notes ## Features - Cross-Encoder reranking for improved search relevance - OpenCode CLI tool support - Issue multi-queue parallel execution - Service architecture improvements (cache-manager, preload-service) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,8 @@ Minimal orchestrator that dispatches **solution IDs** to executors. Each executo
|
||||
**Executor & Dry-run**: Selected via interactive prompt (AskUserQuestion)
|
||||
**Worktree**: Creates isolated git worktrees for each parallel executor
|
||||
|
||||
**⭐ Recommended Executor**: **Codex** - Best for long-running autonomous work (2hr timeout), supports background execution and full write access
|
||||
|
||||
**Worktree Options**:
|
||||
- `--worktree` - Create a new worktree with timestamp-based name
|
||||
- `--worktree <existing-path>` - Resume in an existing worktree (for recovery/continuation)
|
||||
|
||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -5,6 +5,67 @@ All notable changes to Claude Code Workflow (CCW) will be documented in this fil
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [6.3.19] - 2026-01-12
|
||||
|
||||
### 🚀 Major New Features | 主要新功能
|
||||
|
||||
#### SPLADE & Dense Reranker Search System | SPLADE 与密集重排序搜索系统
|
||||
- **Added**: SPLADE sparse encoder implementation for precise semantic search (currently hidden, dense mode primary)
|
||||
- **Added**: Cross-Encoder reranking with FastEmbed integration for improved result relevance
|
||||
- **Added**: Unified reranker architecture with file watcher support
|
||||
- **Added**: Centralized vector storage and metadata management for embeddings
|
||||
- **Added**: Dynamic batch size calculation for embedding generation
|
||||
- **Added**: Multiple embedding backends for cascade retrieval
|
||||
|
||||
#### CLI Tools System Overhaul | CLI 工具系统全面升级
|
||||
- **Added**: OpenCode AI assistant support with full CLI integration
|
||||
- **Added**: CLI Wrapper endpoints management with Dashboard UI
|
||||
- **Added**: Smart Content Formatter for intelligent output processing
|
||||
- **Added**: Structured Intermediate Representation (IR) for CLI output
|
||||
- **Added**: High-availability model pool with path resolution
|
||||
- **Added**: Custom API header support and tool type descriptions
|
||||
|
||||
#### Service Architecture | 服务架构
|
||||
- **Added**: Core service modules: cache-manager, event-manager, preload-service
|
||||
- **Added**: CLI state caching with preload optimization
|
||||
- **Added**: UV package manager support for optimized installation
|
||||
- **Added**: ccw-litellm installation improvements with venv prioritization
|
||||
|
||||
#### Issue Management | Issue 管理
|
||||
- **Added**: Multi-queue parallel execution support
|
||||
- **Added**: Worktree auto-detection with user choice (merge/PR/keep)
|
||||
- **Added**: Enhanced worktree management with recovery support
|
||||
|
||||
### 🎨 Dashboard & UI Improvements | Dashboard 与 UI 改进
|
||||
|
||||
- **Added**: Workspace index status interface with real-time monitoring
|
||||
- **Added**: Watcher status handling and control modal
|
||||
- **Added**: CLI stream viewer with active execution synchronization
|
||||
- **Added**: Danger protection hooks with i18n confirmation dialogs
|
||||
- **Added**: Navigation status routes with badge aggregation
|
||||
|
||||
### 🛠️ Skills & Templates | 技能与模板
|
||||
|
||||
- **Added**: CCW orchestrator skill for workflow automation
|
||||
- **Added**: Code analysis and LLM action templates
|
||||
- **Added**: Autonomous actions and sequential phase templates
|
||||
- **Added**: Swagger docs command for RESTful API documentation
|
||||
- **Added**: Debug explore agent with 5-phase workflow and NDJSON logging
|
||||
|
||||
### 🔒 Security & Quality | 安全与质量
|
||||
|
||||
- **Fixed**: Command injection prevention with strengthened input validation
|
||||
- **Fixed**: Path validation for CLI executor --cd parameter
|
||||
- **Added**: E2E tests for MCP tool execution and session lifecycle
|
||||
- **Added**: Integration tests for CodexLens UV installation
|
||||
|
||||
### 🌐 Internationalization | 国际化
|
||||
|
||||
- **Added**: Index management, incremental update translations
|
||||
- **Added**: Environment variables and dynamic batch size i18n support
|
||||
|
||||
---
|
||||
|
||||
## [6.3.11] - 2025-12-28
|
||||
|
||||
### 🔧 Issue System Enhancements | Issue系统增强
|
||||
|
||||
20
README.md
20
README.md
@@ -5,7 +5,7 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/catlog22/Claude-Code-Workflow/releases)
|
||||
[](https://github.com/catlog22/Claude-Code-Workflow/releases)
|
||||
[](https://www.npmjs.com/package/claude-code-workflow)
|
||||
[](LICENSE)
|
||||
[]()
|
||||
@@ -19,18 +19,16 @@
|
||||
|
||||
**Claude Code Workflow (CCW)** is a JSON-driven multi-agent development framework with intelligent CLI orchestration (Gemini/Qwen/Codex), context-first architecture, and automated workflow execution. It transforms AI development from simple prompt chaining into a powerful orchestration system.
|
||||
|
||||
> **🎉 Version 6.3.18: Native CodexLens & Dashboard Revolution**
|
||||
> **🎉 Version 6.3.19: Search Enhancement & CLI Tools Upgrade**
|
||||
>
|
||||
> **Breaking Changes**:
|
||||
> - ⚠️ CLI command refactored: `ccw cli exec` → `ccw cli -p`
|
||||
> - ⚠️ Code Index MCP replaced with native CodexLens
|
||||
> - ⚠️ Knowledge Graph replaced with Session Clustering
|
||||
> **New Features**:
|
||||
> - 🔍 **Dense + Reranker Search**: Cross-Encoder reranking for improved result relevance
|
||||
> - 💻 **OpenCode AI Support**: New OpenCode CLI tool integration
|
||||
> - 🛠️ **Service Architecture**: Preload service, cache management, UV package manager support
|
||||
> - 📊 **Issue Multi-Queue Execution**: Supports Codex for long-running autonomous work
|
||||
>
|
||||
> **Core Features**:
|
||||
> - 🔍 **Native CodexLens**: Full-Text Search + Semantic Search + HNSW vector index
|
||||
> - 🖥️ **New Dashboard Views**: CLAUDE.md Manager, Skills Manager, Graph Explorer, Core Memory
|
||||
> - 📘 **TypeScript Migration**: Full backend modernization
|
||||
> - 🧠 **Session Clustering**: Intelligent memory management with cluster visualization
|
||||
> **Recommended Workflow**:
|
||||
> - 🚀 **Issue Workflow** (`/issue:plan` → `/issue:queue` → `/issue:execute`): Recommend **Codex** executor for long-running autonomous coding
|
||||
>
|
||||
> See [CHANGELOG.md](CHANGELOG.md) for complete details and migration guide.
|
||||
|
||||
|
||||
20
README_CN.md
20
README_CN.md
@@ -5,7 +5,7 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/catlog22/Claude-Code-Workflow/releases)
|
||||
[](https://github.com/catlog22/Claude-Code-Workflow/releases)
|
||||
[](https://www.npmjs.com/package/claude-code-workflow)
|
||||
[](LICENSE)
|
||||
[]()
|
||||
@@ -18,18 +18,16 @@
|
||||
|
||||
**Claude Code Workflow (CCW)** 是一个 JSON 驱动的多智能体开发框架,具有智能 CLI 编排(Gemini/Qwen/Codex)、上下文优先架构和自动化工作流执行。它将 AI 开发从简单的提示词链接转变为一个强大的编排系统。
|
||||
|
||||
> **🎉 版本 6.3.18: 原生 CodexLens 与 Dashboard 革新**
|
||||
> **🎉 版本 6.3.19: 搜索增强与 CLI 工具升级**
|
||||
>
|
||||
> **破坏性变更**:
|
||||
> - ⚠️ CLI 命令重构: `ccw cli exec` → `ccw cli -p`
|
||||
> - ⚠️ Code Index MCP 替换为原生 CodexLens
|
||||
> - ⚠️ 知识图谱替换为会话聚类系统
|
||||
> **新功能亮点**:
|
||||
> - 🔍 **Dense + Reranker 搜索**: Cross-Encoder 重排序提升结果相关性
|
||||
> - 💻 **OpenCode AI 支持**: 新增 OpenCode CLI 工具集成
|
||||
> - 🛠️ **服务架构优化**: 预加载服务、缓存管理、UV 包管理器支持
|
||||
> - 📊 **Issue 多队列执行**: 支持 Codex 长时间自主工作
|
||||
>
|
||||
> **核心功能**:
|
||||
> - 🔍 **原生 CodexLens**: 全文搜索 + 语义搜索 + HNSW 向量索引
|
||||
> - 🖥️ **新 Dashboard 视图**: CLAUDE.md 管理器、技能管理器、图浏览器、核心记忆
|
||||
> - 📘 **TypeScript 迁移**: 后端全面现代化
|
||||
> - 🧠 **会话聚类**: 智能记忆管理与聚类可视化
|
||||
> **推荐工作流**:
|
||||
> - 🚀 **Issue 工作流** (`/issue:plan` → `/issue:queue` → `/issue:execute`): 推荐使用 **Codex** 执行器,支持长时间自主编码工作
|
||||
>
|
||||
> 详见 [CHANGELOG.md](CHANGELOG.md) 获取完整详情和迁移指南。
|
||||
|
||||
|
||||
@@ -778,6 +778,46 @@ Phase 1: Gemini 分析 ──┐
|
||||
|
||||
## 💡 专家建议
|
||||
|
||||
### 🔧 Issue 批量执行工作流 (v6.3.19 新增)
|
||||
|
||||
**适用场景**: 多个相关 Issue 需要批量规划和执行,支持长时间自主工作
|
||||
|
||||
| 阶段 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| **规划** | `/issue:plan` | 为 Issue 生成解决方案和任务分解 |
|
||||
| **排队** | `/issue:queue` | 将解决方案形成执行队列,分析依赖关系 |
|
||||
| **执行** | `/issue:execute` | DAG 驱动的并行执行,每个解决方案一次提交 |
|
||||
|
||||
**执行器选择**:
|
||||
| 执行器 | 推荐场景 | 超时 |
|
||||
|--------|----------|------|
|
||||
| **Codex (推荐)** | 长时间自主编码,复杂多任务解决方案 | 2小时 |
|
||||
| Gemini | 需要大上下文分析的实现 | 1小时 |
|
||||
| Agent | Claude Code 子代理执行复杂任务 | 同步 |
|
||||
|
||||
**为什么推荐 Codex**:
|
||||
- ✅ **长时间自主工作**: 2小时超时,适合复杂解决方案
|
||||
- ✅ **完整写权限**: 自主创建、修改、删除文件
|
||||
- ✅ **后台执行**: 支持 `run_in_background: true`,不阻塞其他工作
|
||||
- ✅ **工作树隔离**: 配合 `--worktree` 实现真正并行执行
|
||||
|
||||
**示例工作流**:
|
||||
```bash
|
||||
# 1. 规划多个 Issue
|
||||
/issue:plan ISS-001 ISS-002 ISS-003
|
||||
|
||||
# 2. 形成执行队列
|
||||
/issue:queue
|
||||
|
||||
# 3. 使用 Codex 执行(推荐长时间任务)
|
||||
/issue:execute --worktree
|
||||
# → 选择 Codex 执行器
|
||||
# → 启用工作树隔离
|
||||
# → 并行批次自动执行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ✅ 最佳实践
|
||||
|
||||
1. **不确定时用头脑风暴**:宁可多花10分钟探索方案,也不要盲目实现后推翻重来
|
||||
@@ -786,6 +826,7 @@ Phase 1: Gemini 分析 ──┐
|
||||
4. **小任务用Lite工作流**:快速完成,减少overhead
|
||||
5. **关键模块用TDD**:测试驱动开发保证质量
|
||||
6. **定期更新内存**:`/memory:update-related` 保持上下文准确
|
||||
7. **批量 Issue 用 issue 工作流**:`/issue:plan` → `/issue:queue` → `/issue:execute`,推荐 Codex 执行长时间任务
|
||||
|
||||
### ❌ 常见陷阱
|
||||
|
||||
@@ -808,5 +849,5 @@ Phase 1: Gemini 分析 ──┐
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2025-01-23
|
||||
**版本**: 5.9.2
|
||||
**最后更新**: 2026-01-12
|
||||
**版本**: 6.3.19
|
||||
|
||||
@@ -671,6 +671,46 @@ Use gemini to review code quality
|
||||
|
||||
## 💡 Expert Advice
|
||||
|
||||
### 🔧 Issue Batch Execution Workflow (v6.3.19 New)
|
||||
|
||||
**Use Case**: Multiple related issues requiring batch planning and execution, supports long-running autonomous work
|
||||
|
||||
| Phase | Command | Description |
|
||||
|-------|---------|-------------|
|
||||
| **Plan** | `/issue:plan` | Generate solutions and task breakdown for issues |
|
||||
| **Queue** | `/issue:queue` | Form execution queue, analyze dependencies |
|
||||
| **Execute** | `/issue:execute` | DAG-driven parallel execution, one commit per solution |
|
||||
|
||||
**Executor Selection**:
|
||||
| Executor | Recommended For | Timeout |
|
||||
|----------|-----------------|---------|
|
||||
| **Codex (Recommended)** | Long-running autonomous coding, complex multi-task solutions | 2 hours |
|
||||
| Gemini | Large context analysis and implementation | 1 hour |
|
||||
| Agent | Claude Code sub-agent for complex tasks | Sync |
|
||||
|
||||
**Why Codex is Recommended**:
|
||||
- ✅ **Long-running autonomous work**: 2-hour timeout, suitable for complex solutions
|
||||
- ✅ **Full write access**: Autonomously create, modify, delete files
|
||||
- ✅ **Background execution**: Supports `run_in_background: true`, non-blocking
|
||||
- ✅ **Worktree isolation**: Combined with `--worktree` for true parallel execution
|
||||
|
||||
**Example Workflow**:
|
||||
```bash
|
||||
# 1. Plan multiple issues
|
||||
/issue:plan ISS-001 ISS-002 ISS-003
|
||||
|
||||
# 2. Form execution queue
|
||||
/issue:queue
|
||||
|
||||
# 3. Execute with Codex (recommended for long tasks)
|
||||
/issue:execute --worktree
|
||||
# → Select Codex executor
|
||||
# → Enable worktree isolation
|
||||
# → Parallel batches auto-execute
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ✅ Best Practices
|
||||
|
||||
1. **Use brainstorming when uncertain**: Better to spend 10 minutes exploring solutions than blindly implementing and rewriting
|
||||
@@ -678,6 +718,7 @@ Use gemini to review code quality
|
||||
3. **Use Lite workflow for small tasks**: Complete quickly, reduce overhead
|
||||
4. **Use TDD for critical modules**: Test-driven development ensures quality
|
||||
5. **Regularly update memory**: `/memory:update-related` keeps context accurate
|
||||
6. **Use issue workflow for batch issues**: `/issue:plan` → `/issue:queue` → `/issue:execute`, recommend Codex for long-running tasks
|
||||
|
||||
### ❌ Common Pitfalls
|
||||
|
||||
@@ -699,5 +740,5 @@ Use gemini to review code quality
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-11-20
|
||||
**Version**: 5.8.1
|
||||
**Last Updated**: 2026-01-12
|
||||
**Version**: 6.3.19
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCW - Claude Code Workflow CLI
|
||||
|
||||
[](https://github.com/catlog22/Claude-Code-Workflow/releases)
|
||||
[](https://github.com/catlog22/Claude-Code-Workflow/releases)
|
||||
|
||||
A powerful command-line tool for managing Claude Code Workflow with native CodexLens code intelligence, multi-model CLI orchestration, and interactive dashboard.
|
||||
|
||||
@@ -36,17 +36,23 @@ ccw view -o report.html
|
||||
|
||||
## Features
|
||||
|
||||
### 🔍 Native CodexLens (v6.2)
|
||||
### 🔍 Native CodexLens (v6.3)
|
||||
- **Full-Text Search (FTS)**: SQLite-based fast keyword search with symbol extraction
|
||||
- **Semantic Search**: Embedding-based similarity search with vector store
|
||||
- **Semantic Search**: Dense embedding-based similarity search with vector store
|
||||
- **Hybrid Search**: RRF (Reciprocal Rank Fusion) combining FTS and semantic results
|
||||
- **Cross-Encoder Reranking**: Second-stage reranker for improved result relevance
|
||||
- **HNSW Index**: Approximate Nearest Neighbor index for significantly faster vector search
|
||||
- **Dynamic Batch Processing**: Intelligent batch size calculation for embedding generation
|
||||
- **Workspace Index Status**: Real-time index status monitoring and management
|
||||
|
||||
### 💻 CLI Tools Integration
|
||||
- **Multi-Model Support**: Execute prompts with Gemini, Qwen, or Codex
|
||||
- **Multi-Model Support**: Execute prompts with Gemini, Qwen, Codex, Claude, or OpenCode
|
||||
- **CLI Wrapper Endpoints**: Custom API endpoints with tool calling support
|
||||
- **Smart Content Formatter**: Intelligent output formatting with structured IR
|
||||
- **Session Resume**: Resume from last session or merge multiple sessions
|
||||
- **SQLite History**: Persistent execution history with conversation tracking
|
||||
- **Custom IDs**: Support for custom execution IDs and multi-turn conversations
|
||||
- **Preload Service**: Optimized data fetching with caching for faster responses
|
||||
|
||||
### 🧠 Core Memory & Clustering
|
||||
- **Session Clustering**: Intelligent grouping of related sessions
|
||||
|
||||
@@ -545,18 +545,34 @@ export function getEmbeddingProvidersForRotation(baseDir: string): Array<{
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate rotation endpoints for ccw_litellm
|
||||
* Creates endpoint list from rotation config for parallel embedding
|
||||
* Supports both legacy codexlensEmbeddingRotation and new embeddingPoolConfig
|
||||
* Extended rotation endpoint with routing and health check info
|
||||
*/
|
||||
export function generateRotationEndpoints(baseDir: string): Array<{
|
||||
export interface RotationEndpointConfig {
|
||||
name: string;
|
||||
api_key: string;
|
||||
api_base: string;
|
||||
model: string;
|
||||
weight: number;
|
||||
max_concurrent: number;
|
||||
}> {
|
||||
/** Routing strategy for load balancing */
|
||||
routing_strategy?: string;
|
||||
/** Health check configuration */
|
||||
health_check?: {
|
||||
enabled: boolean;
|
||||
interval_seconds: number;
|
||||
cooldown_seconds: number;
|
||||
failure_threshold: number;
|
||||
};
|
||||
/** Last recorded latency in milliseconds */
|
||||
last_latency_ms?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate rotation endpoints for ccw_litellm
|
||||
* Creates endpoint list from rotation config for parallel embedding
|
||||
* Supports both legacy codexlensEmbeddingRotation and new embeddingPoolConfig
|
||||
*/
|
||||
export function generateRotationEndpoints(baseDir: string): RotationEndpointConfig[] {
|
||||
const config = loadLiteLLMApiConfig(baseDir);
|
||||
|
||||
// Prefer embeddingPoolConfig, fallback to codexlensEmbeddingRotation for backward compatibility
|
||||
@@ -583,22 +599,8 @@ function generateEndpointsFromPool(
|
||||
baseDir: string,
|
||||
poolConfig: EmbeddingPoolConfig,
|
||||
config: LiteLLMApiConfig
|
||||
): Array<{
|
||||
name: string;
|
||||
api_key: string;
|
||||
api_base: string;
|
||||
model: string;
|
||||
weight: number;
|
||||
max_concurrent: number;
|
||||
}> {
|
||||
const endpoints: Array<{
|
||||
name: string;
|
||||
api_key: string;
|
||||
api_base: string;
|
||||
model: string;
|
||||
weight: number;
|
||||
max_concurrent: number;
|
||||
}> = [];
|
||||
): RotationEndpointConfig[] {
|
||||
const endpoints: RotationEndpointConfig[] = [];
|
||||
|
||||
if (poolConfig.autoDiscover) {
|
||||
// Auto-discover all providers offering targetModel
|
||||
@@ -626,10 +628,20 @@ function generateEndpointsFromPool(
|
||||
let keysToUse: Array<{ id: string; key: string; label: string }> = [];
|
||||
|
||||
if (provider.apiKeys && provider.apiKeys.length > 0) {
|
||||
// Use all enabled keys
|
||||
keysToUse = provider.apiKeys
|
||||
.filter(k => k.enabled)
|
||||
.map(k => ({ id: k.id, key: k.key, label: k.label || k.id }));
|
||||
// Use all enabled and healthy keys (filter out unhealthy)
|
||||
const healthyKeys = provider.apiKeys.filter(k =>
|
||||
k.enabled && k.healthStatus !== 'unhealthy'
|
||||
);
|
||||
|
||||
// Log filtered keys for debugging
|
||||
const unhealthyCount = provider.apiKeys.filter(k =>
|
||||
k.enabled && k.healthStatus === 'unhealthy'
|
||||
).length;
|
||||
if (unhealthyCount > 0) {
|
||||
console.log(`[RotationEndpoints] Filtered ${unhealthyCount} unhealthy key(s) from provider ${provider.name}`);
|
||||
}
|
||||
|
||||
keysToUse = healthyKeys.map(k => ({ id: k.id, key: k.key, label: k.label || k.id }));
|
||||
} else if (provider.apiKey) {
|
||||
// Single key fallback
|
||||
keysToUse = [{ id: 'default', key: provider.apiKey, label: 'Default' }];
|
||||
@@ -637,14 +649,37 @@ function generateEndpointsFromPool(
|
||||
|
||||
// Create endpoint for each key
|
||||
for (const keyInfo of keysToUse) {
|
||||
endpoints.push({
|
||||
const endpoint: RotationEndpointConfig = {
|
||||
name: `${provider.name}-${keyInfo.label}`,
|
||||
api_key: resolveEnvVar(keyInfo.key),
|
||||
api_base: apiBase,
|
||||
model: embeddingModel.name,
|
||||
weight: 1.0, // Default weight for auto-discovered providers
|
||||
max_concurrent: poolConfig.defaultMaxConcurrentPerKey,
|
||||
});
|
||||
};
|
||||
|
||||
// Add routing strategy from provider config
|
||||
if (provider.routingStrategy) {
|
||||
endpoint.routing_strategy = provider.routingStrategy;
|
||||
}
|
||||
|
||||
// Add health check config from provider
|
||||
if (provider.healthCheck) {
|
||||
endpoint.health_check = {
|
||||
enabled: provider.healthCheck.enabled,
|
||||
interval_seconds: provider.healthCheck.intervalSeconds,
|
||||
cooldown_seconds: provider.healthCheck.cooldownSeconds,
|
||||
failure_threshold: provider.healthCheck.failureThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
// Add last latency if available from key entry
|
||||
const keyEntry = provider.apiKeys?.find(k => k.id === keyInfo.id);
|
||||
if (keyEntry && keyEntry.lastLatencyMs !== undefined) {
|
||||
endpoint.last_latency_ms = keyEntry.lastLatencyMs;
|
||||
}
|
||||
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -659,22 +694,8 @@ function generateEndpointsFromLegacyRotation(
|
||||
baseDir: string,
|
||||
rotationConfig: CodexLensEmbeddingRotation,
|
||||
config: LiteLLMApiConfig
|
||||
): Array<{
|
||||
name: string;
|
||||
api_key: string;
|
||||
api_base: string;
|
||||
model: string;
|
||||
weight: number;
|
||||
max_concurrent: number;
|
||||
}> {
|
||||
const endpoints: Array<{
|
||||
name: string;
|
||||
api_key: string;
|
||||
api_base: string;
|
||||
model: string;
|
||||
weight: number;
|
||||
max_concurrent: number;
|
||||
}> = [];
|
||||
): RotationEndpointConfig[] {
|
||||
const endpoints: RotationEndpointConfig[] = [];
|
||||
|
||||
for (const rotationProvider of rotationConfig.providers) {
|
||||
if (!rotationProvider.enabled) continue;
|
||||
@@ -696,15 +717,26 @@ function generateEndpointsFromLegacyRotation(
|
||||
let keysToUse: Array<{ id: string; key: string; label: string }> = [];
|
||||
|
||||
if (provider.apiKeys && provider.apiKeys.length > 0) {
|
||||
// Filter out unhealthy keys first
|
||||
const healthyKeys = provider.apiKeys.filter(k =>
|
||||
k.enabled && k.healthStatus !== 'unhealthy'
|
||||
);
|
||||
|
||||
// Log filtered keys for debugging
|
||||
const unhealthyCount = provider.apiKeys.filter(k =>
|
||||
k.enabled && k.healthStatus === 'unhealthy'
|
||||
).length;
|
||||
if (unhealthyCount > 0) {
|
||||
console.log(`[RotationEndpoints] Filtered ${unhealthyCount} unhealthy key(s) from provider ${provider.name}`);
|
||||
}
|
||||
|
||||
if (rotationProvider.useAllKeys) {
|
||||
// Use all enabled keys
|
||||
keysToUse = provider.apiKeys
|
||||
.filter(k => k.enabled)
|
||||
.map(k => ({ id: k.id, key: k.key, label: k.label || k.id }));
|
||||
// Use all enabled and healthy keys
|
||||
keysToUse = healthyKeys.map(k => ({ id: k.id, key: k.key, label: k.label || k.id }));
|
||||
} else if (rotationProvider.selectedKeyIds && rotationProvider.selectedKeyIds.length > 0) {
|
||||
// Use only selected keys
|
||||
keysToUse = provider.apiKeys
|
||||
.filter(k => k.enabled && rotationProvider.selectedKeyIds!.includes(k.id))
|
||||
// Use only selected healthy keys
|
||||
keysToUse = healthyKeys
|
||||
.filter(k => rotationProvider.selectedKeyIds!.includes(k.id))
|
||||
.map(k => ({ id: k.id, key: k.key, label: k.label || k.id }));
|
||||
}
|
||||
} else if (provider.apiKey) {
|
||||
@@ -714,14 +746,37 @@ function generateEndpointsFromLegacyRotation(
|
||||
|
||||
// Create endpoint for each key
|
||||
for (const keyInfo of keysToUse) {
|
||||
endpoints.push({
|
||||
const endpoint: RotationEndpointConfig = {
|
||||
name: `${provider.name}-${keyInfo.label}`,
|
||||
api_key: resolveEnvVar(keyInfo.key),
|
||||
api_base: apiBase,
|
||||
model: embeddingModel.name,
|
||||
weight: rotationProvider.weight,
|
||||
max_concurrent: rotationProvider.maxConcurrentPerKey,
|
||||
});
|
||||
};
|
||||
|
||||
// Add routing strategy from provider config
|
||||
if (provider.routingStrategy) {
|
||||
endpoint.routing_strategy = provider.routingStrategy;
|
||||
}
|
||||
|
||||
// Add health check config from provider
|
||||
if (provider.healthCheck) {
|
||||
endpoint.health_check = {
|
||||
enabled: provider.healthCheck.enabled,
|
||||
interval_seconds: provider.healthCheck.intervalSeconds,
|
||||
cooldown_seconds: provider.healthCheck.cooldownSeconds,
|
||||
failure_threshold: provider.healthCheck.failureThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
// Add last latency if available from key entry
|
||||
const keyEntry = provider.apiKeys?.find(k => k.id === keyInfo.id);
|
||||
if (keyEntry && keyEntry.lastLatencyMs !== undefined) {
|
||||
endpoint.last_latency_ms = keyEntry.lastLatencyMs;
|
||||
}
|
||||
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1289,4 +1344,4 @@ export function getAvailableModelsForType(
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type { ProviderCredential, CustomEndpoint, ProviderType, CacheStrategy, CodexLensEmbeddingRotation, CodexLensEmbeddingProvider, EmbeddingPoolConfig };
|
||||
export type { ProviderCredential, CustomEndpoint, ProviderType, CacheStrategy, CodexLensEmbeddingRotation, CodexLensEmbeddingProvider, EmbeddingPoolConfig, RotationEndpointConfig };
|
||||
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
} from '../../config/litellm-api-config-manager.js';
|
||||
import { getContextCacheStore } from '../../tools/context-cache-store.js';
|
||||
import { getLiteLLMClient } from '../../tools/litellm-client.js';
|
||||
import { testApiKeyConnection, getDefaultApiBase } from '../services/api-key-tester.js';
|
||||
|
||||
// Cache for ccw-litellm status check
|
||||
let ccwLitellmStatusCache: {
|
||||
@@ -338,6 +339,201 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/litellm-api/providers/:id/test-key - Test specific API key
|
||||
const providerTestKeyMatch = pathname.match(/^\/api\/litellm-api\/providers\/([^/]+)\/test-key$/);
|
||||
if (providerTestKeyMatch && req.method === 'POST') {
|
||||
const providerId = providerTestKeyMatch[1];
|
||||
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const { keyId } = body as { keyId?: string };
|
||||
|
||||
if (!keyId) {
|
||||
return { valid: false, error: 'keyId is required', status: 400 };
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = getProvider(initialPath, providerId);
|
||||
|
||||
if (!provider) {
|
||||
return { valid: false, error: 'Provider not found', status: 404 };
|
||||
}
|
||||
|
||||
// Find the specific API key
|
||||
let apiKeyValue: string | null = null;
|
||||
let keyLabel = 'Default';
|
||||
|
||||
if (keyId === 'default' && provider.apiKey) {
|
||||
// Use the single default apiKey
|
||||
apiKeyValue = provider.apiKey;
|
||||
} else if (provider.apiKeys && provider.apiKeys.length > 0) {
|
||||
const keyEntry = provider.apiKeys.find(k => k.id === keyId);
|
||||
if (keyEntry) {
|
||||
apiKeyValue = keyEntry.key;
|
||||
keyLabel = keyEntry.label || keyEntry.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!apiKeyValue) {
|
||||
return { valid: false, error: 'API key not found' };
|
||||
}
|
||||
|
||||
// Resolve environment variables
|
||||
const { resolveEnvVar } = await import('../../config/litellm-api-config-manager.js');
|
||||
const resolvedKey = resolveEnvVar(apiKeyValue);
|
||||
|
||||
if (!resolvedKey) {
|
||||
return { valid: false, error: 'API key is empty or environment variable not set' };
|
||||
}
|
||||
|
||||
// Determine API base URL
|
||||
const apiBase = provider.apiBase || getDefaultApiBase(provider.type);
|
||||
|
||||
// Test the API key with appropriate endpoint based on provider type
|
||||
const startTime = Date.now();
|
||||
const testResult = await testApiKeyConnection(provider.type, apiBase, resolvedKey);
|
||||
const latencyMs = Date.now() - startTime;
|
||||
|
||||
// Update key health status in provider config
|
||||
if (provider.apiKeys && provider.apiKeys.length > 0) {
|
||||
const keyEntry = provider.apiKeys.find(k => k.id === keyId);
|
||||
if (keyEntry) {
|
||||
keyEntry.healthStatus = testResult.valid ? 'healthy' : 'unhealthy';
|
||||
keyEntry.lastHealthCheck = new Date().toISOString();
|
||||
if (!testResult.valid) {
|
||||
keyEntry.lastError = testResult.error;
|
||||
} else {
|
||||
delete keyEntry.lastError;
|
||||
}
|
||||
|
||||
// Save updated provider
|
||||
try {
|
||||
updateProvider(initialPath, providerId, { apiKeys: provider.apiKeys });
|
||||
} catch (updateErr) {
|
||||
console.warn('[test-key] Failed to update key health status:', updateErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: testResult.valid,
|
||||
error: testResult.error,
|
||||
latencyMs: testResult.valid ? latencyMs : undefined,
|
||||
keyLabel,
|
||||
};
|
||||
} catch (err) {
|
||||
return { valid: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/litellm-api/providers/:id/health-status - Get health status for all keys
|
||||
const providerHealthStatusMatch = pathname.match(/^\/api\/litellm-api\/providers\/([^/]+)\/health-status$/);
|
||||
if (providerHealthStatusMatch && req.method === 'GET') {
|
||||
const providerId = providerHealthStatusMatch[1];
|
||||
|
||||
try {
|
||||
const provider = getProvider(initialPath, providerId);
|
||||
|
||||
if (!provider) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Provider not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Import health check service to get runtime state
|
||||
const { getHealthCheckService } = await import('../services/health-check-service.js');
|
||||
const healthService = getHealthCheckService();
|
||||
const healthStatus = healthService.getProviderHealthStatus(providerId);
|
||||
|
||||
// Merge persisted key data with runtime health status
|
||||
const keys = (provider.apiKeys || []).map(key => {
|
||||
const runtimeStatus = healthStatus.find(s => s.keyId === key.id);
|
||||
return {
|
||||
keyId: key.id,
|
||||
label: key.label || key.id,
|
||||
status: runtimeStatus?.status || key.healthStatus || 'unknown',
|
||||
lastCheck: runtimeStatus?.lastCheck || key.lastHealthCheck,
|
||||
lastLatencyMs: key.lastLatencyMs,
|
||||
consecutiveFailures: runtimeStatus?.consecutiveFailures || 0,
|
||||
inCooldown: runtimeStatus?.inCooldown || false,
|
||||
lastError: runtimeStatus?.lastError || key.lastError,
|
||||
};
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
providerId,
|
||||
providerName: provider.name,
|
||||
keys,
|
||||
}));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/litellm-api/providers/:id/health-check-now - Trigger immediate health check
|
||||
const providerHealthCheckNowMatch = pathname.match(/^\/api\/litellm-api\/providers\/([^/]+)\/health-check-now$/);
|
||||
if (providerHealthCheckNowMatch && req.method === 'POST') {
|
||||
const providerId = providerHealthCheckNowMatch[1];
|
||||
|
||||
try {
|
||||
const provider = getProvider(initialPath, providerId);
|
||||
|
||||
if (!provider) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Provider not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Import health check service and trigger check
|
||||
const { getHealthCheckService } = await import('../services/health-check-service.js');
|
||||
const healthService = getHealthCheckService();
|
||||
|
||||
// Trigger immediate check (async, but we wait for completion)
|
||||
await healthService.checkProviderNow(providerId);
|
||||
|
||||
// Get updated status
|
||||
const healthStatus = healthService.getProviderHealthStatus(providerId);
|
||||
|
||||
// Reload provider to get updated persisted data
|
||||
const updatedProvider = getProvider(initialPath, providerId);
|
||||
const keys = (updatedProvider?.apiKeys || []).map(key => {
|
||||
const runtimeStatus = healthStatus.find(s => s.keyId === key.id);
|
||||
return {
|
||||
keyId: key.id,
|
||||
label: key.label || key.id,
|
||||
status: runtimeStatus?.status || key.healthStatus || 'unknown',
|
||||
lastCheck: runtimeStatus?.lastCheck || key.lastHealthCheck,
|
||||
lastLatencyMs: key.lastLatencyMs,
|
||||
consecutiveFailures: runtimeStatus?.consecutiveFailures || 0,
|
||||
inCooldown: runtimeStatus?.inCooldown || false,
|
||||
lastError: runtimeStatus?.lastError || key.lastError,
|
||||
};
|
||||
});
|
||||
|
||||
broadcastToClients({
|
||||
type: 'PROVIDER_HEALTH_CHECKED',
|
||||
payload: { providerId, keys, timestamp: new Date().toISOString() }
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
providerId,
|
||||
providerName: updatedProvider?.name,
|
||||
keys,
|
||||
checkedAt: new Date().toISOString(),
|
||||
}));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Endpoint Management Routes
|
||||
// ===========================
|
||||
|
||||
@@ -39,6 +39,9 @@ import { csrfValidation } from './auth/csrf-middleware.js';
|
||||
import { getCsrfTokenManager } from './auth/csrf-manager.js';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
// Import health check service
|
||||
import { getHealthCheckService } from './services/health-check-service.js';
|
||||
|
||||
import type { ServerConfig } from '../types/config.js';
|
||||
import type { PostRequestHandler } from './routes/types.js';
|
||||
|
||||
@@ -632,6 +635,15 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
console.log(`Dashboard server running at http://${host}:${serverPort}`);
|
||||
console.log(`WebSocket endpoint available at ws://${host}:${serverPort}/ws`);
|
||||
console.log(`Hook endpoint available at POST http://${host}:${serverPort}/api/hook`);
|
||||
|
||||
// Start health check service for all enabled providers
|
||||
try {
|
||||
const healthCheckService = getHealthCheckService();
|
||||
healthCheckService.startAllHealthChecks(initialPath);
|
||||
} catch (err) {
|
||||
console.warn('[Server] Failed to start health check service:', err);
|
||||
}
|
||||
|
||||
resolve(server);
|
||||
});
|
||||
server.on('error', reject);
|
||||
|
||||
137
ccw/src/core/services/api-key-tester.ts
Normal file
137
ccw/src/core/services/api-key-tester.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* API Key Tester Service
|
||||
* Shared module for testing API key connectivity across different provider types.
|
||||
* Used by both manual testing (litellm-api-routes.ts) and health check service.
|
||||
*/
|
||||
|
||||
import type { ProviderType } from '../../types/litellm-api-config.js';
|
||||
|
||||
/**
|
||||
* Result of an API key connection test
|
||||
*/
|
||||
export interface TestResult {
|
||||
/** Whether the API key is valid */
|
||||
valid: boolean;
|
||||
/** Error message if validation failed */
|
||||
error?: string;
|
||||
/** Latency in milliseconds (only if valid) */
|
||||
latencyMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default API base URL for a provider type
|
||||
*/
|
||||
export function getDefaultApiBase(providerType: ProviderType): string {
|
||||
const defaults: Record<string, string> = {
|
||||
openai: 'https://api.openai.com/v1',
|
||||
anthropic: 'https://api.anthropic.com/v1',
|
||||
custom: 'https://api.openai.com/v1', // Assume OpenAI-compatible by default
|
||||
};
|
||||
return defaults[providerType] || defaults.openai;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API key connection by making a minimal API request
|
||||
* @param providerType - The type of provider (openai, anthropic, custom)
|
||||
* @param apiBase - The base URL for the API
|
||||
* @param apiKey - The API key to test
|
||||
* @param timeout - Timeout in milliseconds (default: 10000)
|
||||
* @returns TestResult indicating if the key is valid
|
||||
*/
|
||||
export async function testApiKeyConnection(
|
||||
providerType: ProviderType,
|
||||
apiBase: string,
|
||||
apiKey: string,
|
||||
timeout: number = 10000
|
||||
): Promise<TestResult> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
if (providerType === 'anthropic') {
|
||||
// Anthropic format: POST /v1/messages with minimal payload
|
||||
const response = await fetch(`${apiBase}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-3-haiku-20240307',
|
||||
max_tokens: 1,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const latencyMs = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, latencyMs };
|
||||
}
|
||||
|
||||
// Parse error response
|
||||
const errorBody = await response.json().catch(() => ({}));
|
||||
const errorMessage = (errorBody as any)?.error?.message || response.statusText;
|
||||
|
||||
// 401 = invalid API key, other 4xx might be valid key with other issues
|
||||
if (response.status === 401) {
|
||||
return { valid: false, error: 'Invalid API key' };
|
||||
}
|
||||
if (response.status === 403) {
|
||||
return { valid: false, error: 'Access denied - check API key permissions' };
|
||||
}
|
||||
if (response.status === 429) {
|
||||
// Rate limited means the key is valid but being throttled
|
||||
return { valid: true, latencyMs };
|
||||
}
|
||||
|
||||
return { valid: false, error: errorMessage };
|
||||
} else {
|
||||
// OpenAI-compatible format: GET /v1/models
|
||||
const modelsUrl = apiBase.endsWith('/v1') ? `${apiBase}/models` : `${apiBase}/v1/models`;
|
||||
const response = await fetch(modelsUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const latencyMs = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, latencyMs };
|
||||
}
|
||||
|
||||
// Parse error response
|
||||
const errorBody = await response.json().catch(() => ({}));
|
||||
const errorMessage = (errorBody as any)?.error?.message || response.statusText;
|
||||
|
||||
if (response.status === 401) {
|
||||
return { valid: false, error: 'Invalid API key' };
|
||||
}
|
||||
if (response.status === 403) {
|
||||
return { valid: false, error: 'Access denied - check API key permissions' };
|
||||
}
|
||||
if (response.status === 429) {
|
||||
// Rate limited means the key is valid but being throttled
|
||||
return { valid: true, latencyMs };
|
||||
}
|
||||
|
||||
return { valid: false, error: errorMessage };
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
return { valid: false, error: 'Connection timed out' };
|
||||
}
|
||||
|
||||
return { valid: false, error: `Connection failed: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
340
ccw/src/core/services/health-check-service.ts
Normal file
340
ccw/src/core/services/health-check-service.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Health Check Service
|
||||
* Singleton service that periodically checks API key health for providers
|
||||
* with health check enabled. Updates key health status and filters unhealthy
|
||||
* keys from rotation endpoints.
|
||||
*/
|
||||
|
||||
import {
|
||||
getAllProviders,
|
||||
getProvider,
|
||||
updateProvider,
|
||||
resolveEnvVar,
|
||||
} from '../../config/litellm-api-config-manager.js';
|
||||
import { testApiKeyConnection, getDefaultApiBase } from './api-key-tester.js';
|
||||
import type { ProviderCredential, ApiKeyEntry, HealthCheckConfig } from '../../types/litellm-api-config.js';
|
||||
|
||||
/**
|
||||
* Internal state for tracking consecutive failures per key
|
||||
*/
|
||||
interface KeyHealthState {
|
||||
consecutiveFailures: number;
|
||||
cooldownUntil?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health Check Service - Singleton
|
||||
* Manages periodic health checks for API keys across all providers
|
||||
*/
|
||||
export class HealthCheckService {
|
||||
private static instance: HealthCheckService;
|
||||
|
||||
/** Timer handles for each provider's health check interval */
|
||||
private timers: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
/** Track consecutive failures and cooldown per key (providerId:keyId -> state) */
|
||||
private keyStates: Map<string, KeyHealthState> = new Map();
|
||||
|
||||
/** Base directory for config operations */
|
||||
private baseDir: string = '';
|
||||
|
||||
/** Lock to prevent concurrent checks on same provider */
|
||||
private checkingProviders: Set<string> = new Set();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(): HealthCheckService {
|
||||
if (!HealthCheckService.instance) {
|
||||
HealthCheckService.instance = new HealthCheckService();
|
||||
}
|
||||
return HealthCheckService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start health check for a specific provider
|
||||
* @param providerId - The provider ID to start health checks for
|
||||
*/
|
||||
startHealthCheck(providerId: string): void {
|
||||
// Stop existing timer if any
|
||||
this.stopHealthCheck(providerId);
|
||||
|
||||
const provider = getProvider(this.baseDir, providerId);
|
||||
if (!provider) {
|
||||
console.warn(`[HealthCheck] Provider not found: ${providerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!provider.enabled) {
|
||||
console.log(`[HealthCheck] Provider ${providerId} is disabled, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const healthConfig = provider.healthCheck;
|
||||
if (!healthConfig || !healthConfig.enabled) {
|
||||
console.log(`[HealthCheck] Health check not enabled for provider ${providerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = (healthConfig.intervalSeconds || 300) * 1000;
|
||||
|
||||
console.log(`[HealthCheck] Starting health check for ${provider.name} (${providerId}), interval: ${healthConfig.intervalSeconds}s`);
|
||||
|
||||
// Run initial check immediately
|
||||
void this.checkProviderNow(providerId);
|
||||
|
||||
// Set up interval
|
||||
const timer = setInterval(() => {
|
||||
void this.checkProviderNow(providerId);
|
||||
}, intervalMs);
|
||||
|
||||
this.timers.set(providerId, timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop health check for a specific provider
|
||||
* @param providerId - The provider ID to stop health checks for
|
||||
*/
|
||||
stopHealthCheck(providerId: string): void {
|
||||
const timer = this.timers.get(providerId);
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
this.timers.delete(providerId);
|
||||
console.log(`[HealthCheck] Stopped health check for provider ${providerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start health checks for all providers that have health check enabled
|
||||
* @param baseDir - Base directory for config operations
|
||||
*/
|
||||
startAllHealthChecks(baseDir: string): void {
|
||||
this.baseDir = baseDir;
|
||||
|
||||
const providers = getAllProviders(baseDir);
|
||||
let startedCount = 0;
|
||||
|
||||
for (const provider of providers) {
|
||||
if (provider.enabled && provider.healthCheck?.enabled) {
|
||||
this.startHealthCheck(provider.id);
|
||||
startedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (startedCount > 0) {
|
||||
console.log(`[HealthCheck] Started health checks for ${startedCount} provider(s)`);
|
||||
} else {
|
||||
console.log('[HealthCheck] No providers with health check enabled');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all active health checks
|
||||
*/
|
||||
stopAllHealthChecks(): void {
|
||||
const providerIds = Array.from(this.timers.keys());
|
||||
for (const providerId of providerIds) {
|
||||
this.stopHealthCheck(providerId);
|
||||
}
|
||||
this.keyStates.clear();
|
||||
console.log('[HealthCheck] Stopped all health checks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger a health check for a provider
|
||||
* @param providerId - The provider ID to check
|
||||
*/
|
||||
async checkProviderNow(providerId: string): Promise<void> {
|
||||
// Prevent concurrent checks on same provider
|
||||
if (this.checkingProviders.has(providerId)) {
|
||||
console.log(`[HealthCheck] Already checking provider ${providerId}, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.checkingProviders.add(providerId);
|
||||
|
||||
try {
|
||||
const provider = getProvider(this.baseDir, providerId);
|
||||
if (!provider) {
|
||||
console.warn(`[HealthCheck] Provider not found: ${providerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!provider.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const healthConfig = provider.healthCheck;
|
||||
if (!healthConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiBase = provider.apiBase || getDefaultApiBase(provider.type);
|
||||
const apiKeys = provider.apiKeys || [];
|
||||
|
||||
if (apiKeys.length === 0 && provider.apiKey) {
|
||||
// Single key mode - create virtual key entry
|
||||
await this.checkSingleKey(provider, 'default', provider.apiKey, apiBase, healthConfig);
|
||||
} else {
|
||||
// Multi-key mode
|
||||
for (const keyEntry of apiKeys) {
|
||||
if (!keyEntry.enabled) continue;
|
||||
await this.checkSingleKey(provider, keyEntry.id, keyEntry.key, apiBase, healthConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist updated health statuses
|
||||
const updatedApiKeys = provider.apiKeys;
|
||||
if (updatedApiKeys && updatedApiKeys.length > 0) {
|
||||
try {
|
||||
updateProvider(this.baseDir, providerId, { apiKeys: updatedApiKeys });
|
||||
} catch (err) {
|
||||
console.error(`[HealthCheck] Failed to persist health status for ${providerId}:`, err);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.checkingProviders.delete(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a single API key's health
|
||||
*/
|
||||
private async checkSingleKey(
|
||||
provider: ProviderCredential,
|
||||
keyId: string,
|
||||
keyValue: string,
|
||||
apiBase: string,
|
||||
healthConfig: HealthCheckConfig
|
||||
): Promise<void> {
|
||||
const stateKey = `${provider.id}:${keyId}`;
|
||||
let state = this.keyStates.get(stateKey);
|
||||
|
||||
if (!state) {
|
||||
state = { consecutiveFailures: 0 };
|
||||
this.keyStates.set(stateKey, state);
|
||||
}
|
||||
|
||||
// Check if in cooldown
|
||||
if (state.cooldownUntil && new Date() < state.cooldownUntil) {
|
||||
console.log(`[HealthCheck] Key ${keyId} for ${provider.name} is in cooldown until ${state.cooldownUntil.toISOString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve environment variables
|
||||
const resolvedKey = resolveEnvVar(keyValue);
|
||||
if (!resolvedKey) {
|
||||
console.warn(`[HealthCheck] Key ${keyId} for ${provider.name} has empty value (env var not set?)`);
|
||||
this.updateKeyHealth(provider, keyId, 'unhealthy', 'API key is empty or environment variable not set');
|
||||
return;
|
||||
}
|
||||
|
||||
// Test the key
|
||||
const result = await testApiKeyConnection(provider.type, apiBase, resolvedKey);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (result.valid) {
|
||||
// Reset failure count on success
|
||||
state.consecutiveFailures = 0;
|
||||
state.cooldownUntil = undefined;
|
||||
this.updateKeyHealth(provider, keyId, 'healthy', undefined, now, result.latencyMs);
|
||||
console.log(`[HealthCheck] Key ${keyId} for ${provider.name}: healthy (${result.latencyMs}ms)`);
|
||||
} else {
|
||||
// Increment failure count
|
||||
state.consecutiveFailures++;
|
||||
|
||||
if (state.consecutiveFailures >= healthConfig.failureThreshold) {
|
||||
// Mark as unhealthy and enter cooldown
|
||||
const cooldownMs = (healthConfig.cooldownSeconds || 60) * 1000;
|
||||
state.cooldownUntil = new Date(Date.now() + cooldownMs);
|
||||
this.updateKeyHealth(provider, keyId, 'unhealthy', result.error, now);
|
||||
console.warn(`[HealthCheck] Key ${keyId} for ${provider.name}: UNHEALTHY after ${state.consecutiveFailures} failures. Cooldown until ${state.cooldownUntil.toISOString()}`);
|
||||
} else {
|
||||
// Still unknown/degraded, not yet unhealthy
|
||||
this.updateKeyHealth(provider, keyId, 'unknown', result.error, now);
|
||||
console.log(`[HealthCheck] Key ${keyId} for ${provider.name}: failed (${state.consecutiveFailures}/${healthConfig.failureThreshold}): ${result.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the health status of a key in the provider's apiKeys array
|
||||
*/
|
||||
private updateKeyHealth(
|
||||
provider: ProviderCredential,
|
||||
keyId: string,
|
||||
status: 'healthy' | 'unhealthy' | 'unknown',
|
||||
error?: string,
|
||||
timestamp?: string,
|
||||
latencyMs?: number
|
||||
): void {
|
||||
if (!provider.apiKeys) return;
|
||||
|
||||
const keyEntry = provider.apiKeys.find(k => k.id === keyId);
|
||||
if (keyEntry) {
|
||||
keyEntry.healthStatus = status;
|
||||
keyEntry.lastHealthCheck = timestamp || new Date().toISOString();
|
||||
if (error) {
|
||||
keyEntry.lastError = error;
|
||||
} else {
|
||||
delete keyEntry.lastError;
|
||||
}
|
||||
// Save latency if provided (only on successful checks)
|
||||
if (latencyMs !== undefined) {
|
||||
keyEntry.lastLatencyMs = latencyMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current health status of all keys for a provider
|
||||
*/
|
||||
getProviderHealthStatus(providerId: string): Array<{
|
||||
keyId: string;
|
||||
status: 'healthy' | 'unhealthy' | 'unknown';
|
||||
lastCheck?: string;
|
||||
lastError?: string;
|
||||
consecutiveFailures: number;
|
||||
inCooldown: boolean;
|
||||
}> {
|
||||
const provider = getProvider(this.baseDir, providerId);
|
||||
if (!provider || !provider.apiKeys) return [];
|
||||
|
||||
return provider.apiKeys.map(key => {
|
||||
const stateKey = `${providerId}:${key.id}`;
|
||||
const state = this.keyStates.get(stateKey) || { consecutiveFailures: 0 };
|
||||
|
||||
return {
|
||||
keyId: key.id,
|
||||
status: key.healthStatus || 'unknown',
|
||||
lastCheck: key.lastHealthCheck,
|
||||
lastError: key.lastError,
|
||||
consecutiveFailures: state.consecutiveFailures,
|
||||
inCooldown: state.cooldownUntil ? new Date() < state.cooldownUntil : false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the service is running health checks for any provider
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.timers.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of provider IDs currently being monitored
|
||||
*/
|
||||
getMonitoredProviders(): string[] {
|
||||
return Array.from(this.timers.keys());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton health check service instance
|
||||
*/
|
||||
export function getHealthCheckService(): HealthCheckService {
|
||||
return HealthCheckService.getInstance();
|
||||
}
|
||||
@@ -3172,16 +3172,75 @@ function renderDiscoveredProviders() {
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time for display (e.g., "2 minutes ago")
|
||||
*/
|
||||
function formatKeyRelativeTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
var date = new Date(dateString);
|
||||
var now = new Date();
|
||||
var diffMs = now - date;
|
||||
var diffSecs = Math.floor(diffMs / 1000);
|
||||
var diffMins = Math.floor(diffSecs / 60);
|
||||
var diffHours = Math.floor(diffMins / 60);
|
||||
var diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSecs < 60) return t('apiSettings.justNow') || 'just now';
|
||||
if (diffMins < 60) return diffMins + (t('apiSettings.minutesAgo') || 'm ago');
|
||||
if (diffHours < 24) return diffHours + (t('apiSettings.hoursAgo') || 'h ago');
|
||||
if (diffDays < 7) return diffDays + (t('apiSettings.daysAgo') || 'd ago');
|
||||
return date.toLocaleDateString();
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status indicator color class
|
||||
*/
|
||||
function getKeyStatusColorClass(status) {
|
||||
switch (status) {
|
||||
case 'healthy': return 'text-success';
|
||||
case 'unhealthy': return 'text-danger';
|
||||
default: return 'text-muted';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render API keys section
|
||||
*/
|
||||
function renderApiKeysSection(provider) {
|
||||
const keys = provider.apiKeys || [];
|
||||
const hasMultipleKeys = keys.length > 0;
|
||||
var keys = provider.apiKeys || [];
|
||||
var hasMultipleKeys = keys.length > 0;
|
||||
|
||||
let keysHtml = '';
|
||||
var keysHtml = '';
|
||||
if (hasMultipleKeys) {
|
||||
keysHtml = keys.map(function(key, index) {
|
||||
var healthStatus = key.healthStatus || 'unknown';
|
||||
var statusColorClass = getKeyStatusColorClass(healthStatus);
|
||||
var lastCheckTime = key.lastHealthCheck ? formatKeyRelativeTime(key.lastHealthCheck) : '';
|
||||
var latencyInfo = (healthStatus === 'healthy' && key.lastLatencyMs !== undefined)
|
||||
? ' (' + key.lastLatencyMs + 'ms)'
|
||||
: '';
|
||||
|
||||
// Status display with latency and time
|
||||
var statusDisplay = '<div class="key-status" style="display: flex; flex-direction: column; gap: 0.25rem;">' +
|
||||
'<div style="display: flex; align-items: center; gap: 0.5rem;">' +
|
||||
'<span class="key-status-indicator ' + healthStatus + '"></span>' +
|
||||
'<span class="key-status-text ' + statusColorClass + '">' +
|
||||
t('apiSettings.' + healthStatus) + latencyInfo +
|
||||
'</span>' +
|
||||
'</div>';
|
||||
|
||||
// Show last check time if available
|
||||
if (lastCheckTime) {
|
||||
statusDisplay += '<div style="font-size: 0.7rem; color: hsl(var(--muted-foreground)); padding-left: 1rem;">' +
|
||||
(t('apiSettings.lastCheck') || 'Last check') + ': ' + lastCheckTime +
|
||||
'</div>';
|
||||
}
|
||||
statusDisplay += '</div>';
|
||||
|
||||
return '<div class="api-key-item" data-key-id="' + key.id + '">' +
|
||||
'<input type="text" class="cli-input key-label" ' +
|
||||
'value="' + (key.label || '') + '" ' +
|
||||
@@ -3198,12 +3257,9 @@ function renderApiKeysSection(provider) {
|
||||
'value="' + (key.weight || 1) + '" min="1" max="100" ' +
|
||||
'placeholder="' + t('apiSettings.keyWeight') + '" ' +
|
||||
'onchange="updateApiKeyField(\'' + provider.id + '\', \'' + key.id + '\', \'weight\', parseInt(this.value))">' +
|
||||
'<div class="key-status">' +
|
||||
'<span class="key-status-indicator ' + (key.healthStatus || 'unknown') + '"></span>' +
|
||||
'<span class="key-status-text">' + t('apiSettings.' + (key.healthStatus || 'unknown')) + '</span>' +
|
||||
'</div>' +
|
||||
statusDisplay +
|
||||
'<div class="api-key-actions">' +
|
||||
'<button type="button" class="test-key-btn" onclick="testApiKey(\'' + provider.id + '\', \'' + key.id + '\')">' +
|
||||
'<button type="button" class="test-key-btn" onclick="testApiKey(\'' + provider.id + '\', \'' + key.id + '\', event)">' +
|
||||
t('apiSettings.testKey') +
|
||||
'</button>' +
|
||||
'<button type="button" class="btn-danger btn-sm" onclick="removeApiKey(\'' + provider.id + '\', \'' + key.id + '\')">' +
|
||||
@@ -3252,18 +3308,37 @@ function renderRoutingSection(provider) {
|
||||
* Render health check section
|
||||
*/
|
||||
function renderHealthCheckSection(provider) {
|
||||
const health = provider.healthCheck || { enabled: false, intervalSeconds: 300, cooldownSeconds: 5, failureThreshold: 3 };
|
||||
var health = provider.healthCheck || { enabled: false, intervalSeconds: 300, cooldownSeconds: 5, failureThreshold: 3 };
|
||||
|
||||
// Calculate next check time based on interval (if enabled)
|
||||
var nextCheckInfo = '';
|
||||
if (health.enabled) {
|
||||
var intervalMinutes = Math.floor(health.intervalSeconds / 60);
|
||||
nextCheckInfo = '<div style="font-size: 0.75rem; color: hsl(var(--muted-foreground)); margin-top: 0.5rem;">' +
|
||||
'<i data-lucide="clock" style="width: 0.875rem; height: 0.875rem; display: inline-block; vertical-align: text-bottom;"></i> ' +
|
||||
(t('apiSettings.checksEvery') || 'Checks every') + ' ' + intervalMinutes + ' ' + (t('apiSettings.minutes') || 'min') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
return '<div class="health-check-section">' +
|
||||
'<div class="health-check-header">' +
|
||||
'<h5>' + t('apiSettings.healthCheck') + '</h5>' +
|
||||
'<div class="health-check-header" style="display: flex; align-items: center; justify-content: space-between; gap: 1rem;">' +
|
||||
'<div style="display: flex; align-items: center; gap: 0.5rem;">' +
|
||||
'<h5 style="margin: 0;">' + t('apiSettings.healthCheck') + '</h5>' +
|
||||
'<label class="toggle-switch">' +
|
||||
'<input type="checkbox"' + (health.enabled ? ' checked' : '') + ' ' +
|
||||
'onchange="updateHealthCheckEnabled(\'' + provider.id + '\', this.checked)">' +
|
||||
'<span class="toggle-slider"></span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="health-check-grid" style="' + (health.enabled ? '' : 'opacity: 0.5; pointer-events: none;') + '">' +
|
||||
'<button type="button" class="btn-secondary btn-sm" id="health-check-now-btn-' + provider.id + '" ' +
|
||||
'onclick="triggerHealthCheckNow(\'' + provider.id + '\')" ' +
|
||||
'style="' + (health.enabled ? '' : 'opacity: 0.5; pointer-events: none;') + '">' +
|
||||
'<i data-lucide="activity" style="width: 0.875rem; height: 0.875rem;"></i> ' +
|
||||
(t('apiSettings.checkNow') || 'Check Now') +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
nextCheckInfo +
|
||||
'<div class="health-check-grid" style="' + (health.enabled ? '' : 'opacity: 0.5; pointer-events: none;') + ' margin-top: 0.75rem;">' +
|
||||
'<div class="health-check-field">' +
|
||||
'<label>' + t('apiSettings.healthInterval') + '</label>' +
|
||||
'<input type="number" class="cli-input" value="' + health.intervalSeconds + '" min="60" max="3600" ' +
|
||||
@@ -3467,7 +3542,7 @@ function updateHealthCheckField(providerId, field, value) {
|
||||
fetch('/api/litellm-api/providers/' + providerId)
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(provider) {
|
||||
const healthCheck = provider.healthCheck || { enabled: false, intervalSeconds: 300, cooldownSeconds: 5, failureThreshold: 3 };
|
||||
var healthCheck = provider.healthCheck || { enabled: false, intervalSeconds: 300, cooldownSeconds: 5, failureThreshold: 3 };
|
||||
healthCheck[field] = value;
|
||||
return csrfFetch('/api/litellm-api/providers/' + providerId, {
|
||||
method: 'PUT',
|
||||
@@ -3481,13 +3556,99 @@ function updateHealthCheckField(providerId, field, value) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API key
|
||||
* Trigger immediate health check for all keys of a provider
|
||||
* @param {string} providerId - Provider ID to check
|
||||
*/
|
||||
function testApiKey(providerId, keyId) {
|
||||
const btn = event.target;
|
||||
function triggerHealthCheckNow(providerId) {
|
||||
var btn = document.getElementById('health-check-now-btn-' + providerId);
|
||||
var originalHtml = '';
|
||||
|
||||
if (btn) {
|
||||
originalHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner" style="display: inline-block; width: 0.875rem; height: 0.875rem; border: 2px solid hsl(var(--muted)); border-top-color: hsl(var(--primary)); border-radius: 50%; animation: spin 0.8s linear infinite;"></span> ' +
|
||||
(t('apiSettings.checking') || 'Checking...');
|
||||
}
|
||||
|
||||
csrfFetch('/api/litellm-api/providers/' + providerId + '/health-check-now', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(result) {
|
||||
// Reset button state
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHtml;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
// Count healthy/unhealthy keys
|
||||
var healthyCount = 0;
|
||||
var unhealthyCount = 0;
|
||||
(result.keys || []).forEach(function(key) {
|
||||
if (key.status === 'healthy') healthyCount++;
|
||||
else if (key.status === 'unhealthy') unhealthyCount++;
|
||||
});
|
||||
|
||||
var statusMsg = (t('apiSettings.healthCheckComplete') || 'Health check complete') + ': ' +
|
||||
healthyCount + ' ' + (t('apiSettings.healthy') || 'healthy');
|
||||
if (unhealthyCount > 0) {
|
||||
statusMsg += ', ' + unhealthyCount + ' ' + (t('apiSettings.unhealthy') || 'unhealthy');
|
||||
}
|
||||
showToast(statusMsg, unhealthyCount > 0 ? 'warning' : 'success');
|
||||
|
||||
// Reload data and refresh modal
|
||||
loadApiSettings(true).then(function() {
|
||||
refreshMultiKeyModal(providerId);
|
||||
});
|
||||
} else {
|
||||
showToast((t('apiSettings.healthCheckFailed') || 'Health check failed') + ': ' + (result.error || 'Unknown error'), 'error');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
// Reset button state on error
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHtml;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
console.error('Failed to trigger health check:', err);
|
||||
showToast((t('apiSettings.healthCheckFailed') || 'Health check failed') + ': ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API key
|
||||
* @param {string} providerId - Provider ID
|
||||
* @param {string} keyId - Key ID to test
|
||||
* @param {Event} event - Click event (optional, for button reference)
|
||||
*/
|
||||
function testApiKey(providerId, keyId, event) {
|
||||
// Get button element - either from event or find by data attributes
|
||||
var btn;
|
||||
if (event && event.target) {
|
||||
btn = event.target.closest('button');
|
||||
}
|
||||
if (!btn) {
|
||||
// Fallback: find button by data attributes
|
||||
btn = document.querySelector('button[onclick*="testApiKey"][onclick*="' + keyId + '"]');
|
||||
}
|
||||
|
||||
if (!btn) {
|
||||
showToast('Test failed: Could not find test button', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store original button text
|
||||
var originalText = btn.textContent;
|
||||
|
||||
// Set loading state
|
||||
btn.disabled = true;
|
||||
btn.classList.add('testing');
|
||||
btn.textContent = t('apiSettings.testingKey');
|
||||
btn.textContent = t('apiSettings.testingKey') || 'Testing...';
|
||||
|
||||
csrfFetch('/api/litellm-api/providers/' + providerId + '/test-key', {
|
||||
method: 'POST',
|
||||
@@ -3496,29 +3657,61 @@ function testApiKey(providerId, keyId) {
|
||||
})
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(result) {
|
||||
// Reset button state
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('testing');
|
||||
btn.textContent = t('apiSettings.testKey');
|
||||
btn.textContent = originalText;
|
||||
|
||||
const keyItem = btn.closest('.api-key-item');
|
||||
const statusIndicator = keyItem.querySelector('.key-status-indicator');
|
||||
const statusText = keyItem.querySelector('.key-status-text');
|
||||
// Find status elements within the key item
|
||||
var keyItem = btn.closest('.api-key-item');
|
||||
if (!keyItem) {
|
||||
// Try parent elements
|
||||
keyItem = btn.parentElement;
|
||||
while (keyItem && !keyItem.classList.contains('api-key-item')) {
|
||||
keyItem = keyItem.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
var statusIndicator = keyItem ? keyItem.querySelector('.key-status-indicator') : null;
|
||||
var statusText = keyItem ? keyItem.querySelector('.key-status-text') : null;
|
||||
|
||||
if (result.valid) {
|
||||
statusIndicator.className = 'key-status-indicator healthy';
|
||||
statusText.textContent = t('apiSettings.healthy');
|
||||
showToast(t('apiSettings.keyValid'), 'success');
|
||||
// Update status indicators if found
|
||||
if (statusIndicator) {
|
||||
statusIndicator.className = 'key-status-indicator healthy';
|
||||
}
|
||||
if (statusText) {
|
||||
var latencyInfo = result.latencyMs ? ' (' + result.latencyMs + 'ms)' : '';
|
||||
statusText.textContent = (t('apiSettings.healthy') || 'Healthy') + latencyInfo;
|
||||
}
|
||||
// Show success toast with latency info
|
||||
var successMsg = (t('apiSettings.keyValid') || 'API key is valid');
|
||||
if (result.latencyMs) {
|
||||
successMsg += ' (' + result.latencyMs + 'ms)';
|
||||
}
|
||||
showToast(successMsg, 'success');
|
||||
} else {
|
||||
statusIndicator.className = 'key-status-indicator unhealthy';
|
||||
statusText.textContent = t('apiSettings.unhealthy');
|
||||
showToast(t('apiSettings.keyInvalid') + ': ' + (result.error || ''), 'error');
|
||||
// Update status indicators if found
|
||||
if (statusIndicator) {
|
||||
statusIndicator.className = 'key-status-indicator unhealthy';
|
||||
}
|
||||
if (statusText) {
|
||||
statusText.textContent = t('apiSettings.unhealthy') || 'Unhealthy';
|
||||
}
|
||||
// Show error toast
|
||||
var errorMsg = (t('apiSettings.keyInvalid') || 'API key is invalid');
|
||||
if (result.error) {
|
||||
errorMsg += ': ' + result.error;
|
||||
}
|
||||
showToast(errorMsg, 'error');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
// Reset button state on error
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('testing');
|
||||
btn.textContent = t('apiSettings.testKey');
|
||||
showToast('Test failed: ' + err.message, 'error');
|
||||
btn.textContent = originalText;
|
||||
showToast('Test failed: ' + (err.message || 'Unknown error'), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,9 @@ export interface ApiKeyEntry {
|
||||
|
||||
/** Error message if unhealthy */
|
||||
lastError?: string;
|
||||
|
||||
/** Last recorded latency in milliseconds */
|
||||
lastLatencyMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user